feat(referrals): rework reward engine + email-verified landing
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:
2026-05-25 13:28:32 +03:00
parent 125c6fc431
commit 3926e276fd
18 changed files with 6260 additions and 392 deletions

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