mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 20:01:09 +02:00
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:
parent
ee52f6c144
commit
a6cc0b83aa
33 changed files with 2634 additions and 68 deletions
522
services/mana-core-auth/src/db/schema/referrals.schema.ts
Normal file
522
services/mana-core-auth/src/db/schema/referrals.schema.ts
Normal 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;
|
||||
191
services/mana-core-auth/src/referrals/dto/index.ts
Normal file
191
services/mana-core-auth/src/referrals/dto/index.ts
Normal 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;
|
||||
};
|
||||
}
|
||||
264
services/mana-core-auth/src/referrals/referrals.controller.ts
Normal file
264
services/mana-core-auth/src/referrals/referrals.controller.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
24
services/mana-core-auth/src/referrals/referrals.module.ts
Normal file
24
services/mana-core-auth/src/referrals/referrals.module.ts
Normal 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 {}
|
||||
3
services/mana-core-auth/src/referrals/services/index.ts
Normal file
3
services/mana-core-auth/src/referrals/services/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export { ReferralCodeService } from './referral-code.service';
|
||||
export { ReferralTierService } from './referral-tier.service';
|
||||
export { ReferralTrackingService } from './referral-tracking.service';
|
||||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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}.`;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue