feat(referrals): rework reward engine + email-verified landing
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
Reward engine: - Recurring milestones (every 3 → +7d, every 5 → +14d) instead of one-time tiers capped at 5; idempotent + transactional grants serialised per referrer so concurrent qualifications can't double-count. - Rewards now gated on the referred user's email verification (afterEmailVerification hook); already-verified referees (OAuth) qualify at apply time. - Reward days banked as users.referral_credit_days when the referrer has no live subscription, consumed on next trial start / activation (no more silently lost rewards). - Accurate cumulative rewardDays in stats; getMyReferrals returns referee name/masked email/status. Hardening / cleanup: - onConflictDoNothing makes apply idempotent (no unhandled unique violation). - Anti-fraud: normalizeEmail blocks self-referral via gmail dot/+tag aliases. - Collision-safe referral code generation at signup. - Single apply path (welcome onboarding modal); removed duplicate calls in register + subscription pages. Input validation on the apply code. Email verification UX: - Verification link now lands on a dedicated /email-verified confirmation page instead of the deep-linked VIN/search page. Schema: referrals.status + qualified_at, users.referral_credit_days (0009). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -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),
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user