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:
@@ -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) => [
|
||||
|
||||
Reference in New Issue
Block a user