feat(auth): add fraud detection, cron jobs, and admin endpoints to referral system

- Add FraudDetectionService with IP/device fingerprinting, velocity checks,
  email pattern detection, and review queue management
- Add ReferralCronService for retention checks (hourly), daily stats
  aggregation, rate limit cleanup, and weekly tier recalculation
- Add ReferralsAdminController with endpoints for review queue,
  fraud patterns, and user referral management
- Integrate referral initialization into user registration flow
  (auto-create referral code, initialize tier, apply referral code)
- Add @nestjs/schedule dependency for cron jobs
- Export referrals schema from db/schema/index.ts

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Till-JS 2025-12-07 16:09:39 +01:00
parent e3ba35b20e
commit 6d918315c7
13 changed files with 1615 additions and 508 deletions

View file

@ -24,6 +24,7 @@
"@nestjs/config": "^3.3.0",
"@nestjs/core": "^10.4.15",
"@nestjs/platform-express": "^10.4.15",
"@nestjs/schedule": "^4.1.2",
"@nestjs/throttler": "^6.2.1",
"bcrypt": "^5.1.1",
"better-auth": "^1.4.3",

View file

@ -6,6 +6,7 @@ import configuration from './config/configuration';
import { AuthModule } from './auth/auth.module';
import { CreditsModule } from './credits/credits.module';
import { FeedbackModule } from './feedback/feedback.module';
import { ReferralsModule } from './referrals/referrals.module';
import { SettingsModule } from './settings/settings.module';
import { AiModule } from './ai/ai.module';
import { HealthModule } from './health/health.module';
@ -28,6 +29,7 @@ import { HttpExceptionFilter } from './common/filters/http-exception.filter';
CreditsModule,
FeedbackModule,
HealthModule,
ReferralsModule,
SettingsModule,
],
providers: [

View file

@ -1,8 +1,10 @@
import { Module } from '@nestjs/common';
import { Module, forwardRef } from '@nestjs/common';
import { AuthController } from './auth.controller';
import { BetterAuthService } from './services/better-auth.service';
import { ReferralsModule } from '../referrals/referrals.module';
@Module({
imports: [forwardRef(() => ReferralsModule)],
controllers: [AuthController],
providers: [BetterAuthService],
exports: [BetterAuthService],

View file

@ -19,12 +19,18 @@ import {
NotFoundException,
ForbiddenException,
UnauthorizedException,
Inject,
forwardRef,
Optional,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { createBetterAuth } from '../better-auth.config';
import type { BetterAuthInstance } from '../better-auth.config';
import { getDb } from '../../db/connection';
import { balances, organizationBalances } from '../../db/schema/credits.schema';
import { ReferralCodeService } from '../../referrals/services/referral-code.service';
import { ReferralTierService } from '../../referrals/services/referral-tier.service';
import { ReferralTrackingService } from '../../referrals/services/referral-tracking.service';
import { hasUser, hasToken, hasMember, hasMembers, hasSession } from '../types/better-auth.types';
import type {
RegisterB2CDto,
@ -91,7 +97,18 @@ export class BetterAuthService {
return this.auth.api as unknown as BetterAuthAPI;
}
constructor(private configService: ConfigService) {
constructor(
private configService: ConfigService,
@Optional()
@Inject(forwardRef(() => ReferralCodeService))
private referralCodeService: ReferralCodeService,
@Optional()
@Inject(forwardRef(() => ReferralTierService))
private referralTierService: ReferralTierService,
@Optional()
@Inject(forwardRef(() => ReferralTrackingService))
private referralTrackingService: ReferralTrackingService
) {
this.databaseUrl = this.configService.get<string>('database.url')!;
this.auth = createBetterAuth(this.databaseUrl);
}
@ -127,6 +144,9 @@ export class BetterAuthService {
// Create personal credit balance
await this.createPersonalCreditBalance(user.id);
// Initialize referral system for new user
await this.initializeUserReferrals(user.id, dto.referralCode, dto.sourceAppId);
return {
user: {
id: user.id,
@ -947,4 +967,53 @@ export class BetterAuthService {
.replace(/--+/g, '-') // Replace multiple hyphens with single
.trim();
}
/**
* Initialize referral system for a new user
*
* This method:
* 1. Creates an automatic referral code for the new user
* 2. Initializes the user's tier (bronze)
* 3. If a referral code was used, applies the referral relationship
*
* @param userId - The new user's ID
* @param referralCode - Optional referral code used during signup
* @param sourceAppId - Optional app ID where the user registered
* @private
*/
private async initializeUserReferrals(
userId: string,
referralCode?: string,
sourceAppId?: string
): Promise<void> {
// Skip if referral services are not available
if (!this.referralCodeService || !this.referralTierService) {
return;
}
try {
// 1. Create automatic referral code for the new user
await this.referralCodeService.createAutoCode(userId);
// 2. Initialize user's tier (starts at bronze)
await this.referralTierService.initializeUserTier(userId);
// 3. If a referral code was provided, apply the referral relationship
if (referralCode && this.referralTrackingService) {
// The applyReferral method handles validation internally
const result = await this.referralTrackingService.applyReferral({
refereeId: userId,
code: referralCode,
sourceAppId: sourceAppId || 'manacore',
});
if (!result.success) {
console.warn('[initializeUserReferrals] Failed to apply referral code:', result.error);
}
}
} catch (error) {
// Log but don't fail registration if referral setup fails
console.error('[initializeUserReferrals] Error setting up referrals:', error);
}
}
}

View file

@ -374,6 +374,8 @@ export interface RegisterB2CDto {
email: string;
password: string;
name: string;
referralCode?: string;
sourceAppId?: string;
}
/**

View file

@ -2,3 +2,4 @@ export * from './auth.schema';
export * from './credits.schema';
export * from './feedback.schema';
export * from './organizations.schema';
export * from './referrals.schema';

View file

@ -0,0 +1,193 @@
/**
* Referrals Admin Controller
*
* Admin-only endpoints for managing the referral system:
* - Review queue management
* - Fraud pattern management
* - Statistics and reporting
*/
import { Controller, Get, Post, Body, Param, Query, HttpCode, HttpStatus } from '@nestjs/common';
import { FraudDetectionService } from './services/fraud-detection.service';
import { ReferralTrackingService } from './services/referral-tracking.service';
// DTOs for admin endpoints
class ProcessReviewDto {
decision: 'approved' | 'rejected';
reviewerId: string;
notes?: string;
}
class AddFraudPatternDto {
patternType: 'email_domain' | 'ip_range' | 'device_pattern';
patternValue: string;
severity: 'low' | 'medium' | 'high' | 'critical';
scoreImpact: number;
description: string;
createdBy: string;
}
class PaginationQuery {
limit?: string;
offset?: string;
}
// Note: In production, add proper auth guard
// @UseGuards(AdminAuthGuard)
@Controller('referrals/admin')
export class ReferralsAdminController {
constructor(
private fraudDetectionService: FraudDetectionService,
private trackingService: ReferralTrackingService
) {}
// ===================================
// REVIEW QUEUE ENDPOINTS
// ===================================
/**
* Get pending review items
* GET /referrals/admin/reviews
*/
@Get('reviews')
async getPendingReviews(@Query() query: PaginationQuery) {
const limit = parseInt(query.limit || '50', 10);
const offset = parseInt(query.offset || '0', 10);
const reviews = await this.fraudDetectionService.getPendingReviews(limit, offset);
return {
items: reviews,
pagination: {
limit,
offset,
},
};
}
/**
* Process a review decision
* POST /referrals/admin/reviews/:id/process
*/
@Post('reviews/:id/process')
@HttpCode(HttpStatus.OK)
async processReview(@Param('id') reviewId: string, @Body() dto: ProcessReviewDto) {
await this.fraudDetectionService.processReview(
reviewId,
dto.decision,
dto.reviewerId,
dto.notes
);
return {
success: true,
message: `Review ${dto.decision}`,
};
}
// ===================================
// FRAUD PATTERN ENDPOINTS
// ===================================
/**
* Add a new fraud pattern
* POST /referrals/admin/fraud-patterns
*/
@Post('fraud-patterns')
@HttpCode(HttpStatus.CREATED)
async addFraudPattern(@Body() dto: AddFraudPatternDto) {
await this.fraudDetectionService.addFraudPattern(
dto.patternType,
dto.patternValue,
dto.severity,
dto.scoreImpact,
dto.description,
dto.createdBy
);
return {
success: true,
message: 'Fraud pattern added',
};
}
// ===================================
// STATISTICS ENDPOINTS
// ===================================
/**
* Get fraud statistics
* GET /referrals/admin/stats/fraud
*/
@Get('stats/fraud')
async getFraudStats() {
return this.fraudDetectionService.getFraudStats();
}
/**
* Get overall referral statistics
* GET /referrals/admin/stats/overview
*/
@Get('stats/overview')
async getOverviewStats() {
return {
message: 'Overview stats endpoint - to be implemented with aggregated data',
};
}
// ===================================
// USER MANAGEMENT ENDPOINTS
// ===================================
/**
* Get referral details for a specific user
* GET /referrals/admin/users/:userId/referrals
*/
@Get('users/:userId/referrals')
async getUserReferrals(
@Param('userId') userId: string,
@Query('status') status: string | undefined,
@Query() query: PaginationQuery
) {
const limit = parseInt(query.limit || '50', 10);
const offset = parseInt(query.offset || '0', 10);
const result = await this.trackingService.getReferredUsers(userId, status, limit, offset);
return result;
}
/**
* Get referral stats for a specific user
* GET /referrals/admin/users/:userId/stats
*/
@Get('users/:userId/stats')
async getUserStats(@Param('userId') userId: string) {
return this.trackingService.getReferralStats(userId);
}
// ===================================
// MANUAL ACTIONS
// ===================================
/**
* Manually trigger stage update (for support/admin use)
* POST /referrals/admin/manual/stage-update
*/
@Post('manual/stage-update')
@HttpCode(HttpStatus.OK)
async manualStageUpdate(
@Body() dto: { userId: string; stage: 'activated' | 'qualified'; appId?: string }
) {
if (dto.stage === 'activated') {
await this.trackingService.checkActivation(dto.userId);
} else if (dto.stage === 'qualified') {
await this.trackingService.checkQualification(dto.userId);
}
return {
success: true,
message: `Stage update processed for user ${dto.userId}`,
};
}
}

View file

@ -6,19 +6,35 @@
* - Referral code management
* - Referral tracking and stage progression
* - Tier calculation and bonus multipliers
* - Fraud detection and prevention
*/
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { ScheduleModule } from '@nestjs/schedule';
import { ReferralsController } from './referrals.controller';
import { ReferralsAdminController } from './referrals-admin.controller';
import { ReferralCodeService } from './services/referral-code.service';
import { ReferralTierService } from './services/referral-tier.service';
import { ReferralTrackingService } from './services/referral-tracking.service';
import { FraudDetectionService } from './services/fraud-detection.service';
import { ReferralCronService } from './services/referral-cron.service';
@Module({
imports: [ConfigModule],
controllers: [ReferralsController],
providers: [ReferralCodeService, ReferralTierService, ReferralTrackingService],
exports: [ReferralCodeService, ReferralTierService, ReferralTrackingService],
imports: [ConfigModule, ScheduleModule.forRoot()],
controllers: [ReferralsController, ReferralsAdminController],
providers: [
ReferralCodeService,
ReferralTierService,
ReferralTrackingService,
FraudDetectionService,
ReferralCronService,
],
exports: [
ReferralCodeService,
ReferralTierService,
ReferralTrackingService,
FraudDetectionService,
],
})
export class ReferralsModule {}

View file

@ -0,0 +1,642 @@
/**
* Fraud Detection Service
*
* Handles fraud detection for the referral system:
* - Device fingerprinting and tracking
* - IP address analysis
* - Pattern detection (velocity, clusters)
* - Fraud scoring
* - Auto-hold and review queue management
*/
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { eq, and, sql, gte, count, desc, or } from 'drizzle-orm';
import { getDb } from '../../db/connection';
import {
fingerprints,
userFingerprints,
fraudPatterns,
rateLimits,
reviewQueue,
relationships,
FRAUD_THRESHOLDS,
FRAUD_SIGNALS,
RATE_LIMITS,
type ReviewQueueItem,
} from '../../db/schema/referrals.schema';
import * as crypto from 'crypto';
/**
* Fraud check input data
*/
export interface FraudCheckInput {
userId: string;
referrerId?: string;
ipAddress?: string;
userAgent?: string;
deviceFingerprint?: string;
email?: string;
}
/**
* Fraud check result
*/
export interface FraudCheckResult {
score: number;
signals: string[];
action: 'allow' | 'hold' | 'reject';
holdReason?: string;
}
/**
* Fingerprint data for storage
*/
export interface FingerprintData {
ipAddress: string;
deviceHash?: string;
userAgent?: string;
}
@Injectable()
export class FraudDetectionService {
private readonly logger = new Logger(FraudDetectionService.name);
constructor(private configService: ConfigService) {}
private getDb() {
const databaseUrl = this.configService.get<string>('database.url');
return getDb(databaseUrl!);
}
/**
* Hash a value for privacy (GDPR compliance)
*/
private hashValue(value: string): string {
return crypto.createHash('sha256').update(value).digest('hex');
}
/**
* Perform comprehensive fraud check for a referral
*/
async checkFraud(input: FraudCheckInput): Promise<FraudCheckResult> {
const signals: string[] = [];
let score = 0;
try {
// 1. Check IP-based signals
if (input.ipAddress) {
const ipSignals = await this.checkIpSignals(input.ipAddress, input.referrerId);
signals.push(...ipSignals.signals);
score += ipSignals.score;
}
// 2. Check device fingerprint signals
if (input.deviceFingerprint) {
const fpSignals = await this.checkFingerprintSignals(
input.deviceFingerprint,
input.referrerId
);
signals.push(...fpSignals.signals);
score += fpSignals.score;
}
// 3. Check referrer velocity (too many referrals too fast)
if (input.referrerId) {
const velocitySignals = await this.checkReferrerVelocity(input.referrerId);
signals.push(...velocitySignals.signals);
score += velocitySignals.score;
}
// 4. Check email patterns
if (input.email) {
const emailSignals = this.checkEmailPatterns(input.email);
signals.push(...emailSignals.signals);
score += emailSignals.score;
}
// 5. Check for known fraud patterns
const patternSignals = await this.checkKnownPatterns(input);
signals.push(...patternSignals.signals);
score += patternSignals.score;
// Determine action based on score
let action: 'allow' | 'hold' | 'reject' = 'allow';
let holdReason: string | undefined;
if (score >= FRAUD_THRESHOLDS.critical) {
action = 'reject';
} else if (score >= FRAUD_THRESHOLDS.highRisk) {
action = 'hold';
holdReason = signals.join(', ');
}
this.logger.debug(
`Fraud check for user ${input.userId}: score=${score}, action=${action}, signals=${signals.join(', ')}`
);
return { score, signals, action, holdReason };
} catch (error) {
this.logger.error('Error during fraud check:', error);
// On error, allow but flag for review
return {
score: FRAUD_THRESHOLDS.highRisk,
signals: ['check_error'],
action: 'hold',
holdReason: 'Fraud check encountered an error',
};
}
}
/**
* Check IP-based fraud signals
*/
private async checkIpSignals(
ipAddress: string,
referrerId?: string
): Promise<{ score: number; signals: string[] }> {
const db = this.getDb();
const signals: string[] = [];
let score = 0;
const ipHash = this.hashValue(ipAddress);
// Check how many users registered from this IP
const [ipCount] = await db
.select({ count: count() })
.from(fingerprints)
.where(eq(fingerprints.ipHash, ipHash));
if (ipCount.count >= 5) {
signals.push('same_ip');
score += FRAUD_SIGNALS.same_ip;
}
// Check if IP was used by referrer
if (referrerId) {
const [referrerIP] = await db
.select()
.from(userFingerprints)
.innerJoin(fingerprints, eq(userFingerprints.fingerprintId, fingerprints.id))
.where(and(eq(userFingerprints.userId, referrerId), eq(fingerprints.ipHash, ipHash)))
.limit(1);
if (referrerIP) {
signals.push('same_ip');
score += FRAUD_SIGNALS.same_ip;
}
}
// Check if IP is from known proxy/VPN ranges
if (this.isProxyIP(ipAddress)) {
signals.push('vpn_proxy');
score += FRAUD_SIGNALS.vpn_proxy;
}
return { score, signals };
}
/**
* Check device fingerprint signals
*/
private async checkFingerprintSignals(
deviceHash: string,
referrerId?: string
): Promise<{ score: number; signals: string[] }> {
const db = this.getDb();
const signals: string[] = [];
let score = 0;
// Check how many users share this device
const [fpCount] = await db
.select({ count: count() })
.from(userFingerprints)
.innerJoin(fingerprints, eq(userFingerprints.fingerprintId, fingerprints.id))
.where(eq(fingerprints.deviceHash, deviceHash));
if (fpCount.count >= 3) {
signals.push('same_device');
score += FRAUD_SIGNALS.same_device;
}
// Check if device was used by referrer
if (referrerId) {
const [referrerDevice] = await db
.select()
.from(userFingerprints)
.innerJoin(fingerprints, eq(userFingerprints.fingerprintId, fingerprints.id))
.where(
and(eq(userFingerprints.userId, referrerId), eq(fingerprints.deviceHash, deviceHash))
)
.limit(1);
if (referrerDevice) {
signals.push('same_device');
score += FRAUD_SIGNALS.same_device;
}
}
return { score, signals };
}
/**
* Check referrer velocity (too many referrals too fast)
*/
private async checkReferrerVelocity(
referrerId: string
): Promise<{ score: number; signals: string[] }> {
const db = this.getDb();
const signals: string[] = [];
let score = 0;
const oneDayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000);
// Check referrals in last day
const [dailyCount] = await db
.select({ count: count() })
.from(relationships)
.where(
and(eq(relationships.referrerId, referrerId), gte(relationships.createdAt, oneDayAgo))
);
if (dailyCount.count >= RATE_LIMITS.registrationsPerReferrer.limit) {
signals.push('rapid_registration');
score += FRAUD_SIGNALS.rapid_registration;
}
if (dailyCount.count >= 10) {
signals.push('bulk_registrations');
score += FRAUD_SIGNALS.bulk_registrations;
}
return { score, signals };
}
/**
* Check email patterns for fraud indicators
*/
private checkEmailPatterns(email: string): { score: number; signals: string[] } {
const signals: string[] = [];
let score = 0;
const lowerEmail = email.toLowerCase();
// Check for disposable email domains
const disposableDomains = [
'tempmail.com',
'throwaway.com',
'guerrillamail.com',
'10minutemail.com',
'mailinator.com',
'yopmail.com',
'fakeinbox.com',
'trashmail.com',
];
const domain = lowerEmail.split('@')[1];
if (disposableDomains.some((d) => domain?.includes(d))) {
signals.push('disposable_email');
score += FRAUD_SIGNALS.disposable_email;
}
// Check for plus-addressing pattern abuse (test+1@gmail.com)
if (lowerEmail.includes('+') && /\+\d+@/.test(lowerEmail)) {
signals.push('similar_email');
score += FRAUD_SIGNALS.similar_email;
}
return { score, signals };
}
/**
* Check for known fraud patterns in database
*/
private async checkKnownPatterns(
input: FraudCheckInput
): Promise<{ score: number; signals: string[] }> {
const db = this.getDb();
const signals: string[] = [];
let score = 0;
if (!input.email) {
return { score, signals };
}
const domain = input.email.split('@')[1];
if (!domain) {
return { score, signals };
}
// Check for known bad email domains
const patterns = await db
.select()
.from(fraudPatterns)
.where(
and(
eq(fraudPatterns.isActive, true),
eq(fraudPatterns.patternType, 'email_domain'),
eq(fraudPatterns.patternValue, domain)
)
);
for (const pattern of patterns) {
signals.push(`known_pattern_${pattern.patternType}`);
score += pattern.scoreImpact;
}
return { score, signals };
}
/**
* Simple check for proxy/VPN IPs
*/
private isProxyIP(_ip: string): boolean {
// In production, use services like IPQualityScore, MaxMind, or IP2Proxy
// For now, return false (disabled)
return false;
}
/**
* Store device fingerprint for a user
*/
async storeFingerprint(userId: string, data: FingerprintData): Promise<void> {
const db = this.getDb();
try {
const ipHash = this.hashValue(data.ipAddress);
const deviceHash = data.deviceHash || null;
const userAgentHash = data.userAgent ? this.hashValue(data.userAgent) : null;
// Check if fingerprint already exists
let [existingFp] = await db
.select()
.from(fingerprints)
.where(
and(
eq(fingerprints.ipHash, ipHash),
deviceHash
? eq(fingerprints.deviceHash, deviceHash)
: sql`${fingerprints.deviceHash} IS NULL`
)
)
.limit(1);
if (!existingFp) {
// Create new fingerprint
[existingFp] = await db
.insert(fingerprints)
.values({
ipHash,
deviceHash,
userAgentHash,
firstSeenAt: new Date(),
lastSeenAt: new Date(),
registrationCount: 1,
})
.returning();
} else {
// Update existing
await db
.update(fingerprints)
.set({
lastSeenAt: new Date(),
registrationCount: sql`${fingerprints.registrationCount} + 1`,
})
.where(eq(fingerprints.id, existingFp.id));
}
// Link fingerprint to user (check if exists first)
const [existingLink] = await db
.select()
.from(userFingerprints)
.where(
and(
eq(userFingerprints.userId, userId),
eq(userFingerprints.fingerprintId, existingFp.id)
)
)
.limit(1);
if (!existingLink) {
await db.insert(userFingerprints).values({
userId,
fingerprintId: existingFp.id,
seenAt: new Date(),
context: 'registration',
});
}
} catch (error) {
this.logger.error('Error storing fingerprint:', error);
}
}
/**
* Add item to review queue
*/
async addToReviewQueue(
relationshipId: string,
fraudScore: number,
signals: string[],
_reason: string
): Promise<void> {
const db = this.getDb();
try {
const priority =
fraudScore >= FRAUD_THRESHOLDS.critical
? 'critical'
: fraudScore >= FRAUD_THRESHOLDS.highRisk
? 'high'
: fraudScore >= FRAUD_THRESHOLDS.mediumRisk
? 'medium'
: 'low';
await db.insert(reviewQueue).values({
relationshipId,
fraudScore,
fraudSignals: JSON.stringify(signals),
priority,
status: 'pending',
createdAt: new Date(),
});
} catch (error) {
this.logger.error('Error adding to review queue:', error);
}
}
/**
* Get pending review items
*/
async getPendingReviews(limit: number = 50, offset: number = 0): Promise<ReviewQueueItem[]> {
const db = this.getDb();
return db
.select()
.from(reviewQueue)
.where(eq(reviewQueue.status, 'pending'))
.orderBy(desc(reviewQueue.fraudScore), reviewQueue.createdAt)
.limit(limit)
.offset(offset);
}
/**
* Process review decision
*/
async processReview(
reviewId: string,
decision: 'approved' | 'rejected',
_reviewerId: string,
notes?: string
): Promise<void> {
const db = this.getDb();
await db
.update(reviewQueue)
.set({
status: decision,
reviewedAt: new Date(),
notes,
})
.where(eq(reviewQueue.id, reviewId));
// Get review to find relationship
const [review] = await db
.select()
.from(reviewQueue)
.where(eq(reviewQueue.id, reviewId))
.limit(1);
if (!review) return;
if (decision === 'approved') {
// Reset fraud score
await db
.update(relationships)
.set({ fraudScore: 0, isFlagged: false })
.where(eq(relationships.id, review.relationshipId));
} else if (decision === 'rejected') {
// Mark as fraudulent
await db
.update(relationships)
.set({ fraudScore: 100, isFlagged: true })
.where(eq(relationships.id, review.relationshipId));
}
}
/**
* Add a fraud pattern to the database
*/
async addFraudPattern(
patternType: 'email_domain' | 'ip_range' | 'device_pattern',
patternValue: string,
severity: 'low' | 'medium' | 'high' | 'critical',
scoreImpact: number,
description: string,
createdBy: string
): Promise<void> {
const db = this.getDb();
await db.insert(fraudPatterns).values({
patternType,
patternValue,
severity,
scoreImpact,
description,
createdBy,
isActive: true,
createdAt: new Date(),
});
}
/**
* Check rate limit for an action
*/
async checkRateLimit(
identifier: string,
identifierType: string,
action: string,
limit: number,
windowMinutes: number
): Promise<{ allowed: boolean; remaining: number }> {
const db = this.getDb();
const windowStart = new Date(Date.now() - windowMinutes * 60 * 1000);
const windowEnd = new Date(Date.now() + windowMinutes * 60 * 1000);
const [record] = await db
.select()
.from(rateLimits)
.where(
and(
eq(rateLimits.identifier, identifier),
eq(rateLimits.identifierType, identifierType),
eq(rateLimits.action, action),
gte(rateLimits.windowStart, windowStart)
)
)
.limit(1);
if (!record) {
// Create new rate limit record
await db.insert(rateLimits).values({
identifier,
identifierType,
action,
count: 1,
windowStart: new Date(),
windowEnd,
});
return { allowed: true, remaining: limit - 1 };
}
if (record.count >= limit) {
return { allowed: false, remaining: 0 };
}
// Increment count
await db
.update(rateLimits)
.set({ count: record.count + 1 })
.where(eq(rateLimits.id, record.id));
return { allowed: true, remaining: limit - record.count - 1 };
}
/**
* Get fraud statistics for admin dashboard
*/
async getFraudStats(): Promise<{
pendingReviews: number;
rejectedToday: number;
flaggedReferrals: number;
}> {
const db = this.getDb();
const today = new Date();
today.setHours(0, 0, 0, 0);
// Count pending reviews
const [pendingCount] = await db
.select({ count: count() })
.from(reviewQueue)
.where(eq(reviewQueue.status, 'pending'));
// Count rejected today
const [rejectedCount] = await db
.select({ count: count() })
.from(reviewQueue)
.where(and(eq(reviewQueue.status, 'rejected'), gte(reviewQueue.reviewedAt, today)));
// Count flagged referrals
const [flaggedCount] = await db
.select({ count: count() })
.from(relationships)
.where(eq(relationships.isFlagged, true));
return {
pendingReviews: pendingCount.count,
rejectedToday: rejectedCount.count,
flaggedReferrals: flaggedCount.count,
};
}
}

View file

@ -1,3 +1,5 @@
export { ReferralCodeService } from './referral-code.service';
export { ReferralTierService } from './referral-tier.service';
export { ReferralTrackingService } from './referral-tracking.service';
export { FraudDetectionService } from './fraud-detection.service';
export { ReferralCronService } from './referral-cron.service';

View file

@ -0,0 +1,327 @@
/**
* Referral Cron Service
*
* Handles scheduled tasks for the referral system:
* - Retention checking (30-day mark)
* - Daily statistics aggregation
* - Cleanup of expired rate limits
*/
import { Injectable, Logger } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule';
import { ConfigService } from '@nestjs/config';
import { eq, and, sql, lte, gte, count, isNull } from 'drizzle-orm';
import { getDb } from '../../db/connection';
import {
relationships,
bonusEvents,
dailyStats,
rateLimits,
userTiers,
BONUS_AMOUNTS,
TIMING_RULES,
} from '../../db/schema/referrals.schema';
import { balances } from '../../db/schema/credits.schema';
import { ReferralTierService } from './referral-tier.service';
@Injectable()
export class ReferralCronService {
private readonly logger = new Logger(ReferralCronService.name);
constructor(
private configService: ConfigService,
private tierService: ReferralTierService
) {}
private getDb() {
const databaseUrl = this.configService.get<string>('database.url');
return getDb(databaseUrl!);
}
/**
* Check for retained referrals (30 days after qualification)
* Runs every hour
*/
@Cron(CronExpression.EVERY_HOUR)
async processRetentionCheck(): Promise<void> {
this.logger.log('Starting retention check...');
const db = this.getDb();
try {
const retentionDays = TIMING_RULES.retentionCheckDays;
const retentionDate = new Date(Date.now() - retentionDays * 24 * 60 * 60 * 1000);
// Find qualified referrals that are now retained
const eligibleReferrals = await db
.select()
.from(relationships)
.where(
and(
eq(relationships.status, 'qualified'),
lte(relationships.qualifiedAt, retentionDate),
isNull(relationships.retainedAt)
)
)
.limit(100);
let processedCount = 0;
let errorCount = 0;
for (const referral of eligibleReferrals) {
try {
await this.processRetention(referral);
processedCount++;
} catch (error) {
errorCount++;
this.logger.error(`Error processing retention for referral ${referral.id}:`, error);
}
}
this.logger.log(
`Retention check complete: ${processedCount} processed, ${errorCount} errors`
);
} catch (error) {
this.logger.error('Error in retention check:', error);
}
}
/**
* Process a single retention transition
*/
private async processRetention(referral: typeof relationships.$inferSelect): Promise<void> {
const db = this.getDb();
// Get referrer's tier for multiplier
const multiplier = await this.tierService.getMultiplier(referral.referrerId);
const baseBonus = BONUS_AMOUNTS.retained.referrer;
const finalBonus = Math.round(baseBonus * multiplier);
// Get referrer's current tier
const tierInfo = await this.tierService.getUserTier(referral.referrerId);
// Update relationship to retained
await db
.update(relationships)
.set({
status: 'retained',
retainedAt: new Date(),
updatedAt: new Date(),
})
.where(eq(relationships.id, referral.id));
// Award retention bonus to referrer (if not held for fraud)
if (referral.fraudScore < 50) {
await db
.update(balances)
.set({
balance: sql`${balances.balance} + ${finalBonus}`,
totalEarned: sql`${balances.totalEarned} + ${finalBonus}`,
})
.where(eq(balances.userId, referral.referrerId));
// Record bonus event
await db.insert(bonusEvents).values({
relationshipId: referral.id,
userId: referral.referrerId,
eventType: 'retained',
creditsBase: baseBonus,
tierMultiplier: multiplier,
creditsFinal: finalBonus,
tierAtTime: tierInfo.current,
status: 'paid',
createdAt: new Date(),
});
} else {
// Record as held due to fraud score
await db.insert(bonusEvents).values({
relationshipId: referral.id,
userId: referral.referrerId,
eventType: 'retained',
creditsBase: baseBonus,
tierMultiplier: multiplier,
creditsFinal: finalBonus,
tierAtTime: tierInfo.current,
status: 'held',
holdReason: `High fraud score: ${referral.fraudScore}`,
createdAt: new Date(),
});
}
}
/**
* Aggregate daily statistics
* Runs at midnight every day
*/
@Cron(CronExpression.EVERY_DAY_AT_MIDNIGHT)
async aggregateDailyStats(): Promise<void> {
this.logger.log('Starting daily stats aggregation...');
const db = this.getDb();
try {
const yesterday = new Date();
yesterday.setDate(yesterday.getDate() - 1);
yesterday.setHours(0, 0, 0, 0);
const today = new Date();
today.setHours(0, 0, 0, 0);
// Count registrations
const [registrationsResult] = await db
.select({ count: count() })
.from(relationships)
.where(and(gte(relationships.createdAt, yesterday), lte(relationships.createdAt, today)));
// Count activations
const [activationsResult] = await db
.select({ count: count() })
.from(relationships)
.where(
and(gte(relationships.activatedAt, yesterday), lte(relationships.activatedAt, today))
);
// Count qualifications
const [qualificationsResult] = await db
.select({ count: count() })
.from(relationships)
.where(
and(gte(relationships.qualifiedAt, yesterday), lte(relationships.qualifiedAt, today))
);
// Count retentions
const [retentionsResult] = await db
.select({ count: count() })
.from(relationships)
.where(and(gte(relationships.retainedAt, yesterday), lte(relationships.retainedAt, today)));
// Sum credits paid
const [creditsPaidResult] = await db
.select({ total: sql<number>`COALESCE(SUM(${bonusEvents.creditsFinal}), 0)` })
.from(bonusEvents)
.where(
and(
eq(bonusEvents.status, 'paid'),
gte(bonusEvents.createdAt, yesterday),
lte(bonusEvents.createdAt, today)
)
);
// Sum credits held
const [creditsHeldResult] = await db
.select({ total: sql<number>`COALESCE(SUM(${bonusEvents.creditsFinal}), 0)` })
.from(bonusEvents)
.where(
and(
eq(bonusEvents.status, 'held'),
gte(bonusEvents.createdAt, yesterday),
lte(bonusEvents.createdAt, today)
)
);
// Count fraud blocked
const [fraudBlockedResult] = await db
.select({ count: count() })
.from(relationships)
.where(
and(
gte(relationships.fraudScore, 90),
gte(relationships.createdAt, yesterday),
lte(relationships.createdAt, today)
)
);
// Insert daily stats
await db.insert(dailyStats).values({
date: yesterday,
registrations: registrationsResult.count,
activations: activationsResult.count,
qualifications: qualificationsResult.count,
retentions: retentionsResult.count,
creditsPaid: creditsPaidResult.total || 0,
creditsHeld: creditsHeldResult.total || 0,
fraudBlocked: fraudBlockedResult.count,
});
this.logger.log('Daily stats aggregation complete');
} catch (error) {
this.logger.error('Error aggregating daily stats:', error);
}
}
/**
* Cleanup expired rate limits
* Runs every 6 hours
*/
@Cron(CronExpression.EVERY_6_HOURS)
async cleanupRateLimits(): Promise<void> {
this.logger.log('Cleaning up expired rate limits...');
const db = this.getDb();
try {
await db.delete(rateLimits).where(lte(rateLimits.windowEnd, new Date()));
this.logger.log('Rate limit cleanup complete');
} catch (error) {
this.logger.error('Error cleaning up rate limits:', error);
}
}
/**
* Recalculate tier standings for all users
* Runs weekly on Sunday at 3am
*/
@Cron('0 3 * * 0')
async recalculateTiers(): Promise<void> {
this.logger.log('Recalculating all user tiers...');
const db = this.getDb();
try {
// Get all user tiers
const allTiers = await db.select().from(userTiers);
let updatedCount = 0;
for (const userTier of allTiers) {
// Recalculate qualified count from relationships
const [actualCount] = await db
.select({ count: count() })
.from(relationships)
.where(
and(
eq(relationships.referrerId, userTier.userId),
eq(relationships.status, 'qualified')
)
);
// Add retained counts too
const [retainedCount] = await db
.select({ count: count() })
.from(relationships)
.where(
and(eq(relationships.referrerId, userTier.userId), eq(relationships.status, 'retained'))
);
const totalQualified = actualCount.count + retainedCount.count;
// Update if different
if (totalQualified !== userTier.qualifiedCount) {
const newTier = this.tierService.calculateTierFromCount(totalQualified);
await db
.update(userTiers)
.set({
qualifiedCount: totalQualified,
tier: newTier,
updatedAt: new Date(),
})
.where(eq(userTiers.userId, userTier.userId));
updatedCount++;
}
}
this.logger.log(`Tier recalculation complete: ${updatedCount} users updated`);
} catch (error) {
this.logger.error('Error recalculating tiers:', error);
}
}
}

View file

@ -80,12 +80,27 @@ export class ReferralTierService {
}
/**
* Get the multiplier for a given tier
* Get the multiplier for a given tier name
*/
getMultiplier(tier: TierName): number {
getMultiplierForTier(tier: TierName): number {
return TIER_CONFIG[tier]?.multiplier || 1.0;
}
/**
* Get the multiplier for a user by their ID
*/
async getMultiplier(userId: string): Promise<number> {
const db = this.getDb();
const [tier] = await db.select().from(userTiers).where(eq(userTiers.userId, userId)).limit(1);
if (!tier) {
return 1.0; // Default bronze multiplier
}
return this.getMultiplierForTier(tier.tier as TierName);
}
/**
* Calculate bonus credits with tier multiplier
*/
@ -96,7 +111,7 @@ export class ReferralTierService {
): { base: number; multiplier: number; final: number } {
const bonusConfig = BONUS_AMOUNTS[eventType];
const base = isReferrer ? bonusConfig.referrer : bonusConfig.referee;
const multiplier = isReferrer ? this.getMultiplier(tier) : 1.0; // Referee doesn't get tier bonus
const multiplier = isReferrer ? this.getMultiplierForTier(tier) : 1.0; // Referee doesn't get tier bonus
const final = Math.round(base * multiplier);
return { base, multiplier, final };
@ -203,7 +218,7 @@ export class ReferralTierService {
/**
* Calculate tier from qualified count
*/
private calculateTierFromCount(count: number): TierName {
calculateTierFromCount(count: number): TierName {
if (count >= TIER_CONFIG.platinum.minQualified) return 'platinum';
if (count >= TIER_CONFIG.gold.minQualified) return 'gold';
if (count >= TIER_CONFIG.silver.minQualified) return 'silver';