/opt/canhelp/apps/api/src/lib
Edit: /opt/canhelp/apps/api/src/lib/referral.ts (6110B)
import { eq, gte } from 'drizzle-orm'
import { db } from '../db.js'
import { users, plans, settings as settingsTable, referralRewards } from '@canhelp/db'
import { notifyReferralReward } from './notif.js'
import { emailReferralReward } from './email.js'
import { logPlanHistory } from './plan-history.js'
const TIER_ORDER: Record
= { free: 0, pro: 1, ultimate: 2 }
/** Generate a short unique referral code for a user */
export function generateReferralCode(): string {
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'
let code = ''
for (let i = 0; i < 8; i++) {
code += chars[Math.floor(Math.random() * chars.length)]
}
return code
}
/** Get current referral program settings from DB */
export async function getReferralSettings(): Promise<{
enabled: boolean
reward: { planId: string; days: number } | null
} | null> {
const rows = await db
.select()
.from(settingsTable)
.where(gte(settingsTable.key, 'referral_'))
const map: Record = {}
for (const row of rows) {
if (row.key.startsWith('referral_')) map[row.key] = row.value
}
if (map['referral_enabled'] !== 'true') return null
const planId = map['referral_plan_id'] || map['referral_customer_plan_id'] || map['referral_specialist_plan_id'] || ''
const days = Number(map['referral_days'] || map['referral_customer_days'] || map['referral_specialist_days'] || '0')
return {
enabled: true,
reward: planId && days > 0 ? { planId, days } : null,
}
}
/**
* Apply referral reward to the referrer after a new user registers.
* - Looks up referrer by referral code
* - Ensures this referee hasn't been used before
* - Assigns / extends planExpiresAt on the referrer
* - Logs to referral_rewards
*/
export async function applyReferral(refereeId: string, refCode: string): Promise {
const cfg = await getReferralSettings()
if (!cfg) return
// Find referrer
const [referrer] = await db
.select({
user: users,
planRole: plans.role,
})
.from(users)
.leftJoin(plans, eq(users.planId, plans.id))
.where(eq(users.referralCode, refCode))
.limit(1)
const referrerUser = referrer?.user
if (!referrerUser || referrerUser.id === refereeId) return
// Prevent duplicate reward for same referee
const existing = await db
.select({ id: referralRewards.id })
.from(referralRewards)
.where(eq(referralRewards.refereeId, refereeId))
.limit(1)
if (existing.length) return
const rewardCfg = cfg.reward
if (!rewardCfg) return
// Verify reward plan exists
const [rewardPlan] = await db.select().from(plans).where(eq(plans.id, rewardCfg.planId)).limit(1)
if (!rewardPlan) return
// If referrer's current plan tier >= reward plan tier — keep their plan, only extend days
let assignPlanId = rewardCfg.planId
if (referrerUser.planId) {
const [currentPlan] = await db
.select({ tier: plans.tier })
.from(plans)
.where(eq(plans.id, referrerUser.planId))
.limit(1)
if (currentPlan) {
const currentTier = TIER_ORDER[currentPlan.tier] ?? 0
const rewardTier = TIER_ORDER[rewardPlan.tier] ?? 0
if (currentTier >= rewardTier) {
assignPlanId = referrerUser.planId // don't downgrade — keep current plan
}
}
}
// Calculate new expiry (extend from current expiry or now)
const now = new Date()
const base = referrerUser.planExpiresAt && referrerUser.planExpiresAt > now
? referrerUser.planExpiresAt
: now
const newExpiry = new Date(base.getTime() + rewardCfg.days * 24 * 60 * 60 * 1000)
// Update referrer
await db
.update(users)
.set({ planId: assignPlanId, planExpiresAt: newExpiry, updatedAt: new Date() })
.where(eq(users.id, referrerUser.id))
// Update referee's referredBy (store referrer userId now)
await db
.update(users)
.set({ referredBy: referrerUser.id, updatedAt: new Date() })
.where(eq(users.id, refereeId))
// Log reward
const [reward] = await db.insert(referralRewards).values({
referrerId: referrerUser.id,
refereeId,
planId: assignPlanId,
daysAdded: rewardCfg.days,
expiresAt: newExpiry,
}).returning()
// Send in-app notification + email to referrer
// Use the name of the actually assigned plan (may differ from reward plan if referrer has higher tier)
let planName = rewardPlan.name
if (assignPlanId !== rewardCfg.planId) {
const [assignedPlan] = await db.select({ name: plans.name }).from(plans).where(eq(plans.id, assignPlanId)).limit(1)
if (assignedPlan) planName = assignedPlan.name
}
// Log plan history
logPlanHistory({
userId: referrerUser.id,
event: 'referral_reward',
planId: assignPlanId,
planName,
previousPlanId: referrerUser.planId,
previousPlanName: referrerUser.planId === assignPlanId ? planName : rewardPlan.name,
expiresAt: newExpiry,
daysAdded: rewardCfg.days,
referralRewardId: reward.id,
})
const loc = (referrerUser.locale === 'en' || referrerUser.locale === 'ru' ? referrerUser.locale : 'el') as 'el' | 'en' | 'ru'
await Promise.all([
notifyReferralReward(referrerUser.id, rewardCfg.days, planName, reward.id),
emailReferralReward({ to: referrerUser.email, name: referrerUser.name, days: rewardCfg.days, planName, locale: loc }),
])
}
/**
* Called after email verification.
* Reads the pending referral code stored in referredBy and applies the reward.
* referredBy at this point contains a raw referral code (before applyReferral resolves it to userId).
*/
export async function applyPendingReferral(userId: string): Promise {
const [referee] = await db
.select({ id: users.id, referredBy: users.referredBy })
.from(users)
.where(eq(users.id, userId))
.limit(1)
if (!referee?.referredBy) return
// If referredBy looks like a userId (already resolved), skip
// Raw ref codes are 8 chars uppercase alphanumeric; userIDs are longer
const pending = referee.referredBy
if (pending.length > 12) return // already a userId — reward already applied
await applyReferral(userId, pending)
}