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

@@ -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) => [