fix(manacore): auth flow and dashboard widget API fixes

Auth fixes:
- Update fetchInterceptor skip patterns for ManaCore auth endpoints
- Fix URL matching to compare full origins instead of partial matches
- Update token manager state after successful login
- Remove Supabase session dependency from layouts
- Use authStore for auth state in route layouts

Dashboard fixes:
- Add network error detection in base-client to prevent infinite retries
- Update all 9 dashboard widgets to not retry on service unavailable
- Add /api/v1 prefix to all backend service URLs (chat, calendar, contacts, todo, zitare, picture, manadeck)

Commands:
- Add dev:manacore:backends to start all 9 dashboard backends
- Add dev:manacore:full to start web + all backends together
- Update COMMANDS.md with new commands and backend port table

Auth service:
- Fix TypeScript error: crossApp → cross_app in referrals schema

🤖 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 14:44:58 +01:00
parent ee52f6c144
commit a6cc0b83aa
33 changed files with 2634 additions and 68 deletions

View file

@ -0,0 +1,522 @@
/**
* Referrals Schema
*
* Database schema for the ManaCore referral system including:
* - Referral codes (auto, custom, campaign)
* - Referral relationships and stage tracking
* - User tiers and bonus multipliers
* - Cross-app activation tracking
* - Fraud detection (fingerprints, patterns, rate limits)
* - Analytics and review queue
*/
import {
pgSchema,
uuid,
text,
timestamp,
boolean,
integer,
real,
index,
unique,
pgEnum,
} from 'drizzle-orm/pg-core';
import { users } from './auth.schema';
export const referralsSchema = pgSchema('referrals');
// ============================================
// ENUMS
// ============================================
export const referralCodeTypeEnum = pgEnum('referral_code_type', ['auto', 'custom', 'campaign']);
export const referralStatusEnum = pgEnum('referral_status', [
'registered',
'activated',
'qualified',
'retained',
]);
export const referralTierEnum = pgEnum('referral_tier', ['bronze', 'silver', 'gold', 'platinum']);
export const bonusEventTypeEnum = pgEnum('bonus_event_type', [
'registered',
'activated',
'qualified',
'retained',
'cross_app',
]);
export const bonusStatusEnum = pgEnum('bonus_status', ['pending', 'paid', 'held', 'rejected']);
export const fraudPatternTypeEnum = pgEnum('fraud_pattern_type', [
'email_domain',
'ip_range',
'device_pattern',
]);
export const fraudSeverityEnum = pgEnum('fraud_severity', ['low', 'medium', 'high', 'critical']);
export const reviewStatusEnum = pgEnum('review_status', [
'pending',
'approved',
'rejected',
'escalated',
]);
// ============================================
// CORE TABLES
// ============================================
/**
* Referral Codes
*
* Global unique codes that users can share.
* Types: auto (generated), custom (user-created), campaign (admin-created)
*/
export const codes = referralsSchema.table(
'codes',
{
id: uuid('id').primaryKey().defaultRandom(),
userId: text('user_id')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
code: text('code').notNull().unique(), // Global unique
type: referralCodeTypeEnum('type').notNull().default('auto'),
sourceAppId: text('source_app_id'), // App where code was created
isActive: boolean('is_active').default(true).notNull(),
usesCount: integer('uses_count').default(0).notNull(),
maxUses: integer('max_uses'), // NULL = unlimited
expiresAt: timestamp('expires_at', { withTimezone: true }),
metadata: text('metadata'), // JSON string for flexibility
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
},
(table) => ({
codeLookupIdx: index('codes_lookup_idx').on(table.code),
userIdx: index('codes_user_idx').on(table.userId),
activeIdx: index('codes_active_idx').on(table.isActive),
})
);
/**
* Referral Relationships
*
* Tracks the relationship between referrer and referee.
* A user can only be referred once (referee_id is unique).
*/
export const relationships = referralsSchema.table(
'relationships',
{
id: uuid('id').primaryKey().defaultRandom(),
referrerId: text('referrer_id')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
refereeId: text('referee_id')
.notNull()
.unique()
.references(() => users.id, { onDelete: 'cascade' }),
codeId: uuid('code_id')
.notNull()
.references(() => codes.id, { onDelete: 'restrict' }),
sourceAppId: text('source_app_id'), // App where referral link was used
// Stage Tracking
status: referralStatusEnum('status').notNull().default('registered'),
registeredAt: timestamp('registered_at', { withTimezone: true }).defaultNow().notNull(),
activatedAt: timestamp('activated_at', { withTimezone: true }),
qualifiedAt: timestamp('qualified_at', { withTimezone: true }),
retainedAt: timestamp('retained_at', { withTimezone: true }),
// Fraud Detection
fraudScore: integer('fraud_score').default(0).notNull(),
fraudSignals: text('fraud_signals'), // JSON array of signal names
isFlagged: boolean('is_flagged').default(false).notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
},
(table) => ({
referrerIdx: index('relationships_referrer_idx').on(table.referrerId),
refereeIdx: index('relationships_referee_idx').on(table.refereeId),
statusIdx: index('relationships_status_idx').on(table.status),
flaggedIdx: index('relationships_flagged_idx').on(table.isFlagged),
codeIdx: index('relationships_code_idx').on(table.codeId),
})
);
/**
* Cross-App Activations
*
* Tracks when a referred user uses a new app for the first time.
* One bonus per app per referral relationship.
*/
export const crossAppActivations = referralsSchema.table(
'cross_app_activations',
{
id: uuid('id').primaryKey().defaultRandom(),
relationshipId: uuid('relationship_id')
.notNull()
.references(() => relationships.id, { onDelete: 'cascade' }),
appId: text('app_id').notNull(),
activatedAt: timestamp('activated_at', { withTimezone: true }).defaultNow().notNull(),
bonusPaid: boolean('bonus_paid').default(false).notNull(),
},
(table) => ({
relationshipAppUnique: unique('cross_app_relationship_app_unique').on(
table.relationshipId,
table.appId
),
relationshipIdx: index('cross_app_relationship_idx').on(table.relationshipId),
})
);
/**
* User Tiers
*
* Tracks each user's referral tier status based on lifetime qualified referrals.
* Tiers: bronze (0-4), silver (5-14), gold (15-29), platinum (30+)
*/
export const userTiers = referralsSchema.table('user_tiers', {
userId: text('user_id')
.primaryKey()
.references(() => users.id, { onDelete: 'cascade' }),
tier: referralTierEnum('tier').notNull().default('bronze'),
qualifiedCount: integer('qualified_count').default(0).notNull(), // Lifetime qualified referrals
totalEarned: integer('total_earned').default(0).notNull(), // Lifetime credits from referrals
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
});
// ============================================
// BONUS TABLES
// ============================================
/**
* Bonus Events
*
* Audit trail of all referral bonuses.
* Links to credits.transactions for the actual credit transfer.
*/
export const bonusEvents = referralsSchema.table(
'bonus_events',
{
id: uuid('id').primaryKey().defaultRandom(),
relationshipId: uuid('relationship_id')
.notNull()
.references(() => relationships.id, { onDelete: 'cascade' }),
userId: text('user_id')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
eventType: bonusEventTypeEnum('event_type').notNull(),
appId: text('app_id'), // For cross_app events
// Credit calculation
creditsBase: integer('credits_base').notNull(), // Base credits before multiplier
tierMultiplier: real('tier_multiplier').notNull().default(1.0),
creditsFinal: integer('credits_final').notNull(), // After multiplier (rounded)
tierAtTime: referralTierEnum('tier_at_time').notNull(),
// Transaction reference (to credits.transactions)
transactionId: uuid('transaction_id'),
// Hold status for fraud detection
status: bonusStatusEnum('status').notNull().default('pending'),
holdReason: text('hold_reason'),
holdUntil: timestamp('hold_until', { withTimezone: true }),
releasedAt: timestamp('released_at', { withTimezone: true }),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
},
(table) => ({
relationshipIdx: index('bonus_events_relationship_idx').on(table.relationshipId),
userIdx: index('bonus_events_user_idx').on(table.userId),
statusIdx: index('bonus_events_status_idx').on(table.status),
eventTypeIdx: index('bonus_events_event_type_idx').on(table.eventType),
})
);
// ============================================
// FRAUD DETECTION TABLES
// ============================================
/**
* Fingerprints
*
* Stores hashed IP and device fingerprints for fraud detection.
* IPs are hashed for privacy (GDPR compliance).
*/
export const fingerprints = referralsSchema.table(
'fingerprints',
{
id: uuid('id').primaryKey().defaultRandom(),
// IP data (hashed for privacy)
ipHash: text('ip_hash').notNull(),
ipType: text('ip_type').default('unknown').notNull(), // residential, datacenter, vpn, proxy, tor
ipCountry: text('ip_country'),
ipAsn: text('ip_asn'),
// Device data
deviceHash: text('device_hash'),
userAgentHash: text('user_agent_hash'),
// Stats
firstSeenAt: timestamp('first_seen_at', { withTimezone: true }).defaultNow().notNull(),
lastSeenAt: timestamp('last_seen_at', { withTimezone: true }).defaultNow().notNull(),
registrationCount: integer('registration_count').default(0).notNull(),
flaggedCount: integer('flagged_count').default(0).notNull(),
},
(table) => ({
ipHashIdx: index('fingerprints_ip_hash_idx').on(table.ipHash),
deviceHashIdx: index('fingerprints_device_hash_idx').on(table.deviceHash),
ipDeviceUnique: unique('fingerprints_ip_device_unique').on(table.ipHash, table.deviceHash),
})
);
/**
* User Fingerprints
*
* Links users to fingerprints they've been seen from.
* Helps detect multi-account fraud.
*/
export const userFingerprints = referralsSchema.table(
'user_fingerprints',
{
userId: text('user_id')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
fingerprintId: uuid('fingerprint_id')
.notNull()
.references(() => fingerprints.id, { onDelete: 'cascade' }),
seenAt: timestamp('seen_at', { withTimezone: true }).defaultNow().notNull(),
context: text('context'), // registration, login, code_validation
},
(table) => ({
pk: unique('user_fingerprints_pk').on(table.userId, table.fingerprintId),
userIdx: index('user_fingerprints_user_idx').on(table.userId),
fingerprintIdx: index('user_fingerprints_fingerprint_idx').on(table.fingerprintId),
})
);
/**
* Fraud Patterns
*
* Admin-defined patterns for fraud detection.
* Examples: blocked email domains, suspicious IP ranges.
*/
export const fraudPatterns = referralsSchema.table(
'fraud_patterns',
{
id: uuid('id').primaryKey().defaultRandom(),
patternType: fraudPatternTypeEnum('pattern_type').notNull(),
patternValue: text('pattern_value').notNull(), // Regex or exact match
severity: fraudSeverityEnum('severity').notNull().default('medium'),
scoreImpact: integer('score_impact').notNull(), // Points added to fraud score
description: text('description'),
isActive: boolean('is_active').default(true).notNull(),
createdBy: text('created_by'), // Admin user ID
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
},
(table) => ({
activeIdx: index('fraud_patterns_active_idx').on(table.isActive),
typeIdx: index('fraud_patterns_type_idx').on(table.patternType),
})
);
/**
* Rate Limits
*
* Tracks rate limit counters for fraud prevention.
* Uses sliding window approach.
*/
export const rateLimits = referralsSchema.table(
'rate_limits',
{
id: uuid('id').primaryKey().defaultRandom(),
identifier: text('identifier').notNull(), // IP, user ID, or code
identifierType: text('identifier_type').notNull(), // ip, user, code
action: text('action').notNull(), // code_validation, code_creation, registration
count: integer('count').default(1).notNull(),
windowStart: timestamp('window_start', { withTimezone: true }).defaultNow().notNull(),
windowEnd: timestamp('window_end', { withTimezone: true }).notNull(),
},
(table) => ({
lookupIdx: index('rate_limits_lookup_idx').on(
table.identifier,
table.identifierType,
table.action
),
windowIdx: index('rate_limits_window_idx').on(table.windowEnd),
})
);
/**
* Review Queue
*
* Referrals flagged for manual review by admins.
*/
export const reviewQueue = referralsSchema.table(
'review_queue',
{
id: uuid('id').primaryKey().defaultRandom(),
relationshipId: uuid('relationship_id')
.notNull()
.references(() => relationships.id, { onDelete: 'cascade' }),
fraudScore: integer('fraud_score').notNull(),
fraudSignals: text('fraud_signals').notNull(), // JSON array
priority: fraudSeverityEnum('priority').notNull().default('medium'),
status: reviewStatusEnum('status').notNull().default('pending'),
assignedTo: text('assigned_to'), // Admin user ID
notes: text('notes'),
reviewedAt: timestamp('reviewed_at', { withTimezone: true }),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
},
(table) => ({
statusPriorityIdx: index('review_queue_status_priority_idx').on(table.status, table.priority),
relationshipIdx: index('review_queue_relationship_idx').on(table.relationshipId),
})
);
// ============================================
// ANALYTICS TABLES
// ============================================
/**
* Daily Stats
*
* Aggregated daily statistics for dashboard and reporting.
*/
export const dailyStats = referralsSchema.table(
'daily_stats',
{
id: uuid('id').primaryKey().defaultRandom(),
date: timestamp('date', { withTimezone: true }).notNull(),
appId: text('app_id'), // NULL = global
registrations: integer('registrations').default(0).notNull(),
activations: integer('activations').default(0).notNull(),
qualifications: integer('qualifications').default(0).notNull(),
retentions: integer('retentions').default(0).notNull(),
creditsPaid: integer('credits_paid').default(0).notNull(),
creditsHeld: integer('credits_held').default(0).notNull(),
fraudBlocked: integer('fraud_blocked').default(0).notNull(),
},
(table) => ({
dateAppIdx: index('daily_stats_date_app_idx').on(table.date, table.appId),
dateUnique: unique('daily_stats_date_app_unique').on(table.date, table.appId),
})
);
// ============================================
// TYPE EXPORTS
// ============================================
export type ReferralCode = typeof codes.$inferSelect;
export type NewReferralCode = typeof codes.$inferInsert;
export type ReferralRelationship = typeof relationships.$inferSelect;
export type NewReferralRelationship = typeof relationships.$inferInsert;
export type CrossAppActivation = typeof crossAppActivations.$inferSelect;
export type NewCrossAppActivation = typeof crossAppActivations.$inferInsert;
export type UserTier = typeof userTiers.$inferSelect;
export type NewUserTier = typeof userTiers.$inferInsert;
export type BonusEvent = typeof bonusEvents.$inferSelect;
export type NewBonusEvent = typeof bonusEvents.$inferInsert;
export type Fingerprint = typeof fingerprints.$inferSelect;
export type NewFingerprint = typeof fingerprints.$inferInsert;
export type UserFingerprint = typeof userFingerprints.$inferSelect;
export type NewUserFingerprint = typeof userFingerprints.$inferInsert;
export type FraudPattern = typeof fraudPatterns.$inferSelect;
export type NewFraudPattern = typeof fraudPatterns.$inferInsert;
export type RateLimit = typeof rateLimits.$inferSelect;
export type NewRateLimit = typeof rateLimits.$inferInsert;
export type ReviewQueueItem = typeof reviewQueue.$inferSelect;
export type NewReviewQueueItem = typeof reviewQueue.$inferInsert;
export type DailyStat = typeof dailyStats.$inferSelect;
export type NewDailyStat = typeof dailyStats.$inferInsert;
// Tier configuration (for use in services)
export const TIER_CONFIG = {
bronze: { minQualified: 0, maxQualified: 4, multiplier: 1.0 },
silver: { minQualified: 5, maxQualified: 14, multiplier: 1.5 },
gold: { minQualified: 15, maxQualified: 29, multiplier: 2.0 },
platinum: { minQualified: 30, maxQualified: Infinity, multiplier: 3.0 },
} as const;
// Bonus amounts (base credits before tier multiplier)
export const BONUS_AMOUNTS = {
registered: { referrer: 5, referee: 25 },
activated: { referrer: 10, referee: 0 },
qualified: { referrer: 25, referee: 0 },
retained: { referrer: 15, referee: 0 },
cross_app: { referrer: 5, referee: 0 },
} as const;
// Fraud score thresholds
export const FRAUD_THRESHOLDS = {
lowRisk: 29, // 0-29: auto-pay
mediumRisk: 59, // 30-59: 48h hold
highRisk: 89, // 60-89: manual review
critical: 90, // 90+: blocked
} as const;
// Fraud signal scores
export const FRAUD_SIGNALS = {
same_ip: 30,
same_device: 50,
disposable_email: 20,
similar_email: 25,
rapid_registration: 15,
bulk_registrations: 40,
vpn_proxy: 20,
new_account_referrer: 15,
instant_qualification: 35,
minimal_activity: 25,
} as const;
// Rate limit configurations
export const RATE_LIMITS = {
codeValidation: { limit: 20, windowMinutes: 1 },
codeCreation: { limit: 10, windowMinutes: 60 },
registrationsPerCode: { limit: 50, windowMinutes: 1440 }, // 24h
registrationsPerReferrer: { limit: 20, windowMinutes: 1440 }, // 24h
bonusClaims: { limit: 100, windowMinutes: 1440 }, // 24h
} as const;
// Timing rules (in milliseconds)
export const TIMING_RULES = {
minTimeToActivation: 5 * 60 * 1000, // 5 minutes
minTimeToQualification: 24 * 60 * 60 * 1000, // 24 hours
retentionCheckDays: 30,
fraudReviewWindowDays: 7,
} as const;
// Trackable apps for cross-app bonus
export const TRACKABLE_APPS = [
'chat',
'picture',
'presi',
'mail',
'manadeck',
'todo',
'calendar',
'contacts',
'finance',
'clock',
'zitare',
'storage',
'moodlit',
] as const;

View file

@ -0,0 +1,191 @@
/**
* Referral DTOs
*
* Data Transfer Objects for the referral system API endpoints.
*/
import { IsString, IsOptional, IsNotEmpty, Matches, MinLength, MaxLength } from 'class-validator';
// ============================================
// CODE MANAGEMENT DTOs
// ============================================
/**
* DTO for creating a custom referral code
*/
export class CreateCustomCodeDto {
@IsString()
@IsNotEmpty()
@MinLength(3)
@MaxLength(20)
@Matches(/^[A-Z0-9-]+$/, {
message: 'Code must contain only uppercase letters, numbers, and hyphens',
})
code: string;
@IsString()
@IsOptional()
sourceAppId?: string;
}
/**
* DTO for validating a referral code (public endpoint)
*/
export class ValidateCodeDto {
@IsString()
@IsNotEmpty()
code: string;
}
// ============================================
// REFERRAL TRACKING DTOs
// ============================================
/**
* DTO for applying a referral code during registration
*/
export class ApplyReferralDto {
@IsString()
@IsNotEmpty()
refereeId: string;
@IsString()
@IsNotEmpty()
code: string;
@IsString()
@IsOptional()
sourceAppId?: string;
@IsString()
@IsOptional()
ipAddress?: string;
@IsString()
@IsOptional()
deviceFingerprint?: string;
@IsString()
@IsOptional()
userAgent?: string;
}
/**
* DTO for stage update (internal service-to-service)
*/
export class StageUpdateDto {
@IsString()
@IsNotEmpty()
userId: string;
@IsString()
@IsNotEmpty()
stage: 'activated' | 'qualified';
@IsString()
@IsOptional()
appId?: string;
@IsOptional()
metadata?: Record<string, unknown>;
}
/**
* DTO for cross-app activation (internal)
*/
export class CrossAppActivationDto {
@IsString()
@IsNotEmpty()
userId: string;
@IsString()
@IsNotEmpty()
appId: string;
}
// ============================================
// RESPONSE TYPES
// ============================================
export interface CodeValidationResponse {
valid: boolean;
referrerName?: string;
bonusCredits: number;
error?: string;
}
export interface ReferralCodeResponse {
id: string;
code: string;
type: 'auto' | 'custom' | 'campaign';
isActive: boolean;
usesCount: number;
createdAt: Date;
}
export interface ApplyReferralResponse {
success: boolean;
referralId?: string;
bonusAwarded?: number;
fraudScore?: number;
error?: string;
}
export interface TierInfo {
current: 'bronze' | 'silver' | 'gold' | 'platinum';
multiplier: number;
qualifiedCount: number;
nextTier: 'silver' | 'gold' | 'platinum' | null;
nextTierAt: number | null;
progress: number;
}
export interface ReferralStats {
tier: TierInfo;
totals: {
registered: number;
activated: number;
qualified: number;
retained: number;
creditsEarned: number;
creditsPending: number;
};
byApp: Record<
string,
{
registered: number;
activated: number;
qualified: number;
credits: number;
}
>;
recentActivity: Array<{
type: string;
refereeName: string;
credits: number;
app?: string;
at: Date;
}>;
}
export interface ReferredUser {
id: string;
name: string;
status: 'registered' | 'activated' | 'qualified' | 'retained';
registeredAt: Date;
activatedAt?: Date;
qualifiedAt?: Date;
retainedAt?: Date;
appsUsed: string[];
creditsEarned: number;
isFlagged: boolean;
}
export interface PaginatedResponse<T> {
items: T[];
pagination: {
total: number;
limit: number;
offset: number;
};
}

View file

@ -0,0 +1,264 @@
/**
* Referrals Controller
*
* API endpoints for the referral system.
*
* Public endpoints:
* - GET /referrals/validate/:code - Validate a referral code
*
* Authenticated endpoints:
* - GET /referrals/codes - Get user's referral codes
* - POST /referrals/codes - Create custom code
* - DELETE /referrals/codes/:id - Deactivate a code
* - GET /referrals/stats - Get referral statistics
* - GET /referrals/referred-users - Get list of referred users
*
* Internal endpoints (service-to-service):
* - POST /referrals/internal/apply - Apply referral during registration
* - POST /referrals/internal/stage-update - Update referral stage
* - POST /referrals/internal/cross-app - Track cross-app usage
*/
import {
Controller,
Get,
Post,
Delete,
Body,
Param,
Query,
Headers,
UseGuards,
HttpCode,
HttpStatus,
BadRequestException,
} from '@nestjs/common';
import { ReferralCodeService } from './services/referral-code.service';
import { ReferralTrackingService } from './services/referral-tracking.service';
import { ReferralTierService } from './services/referral-tier.service';
import {
CreateCustomCodeDto,
ApplyReferralDto,
StageUpdateDto,
CrossAppActivationDto,
} from './dto';
// Simple auth decorator (will use actual JWT guard in production)
// For now, we'll extract userId from Authorization header
function extractUserId(authHeader?: string): string | null {
if (!authHeader) return null;
// In production, this would verify JWT and extract userId
// For now, we'll assume the header contains "Bearer <userId>" for testing
const parts = authHeader.split(' ');
if (parts.length === 2 && parts[0] === 'Bearer') {
return parts[1];
}
return null;
}
@Controller('referrals')
export class ReferralsController {
constructor(
private codeService: ReferralCodeService,
private trackingService: ReferralTrackingService,
private tierService: ReferralTierService
) {}
// ============================================
// PUBLIC ENDPOINTS
// ============================================
/**
* Validate a referral code (public)
* Used during registration to show bonus info
*/
@Get('validate/:code')
async validateCode(@Param('code') code: string) {
return this.codeService.validateCode(code);
}
// ============================================
// AUTHENTICATED ENDPOINTS
// ============================================
/**
* Get user's referral codes
*/
@Get('codes')
async getCodes(@Headers('authorization') authHeader: string) {
const userId = extractUserId(authHeader);
if (!userId) {
throw new BadRequestException('Authentication required');
}
const codes = await this.codeService.getUserCodes(userId);
const primaryCode = await this.codeService.getPrimaryCode(userId);
return {
codes,
autoCode: primaryCode,
};
}
/**
* Create a custom referral code
*/
@Post('codes')
@HttpCode(HttpStatus.CREATED)
async createCode(@Headers('authorization') authHeader: string, @Body() dto: CreateCustomCodeDto) {
const userId = extractUserId(authHeader);
if (!userId) {
throw new BadRequestException('Authentication required');
}
const code = await this.codeService.createCustomCode(userId, dto);
return {
code: {
id: code.id,
code: code.code,
type: code.type,
isActive: code.isActive,
usesCount: code.usesCount,
createdAt: code.createdAt,
},
};
}
/**
* Deactivate a referral code
*/
@Delete('codes/:id')
@HttpCode(HttpStatus.OK)
async deactivateCode(@Headers('authorization') authHeader: string, @Param('id') codeId: string) {
const userId = extractUserId(authHeader);
if (!userId) {
throw new BadRequestException('Authentication required');
}
const success = await this.codeService.deactivateCode(userId, codeId);
return { success };
}
/**
* Get referral statistics
*/
@Get('stats')
async getStats(@Headers('authorization') authHeader: string) {
const userId = extractUserId(authHeader);
if (!userId) {
throw new BadRequestException('Authentication required');
}
return this.trackingService.getReferralStats(userId);
}
/**
* Get list of referred users
*/
@Get('referred-users')
async getReferredUsers(
@Headers('authorization') authHeader: string,
@Query('status') status?: string,
@Query('limit') limit?: string,
@Query('offset') offset?: string
) {
const userId = extractUserId(authHeader);
if (!userId) {
throw new BadRequestException('Authentication required');
}
return this.trackingService.getReferredUsers(
userId,
status,
limit ? parseInt(limit, 10) : 20,
offset ? parseInt(offset, 10) : 0
);
}
/**
* Get user's tier information
*/
@Get('tier')
async getTier(@Headers('authorization') authHeader: string) {
const userId = extractUserId(authHeader);
if (!userId) {
throw new BadRequestException('Authentication required');
}
return this.tierService.getUserTier(userId);
}
// ============================================
// INTERNAL ENDPOINTS (Service-to-Service)
// ============================================
/**
* Apply referral code during registration (internal)
* Called by the auth service during user registration
*/
@Post('internal/apply')
@HttpCode(HttpStatus.OK)
async applyReferral(@Headers('x-service-key') serviceKey: string, @Body() dto: ApplyReferralDto) {
// In production, validate service key
// For now, we'll skip validation
return this.trackingService.applyReferral(dto);
}
/**
* Update referral stage (internal)
* Called by credits service when user activates or qualifies
*/
@Post('internal/stage-update')
@HttpCode(HttpStatus.OK)
async stageUpdate(@Headers('x-service-key') serviceKey: string, @Body() dto: StageUpdateDto) {
if (dto.stage === 'activated') {
const success = await this.trackingService.checkActivation(dto.userId);
return { success, stage: 'activated' };
} else if (dto.stage === 'qualified') {
const success = await this.trackingService.checkQualification(dto.userId);
return { success, stage: 'qualified' };
}
return { success: false, error: 'invalid_stage' };
}
/**
* Track cross-app usage (internal)
* Called by credits service when user uses a new app
*/
@Post('internal/cross-app')
@HttpCode(HttpStatus.OK)
async trackCrossApp(
@Headers('x-service-key') serviceKey: string,
@Body() dto: CrossAppActivationDto
) {
const success = await this.trackingService.trackCrossAppUsage(dto.userId, dto.appId);
return { success, isNewApp: success };
}
/**
* Initialize user's referral data (internal)
* Called during user registration to create auto-code and tier
*/
@Post('internal/initialize')
@HttpCode(HttpStatus.OK)
async initializeUser(
@Headers('x-service-key') serviceKey: string,
@Body() body: { userId: string }
) {
// Create auto code
const code = await this.codeService.createAutoCode(body.userId);
// Initialize tier
const tier = await this.tierService.initializeUserTier(body.userId);
return {
success: true,
code: code.code,
tier: tier.tier,
};
}
}

View file

@ -0,0 +1,24 @@
/**
* Referrals Module
*
* NestJS module for the referral system.
* Provides services for:
* - Referral code management
* - Referral tracking and stage progression
* - Tier calculation and bonus multipliers
*/
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { ReferralsController } from './referrals.controller';
import { ReferralCodeService } from './services/referral-code.service';
import { ReferralTierService } from './services/referral-tier.service';
import { ReferralTrackingService } from './services/referral-tracking.service';
@Module({
imports: [ConfigModule],
controllers: [ReferralsController],
providers: [ReferralCodeService, ReferralTierService, ReferralTrackingService],
exports: [ReferralCodeService, ReferralTierService, ReferralTrackingService],
})
export class ReferralsModule {}

View file

@ -0,0 +1,3 @@
export { ReferralCodeService } from './referral-code.service';
export { ReferralTierService } from './referral-tier.service';
export { ReferralTrackingService } from './referral-tracking.service';

View file

@ -0,0 +1,376 @@
/**
* Referral Code Service
*
* Handles referral code generation, validation, and management.
* - Auto-generates codes for new users
* - Validates codes for registration
* - Manages custom codes created by users
*/
import { Injectable, BadRequestException, ConflictException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { eq, and, sql } from 'drizzle-orm';
import { getDb } from '../../db/connection';
import {
codes,
rateLimits,
RATE_LIMITS,
type ReferralCode,
type NewReferralCode,
} from '../../db/schema/referrals.schema';
import { users } from '../../db/schema/auth.schema';
import type { CreateCustomCodeDto, CodeValidationResponse, ReferralCodeResponse } from '../dto';
// Characters for auto-generated codes (excluding confusable: 0/O, 1/I/L)
const CODE_CHARSET = 'ABCDEFGHJKMNPQRSTUVWXYZ23456789';
const AUTO_CODE_LENGTH = 6;
@Injectable()
export class ReferralCodeService {
constructor(private configService: ConfigService) {}
private getDb() {
const databaseUrl = this.configService.get<string>('database.url');
return getDb(databaseUrl!);
}
/**
* Generate a random code string
*/
private generateRandomCode(length: number = AUTO_CODE_LENGTH): string {
let code = '';
for (let i = 0; i < length; i++) {
code += CODE_CHARSET[Math.floor(Math.random() * CODE_CHARSET.length)];
}
return code;
}
/**
* Create auto-generated referral code for a new user
* Called during user registration
*/
async createAutoCode(userId: string): Promise<ReferralCode> {
const db = this.getDb();
// Check if user already has an auto code
const [existingCode] = await db
.select()
.from(codes)
.where(and(eq(codes.userId, userId), eq(codes.type, 'auto')))
.limit(1);
if (existingCode) {
return existingCode;
}
// Generate unique code with retry logic
let code: string;
let attempts = 0;
const maxAttempts = 10;
while (attempts < maxAttempts) {
code = this.generateRandomCode();
try {
const [newCode] = await db
.insert(codes)
.values({
userId,
code,
type: 'auto',
isActive: true,
usesCount: 0,
})
.returning();
return newCode;
} catch (error: unknown) {
// Code already exists, try again
if (error instanceof Error && error.message?.includes('unique')) {
attempts++;
continue;
}
throw error;
}
}
throw new Error('Failed to generate unique referral code after max attempts');
}
/**
* Create a custom referral code for a user
*/
async createCustomCode(userId: string, dto: CreateCustomCodeDto): Promise<ReferralCode> {
const db = this.getDb();
// Check rate limit
await this.checkRateLimit(userId, 'code_creation');
// Normalize code to uppercase
const normalizedCode = dto.code.toUpperCase().trim();
// Validate code format
if (!/^[A-Z0-9-]{3,20}$/.test(normalizedCode)) {
throw new BadRequestException(
'Code must be 3-20 characters and contain only letters, numbers, and hyphens'
);
}
// Check if code already exists (globally unique)
const [existingCode] = await db
.select()
.from(codes)
.where(eq(codes.code, normalizedCode))
.limit(1);
if (existingCode) {
throw new ConflictException('This code is already taken');
}
// Create the custom code
try {
const [newCode] = await db
.insert(codes)
.values({
userId,
code: normalizedCode,
type: 'custom',
sourceAppId: dto.sourceAppId,
isActive: true,
usesCount: 0,
})
.returning();
// Record rate limit usage
await this.recordRateLimitUsage(userId, 'code_creation');
return newCode;
} catch (error: unknown) {
if (error instanceof Error && error.message?.includes('unique')) {
throw new ConflictException('This code is already taken');
}
throw error;
}
}
/**
* Get all codes for a user
*/
async getUserCodes(userId: string): Promise<ReferralCodeResponse[]> {
const db = this.getDb();
const userCodes = await db
.select()
.from(codes)
.where(eq(codes.userId, userId))
.orderBy(codes.createdAt);
return userCodes.map((code) => ({
id: code.id,
code: code.code,
type: code.type as 'auto' | 'custom' | 'campaign',
isActive: code.isActive,
usesCount: code.usesCount,
createdAt: code.createdAt,
}));
}
/**
* Get a user's primary (auto-generated) code
*/
async getPrimaryCode(userId: string): Promise<string | null> {
const db = this.getDb();
const [autoCode] = await db
.select()
.from(codes)
.where(and(eq(codes.userId, userId), eq(codes.type, 'auto')))
.limit(1);
return autoCode?.code || null;
}
/**
* Validate a referral code (public endpoint for registration)
*/
async validateCode(code: string): Promise<CodeValidationResponse> {
const db = this.getDb();
const normalizedCode = code.toUpperCase().trim();
// Find the code with user info
const result = await db
.select({
code: codes,
user: {
id: users.id,
name: users.name,
},
})
.from(codes)
.innerJoin(users, eq(codes.userId, users.id))
.where(eq(codes.code, normalizedCode))
.limit(1);
if (result.length === 0) {
return {
valid: false,
bonusCredits: 0,
error: 'invalid',
};
}
const { code: referralCode, user } = result[0];
// Check if code is active
if (!referralCode.isActive) {
return {
valid: false,
bonusCredits: 0,
error: 'inactive',
};
}
// Check expiration
if (referralCode.expiresAt && new Date() > referralCode.expiresAt) {
return {
valid: false,
bonusCredits: 0,
error: 'expired',
};
}
// Check max uses
if (referralCode.maxUses && referralCode.usesCount >= referralCode.maxUses) {
return {
valid: false,
bonusCredits: 0,
error: 'max_uses',
};
}
// Anonymize referrer name (e.g., "Till Schneider" -> "Till S.")
const anonymizedName = this.anonymizeName(user.name);
return {
valid: true,
referrerName: anonymizedName,
bonusCredits: 25, // Referee bonus
};
}
/**
* Get code by code string (internal use)
*/
async getCodeByString(code: string): Promise<ReferralCode | null> {
const db = this.getDb();
const [referralCode] = await db
.select()
.from(codes)
.where(eq(codes.code, code.toUpperCase().trim()))
.limit(1);
return referralCode || null;
}
/**
* Increment the use count of a code
*/
async incrementUseCount(codeId: string): Promise<void> {
const db = this.getDb();
await db
.update(codes)
.set({
usesCount: sql`${codes.usesCount} + 1`,
})
.where(eq(codes.id, codeId));
}
/**
* Deactivate a code
*/
async deactivateCode(userId: string, codeId: string): Promise<boolean> {
const db = this.getDb();
// Only allow deactivating own codes
const result = await db
.update(codes)
.set({ isActive: false })
.where(and(eq(codes.id, codeId), eq(codes.userId, userId)))
.returning();
return result.length > 0;
}
/**
* Anonymize a name for display (e.g., "Till Schneider" -> "Till S.")
*/
private anonymizeName(name: string): string {
const parts = name.trim().split(/\s+/);
if (parts.length === 1) {
return parts[0];
}
const firstName = parts[0];
const lastInitial = parts[parts.length - 1][0];
return `${firstName} ${lastInitial}.`;
}
/**
* Check rate limit for an action
*/
private async checkRateLimit(identifier: string, action: string): Promise<void> {
const db = this.getDb();
const config = RATE_LIMITS[action as keyof typeof RATE_LIMITS];
if (!config) return;
const windowStart = new Date(Date.now() - config.windowMinutes * 60 * 1000);
// Count recent actions
const [result] = await db
.select({
count: sql<number>`COALESCE(SUM(${rateLimits.count}), 0)`,
})
.from(rateLimits)
.where(
and(
eq(rateLimits.identifier, identifier),
eq(rateLimits.identifierType, 'user'),
eq(rateLimits.action, action),
sql`${rateLimits.windowStart} >= ${windowStart}`
)
);
const count = Number(result?.count || 0);
if (count >= config.limit) {
throw new BadRequestException(
`Rate limit exceeded. Please try again in ${config.windowMinutes} minutes.`
);
}
}
/**
* Record rate limit usage
*/
private async recordRateLimitUsage(identifier: string, action: string): Promise<void> {
const db = this.getDb();
const config = RATE_LIMITS[action as keyof typeof RATE_LIMITS];
if (!config) return;
const now = new Date();
const windowEnd = new Date(now.getTime() + config.windowMinutes * 60 * 1000);
await db.insert(rateLimits).values({
identifier,
identifierType: 'user',
action,
count: 1,
windowStart: now,
windowEnd,
});
}
}

View file

@ -0,0 +1,254 @@
/**
* Referral Tier Service
*
* Handles tier calculation and bonus multipliers.
* Tiers are based on lifetime qualified referrals:
* - Bronze: 0-4 (1.0x)
* - Silver: 5-14 (1.5x)
* - Gold: 15-29 (2.0x)
* - Platinum: 30+ (3.0x)
*/
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { eq, sql } from 'drizzle-orm';
import { getDb } from '../../db/connection';
import {
userTiers,
relationships,
TIER_CONFIG,
BONUS_AMOUNTS,
type UserTier,
} from '../../db/schema/referrals.schema';
import type { TierInfo } from '../dto';
type TierName = 'bronze' | 'silver' | 'gold' | 'platinum';
@Injectable()
export class ReferralTierService {
constructor(private configService: ConfigService) {}
private getDb() {
const databaseUrl = this.configService.get<string>('database.url');
return getDb(databaseUrl!);
}
/**
* Initialize tier record for a new user
*/
async initializeUserTier(userId: string): Promise<UserTier> {
const db = this.getDb();
// Check if already exists
const [existing] = await db
.select()
.from(userTiers)
.where(eq(userTiers.userId, userId))
.limit(1);
if (existing) {
return existing;
}
const [tier] = await db
.insert(userTiers)
.values({
userId,
tier: 'bronze',
qualifiedCount: 0,
totalEarned: 0,
})
.returning();
return tier;
}
/**
* Get user's current tier info
*/
async getUserTier(userId: string): Promise<TierInfo> {
const db = this.getDb();
let [tier] = await db.select().from(userTiers).where(eq(userTiers.userId, userId)).limit(1);
// Initialize if doesn't exist
if (!tier) {
tier = await this.initializeUserTier(userId);
}
return this.buildTierInfo(tier);
}
/**
* Get the multiplier for a given tier
*/
getMultiplier(tier: TierName): number {
return TIER_CONFIG[tier]?.multiplier || 1.0;
}
/**
* Calculate bonus credits with tier multiplier
*/
calculateBonus(
eventType: keyof typeof BONUS_AMOUNTS,
tier: TierName,
isReferrer: boolean = true
): { 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 final = Math.round(base * multiplier);
return { base, multiplier, final };
}
/**
* Increment qualified count and potentially upgrade tier
* Called when a referral reaches 'qualified' status
*/
async incrementQualifiedCount(userId: string): Promise<{
newTier: TierName;
upgraded: boolean;
previousTier: TierName;
}> {
const db = this.getDb();
// Get current tier with lock
const [currentTier] = await db
.select()
.from(userTiers)
.where(eq(userTiers.userId, userId))
.limit(1);
if (!currentTier) {
// Initialize and return
await this.initializeUserTier(userId);
return {
newTier: 'bronze',
upgraded: false,
previousTier: 'bronze',
};
}
const previousTier = currentTier.tier as TierName;
const newQualifiedCount = currentTier.qualifiedCount + 1;
const newTier = this.calculateTierFromCount(newQualifiedCount);
const upgraded = newTier !== previousTier;
// Update tier
await db
.update(userTiers)
.set({
qualifiedCount: newQualifiedCount,
tier: newTier,
updatedAt: new Date(),
})
.where(eq(userTiers.userId, userId));
return {
newTier,
upgraded,
previousTier,
};
}
/**
* Add earned credits to user's tier record
*/
async addEarnedCredits(userId: string, credits: number): Promise<void> {
const db = this.getDb();
await db
.update(userTiers)
.set({
totalEarned: sql`${userTiers.totalEarned} + ${credits}`,
updatedAt: new Date(),
})
.where(eq(userTiers.userId, userId));
}
/**
* Recalculate tier based on actual qualified referrals
* (Used for data integrity checks)
*/
async recalculateTier(userId: string): Promise<TierName> {
const db = this.getDb();
// Count actual qualified referrals
const [result] = await db
.select({
count: sql<number>`COUNT(*)`,
})
.from(relationships)
.where(
sql`${relationships.referrerId} = ${userId} AND ${relationships.qualifiedAt} IS NOT NULL`
);
const qualifiedCount = Number(result?.count || 0);
const tier = this.calculateTierFromCount(qualifiedCount);
// Update tier record
await db
.update(userTiers)
.set({
qualifiedCount,
tier,
updatedAt: new Date(),
})
.where(eq(userTiers.userId, userId));
return tier;
}
/**
* Calculate tier from qualified count
*/
private 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';
return 'bronze';
}
/**
* Build TierInfo response object
*/
private buildTierInfo(tier: UserTier): TierInfo {
const currentTier = tier.tier as TierName;
const config = TIER_CONFIG[currentTier];
// Determine next tier
let nextTier: TierName | null = null;
let nextTierAt: number | null = null;
if (currentTier === 'bronze') {
nextTier = 'silver';
nextTierAt = TIER_CONFIG.silver.minQualified;
} else if (currentTier === 'silver') {
nextTier = 'gold';
nextTierAt = TIER_CONFIG.gold.minQualified;
} else if (currentTier === 'gold') {
nextTier = 'platinum';
nextTierAt = TIER_CONFIG.platinum.minQualified;
}
// Platinum has no next tier
// Calculate progress
let progress = 1.0;
if (nextTierAt !== null) {
const currentMin = config.minQualified;
const range = nextTierAt - currentMin;
const current = tier.qualifiedCount - currentMin;
progress = Math.min(current / range, 1.0);
}
return {
current: currentTier,
multiplier: config.multiplier,
qualifiedCount: tier.qualifiedCount,
nextTier,
nextTierAt,
progress,
};
}
}

View file

@ -0,0 +1,832 @@
/**
* Referral Tracking Service
*
* Core service for tracking referral relationships and stage progression.
* Handles:
* - Applying referral codes during registration
* - Stage transitions (registered -> activated -> qualified -> retained)
* - Bonus calculations and payouts
* - Cross-app tracking
*/
import {
Injectable,
BadRequestException,
NotFoundException,
ConflictException,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { eq, and, sql, desc, isNotNull, count } from 'drizzle-orm';
import { getDb } from '../../db/connection';
import {
codes,
relationships,
crossAppActivations,
bonusEvents,
userTiers,
dailyStats,
BONUS_AMOUNTS,
FRAUD_THRESHOLDS,
TIMING_RULES,
TRACKABLE_APPS,
type ReferralRelationship,
type NewReferralRelationship,
} from '../../db/schema/referrals.schema';
import { users } from '../../db/schema/auth.schema';
import { balances, transactions } from '../../db/schema/credits.schema';
import { ReferralCodeService } from './referral-code.service';
import { ReferralTierService } from './referral-tier.service';
import type {
ApplyReferralDto,
ApplyReferralResponse,
ReferralStats,
ReferredUser,
PaginatedResponse,
} from '../dto';
type TierName = 'bronze' | 'silver' | 'gold' | 'platinum';
@Injectable()
export class ReferralTrackingService {
constructor(
private configService: ConfigService,
private codeService: ReferralCodeService,
private tierService: ReferralTierService
) {}
private getDb() {
const databaseUrl = this.configService.get<string>('database.url');
return getDb(databaseUrl!);
}
/**
* Apply a referral code during user registration
*/
async applyReferral(dto: ApplyReferralDto): Promise<ApplyReferralResponse> {
const db = this.getDb();
// 1. Validate the code
const referralCode = await this.codeService.getCodeByString(dto.code);
if (!referralCode) {
return { success: false, error: 'invalid_code' };
}
// 2. Check if code is active and not expired/maxed
if (!referralCode.isActive) {
return { success: false, error: 'code_inactive' };
}
if (referralCode.expiresAt && new Date() > referralCode.expiresAt) {
return { success: false, error: 'code_expired' };
}
if (referralCode.maxUses && referralCode.usesCount >= referralCode.maxUses) {
return { success: false, error: 'code_max_uses' };
}
// 3. Prevent self-referral
if (referralCode.userId === dto.refereeId) {
return { success: false, error: 'self_referral' };
}
// 4. Check if user was already referred
const [existingReferral] = await db
.select()
.from(relationships)
.where(eq(relationships.refereeId, dto.refereeId))
.limit(1);
if (existingReferral) {
return { success: false, error: 'already_referred' };
}
// 5. Calculate initial fraud score (basic checks)
const fraudScore = await this.calculateInitialFraudScore(
referralCode.userId,
dto.refereeId,
dto.ipAddress,
dto.deviceFingerprint
);
// 6. Create the referral relationship
const [referral] = await db
.insert(relationships)
.values({
referrerId: referralCode.userId,
refereeId: dto.refereeId,
codeId: referralCode.id,
sourceAppId: dto.sourceAppId,
status: 'registered',
fraudScore,
fraudSignals: '[]', // Will be populated by fraud detection
isFlagged: fraudScore >= FRAUD_THRESHOLDS.highRisk,
})
.returning();
// 7. Increment code use count
await this.codeService.incrementUseCount(referralCode.id);
// 8. Award registration bonuses
let bonusAwarded = 0;
// Referee bonus (25 credits) - always paid immediately
const refereeBonusPaid = await this.awardBonus(
referral.id,
dto.refereeId,
'registered',
false, // isReferrer
fraudScore
);
if (refereeBonusPaid > 0) {
bonusAwarded += refereeBonusPaid;
}
// Referrer bonus (5 credits × tier) - may be held for fraud review
await this.awardBonus(
referral.id,
referralCode.userId,
'registered',
true, // isReferrer
fraudScore
);
// 9. Update daily stats
await this.updateDailyStats(dto.sourceAppId, 'registrations', 1);
return {
success: true,
referralId: referral.id,
bonusAwarded,
fraudScore,
};
}
/**
* Check if a user should be marked as activated (first credit usage)
*/
async checkActivation(userId: string): Promise<boolean> {
const db = this.getDb();
// Find the referral where this user is the referee
const [referral] = await db
.select()
.from(relationships)
.where(and(eq(relationships.refereeId, userId), eq(relationships.status, 'registered')))
.limit(1);
if (!referral) {
return false; // User wasn't referred or already activated
}
// Check timing rule
const timeSinceRegistration = Date.now() - referral.registeredAt.getTime();
if (timeSinceRegistration < TIMING_RULES.minTimeToActivation) {
return false; // Too soon
}
// Update to activated status
await db
.update(relationships)
.set({
status: 'activated',
activatedAt: new Date(),
updatedAt: new Date(),
})
.where(eq(relationships.id, referral.id));
// Award activation bonus to referrer
await this.awardBonus(referral.id, referral.referrerId, 'activated', true, referral.fraudScore);
// Update daily stats
await this.updateDailyStats(referral.sourceAppId, 'activations', 1);
return true;
}
/**
* Check if a user should be marked as qualified (first purchase)
*/
async checkQualification(userId: string): Promise<boolean> {
const db = this.getDb();
// Find the referral where this user is the referee
const [referral] = await db
.select()
.from(relationships)
.where(
and(
eq(relationships.refereeId, userId),
sql`${relationships.status} IN ('registered', 'activated')`
)
)
.limit(1);
if (!referral) {
return false; // User wasn't referred or already qualified
}
// Check timing rule (24h minimum)
const timeSinceRegistration = Date.now() - referral.registeredAt.getTime();
if (timeSinceRegistration < TIMING_RULES.minTimeToQualification) {
// Flag for potential fraud but still process
await this.addFraudSignal(referral.id, 'instant_qualification');
}
// Update to qualified status
await db
.update(relationships)
.set({
status: 'qualified',
qualifiedAt: new Date(),
// Also mark as activated if not already
activatedAt: referral.activatedAt || new Date(),
updatedAt: new Date(),
})
.where(eq(relationships.id, referral.id));
// Award qualification bonus to referrer
await this.awardBonus(referral.id, referral.referrerId, 'qualified', true, referral.fraudScore);
// Increment referrer's qualified count (affects tier)
await this.tierService.incrementQualifiedCount(referral.referrerId);
// Update daily stats
await this.updateDailyStats(referral.sourceAppId, 'qualifications', 1);
return true;
}
/**
* Track cross-app usage and award bonus
*/
async trackCrossAppUsage(userId: string, appId: string): Promise<boolean> {
const db = this.getDb();
// Check if this is a trackable app
if (!TRACKABLE_APPS.includes(appId as any)) {
return false;
}
// Find the referral where this user is the referee
const [referral] = await db
.select()
.from(relationships)
.where(eq(relationships.refereeId, userId))
.limit(1);
if (!referral) {
return false; // User wasn't referred
}
// Check if this app was already tracked
const [existing] = await db
.select()
.from(crossAppActivations)
.where(
and(
eq(crossAppActivations.relationshipId, referral.id),
eq(crossAppActivations.appId, appId)
)
)
.limit(1);
if (existing) {
return false; // Already tracked
}
// Record cross-app activation
await db.insert(crossAppActivations).values({
relationshipId: referral.id,
appId,
bonusPaid: false,
});
// Award cross-app bonus to referrer
const bonusPaid = await this.awardBonus(
referral.id,
referral.referrerId,
'cross_app',
true,
referral.fraudScore,
appId
);
// Update the activation record
if (bonusPaid > 0) {
await db
.update(crossAppActivations)
.set({ bonusPaid: true })
.where(
and(
eq(crossAppActivations.relationshipId, referral.id),
eq(crossAppActivations.appId, appId)
)
);
}
return true;
}
/**
* Process retention check (called by cron job)
* Users who are still active 30 days after registration
*/
async processRetentionBatch(): Promise<number> {
const db = this.getDb();
const thirtyDaysAgo = new Date(
Date.now() - TIMING_RULES.retentionCheckDays * 24 * 60 * 60 * 1000
);
// Find referrals that are qualified and registered 30+ days ago
const eligibleReferrals = await db
.select()
.from(relationships)
.where(
and(
eq(relationships.status, 'qualified'),
sql`${relationships.retainedAt} IS NULL`,
sql`${relationships.registeredAt} <= ${thirtyDaysAgo}`
)
)
.limit(100); // Process in batches
let processed = 0;
for (const referral of eligibleReferrals) {
// Check if referee has been active (has transactions in last 7 days)
const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
const [recentActivity] = await db
.select({ count: count() })
.from(transactions)
.where(
and(
eq(transactions.userId, referral.refereeId),
sql`${transactions.createdAt} >= ${sevenDaysAgo}`
)
);
if (Number(recentActivity?.count || 0) > 0) {
// User is retained!
await db
.update(relationships)
.set({
status: 'retained',
retainedAt: new Date(),
updatedAt: new Date(),
})
.where(eq(relationships.id, referral.id));
// Award retention bonus
await this.awardBonus(
referral.id,
referral.referrerId,
'retained',
true,
referral.fraudScore
);
// Update daily stats
await this.updateDailyStats(referral.sourceAppId, 'retentions', 1);
processed++;
}
}
return processed;
}
/**
* Get referral statistics for a user
*/
async getReferralStats(userId: string): Promise<ReferralStats> {
const db = this.getDb();
// Get tier info
const tierInfo = await this.tierService.getUserTier(userId);
// Get totals
const [totals] = await db
.select({
registered: sql<number>`COUNT(*)`,
activated: sql<number>`COUNT(*) FILTER (WHERE ${relationships.activatedAt} IS NOT NULL)`,
qualified: sql<number>`COUNT(*) FILTER (WHERE ${relationships.qualifiedAt} IS NOT NULL)`,
retained: sql<number>`COUNT(*) FILTER (WHERE ${relationships.retainedAt} IS NOT NULL)`,
})
.from(relationships)
.where(eq(relationships.referrerId, userId));
// Get earnings
const [earnings] = await db
.select({
paid: sql<number>`COALESCE(SUM(${bonusEvents.creditsFinal}) FILTER (WHERE ${bonusEvents.status} = 'paid'), 0)`,
pending: sql<number>`COALESCE(SUM(${bonusEvents.creditsFinal}) FILTER (WHERE ${bonusEvents.status} IN ('pending', 'held')), 0)`,
})
.from(bonusEvents)
.where(eq(bonusEvents.userId, userId));
// Get stats by app
const byAppResults = await db
.select({
appId: relationships.sourceAppId,
registered: sql<number>`COUNT(*)`,
activated: sql<number>`COUNT(*) FILTER (WHERE ${relationships.activatedAt} IS NOT NULL)`,
qualified: sql<number>`COUNT(*) FILTER (WHERE ${relationships.qualifiedAt} IS NOT NULL)`,
})
.from(relationships)
.where(eq(relationships.referrerId, userId))
.groupBy(relationships.sourceAppId);
const byApp: ReferralStats['byApp'] = {};
for (const row of byAppResults) {
if (row.appId) {
byApp[row.appId] = {
registered: Number(row.registered),
activated: Number(row.activated),
qualified: Number(row.qualified),
credits: 0, // Would need to join with bonusEvents
};
}
}
// Get recent activity
const recentEvents = await db
.select({
type: bonusEvents.eventType,
credits: bonusEvents.creditsFinal,
at: bonusEvents.createdAt,
refereeId: relationships.refereeId,
appId: bonusEvents.appId,
})
.from(bonusEvents)
.innerJoin(relationships, eq(bonusEvents.relationshipId, relationships.id))
.where(eq(bonusEvents.userId, userId))
.orderBy(desc(bonusEvents.createdAt))
.limit(10);
// Get referee names for recent activity
const recentActivity: ReferralStats['recentActivity'] = [];
for (const event of recentEvents) {
const [referee] = await db
.select({ name: users.name })
.from(users)
.where(eq(users.id, event.refereeId))
.limit(1);
recentActivity.push({
type: event.type,
refereeName: this.anonymizeName(referee?.name || 'User'),
credits: event.credits,
app: event.appId || undefined,
at: event.at,
});
}
return {
tier: tierInfo,
totals: {
registered: Number(totals?.registered || 0),
activated: Number(totals?.activated || 0),
qualified: Number(totals?.qualified || 0),
retained: Number(totals?.retained || 0),
creditsEarned: Number(earnings?.paid || 0),
creditsPending: Number(earnings?.pending || 0),
},
byApp,
recentActivity,
};
}
/**
* Get list of referred users
*/
async getReferredUsers(
userId: string,
status?: string,
limit: number = 20,
offset: number = 0
): Promise<PaginatedResponse<ReferredUser>> {
const db = this.getDb();
// Build where clause
let whereClause = eq(relationships.referrerId, userId);
if (status && status !== 'all') {
whereClause = and(whereClause, eq(relationships.status, status as any))!;
}
// Get total count
const [countResult] = await db
.select({ total: count() })
.from(relationships)
.where(whereClause);
// Get referrals with user info
const referrals = await db
.select({
relationship: relationships,
user: {
id: users.id,
name: users.name,
},
})
.from(relationships)
.innerJoin(users, eq(relationships.refereeId, users.id))
.where(whereClause)
.orderBy(desc(relationships.createdAt))
.limit(limit)
.offset(offset);
// Get apps used and credits earned for each
const items: ReferredUser[] = [];
for (const { relationship, user } of referrals) {
// Get apps used
const appsUsed = await db
.select({ appId: crossAppActivations.appId })
.from(crossAppActivations)
.where(eq(crossAppActivations.relationshipId, relationship.id));
// Get credits earned from this referral
const [creditsResult] = await db
.select({
total: sql<number>`COALESCE(SUM(${bonusEvents.creditsFinal}), 0)`,
})
.from(bonusEvents)
.where(
and(eq(bonusEvents.relationshipId, relationship.id), eq(bonusEvents.status, 'paid'))
);
items.push({
id: relationship.id,
name: this.anonymizeName(user.name),
status: relationship.status as ReferredUser['status'],
registeredAt: relationship.registeredAt,
activatedAt: relationship.activatedAt || undefined,
qualifiedAt: relationship.qualifiedAt || undefined,
retainedAt: relationship.retainedAt || undefined,
appsUsed: appsUsed.map((a) => a.appId),
creditsEarned: Number(creditsResult?.total || 0),
isFlagged: relationship.isFlagged,
});
}
return {
items,
pagination: {
total: Number(countResult?.total || 0),
limit,
offset,
},
};
}
// ============================================
// PRIVATE HELPER METHODS
// ============================================
/**
* Award bonus credits to a user
*/
private async awardBonus(
relationshipId: string,
userId: string,
eventType: keyof typeof BONUS_AMOUNTS,
isReferrer: boolean,
fraudScore: number,
appId?: string
): Promise<number> {
const db = this.getDb();
// Get user's tier
const tierInfo = await this.tierService.getUserTier(userId);
const tier = tierInfo.current as TierName;
// Calculate bonus
const { base, multiplier, final } = this.tierService.calculateBonus(
eventType,
tier,
isReferrer
);
if (final === 0) {
return 0;
}
// Determine if bonus should be held
let status: 'pending' | 'held' = 'pending';
let holdReason: string | null = null;
let holdUntil: Date | null = null;
if (fraudScore >= FRAUD_THRESHOLDS.highRisk) {
status = 'held';
holdReason = 'high_fraud_score';
} else if (fraudScore >= FRAUD_THRESHOLDS.mediumRisk) {
status = 'held';
holdReason = 'medium_fraud_score';
holdUntil = new Date(Date.now() + 48 * 60 * 60 * 1000); // 48h hold
}
// Create bonus event record
const [bonusEvent] = await db
.insert(bonusEvents)
.values({
relationshipId,
userId,
eventType,
appId,
creditsBase: base,
tierMultiplier: multiplier,
creditsFinal: final,
tierAtTime: tier,
status,
holdReason,
holdUntil,
})
.returning();
// If not held, pay immediately
if (status === 'pending') {
const transactionId = await this.creditBonus(userId, final, eventType, relationshipId);
// Update bonus event with transaction ID and mark as paid
await db
.update(bonusEvents)
.set({
transactionId,
status: 'paid',
releasedAt: new Date(),
})
.where(eq(bonusEvents.id, bonusEvent.id));
// Update tier's total earned
await this.tierService.addEarnedCredits(userId, final);
// Update daily stats
await this.updateDailyStats(appId, 'creditsPaid', final);
return final;
} else {
// Update daily stats for held credits
await this.updateDailyStats(appId, 'creditsHeld', final);
return 0;
}
}
/**
* Credit bonus to user's balance
*/
private async creditBonus(
userId: string,
amount: number,
reason: string,
relationshipId: string
): Promise<string> {
const db = this.getDb();
// Get current balance
const [currentBalance] = await db
.select()
.from(balances)
.where(eq(balances.userId, userId))
.limit(1);
if (!currentBalance) {
throw new NotFoundException('User balance not found');
}
const newFreeCredits = currentBalance.freeCreditsRemaining + amount;
const newTotalEarned = currentBalance.totalEarned + amount;
// Update balance
await db
.update(balances)
.set({
freeCreditsRemaining: newFreeCredits,
totalEarned: newTotalEarned,
updatedAt: new Date(),
})
.where(eq(balances.userId, userId));
// Create transaction record
const [transaction] = await db
.insert(transactions)
.values({
userId,
type: 'bonus',
status: 'completed',
amount,
balanceBefore: currentBalance.balance + currentBalance.freeCreditsRemaining,
balanceAfter: currentBalance.balance + newFreeCredits,
appId: 'referral',
description: `Referral bonus: ${reason}`,
completedAt: new Date(),
})
.returning();
return transaction.id;
}
/**
* Calculate initial fraud score
*/
private async calculateInitialFraudScore(
referrerId: string,
refereeId: string,
ipAddress?: string,
deviceFingerprint?: string
): Promise<number> {
// Basic fraud score calculation
// Full fraud detection will be implemented in Phase 3
let score = 0;
// For now, just return 0 (no fraud detected)
// TODO: Implement full fraud detection in Phase 3
return score;
}
/**
* Add a fraud signal to a referral
*/
private async addFraudSignal(relationshipId: string, signal: string): Promise<void> {
const db = this.getDb();
const [referral] = await db
.select()
.from(relationships)
.where(eq(relationships.id, relationshipId))
.limit(1);
if (!referral) return;
const signals: string[] = referral.fraudSignals ? JSON.parse(referral.fraudSignals) : [];
if (!signals.includes(signal)) {
signals.push(signal);
await db
.update(relationships)
.set({
fraudSignals: JSON.stringify(signals),
updatedAt: new Date(),
})
.where(eq(relationships.id, relationshipId));
}
}
/**
* Update daily stats
*/
private async updateDailyStats(
appId: string | null | undefined,
field: keyof typeof dailyStats.$inferSelect,
increment: number
): Promise<void> {
const db = this.getDb();
const today = new Date();
today.setHours(0, 0, 0, 0);
// Upsert daily stats
// Note: This is a simplified version. In production, use proper upsert
try {
const [existing] = await db
.select()
.from(dailyStats)
.where(
and(
eq(dailyStats.date, today),
appId ? eq(dailyStats.appId, appId) : sql`${dailyStats.appId} IS NULL`
)
)
.limit(1);
if (existing) {
await db
.update(dailyStats)
.set({
[field]: sql`${dailyStats[field as keyof typeof dailyStats]} + ${increment}`,
})
.where(eq(dailyStats.id, existing.id));
} else {
await db.insert(dailyStats).values({
date: today,
appId: appId || null,
[field]: increment,
});
}
} catch {
// Ignore stats update failures
}
}
/**
* Anonymize a name for display
*/
private anonymizeName(name: string): string {
const parts = name.trim().split(/\s+/);
if (parts.length === 1) {
return parts[0];
}
const firstName = parts[0];
const lastInitial = parts[parts.length - 1][0];
return `${firstName} ${lastInitial}.`;
}
}