Merge pull request 'feat(referrals): rework reward engine + email-verified landing' (#48) from dev into main

Reviewed-on: #48
This commit was merged in pull request #48.
This commit is contained in:
2026-05-25 11:13:35 +00:00
18 changed files with 6260 additions and 392 deletions

View File

@@ -0,0 +1,3 @@
ALTER TABLE "referrals" ADD COLUMN "status" varchar(20) DEFAULT 'pending' NOT NULL;--> statement-breakpoint
ALTER TABLE "referrals" ADD COLUMN "qualified_at" timestamp with time zone;--> statement-breakpoint
ALTER TABLE "users" ADD COLUMN "referral_credit_days" integer DEFAULT 0 NOT NULL;

File diff suppressed because it is too large Load Diff

View File

@@ -64,6 +64,13 @@
"when": 1779633572753,
"tag": "0008_common_sumo",
"breakpoints": true
},
{
"idx": 9,
"version": "7",
"when": 1779703007129,
"tag": "0009_referral_rewards_rework",
"breakpoints": true
}
]
}

View File

@@ -1,11 +1,14 @@
import { Module, type OnModuleInit } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { EmailService } from "../email/email.service";
import { ReferralsModule } from "../referrals/referrals.module";
import { ReferralsService } from "../referrals/referrals.service";
import { createAuth } from "./auth";
import { AuthController } from "./auth.controller";
import { AuthService } from "./auth.service";
@Module({
imports: [ReferralsModule],
controllers: [AuthController],
providers: [AuthService],
exports: [AuthService],
@@ -14,6 +17,7 @@ export class AuthModule implements OnModuleInit {
constructor(
private configService: ConfigService,
private emailService: EmailService,
private referralsService: ReferralsService,
) {}
onModuleInit() {
@@ -28,6 +32,7 @@ export class AuthModule implements OnModuleInit {
createAuth(databaseUrl, secret, baseUrl, {
social: { googleClientId, googleClientSecret },
emailService: this.emailService,
onEmailVerified: (userId) => this.referralsService.qualifyReferral(userId),
});
}
}

View File

@@ -2,11 +2,33 @@ import { randomUUID } from "node:crypto";
import { generateReferralCode } from "@sase/shared";
import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { eq } from "drizzle-orm";
import { drizzle } from "drizzle-orm/postgres-js";
import postgres from "postgres";
import * as schema from "../database/schema/core";
import { EmailService } from "../email/email.service";
/**
* Generates a referral code that doesn't collide with an existing one. The code
* lands in a unique-indexed column, so a raw collision would otherwise fail the
* whole signup. Retries a few times, then falls back to a longer code.
*/
async function generateUniqueReferralCode(
db: ReturnType<typeof drizzle<typeof schema>>,
): Promise<string> {
for (let i = 0; i < 5; i++) {
const code = generateReferralCode();
const existing = await db
.select({ id: schema.users.id })
.from(schema.users)
.where(eq(schema.users.referralCode, code))
.limit(1);
if (existing.length === 0) return code;
}
// Astronomically unlikely to reach here; widen the code to defuse collisions.
return `${generateReferralCode()}${generateReferralCode().slice(0, 4)}`;
}
let authInstance: ReturnType<typeof betterAuth> | null = null;
interface SocialCredentials {
@@ -17,6 +39,8 @@ interface SocialCredentials {
interface AuthOptions {
social?: SocialCredentials;
emailService?: EmailService;
/** Called after a user's email is verified (referral qualification, etc.). */
onEmailVerified?: (userId: string) => Promise<void>;
}
export function createAuth(
@@ -56,10 +80,33 @@ export function createAuth(
},
emailVerification: {
sendVerificationEmail: async (data) => {
// better-auth bakes the sign-up `callbackURL` (the deep-linked search/VIN
// page) into data.url. Override it so verifying lands the user on a clear
// confirmation page instead of silently dumping them on a vehicle search.
let verificationUrl = data.url;
try {
const parsed = new URL(data.url);
parsed.searchParams.set("callbackURL", "/email-verified");
verificationUrl = parsed.toString();
} catch {
// Non-absolute URL (shouldn't happen) — fall back to the original.
}
if (options?.emailService) {
await options.emailService.sendEmailVerification(data.user.email, data.url);
await options.emailService.sendEmailVerification(data.user.email, verificationUrl);
} else {
console.log(`[DEV] Verification URL for ${data.user.email}: ${data.url}`);
console.log(`[DEV] Verification URL for ${data.user.email}: ${verificationUrl}`);
}
},
afterEmailVerification: async (user) => {
// Unlock referral rewards once the referred user proves their email.
// Never let a failure here break the verification flow.
if (options?.onEmailVerified) {
try {
await options.onEmailVerified(user.id);
} catch (err) {
console.error("[auth] onEmailVerified hook failed:", err);
}
}
},
sendOnSignUp: true,
@@ -81,7 +128,7 @@ export function createAuth(
return {
data: {
...userData,
referralCode: generateReferralCode(),
referralCode: await generateUniqueReferralCode(db),
},
};
},

View File

@@ -33,6 +33,10 @@ export const users = pgTable(
statusChangedBy: uuid("status_changed_by"),
referralCode: varchar("referral_code", { length: 20 }),
referredBy: uuid("referred_by"),
// Reward days earned via referrals that couldn't be applied to a live
// subscription yet (referrer had no active/trial sub at grant time).
// Consumed when the user next starts a trial or activates a subscription.
referralCreditDays: integer("referral_credit_days").default(0).notNull(),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
},
@@ -476,7 +480,13 @@ export const referrals = pgTable(
referredId: uuid("referred_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
// pending - code applied at signup, reward not yet unlocked
// qualified - referred user verified their email; milestone rewards processed
status: varchar("status", { length: 20 }).default("pending").notNull(),
// True once this referral's qualification milestone reward has been
// processed — guards against double-granting on re-run.
rewardApplied: boolean("reward_applied").default(false).notNull(),
qualifiedAt: timestamp("qualified_at", { withTimezone: true }),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
},
(table) => [

View File

@@ -1,10 +1,8 @@
import { Module } from "@nestjs/common";
import { SubscriptionsModule } from "../subscriptions/subscriptions.module";
import { ReferralsController } from "./referrals.controller";
import { ReferralsService } from "./referrals.service";
@Module({
imports: [SubscriptionsModule],
controllers: [ReferralsController],
providers: [ReferralsService],
exports: [ReferralsService],

View File

@@ -1,322 +1,208 @@
import { BadRequestException, NotFoundException } from "@nestjs/common";
import { BadRequestException, ForbiddenException, NotFoundException } from "@nestjs/common";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { ReferralsService } from "./referrals.service";
vi.mock("@sase/shared", () => ({
REFERRAL_REWARDS: {
TIER_1: { count: 3, extensionDays: 7 },
TIER_2: { count: 5, extensionDays: 30 },
},
generateReferralCode: vi.fn().mockReturnValue("REF-ABC123"),
}));
function createService(db: any) {
const subscriptionsService = {
extendSubscription: vi.fn().mockResolvedValue(undefined),
vi.mock("@sase/shared", async (importOriginal) => {
const actual = await importOriginal<typeof import("@sase/shared")>();
return {
...actual,
// Keep the real reward math + email normaliser; only stub code generation.
generateReferralCode: vi.fn().mockReturnValue("REFABC12"),
};
const service = new ReferralsService(db as any, subscriptionsService as any);
return { service, db, subscriptionsService };
});
/**
* Minimal awaitable drizzle query-builder stub: every builder method returns the
* same proxy, and awaiting it resolves to `result`. A FIFO queue lets each
* `select()` call return a different result in call order.
*/
function awaitable(result: unknown) {
const p = Promise.resolve(result);
const proxy: Record<string, unknown> = new Proxy(
{},
{
get(_t, prop: string) {
if (prop === "then") return p.then.bind(p);
if (prop === "catch") return p.catch.bind(p);
if (prop === "finally") return p.finally.bind(p);
return () => proxy;
},
},
);
return proxy;
}
interface DbOpts {
selectResults?: unknown[];
insertReturning?: unknown[];
txSelectResults?: unknown[];
txInsertReturning?: unknown[];
}
function makeDb(opts: DbOpts = {}) {
const selectQueue = [...(opts.selectResults ?? [])];
const txSelectQueue = [...(opts.txSelectResults ?? [])];
const txUpdate = vi.fn(() => awaitable(undefined));
const txInsert = vi.fn(() => awaitable(opts.txInsertReturning ?? [{ id: "ref-1" }]));
const tx = {
select: vi.fn(() => awaitable(txSelectQueue.shift() ?? [])),
insert: txInsert,
update: txUpdate,
};
const db = {
select: vi.fn(() => awaitable(selectQueue.shift() ?? [])),
insert: vi.fn(() => awaitable(opts.insertReturning ?? [])),
update: vi.fn(() => awaitable(undefined)),
transaction: vi.fn(async (cb: (t: typeof tx) => unknown) => cb(tx)),
};
return { db, tx, txUpdate, txInsert };
}
function createService(db: unknown) {
return new ReferralsService(db as never);
}
describe("ReferralsService", () => {
beforeEach(() => {
vi.clearAllMocks();
});
beforeEach(() => vi.clearAllMocks());
describe("getStats", () => {
it("should throw NotFoundException when user not found", async () => {
const db = {
select: vi.fn().mockReturnValue({
from: vi.fn().mockReturnThis(),
where: vi.fn().mockReturnThis(),
limit: vi.fn().mockReturnValue([]),
}),
};
const { service } = createService(db);
await expect(service.getStats("nonexistent")).rejects.toThrow(NotFoundException);
it("throws NotFound when user is missing", async () => {
const { db } = makeDb({ selectResults: [[]] });
await expect(createService(db).getStats("u1")).rejects.toThrow(NotFoundException);
});
it("should return 0 rewardDays when 0 referrals", async () => {
let selectCall = 0;
const db = {
select: vi.fn().mockImplementation(() => {
selectCall++;
const captured = selectCall;
const chain: Record<string, any> = {};
chain.from = vi.fn().mockReturnValue(chain);
chain.where = vi.fn().mockImplementation(() => {
if (captured === 2) return [{ count: 0 }]; // terminal for count
return chain;
});
chain.limit = vi.fn().mockReturnValue([{ id: "u1" }]);
return chain;
}),
};
const { service } = createService(db);
const result = await service.getStats("u1");
expect(result.totalReferrals).toBe(0);
expect(result.rewardDays).toBe(0);
});
it("should return tier 1 rewardDays when at threshold", async () => {
let selectCall = 0;
const db = {
select: vi.fn().mockImplementation(() => {
selectCall++;
const captured = selectCall;
const chain: Record<string, any> = {};
chain.from = vi.fn().mockReturnValue(chain);
chain.where = vi.fn().mockImplementation(() => {
if (captured === 2) return [{ count: 3 }];
return chain;
});
chain.limit = vi.fn().mockReturnValue([{ id: "u1" }]);
return chain;
}),
};
const { service } = createService(db);
const result = await service.getStats("u1");
expect(result.totalReferrals).toBe(3);
expect(result.rewardDays).toBe(7);
});
it("should return tier 2 rewardDays when at threshold", async () => {
let selectCall = 0;
const db = {
select: vi.fn().mockImplementation(() => {
selectCall++;
const captured = selectCall;
const chain: Record<string, any> = {};
chain.from = vi.fn().mockReturnValue(chain);
chain.where = vi.fn().mockImplementation(() => {
if (captured === 2) return [{ count: 5 }];
return chain;
});
chain.limit = vi.fn().mockReturnValue([{ id: "u1" }]);
return chain;
}),
};
const { service } = createService(db);
const result = await service.getStats("u1");
it("returns qualified/pending counts and cumulative reward days", async () => {
const { db } = makeDb({
selectResults: [
[{ creditDays: 5 }], // user lookup
[
{ status: "qualified", count: 5 },
{ status: "pending", count: 2 },
], // grouped counts
],
});
const result = await createService(db).getStats("u1");
expect(result.totalReferrals).toBe(5);
expect(result.rewardDays).toBe(30);
});
});
describe("getMyReferrals", () => {
it("should return referrals with code and total", async () => {
let selectCall = 0;
const db = {
select: vi.fn().mockImplementation(() => {
selectCall++;
const captured = selectCall;
const chain: Record<string, any> = {};
chain.from = vi.fn().mockReturnValue(chain);
chain.where = vi.fn().mockImplementation(() => {
if (captured === 2) return [{ id: "r1" }, { id: "r2" }]; // terminal
return chain;
});
chain.limit = vi.fn().mockReturnValue([{ id: "u1", referralCode: "REF-XYZ" }]);
return chain;
}),
};
const { service } = createService(db);
const result = await service.getMyReferrals("u1");
expect(result.referralCode).toBe("REF-XYZ");
expect(result.totalReferrals).toBe(2);
expect(result.referrals).toHaveLength(2);
expect(result.pendingReferrals).toBe(2);
expect(result.rewardDays).toBe(21); // 5 qualified → 1×7 + 1×14
expect(result.creditDays).toBe(5);
});
});
describe("applyReferralCode", () => {
it("should throw NotFoundException for invalid code", async () => {
const db = {
select: vi.fn().mockReturnValue({
from: vi.fn().mockReturnThis(),
where: vi.fn().mockReturnThis(),
limit: vi.fn().mockReturnValue([]),
}),
};
const { service } = createService(db);
await expect(service.applyReferralCode("u1", "INVALID")).rejects.toThrow(NotFoundException);
});
it("should throw BadRequestException for self-referral", async () => {
const db = {
select: vi.fn().mockReturnValue({
from: vi.fn().mockReturnThis(),
where: vi.fn().mockReturnThis(),
limit: vi.fn().mockReturnValue([{ id: "u1", referralCode: "SELF-CODE" }]),
}),
};
const { service } = createService(db);
await expect(service.applyReferralCode("u1", "SELF-CODE")).rejects.toThrow(
it("rejects an empty code", async () => {
const { db } = makeDb();
await expect(createService(db).applyReferralCode("u1", " ")).rejects.toThrow(
BadRequestException,
);
});
it("should throw BadRequestException when already referred", async () => {
let selectCall = 0;
const db = {
select: vi.fn().mockImplementation(() => {
selectCall++;
return {
from: vi.fn().mockReturnThis(),
where: vi.fn().mockReturnThis(),
limit: vi.fn().mockImplementation(() => {
if (selectCall === 1) return [{ id: "u2", referralCode: "REF-U2" }];
if (selectCall === 2) return [{ id: "existing" }];
return [];
}),
};
}),
};
const { service } = createService(db);
await expect(service.applyReferralCode("u1", "REF-U2")).rejects.toThrow(BadRequestException);
it("rejects an oversized code", async () => {
const { db } = makeDb();
await expect(createService(db).applyReferralCode("u1", "X".repeat(40))).rejects.toThrow(
BadRequestException,
);
});
it("should apply referral code successfully", async () => {
let selectCall = 0;
const db = {
select: vi.fn().mockImplementation(() => {
selectCall++;
const captured = selectCall;
const chain: Record<string, any> = {};
chain.from = vi.fn().mockReturnValue(chain);
chain.where = vi.fn().mockImplementation(() => {
// Count query (3rd select) — where is terminal
if (captured === 3) return [{ count: 1 }];
return chain;
});
chain.limit = vi.fn().mockImplementation(() => {
if (captured === 1) return [{ id: "referrer-1", referralCode: "REF-CODE" }];
return []; // not already referred
});
return chain;
}),
insert: vi.fn().mockReturnValue({
values: vi.fn().mockReturnThis(),
returning: vi.fn().mockReturnValue([]),
}),
update: vi.fn().mockReturnValue({
set: vi.fn().mockReturnThis(),
where: vi.fn().mockResolvedValue(undefined),
}),
};
const { service } = createService(db);
const result = await service.applyReferralCode("u1", "REF-CODE");
expect(result).toEqual({ success: true });
it("throws NotFound for an unknown code", async () => {
const { db } = makeDb({ selectResults: [[]] });
await expect(createService(db).applyReferralCode("u1", "NOPE")).rejects.toThrow(
NotFoundException,
);
});
it("should trigger tier 1 reward when count reaches threshold", async () => {
// applyReferralCode select calls:
// 1: find referrer by code (where→limit)
// 2: check existing referral (where→limit)
// then: insert + update (not select)
// 3: count referrals (where is terminal)
let selectCall = 0;
const db = {
select: vi.fn().mockImplementation(() => {
selectCall++;
const captured = selectCall;
const chain: Record<string, any> = {};
chain.from = vi.fn().mockReturnValue(chain);
chain.where = vi.fn().mockImplementation(() => {
if (captured === 3) return [{ count: 3 }]; // tier 1
return chain;
});
chain.limit = vi.fn().mockImplementation(() => {
if (captured === 1) return [{ id: "referrer-1", referralCode: "REF-CODE" }];
return [];
});
return chain;
}),
insert: vi.fn().mockReturnValue({
values: vi.fn().mockReturnThis(),
returning: vi.fn().mockReturnValue([]),
}),
update: vi.fn().mockReturnValue({
set: vi.fn().mockReturnThis(),
where: vi.fn().mockResolvedValue(undefined),
}),
};
const { service, subscriptionsService } = createService(db);
await service.applyReferralCode("u1", "REF-CODE");
expect(subscriptionsService.extendSubscription).toHaveBeenCalledWith("referrer-1", 7);
it("throws BadRequest for self-referral", async () => {
const { db } = makeDb({ selectResults: [[{ id: "u1", email: "me@a.com" }]] });
await expect(createService(db).applyReferralCode("u1", "SELF")).rejects.toThrow(
BadRequestException,
);
});
it("should trigger tier 2 reward when count reaches threshold", async () => {
let selectCall = 0;
const db = {
select: vi.fn().mockImplementation(() => {
selectCall++;
const captured = selectCall;
const chain: Record<string, any> = {};
chain.from = vi.fn().mockReturnValue(chain);
chain.where = vi.fn().mockImplementation(() => {
if (captured === 3) return [{ count: 5 }]; // tier 2
return chain;
});
chain.limit = vi.fn().mockImplementation(() => {
if (captured === 1) return [{ id: "referrer-1", referralCode: "REF-CODE" }];
return [];
});
return chain;
}),
insert: vi.fn().mockReturnValue({
values: vi.fn().mockReturnThis(),
returning: vi.fn().mockReturnValue([]),
}),
update: vi.fn().mockReturnValue({
set: vi.fn().mockReturnThis(),
where: vi.fn().mockResolvedValue(undefined),
}),
};
const { service, subscriptionsService } = createService(db);
it("forbids referring your own gmail dot/+ alias", async () => {
const { db } = makeDb({
selectResults: [
[{ id: "r1", email: "john.doe@gmail.com" }], // referrer
[{ email: "johndoe+x@gmail.com", emailVerified: false }], // me — same identity
],
});
await expect(createService(db).applyReferralCode("u2", "REFABC12")).rejects.toThrow(
ForbiddenException,
);
});
await service.applyReferralCode("u1", "REF-CODE");
expect(subscriptionsService.extendSubscription).toHaveBeenCalledWith("referrer-1", 30);
it("links a fresh, unverified referral without granting a reward", async () => {
const { db, tx } = makeDb({
selectResults: [
[{ id: "r1", email: "ref@a.com" }],
[{ email: "new@b.com", emailVerified: false }],
],
txInsertReturning: [{ id: "ref-1" }],
});
const result = await createService(db).applyReferralCode("u2", "REFABC12");
expect(result).toEqual({ success: true, alreadyApplied: false });
expect(tx.insert).toHaveBeenCalled();
});
it("is idempotent when the user was already referred", async () => {
const { db } = makeDb({
selectResults: [
[{ id: "r1", email: "ref@a.com" }],
[{ email: "new@b.com", emailVerified: false }],
],
txInsertReturning: [], // onConflictDoNothing → no row
});
const result = await createService(db).applyReferralCode("u2", "REFABC12");
expect(result).toEqual({ success: true, alreadyApplied: true });
});
});
describe("ensureReferralCode", () => {
it("should return existing code if user already has one", async () => {
const db = {
select: vi.fn().mockReturnValue({
from: vi.fn().mockReturnThis(),
where: vi.fn().mockReturnThis(),
limit: vi.fn().mockReturnValue([{ id: "u1", referralCode: "EXISTING-CODE" }]),
}),
};
const { service } = createService(db);
const result = await service.ensureReferralCode("u1");
expect(result).toBe("EXISTING-CODE");
describe("qualifyReferral", () => {
it("no-ops when there is no pending referral", async () => {
const { db, txUpdate } = makeDb({ txSelectResults: [[]] });
await createService(db).qualifyReferral("u2");
expect(txUpdate).not.toHaveBeenCalled();
});
it("should generate and save new code when user has none", async () => {
const db = {
select: vi.fn().mockReturnValue({
from: vi.fn().mockReturnThis(),
where: vi.fn().mockReturnThis(),
limit: vi.fn().mockReturnValue([{ id: "u1", referralCode: null }]),
}),
update: vi.fn().mockReturnValue({
set: vi.fn().mockReturnThis(),
where: vi.fn().mockResolvedValue(undefined),
}),
};
const { service } = createService(db);
it("qualifies and grants a reward at a milestone count", async () => {
const { db, txUpdate } = makeDb({
txSelectResults: [
[{ id: "ref-1", referrerId: "r1" }], // pending referral (FOR UPDATE)
[{ id: "r1" }], // referrer lock (FOR UPDATE)
[{ count: 3 }], // qualified count after update → milestone (+7)
[{ id: "sub-1", endDate: new Date("2026-01-01T00:00:00Z") }], // active sub to extend
],
});
await createService(db).qualifyReferral("u2");
// One update to mark qualified, one to extend the subscription.
expect(txUpdate).toHaveBeenCalledTimes(2);
});
const result = await service.ensureReferralCode("u1");
expect(result).toBe("REF-ABC123");
expect(db.update).toHaveBeenCalled();
it("banks credit when there is no live subscription to extend", async () => {
const { db, txUpdate } = makeDb({
txSelectResults: [
[{ id: "ref-1", referrerId: "r1" }],
[{ id: "r1" }],
[{ count: 3 }],
[], // no active/trial subscription
],
});
await createService(db).qualifyReferral("u2");
expect(txUpdate).toHaveBeenCalledTimes(2); // mark qualified + bank credit
});
it("qualifies without reward between milestones", async () => {
const { db, txUpdate } = makeDb({
txSelectResults: [
[{ id: "ref-1", referrerId: "r1" }],
[{ id: "r1" }],
[{ count: 4 }], // no milestone → no grant
],
});
await createService(db).qualifyReferral("u2");
expect(txUpdate).toHaveBeenCalledTimes(1); // only the qualified mark
});
});
});

View File

@@ -1,123 +1,273 @@
import { BadRequestException, Inject, Injectable, NotFoundException } from "@nestjs/common";
import { REFERRAL_REWARDS } from "@sase/shared";
import { generateReferralCode } from "@sase/shared";
import { and, eq, sql } from "drizzle-orm";
import {
BadRequestException,
ForbiddenException,
Inject,
Injectable,
Logger,
NotFoundException,
} from "@nestjs/common";
import {
generateReferralCode,
normalizeEmail,
referralRewardForCount,
referralTotalRewardDays,
} from "@sase/shared";
import { and, desc, eq, or, sql } from "drizzle-orm";
import { DATABASE, type Database } from "../database/database.provider";
import { referrals, users } from "../database/schema/core";
import { SubscriptionsService } from "../subscriptions/subscriptions.service";
import { referrals, userSubscriptions, users } from "../database/schema/core";
/** Transaction handle type derived from the drizzle db's `transaction` callback. */
type Tx = Parameters<Parameters<Database["transaction"]>[0]>[0];
function maskEmail(email: string): string {
const at = email.indexOf("@");
if (at <= 0) return "***";
const local = email.slice(0, at);
const domain = email.slice(at + 1);
const head = local.slice(0, 2);
return `${head}${"*".repeat(Math.max(1, local.length - head.length))}@${domain}`;
}
@Injectable()
export class ReferralsService {
constructor(
@Inject(DATABASE) private db: Database,
private subscriptionsService: SubscriptionsService,
) {}
private readonly logger = new Logger(ReferralsService.name);
constructor(@Inject(DATABASE) private db: Database) {}
async getStats(userId: string) {
const user = await this.db.select().from(users).where(eq(users.id, userId)).limit(1);
if (user.length === 0) throw new NotFoundException("Kullanıcı bulunamadı");
const [user] = await this.db
.select({ creditDays: users.referralCreditDays })
.from(users)
.where(eq(users.id, userId))
.limit(1);
if (!user) throw new NotFoundException("Kullanıcı bulunamadı");
const totalResult = await this.db
.select({ count: sql<number>`count(*)` })
const rows = await this.db
.select({ status: referrals.status, count: sql<number>`count(*)` })
.from(referrals)
.where(eq(referrals.referrerId, userId));
.where(eq(referrals.referrerId, userId))
.groupBy(referrals.status);
const totalReferrals = Number(totalResult[0]?.count || 0);
let rewardDays = 0;
if (totalReferrals >= REFERRAL_REWARDS.TIER_2.count) {
rewardDays = REFERRAL_REWARDS.TIER_2.extensionDays;
} else if (totalReferrals >= REFERRAL_REWARDS.TIER_1.count) {
rewardDays = REFERRAL_REWARDS.TIER_1.extensionDays;
let qualified = 0;
let pending = 0;
for (const r of rows) {
const c = Number(r.count || 0);
if (r.status === "qualified") qualified += c;
else pending += c;
}
return { totalReferrals, rewardDays };
return {
totalReferrals: qualified,
pendingReferrals: pending,
// Cumulative reward days actually earned for `qualified` referrals.
rewardDays: referralTotalRewardDays(qualified),
// Reward days banked because there was no live subscription to extend.
creditDays: user.creditDays ?? 0,
};
}
async getMyReferrals(userId: string) {
const referralCode = await this.ensureReferralCode(userId);
const myReferrals = await this.db
.select()
const rows = await this.db
.select({
id: referrals.id,
status: referrals.status,
createdAt: referrals.createdAt,
qualifiedAt: referrals.qualifiedAt,
referredName: users.name,
referredEmail: users.email,
})
.from(referrals)
.where(eq(referrals.referrerId, userId));
.innerJoin(users, eq(referrals.referredId, users.id))
.where(eq(referrals.referrerId, userId))
.orderBy(desc(referrals.createdAt));
const list = rows.map((r) => ({
id: r.id,
status: r.status,
createdAt: r.createdAt,
qualifiedAt: r.qualifiedAt,
name: r.referredName,
email: maskEmail(r.referredEmail),
}));
return {
referralCode,
totalReferrals: myReferrals.length,
referrals: myReferrals,
totalReferrals: list.filter((r) => r.status === "qualified").length,
pendingReferrals: list.filter((r) => r.status !== "qualified").length,
referrals: list,
};
}
async applyReferralCode(userId: string, code: string) {
// Find referrer by code
/**
* Links the current user to a referrer via their code. Idempotent and
* transactional. No reward is granted here — the referral starts as
* `pending` and only unlocks rewards once the referred user verifies their
* email (see {@link qualifyReferral}). For users already verified at apply
* time (e.g. Google OAuth signups), qualification runs immediately.
*/
async applyReferralCode(userId: string, rawCode: string) {
if (typeof rawCode !== "string") throw new BadRequestException("Referans kodu gerekli");
const code = rawCode.trim().toUpperCase();
if (!code) throw new BadRequestException("Referans kodu gerekli");
// Codes are 8 chars; reject oversized input outright (the column is varchar(20)).
if (code.length > 20) throw new BadRequestException("Geçersiz referans kodu");
const [referrer] = await this.db
.select()
.select({ id: users.id, email: users.email })
.from(users)
.where(eq(users.referralCode, code))
.limit(1);
if (!referrer) throw new NotFoundException("Geçersiz referans kodu");
if (referrer.id === userId)
throw new BadRequestException("Kendi referans kodunuzu kullanamazsınız");
// Check if already referred
const existing = await this.db
.select()
.from(referrals)
.where(eq(referrals.referredId, userId))
const [me] = await this.db
.select({ email: users.email, emailVerified: users.emailVerified })
.from(users)
.where(eq(users.id, userId))
.limit(1);
if (!me) throw new NotFoundException("Kullanıcı bulunamadı");
if (existing.length > 0) {
throw new BadRequestException("Zaten bir referans kodu kullanılmış");
// Anti-fraud: a referrer can't refer their own alternate identity
// (same canonical email — covers gmail dot/+tag tricks).
if (normalizeEmail(me.email) === normalizeEmail(referrer.email)) {
throw new ForbiddenException("Bu referans kodu kullanılamaz");
}
// Create referral
await this.db.insert(referrals).values({
referrerId: referrer.id,
referredId: userId,
const result = await this.db.transaction(async (tx) => {
// Unique index on referred_id => a user can be referred at most once.
const [inserted] = await tx
.insert(referrals)
.values({ referrerId: referrer.id, referredId: userId, status: "pending" })
.onConflictDoNothing({ target: referrals.referredId })
.returning({ id: referrals.id });
if (!inserted) {
// Already referred (same or a different code) — stay idempotent.
return { success: true, alreadyApplied: true };
}
await tx
.update(users)
.set({ referredBy: referrer.id, updatedAt: new Date() })
.where(eq(users.id, userId));
return { success: true, alreadyApplied: false };
});
// Update referred user
await this.db
.update(users)
.set({ referredBy: referrer.id, updatedAt: new Date() })
.where(eq(users.id, userId));
// Count total referrals for reward check
const totalReferrals = await this.db
.select({ count: sql<number>`count(*)` })
.from(referrals)
.where(eq(referrals.referrerId, referrer.id));
const count = Number(totalReferrals[0]?.count || 0);
// Apply rewards
if (count === REFERRAL_REWARDS.TIER_2.count) {
await this.subscriptionsService.extendSubscription(
referrer.id,
REFERRAL_REWARDS.TIER_2.extensionDays,
);
} else if (count === REFERRAL_REWARDS.TIER_1.count) {
await this.subscriptionsService.extendSubscription(
referrer.id,
REFERRAL_REWARDS.TIER_1.extensionDays,
);
// Already-verified referees (OAuth, or applying after verifying) won't get
// an afterEmailVerification event, so qualify them right away.
if (!result.alreadyApplied && me.emailVerified) {
await this.qualifyReferral(userId);
}
return { success: true };
return result;
}
async ensureReferralCode(userId: string): Promise<string> {
const [user] = await this.db.select().from(users).where(eq(users.id, userId)).limit(1);
if (user?.referralCode) return user.referralCode;
/**
* Marks the pending referral for `referredUserId` as qualified and grants the
* referrer their milestone reward. Idempotent (guarded by referral status),
* transactional, and serialised per-referrer so milestone counts can't be
* double-counted under concurrency. Safe to call more than once.
*/
async qualifyReferral(referredUserId: string): Promise<void> {
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
// Serialise concurrent qualifications for the same referrer.
await tx
.select({ id: users.id })
.from(users)
.where(eq(users.id, referral.referrerId))
.for("update")
.limit(1);
await tx
.update(referrals)
.set({ status: "qualified", qualifiedAt: new Date(), rewardApplied: true })
.where(eq(referrals.id, referral.id));
const [row] = await tx
.select({ count: sql<number>`count(*)` })
.from(referrals)
.where(
and(eq(referrals.referrerId, referral.referrerId), eq(referrals.status, "qualified")),
);
const qualifiedCount = Number(row?.count || 0);
const rewardDays = referralRewardForCount(qualifiedCount);
if (rewardDays > 0) {
await this.grantRewardDays(tx, referral.referrerId, rewardDays);
this.logger.log(
`Referral milestone: referrer=${referral.referrerId} count=${qualifiedCount} +${rewardDays}d`,
);
}
});
}
/**
* Grants `days` of subscription time to the referrer: extends a live
* (active/trial) subscription if one exists, otherwise banks the days as
* credit to be consumed when they next start a trial / activate a plan.
*/
private async grantRewardDays(tx: Tx, userId: string, days: number): Promise<void> {
const [sub] = await tx
.select({ id: userSubscriptions.id, endDate: userSubscriptions.endDate })
.from(userSubscriptions)
.where(
and(
eq(userSubscriptions.userId, userId),
or(eq(userSubscriptions.status, "active"), eq(userSubscriptions.status, "trial")),
),
)
.orderBy(desc(userSubscriptions.endDate))
.limit(1);
if (sub?.endDate) {
const newEnd = new Date(sub.endDate);
newEnd.setDate(newEnd.getDate() + days);
await tx
.update(userSubscriptions)
.set({ endDate: newEnd, updatedAt: new Date() })
.where(eq(userSubscriptions.id, sub.id));
return;
}
await tx
.update(users)
.set({
referralCreditDays: sql`${users.referralCreditDays} + ${days}`,
updatedAt: new Date(),
})
.where(eq(users.id, userId));
}
/**
* Returns the user's referral code, generating one on the fly for the rare
* legacy account created before codes were assigned at signup.
*/
private async ensureReferralCode(userId: string): Promise<string> {
const [user] = await this.db
.select({ referralCode: users.referralCode })
.from(users)
.where(eq(users.id, userId))
.limit(1);
if (!user) throw new NotFoundException("Kullanıcı bulunamadı");
if (user.referralCode) return user.referralCode;
const code = generateReferralCode();
await this.db
.update(users)
.set({ referralCode: code, updatedAt: new Date() })
.where(eq(users.id, userId));
return code;
}
}

View File

@@ -7,12 +7,32 @@ import {
} from "@nestjs/common";
import { and, desc, eq, inArray, or } from "drizzle-orm";
import { DATABASE, type Database } from "../database/database.provider";
import { brands, plans, userBrands, userSubscriptions } from "../database/schema/core";
import { brands, plans, userBrands, userSubscriptions, users } from "../database/schema/core";
@Injectable()
export class SubscriptionsService {
constructor(@Inject(DATABASE) private db: Database) {}
/**
* Consumes any banked referral reward days for the user (zeroing the balance)
* and returns the number of days to add to a freshly started subscription.
*/
private async consumeReferralCredit(userId: string): Promise<number> {
const [u] = await this.db
.select({ credit: users.referralCreditDays })
.from(users)
.where(eq(users.id, userId))
.limit(1);
const credit = u?.credit ?? 0;
if (credit > 0) {
await this.db
.update(users)
.set({ referralCreditDays: 0, updatedAt: new Date() })
.where(eq(users.id, userId));
}
return credit;
}
async create(
userId: string,
data: { planId: string; brandIds: string[]; billingPeriod: "monthly" | "yearly" },
@@ -98,6 +118,10 @@ export class SubscriptionsService {
endDate.setMonth(endDate.getMonth() + 1);
}
// Apply any banked referral reward days on top of the paid period.
const creditDays = await this.consumeReferralCredit(sub.userId);
if (creditDays > 0) endDate.setDate(endDate.getDate() + creditDays);
// Update subscription to active
const [updated] = await this.db
.update(userSubscriptions)
@@ -295,6 +319,10 @@ export class SubscriptionsService {
const endDate = new Date(now);
endDate.setDate(endDate.getDate() + 30);
// Apply any banked referral reward days on top of the 30-day trial.
const creditDays = await this.consumeReferralCredit(userId);
if (creditDays > 0) endDate.setDate(endDate.getDate() + creditDays);
// Create trial subscription
const [subscription] = await this.db
.insert(userSubscriptions)

View File

@@ -31,6 +31,7 @@ import { Route as AuthResetPasswordRouteImport } from './routes/_auth/reset-pass
import { Route as AuthRegisterRouteImport } from './routes/_auth/register'
import { Route as AuthLoginRouteImport } from './routes/_auth/login'
import { Route as AuthForgotPasswordRouteImport } from './routes/_auth/forgot-password'
import { Route as AuthEmailVerifiedRouteImport } from './routes/_auth/email-verified'
import { Route as DashboardSubscriptionIndexRouteImport } from './routes/dashboard/subscription/index'
import { Route as DashboardCatalogIndexRouteImport } from './routes/dashboard/catalog/index'
import { Route as DashboardAdminIndexRouteImport } from './routes/dashboard/admin/index'
@@ -160,6 +161,11 @@ const AuthForgotPasswordRoute = AuthForgotPasswordRouteImport.update({
path: '/forgot-password',
getParentRoute: () => AuthRoute,
} as any)
const AuthEmailVerifiedRoute = AuthEmailVerifiedRouteImport.update({
id: '/email-verified',
path: '/email-verified',
getParentRoute: () => AuthRoute,
} as any)
const DashboardSubscriptionIndexRoute =
DashboardSubscriptionIndexRouteImport.update({
id: '/subscription/',
@@ -280,6 +286,7 @@ export interface FileRoutesByFullPath {
'/pricing': typeof PricingRoute
'/privacy': typeof PrivacyRoute
'/terms': typeof TermsRoute
'/email-verified': typeof AuthEmailVerifiedRoute
'/forgot-password': typeof AuthForgotPasswordRoute
'/login': typeof AuthLoginRoute
'/register': typeof AuthRegisterRoute
@@ -321,6 +328,7 @@ export interface FileRoutesByTo {
'/pricing': typeof PricingRoute
'/privacy': typeof PrivacyRoute
'/terms': typeof TermsRoute
'/email-verified': typeof AuthEmailVerifiedRoute
'/forgot-password': typeof AuthForgotPasswordRoute
'/login': typeof AuthLoginRoute
'/register': typeof AuthRegisterRoute
@@ -365,6 +373,7 @@ export interface FileRoutesById {
'/pricing': typeof PricingRoute
'/privacy': typeof PrivacyRoute
'/terms': typeof TermsRoute
'/_auth/email-verified': typeof AuthEmailVerifiedRoute
'/_auth/forgot-password': typeof AuthForgotPasswordRoute
'/_auth/login': typeof AuthLoginRoute
'/_auth/register': typeof AuthRegisterRoute
@@ -409,6 +418,7 @@ export interface FileRouteTypes {
| '/pricing'
| '/privacy'
| '/terms'
| '/email-verified'
| '/forgot-password'
| '/login'
| '/register'
@@ -450,6 +460,7 @@ export interface FileRouteTypes {
| '/pricing'
| '/privacy'
| '/terms'
| '/email-verified'
| '/forgot-password'
| '/login'
| '/register'
@@ -493,6 +504,7 @@ export interface FileRouteTypes {
| '/pricing'
| '/privacy'
| '/terms'
| '/_auth/email-verified'
| '/_auth/forgot-password'
| '/_auth/login'
| '/_auth/register'
@@ -696,6 +708,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AuthForgotPasswordRouteImport
parentRoute: typeof AuthRoute
}
'/_auth/email-verified': {
id: '/_auth/email-verified'
path: '/email-verified'
fullPath: '/email-verified'
preLoaderRoute: typeof AuthEmailVerifiedRouteImport
parentRoute: typeof AuthRoute
}
'/dashboard/subscription/': {
id: '/dashboard/subscription/'
path: '/subscription'
@@ -833,6 +852,7 @@ declare module '@tanstack/react-router' {
}
interface AuthRouteChildren {
AuthEmailVerifiedRoute: typeof AuthEmailVerifiedRoute
AuthForgotPasswordRoute: typeof AuthForgotPasswordRoute
AuthLoginRoute: typeof AuthLoginRoute
AuthRegisterRoute: typeof AuthRegisterRoute
@@ -840,6 +860,7 @@ interface AuthRouteChildren {
}
const AuthRouteChildren: AuthRouteChildren = {
AuthEmailVerifiedRoute: AuthEmailVerifiedRoute,
AuthForgotPasswordRoute: AuthForgotPasswordRoute,
AuthLoginRoute: AuthLoginRoute,
AuthRegisterRoute: AuthRegisterRoute,

View File

@@ -0,0 +1,43 @@
import { useAuthStore } from "@/stores/auth.store";
import { Button } from "@sase/ui";
import { Link, createFileRoute } from "@tanstack/react-router";
import { CheckCircle2 } from "lucide-react";
export const Route = createFileRoute("/_auth/email-verified")({
component: EmailVerifiedPage,
});
function EmailVerifiedPage() {
const user = useAuthStore((s) => s.user);
const isLoading = useAuthStore((s) => s.isLoading);
const isAuthed = !isLoading && !!user;
return (
<div className="space-y-8 text-center">
<div className="flex flex-col items-center gap-4">
<div className="flex size-16 items-center justify-center rounded-full bg-brand/10">
<CheckCircle2 className="size-9 text-brand" />
</div>
<div className="space-y-2">
<h1 className="text-2xl font-bold tracking-tight sm:text-3xl">E-postanız doğrulandı</h1>
<p className="text-sm text-muted-foreground">
Hesabınız başarıyla doğrulandı. Artık tüm özellikleri kullanabilirsiniz.
</p>
</div>
</div>
{isAuthed ? (
<Button asChild className="w-full">
<Link to="/dashboard/search">Aramaya Başla</Link>
</Button>
) : (
<div className="space-y-3">
<Button asChild className="w-full">
<Link to="/login">Giriş Yap</Link>
</Button>
<p className="text-xs text-muted-foreground">Devam etmek için hesabınıza giriş yapın.</p>
</div>
)}
</div>
);
}

View File

@@ -1,4 +1,3 @@
import { api } from "@/lib/api-client";
import { signIn, signUp } from "@/lib/auth-client";
import { startAction } from "@/lib/faro";
import { track as trackMeta } from "@/lib/meta-pixel";
@@ -44,13 +43,9 @@ function RegisterPage() {
await signUp.email({ name, email, password, callbackURL: redirectUrl });
capture("user_signed_up", { method: "email" });
trackMeta("CompleteRegistration", { method: "email" });
if (refCode.trim()) {
try {
await api.post("/referrals/apply", { code: refCode.trim().toUpperCase() });
} catch {
// Geçersiz/kullanılmış kod — sessizce geç
}
}
// The referral code travels via `?ref=` in redirectUrl; the welcome
// onboarding modal on the search page is the single place that applies it
// (covers both email and Google OAuth signups).
toast.success("Hesap oluşturuldu!");
window.location.href = redirectUrl;
} catch {

View File

@@ -174,8 +174,6 @@ export function SubscriptionPage() {
// ─── Search-param-driven flags (read once) ─────────────────────────────────
const [welcome] = useState(() => search.welcome === "1");
const [initialRef] = useState(() => search.ref ?? null);
const hasAppliedRefRef = useRef(false);
const stripeResultRef = useRef(false);
// ─── Stepper state ─────────────────────────────────────────────────────────
@@ -299,12 +297,6 @@ export function SubscriptionPage() {
}
}, [subscription, currentPlanKey]);
useEffect(() => {
if (!initialRef || !subData || hasAppliedRefRef.current) return;
hasAppliedRefRef.current = true;
api.post("/referrals/apply", { code: initialRef.toUpperCase().trim() }).catch(() => {});
}, [initialRef, subData]);
useEffect(() => {
if (!welcome || !subData || hasFiredRef.current) return;
if (!eligibleForTrial) {

View File

@@ -28,9 +28,36 @@ export const PLANS = {
/** Sentinel value representing unlimited brand access for display purposes. */
export const FULL_PLAN_BRAND_LIMIT = 999;
/**
* Referrer reward milestones. Each time the referrer's *qualified* referral
* count reaches a multiple of `every`, they earn `days` of subscription time.
* Milestones are independent and recurring — they stack on a shared multiple
* (e.g. count 15 fires both the every-3 and every-5 milestone). A referral
* qualifies only once the referred user verifies their email.
*/
export const REFERRAL_REWARDS = {
TIER_1: { count: 3, extensionDays: 7 },
TIER_2: { count: 5, extensionDays: 30 },
MILESTONES: [
{ every: 3, days: 7 },
{ every: 5, days: 14 },
],
} as const;
/** Reward days earned exactly when the qualified count reaches `count`. */
export function referralRewardForCount(count: number): number {
if (count <= 0) return 0;
return REFERRAL_REWARDS.MILESTONES.reduce(
(sum, m) => (count % m.every === 0 ? sum + m.days : sum),
0,
);
}
/** Cumulative reward days earned across all referrals up to `count`. */
export function referralTotalRewardDays(count: number): number {
if (count <= 0) return 0;
return REFERRAL_REWARDS.MILESTONES.reduce(
(sum, m) => sum + Math.floor(count / m.every) * m.days,
0,
);
}
export const CURRENCY = "TRY" as const;

View File

@@ -24,6 +24,9 @@ import {
formatDateTime,
slugify,
generateReferralCode,
normalizeEmail,
referralRewardForCount,
referralTotalRewardDays,
// Constants
VIN_REGEX,
EMAIL_REGEX,
@@ -583,6 +586,71 @@ describe("generateReferralCode", () => {
});
});
describe("normalizeEmail", () => {
it("lowercases and trims", () => {
expect(normalizeEmail(" Foo@Example.COM ")).toBe("foo@example.com");
});
it("strips +tag from the local part", () => {
expect(normalizeEmail("user+promo@outlook.com")).toBe("user@outlook.com");
});
it("keeps dots for non-gmail domains", () => {
expect(normalizeEmail("a.b@outlook.com")).toBe("a.b@outlook.com");
});
it("strips dots and +tag for gmail", () => {
expect(normalizeEmail("a.b+x@Gmail.com")).toBe("ab@gmail.com");
expect(normalizeEmail("ab@googlemail.com")).toBe("ab@googlemail.com");
});
it("collapses gmail dot/+ variants to the same identity", () => {
expect(normalizeEmail("john.doe+a@gmail.com")).toBe(normalizeEmail("johndoe@gmail.com"));
});
});
describe("referralRewardForCount (per-milestone, every 3 → 7d, every 5 → 14d)", () => {
it("grants nothing below the first milestone", () => {
expect(referralRewardForCount(0)).toBe(0);
expect(referralRewardForCount(1)).toBe(0);
expect(referralRewardForCount(2)).toBe(0);
});
it("grants 7 on every third referral", () => {
expect(referralRewardForCount(3)).toBe(7);
expect(referralRewardForCount(6)).toBe(7);
expect(referralRewardForCount(9)).toBe(7);
});
it("grants 14 on every fifth referral", () => {
expect(referralRewardForCount(5)).toBe(14);
expect(referralRewardForCount(10)).toBe(14);
});
it("stacks both milestones on a shared multiple", () => {
expect(referralRewardForCount(15)).toBe(21);
expect(referralRewardForCount(30)).toBe(21);
});
it("grants nothing between milestones", () => {
expect(referralRewardForCount(4)).toBe(0);
expect(referralRewardForCount(7)).toBe(0);
});
});
describe("referralTotalRewardDays (cumulative)", () => {
it("is 0 at zero referrals", () => {
expect(referralTotalRewardDays(0)).toBe(0);
});
it("accumulates across milestones", () => {
expect(referralTotalRewardDays(3)).toBe(7);
expect(referralTotalRewardDays(5)).toBe(21); // 1×7 + 1×14
expect(referralTotalRewardDays(6)).toBe(28); // 2×7 + 1×14
expect(referralTotalRewardDays(15)).toBe(77); // 5×7 + 3×14
});
});
// --------------- constants/regex ---------------
describe("VIN_REGEX", () => {
@@ -656,14 +724,11 @@ describe("FULL_PLAN_BRAND_LIMIT", () => {
});
describe("REFERRAL_REWARDS", () => {
it("TIER_1 requires 3 referrals for 7 extension days", () => {
expect(REFERRAL_REWARDS.TIER_1.count).toBe(3);
expect(REFERRAL_REWARDS.TIER_1.extensionDays).toBe(7);
});
it("TIER_2 requires 5 referrals for 30 extension days", () => {
expect(REFERRAL_REWARDS.TIER_2.count).toBe(5);
expect(REFERRAL_REWARDS.TIER_2.extensionDays).toBe(30);
it("defines recurring milestones: every 3 → 7 days, every 5 → 14 days", () => {
expect(REFERRAL_REWARDS.MILESTONES).toEqual([
{ every: 3, days: 7 },
{ every: 5, days: 14 },
]);
});
});

View File

@@ -44,7 +44,14 @@ export { paginationSchema } from "./schemas/pagination.js";
// Constants
export { VIN_REGEX, EMAIL_REGEX, OEM_CODE_REGEX } from "./constants/regex.js";
export { PLANS, REFERRAL_REWARDS, CURRENCY, FULL_PLAN_BRAND_LIMIT } from "./constants/plans.js";
export {
PLANS,
REFERRAL_REWARDS,
CURRENCY,
FULL_PLAN_BRAND_LIMIT,
referralRewardForCount,
referralTotalRewardDays,
} from "./constants/plans.js";
export { ERROR_CODES } from "./constants/error-codes.js";
export type { ErrorCode } from "./constants/error-codes.js";
@@ -62,4 +69,5 @@ export {
formatDateTime,
slugify,
generateReferralCode,
normalizeEmail,
} from "./utils/formatters.js";

View File

@@ -35,6 +35,25 @@ export function slugify(text: string): string {
.replace(/^-|-$/g, "");
}
/**
* Canonicalises an email for "same person" comparison (referral abuse checks).
* Lowercases, and for Gmail/Googlemail strips dots and any `+tag` from the
* local part — so `a.b+x@gmail.com` and `ab@gmail.com` collapse to one identity.
*/
export function normalizeEmail(email: string): string {
const trimmed = email.trim().toLowerCase();
const at = trimmed.lastIndexOf("@");
if (at === -1) return trimmed;
let local = trimmed.slice(0, at);
const domain = trimmed.slice(at + 1);
const plus = local.indexOf("+");
if (plus !== -1) local = local.slice(0, plus);
if (domain === "gmail.com" || domain === "googlemail.com") {
local = local.replace(/\./g, "");
}
return `${local}@${domain}`;
}
export function generateReferralCode(): string {
const chars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
let code = "";