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:
3
apps/api/drizzle/0009_referral_rewards_rework.sql
Normal file
3
apps/api/drizzle/0009_referral_rewards_rework.sql
Normal 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;
|
||||
5564
apps/api/drizzle/meta/0009_snapshot.json
Normal file
5564
apps/api/drizzle/meta/0009_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
@@ -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) => [
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
43
apps/web/src/routes/_auth/email-verified.tsx
Normal file
43
apps/web/src/routes/_auth/email-verified.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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 = "";
|
||||
|
||||
Reference in New Issue
Block a user