mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 19:21:10 +02:00
fix(manacore-web): add missing packages to Dockerfile
Add shared-pwa, qr-export, and wallpaper-generator packages to the Docker build context for manacore-web. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
03d90f2bda
commit
1d44f918c5
33 changed files with 19 additions and 5538 deletions
File diff suppressed because it is too large
Load diff
|
|
@ -118,15 +118,14 @@ Archivierte Apps (memoro, storyteller) wurden bereits entfernt.
|
|||
|
||||
### 3. ✅ Dashboard-Widgets erweitern (GRÖSSTENTEILS ERLEDIGT)
|
||||
|
||||
**Status:** 14 von 16 Widgets implementiert (Finance + Mail fehlen)
|
||||
**Status:** 13 von 15 Widgets implementiert (Finance + Mail fehlen)
|
||||
|
||||
**Existierende Widgets (14 Typen):**
|
||||
**Existierende Widgets (13 Typen):**
|
||||
|
||||
| Widget | App | Status |
|
||||
| ----------------------- | -------------- | ------ |
|
||||
| CreditsWidget | mana-core-auth | ✅ |
|
||||
| TransactionsWidget | mana-core-auth | ✅ |
|
||||
| ReferralWidget | mana-core-auth | ✅ |
|
||||
| QuickActionsWidget | core | ✅ |
|
||||
| TasksTodayWidget | todo | ✅ |
|
||||
| TasksUpcomingWidget | todo | ✅ |
|
||||
|
|
|
|||
|
|
@ -40,6 +40,9 @@ COPY packages/shared-types ./packages/shared-types
|
|||
COPY packages/shared-ui ./packages/shared-ui
|
||||
COPY packages/shared-utils ./packages/shared-utils
|
||||
COPY packages/shared-vite-config ./packages/shared-vite-config
|
||||
COPY packages/shared-pwa ./packages/shared-pwa
|
||||
COPY packages/qr-export ./packages/qr-export
|
||||
COPY packages/wallpaper-generator ./packages/wallpaper-generator
|
||||
|
||||
# Copy manacore web
|
||||
COPY apps/manacore/apps/web ./apps/manacore/apps/web
|
||||
|
|
|
|||
|
|
@ -1,148 +0,0 @@
|
|||
/**
|
||||
* Referrals Service for ManaCore Web App
|
||||
* Handles referral codes, stats, and referral tracking
|
||||
*/
|
||||
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { getManaAuthUrl } from './config';
|
||||
|
||||
// Types
|
||||
export interface ReferralStats {
|
||||
totalReferrals: number;
|
||||
activeReferrals: number;
|
||||
pendingReferrals: number;
|
||||
qualifiedReferrals: number;
|
||||
totalCreditsEarned: number;
|
||||
currentTier: 'bronze' | 'silver' | 'gold' | 'platinum';
|
||||
tierProgress: {
|
||||
current: number;
|
||||
nextTierAt: number;
|
||||
percentToNext: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ReferralCode {
|
||||
code: string;
|
||||
createdAt: string;
|
||||
usageCount: number;
|
||||
maxUses?: number;
|
||||
expiresAt?: string;
|
||||
bonusCredits: number;
|
||||
referrerBonus: number;
|
||||
}
|
||||
|
||||
export interface Referral {
|
||||
id: string;
|
||||
referredUserId: string;
|
||||
referredEmail?: string;
|
||||
stage: 'registered' | 'activated' | 'qualified' | 'retained';
|
||||
creditsEarned: number;
|
||||
registeredAt: string;
|
||||
activatedAt?: string;
|
||||
qualifiedAt?: string;
|
||||
retainedAt?: string;
|
||||
}
|
||||
|
||||
export interface ReferralValidation {
|
||||
valid: boolean;
|
||||
referrerName?: string;
|
||||
bonusCredits?: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// Helper function for authenticated requests
|
||||
async function fetchWithAuth<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
|
||||
const token = await authStore.getAccessToken();
|
||||
|
||||
const response = await fetch(`${getManaAuthUrl()}${endpoint}`, {
|
||||
...options,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
...options.headers,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ message: 'Request failed' }));
|
||||
throw new Error(error.message || `HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
// Referrals Service
|
||||
export const referralsService = {
|
||||
/**
|
||||
* Get referral stats for the current user
|
||||
*/
|
||||
async getStats(): Promise<ReferralStats> {
|
||||
return fetchWithAuth<ReferralStats>('/api/v1/referrals/stats');
|
||||
},
|
||||
|
||||
/**
|
||||
* Get the user's referral code (creates one if doesn't exist)
|
||||
*/
|
||||
async getCode(): Promise<ReferralCode> {
|
||||
return fetchWithAuth<ReferralCode>('/api/v1/referrals/code');
|
||||
},
|
||||
|
||||
/**
|
||||
* Generate a new referral code
|
||||
*/
|
||||
async generateCode(): Promise<ReferralCode> {
|
||||
return fetchWithAuth<ReferralCode>('/api/v1/referrals/code', {
|
||||
method: 'POST',
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Get list of referrals made by the current user
|
||||
*/
|
||||
async getReferrals(limit = 50, offset = 0): Promise<Referral[]> {
|
||||
return fetchWithAuth<Referral[]>(`/api/v1/referrals/list?limit=${limit}&offset=${offset}`);
|
||||
},
|
||||
|
||||
/**
|
||||
* Validate a referral code (public endpoint - for registration)
|
||||
*/
|
||||
async validateCode(code: string): Promise<ReferralValidation> {
|
||||
try {
|
||||
const response = await fetch(`${getManaAuthUrl()}/api/v1/referrals/validate/${code}`);
|
||||
if (!response.ok) {
|
||||
return { valid: false, error: 'Invalid code' };
|
||||
}
|
||||
const data = await response.json();
|
||||
return {
|
||||
valid: data.valid,
|
||||
referrerName: data.referrerName,
|
||||
bonusCredits: data.bonusCredits || 25,
|
||||
error: data.error,
|
||||
};
|
||||
} catch {
|
||||
return { valid: false, error: 'Validation failed' };
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Get shareable referral link
|
||||
*/
|
||||
getShareLink(code: string): string {
|
||||
// Use current origin or fallback
|
||||
const baseUrl = typeof window !== 'undefined' ? window.location.origin : 'https://manacore.app';
|
||||
return `${baseUrl}/register?ref=${code}`;
|
||||
},
|
||||
|
||||
/**
|
||||
* Copy referral link to clipboard
|
||||
*/
|
||||
async copyShareLink(code: string): Promise<boolean> {
|
||||
try {
|
||||
const link = this.getShareLink(code);
|
||||
await navigator.clipboard.writeText(link);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
|
@ -16,7 +16,6 @@
|
|||
import CreditsWidget from './widgets/CreditsWidget.svelte';
|
||||
import QuickActionsWidget from './widgets/QuickActionsWidget.svelte';
|
||||
import TransactionsWidget from './widgets/TransactionsWidget.svelte';
|
||||
import ReferralWidget from './widgets/ReferralWidget.svelte';
|
||||
import TasksTodayWidget from './widgets/TasksTodayWidget.svelte';
|
||||
import TasksUpcomingWidget from './widgets/TasksUpcomingWidget.svelte';
|
||||
import CalendarEventsWidget from './widgets/CalendarEventsWidget.svelte';
|
||||
|
|
@ -58,7 +57,6 @@
|
|||
credits: CreditsWidget,
|
||||
'quick-actions': QuickActionsWidget,
|
||||
transactions: TransactionsWidget,
|
||||
referral: ReferralWidget,
|
||||
'tasks-today': TasksTodayWidget,
|
||||
'tasks-upcoming': TasksUpcomingWidget,
|
||||
'calendar-events': CalendarEventsWidget,
|
||||
|
|
|
|||
|
|
@ -1,145 +0,0 @@
|
|||
<script lang="ts">
|
||||
/**
|
||||
* ReferralWidget - Displays referral code, stats, and sharing options
|
||||
*/
|
||||
|
||||
import { onMount } from 'svelte';
|
||||
import { _ } from 'svelte-i18n';
|
||||
import { referralsService, type ReferralStats, type ReferralCode } from '$lib/api/referrals';
|
||||
import WidgetSkeleton from '../WidgetSkeleton.svelte';
|
||||
import WidgetError from '../WidgetError.svelte';
|
||||
|
||||
let state = $state<'loading' | 'success' | 'error'>('loading');
|
||||
let stats = $state<ReferralStats | null>(null);
|
||||
let code = $state<ReferralCode | null>(null);
|
||||
let error = $state<string | null>(null);
|
||||
let retrying = $state(false);
|
||||
let copied = $state(false);
|
||||
|
||||
async function load() {
|
||||
state = 'loading';
|
||||
retrying = true;
|
||||
|
||||
try {
|
||||
const [statsData, codeData] = await Promise.all([
|
||||
referralsService.getStats(),
|
||||
referralsService.getCode(),
|
||||
]);
|
||||
stats = statsData;
|
||||
code = codeData;
|
||||
state = 'success';
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to load referral data';
|
||||
state = 'error';
|
||||
} finally {
|
||||
retrying = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMount(load);
|
||||
|
||||
async function copyCode() {
|
||||
if (!code) return;
|
||||
|
||||
const success = await referralsService.copyShareLink(code.code);
|
||||
if (success) {
|
||||
copied = true;
|
||||
setTimeout(() => (copied = false), 2000);
|
||||
}
|
||||
}
|
||||
|
||||
function getTierColor(tier: string): string {
|
||||
switch (tier) {
|
||||
case 'platinum':
|
||||
return 'text-purple-500';
|
||||
case 'gold':
|
||||
return 'text-yellow-500';
|
||||
case 'silver':
|
||||
return 'text-gray-400';
|
||||
default:
|
||||
return 'text-amber-700';
|
||||
}
|
||||
}
|
||||
|
||||
function getTierEmoji(tier: string): string {
|
||||
switch (tier) {
|
||||
case 'platinum':
|
||||
return '💎';
|
||||
case 'gold':
|
||||
return '🥇';
|
||||
case 'silver':
|
||||
return '🥈';
|
||||
default:
|
||||
return '🥉';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div>
|
||||
<h3 class="mb-3 flex items-center gap-2 text-lg font-semibold">
|
||||
<span>🤝</span>
|
||||
{$_('dashboard.widgets.referral.title')}
|
||||
</h3>
|
||||
|
||||
{#if state === 'loading'}
|
||||
<WidgetSkeleton lines={4} />
|
||||
{:else if state === 'error'}
|
||||
<WidgetError {error} onRetry={load} {retrying} />
|
||||
{:else if stats && code}
|
||||
<div class="space-y-4">
|
||||
<!-- Referral Code -->
|
||||
<div class="rounded-lg bg-primary/5 p-3">
|
||||
<div class="mb-1 text-xs text-muted-foreground">
|
||||
{$_('dashboard.widgets.referral.your_code')}
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="font-mono text-xl font-bold tracking-wider">{code.code}</span>
|
||||
<button
|
||||
onclick={copyCode}
|
||||
class="rounded-md px-3 py-1 text-sm font-medium transition-colors
|
||||
{copied ? 'bg-green-500/20 text-green-600' : 'bg-primary/10 text-primary hover:bg-primary/20'}"
|
||||
>
|
||||
{copied ? '✓ Copied!' : 'Copy Link'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stats -->
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div class="rounded-lg bg-muted/50 p-2 text-center">
|
||||
<div class="text-2xl font-bold">{stats.totalReferrals}</div>
|
||||
<div class="text-xs text-muted-foreground">{$_('dashboard.widgets.referral.total')}</div>
|
||||
</div>
|
||||
<div class="rounded-lg bg-muted/50 p-2 text-center">
|
||||
<div class="text-2xl font-bold text-green-500">+{stats.totalCreditsEarned}</div>
|
||||
<div class="text-xs text-muted-foreground">{$_('dashboard.widgets.referral.earned')}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tier -->
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<span class="text-muted-foreground">{$_('dashboard.widgets.referral.tier')}</span>
|
||||
<span class="font-medium {getTierColor(stats.currentTier)}">
|
||||
{getTierEmoji(stats.currentTier)}
|
||||
{stats.currentTier.charAt(0).toUpperCase() + stats.currentTier.slice(1)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Progress Bar -->
|
||||
{#if stats.tierProgress.nextTierAt > 0}
|
||||
<div>
|
||||
<div class="mb-1 flex justify-between text-xs text-muted-foreground">
|
||||
<span>{stats.tierProgress.current} / {stats.tierProgress.nextTierAt}</span>
|
||||
<span>{stats.tierProgress.percentToNext}%</span>
|
||||
</div>
|
||||
<div class="h-2 overflow-hidden rounded-full bg-muted">
|
||||
<div
|
||||
class="h-full bg-primary transition-all duration-300"
|
||||
style="width: {stats.tierProgress.percentToNext}%"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
|
@ -101,10 +101,6 @@
|
|||
"recent": "Kürzlich",
|
||||
"empty": "Keine Dateien",
|
||||
"open": "Storage öffnen"
|
||||
},
|
||||
"referral": {
|
||||
"title": "Empfehlungen",
|
||||
"description": "Teile und verdiene Credits"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -101,10 +101,6 @@
|
|||
"recent": "Recent",
|
||||
"empty": "No files",
|
||||
"open": "Open Storage"
|
||||
},
|
||||
"referral": {
|
||||
"title": "Referrals",
|
||||
"description": "Share and earn credits"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -143,9 +143,8 @@ export const authStore = {
|
|||
* Sign up with email and password
|
||||
* @param email User email
|
||||
* @param password User password
|
||||
* @param referralCode Optional referral code for bonus credits
|
||||
*/
|
||||
async signUp(email: string, password: string, referralCode?: string) {
|
||||
async signUp(email: string, password: string) {
|
||||
const authService = getAuthService();
|
||||
if (!authService) {
|
||||
return { success: false, error: 'Auth not available on server', needsVerification: false };
|
||||
|
|
@ -154,7 +153,7 @@ export const authStore = {
|
|||
try {
|
||||
// Pass the current app URL for post-verification redirect
|
||||
const sourceAppUrl = browser ? window.location.origin : undefined;
|
||||
const result = await authService.signUp(email, password, referralCode, sourceAppUrl);
|
||||
const result = await authService.signUp(email, password, undefined, sourceAppUrl);
|
||||
|
||||
if (!result.success) {
|
||||
return { success: false, error: result.error || 'Signup failed', needsVerification: false };
|
||||
|
|
@ -174,27 +173,6 @@ export const authStore = {
|
|||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Validate a referral code
|
||||
*/
|
||||
async validateReferralCode(code: string) {
|
||||
try {
|
||||
const response = await fetch(`${getAuthUrl()}/api/v1/referrals/validate/${code}`);
|
||||
if (!response.ok) {
|
||||
return { valid: false, error: 'Invalid code' };
|
||||
}
|
||||
const data = await response.json();
|
||||
return {
|
||||
valid: data.valid,
|
||||
referrerName: data.referrerName,
|
||||
bonusCredits: data.bonusCredits || 25,
|
||||
error: data.error,
|
||||
};
|
||||
} catch {
|
||||
return { valid: false, error: 'Validation failed' };
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Sign out
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -11,7 +11,6 @@ export type WidgetType =
|
|||
| 'credits' // Credits balance from mana-core-auth
|
||||
| 'quick-actions' // Quick action links
|
||||
| 'transactions' // Recent credit transactions
|
||||
| 'referral' // Referral code and stats
|
||||
| 'tasks-today' // Todo API: today's tasks
|
||||
| 'tasks-upcoming' // Todo API: upcoming 7 days
|
||||
| 'calendar-events' // Calendar API: upcoming events
|
||||
|
|
@ -149,15 +148,6 @@ export const WIDGET_REGISTRY: WidgetMeta[] = [
|
|||
allowMultiple: false,
|
||||
requiredBackend: 'mana-core-auth',
|
||||
},
|
||||
{
|
||||
type: 'referral',
|
||||
nameKey: 'dashboard.widgets.referral.title',
|
||||
descriptionKey: 'dashboard.widgets.referral.description',
|
||||
icon: '🤝',
|
||||
defaultSize: 'medium',
|
||||
allowMultiple: false,
|
||||
requiredBackend: 'mana-core-auth',
|
||||
},
|
||||
{
|
||||
type: 'tasks-today',
|
||||
nameKey: 'dashboard.widgets.tasks_today.title',
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/stores';
|
||||
import { locale } from 'svelte-i18n';
|
||||
import { RegisterPage } from '@manacore/shared-auth-ui';
|
||||
import { getRegisterTranslations } from '@manacore/shared-i18n';
|
||||
|
|
@ -9,18 +8,11 @@
|
|||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import '$lib/i18n';
|
||||
|
||||
// Get referral code from URL if present
|
||||
let initialReferralCode = $derived($page.url.searchParams.get('ref') || '');
|
||||
|
||||
// Get translations based on current locale
|
||||
const translations = $derived(getRegisterTranslations($locale || 'de'));
|
||||
|
||||
async function handleSignUp(email: string, password: string, referralCode?: string) {
|
||||
return authStore.signUp(email, password, referralCode);
|
||||
}
|
||||
|
||||
async function handleValidateReferralCode(code: string) {
|
||||
return authStore.validateReferralCode(code);
|
||||
async function handleSignUp(email: string, password: string) {
|
||||
return authStore.signUp(email, password);
|
||||
}
|
||||
|
||||
async function handleResendVerification(email: string) {
|
||||
|
|
@ -38,8 +30,6 @@
|
|||
primaryColor="#6366f1"
|
||||
onSignUp={handleSignUp}
|
||||
onResendVerification={handleResendVerification}
|
||||
onValidateReferralCode={handleValidateReferralCode}
|
||||
{initialReferralCode}
|
||||
baseSignupCredits={25}
|
||||
{goto}
|
||||
successRedirect="/dashboard"
|
||||
|
|
|
|||
|
|
@ -522,8 +522,8 @@ Drizzle ORM does not support automatic rollbacks. Plan your migrations carefully
|
|||
|
||||
```
|
||||
src/db/migrations/
|
||||
├── 001_add_referrals.up.sql
|
||||
├── 001_add_referrals.down.sql # Manual rollback script
|
||||
├── 001_add_feature.up.sql
|
||||
├── 001_add_feature.down.sql # Manual rollback script
|
||||
```
|
||||
|
||||
2. **Execute rollback manually**:
|
||||
|
|
@ -533,7 +533,7 @@ src/db/migrations/
|
|||
docker compose exec -T postgres psql -U postgres -d manacore_auth
|
||||
|
||||
# Run down migration
|
||||
\i /path/to/001_add_referrals.down.sql
|
||||
\i /path/to/001_add_feature.down.sql
|
||||
```
|
||||
|
||||
### Rollback Checklist
|
||||
|
|
|
|||
|
|
@ -10,14 +10,13 @@ Konzept für fraud-resistente Mechanismen, durch die Nutzer Mana Credits verdien
|
|||
|
||||
## Übersicht
|
||||
|
||||
Das Earning-System besteht aus vier Säulen:
|
||||
Das Earning-System besteht aus drei Säulen:
|
||||
|
||||
| Säule | Beschreibung | Reward-Typ |
|
||||
|-------|--------------|------------|
|
||||
| **Karma/XP System** | Gamification ohne monetären Wert | XP, Badges, Levels |
|
||||
| **Creator Rewards** | Social-Proof-basierte Content-Belohnung | Credits (delayed) |
|
||||
| **Community Bounties** | Kuratierte Belohnungen für Contributions | Credits (manual) |
|
||||
| **Referral Program** | Bestehendes System | Credits |
|
||||
|
||||
### Design-Prinzipien
|
||||
|
||||
|
|
@ -497,31 +496,6 @@ CREATE TABLE community.bounty_pool (
|
|||
|
||||
---
|
||||
|
||||
## 4. Referral Program (Bestehend)
|
||||
|
||||
Das bestehende Referral-System bleibt unverändert. Dokumentation siehe `services/mana-core-auth/src/referrals/`.
|
||||
|
||||
### Zusammenfassung
|
||||
|
||||
| Event | Referee bekommt | Referrer bekommt |
|
||||
|-------|-----------------|------------------|
|
||||
| Registration | 25 Credits | 5 × Tier-Multiplier |
|
||||
| Activation | - | 10 × Tier-Multiplier |
|
||||
| Qualification (1. Kauf) | - | 50 × Tier-Multiplier |
|
||||
| Cross-App Usage | - | 5 × Tier-Multiplier (pro App) |
|
||||
| Retention (30 Tage) | - | 25 × Tier-Multiplier |
|
||||
|
||||
### Tier-Multiplikatoren
|
||||
|
||||
| Tier | Qualifizierte Referrals | Multiplier |
|
||||
|------|------------------------|------------|
|
||||
| Bronze | 0 | 1.0x |
|
||||
| Silver | 1-5 | 1.5x |
|
||||
| Gold | 6-15 | 2.0x |
|
||||
| Platinum | 16+ | 3.0x |
|
||||
|
||||
---
|
||||
|
||||
## Implementation Roadmap
|
||||
|
||||
### Phase 1: Karma/XP System
|
||||
|
|
@ -804,5 +778,4 @@ bugs_triage_queue_size // Gauge: Pending triage
|
|||
## Related Documents
|
||||
|
||||
- [Credit System (bestehend)](../services/mana-core-auth/src/credits/)
|
||||
- [Referral System (bestehend)](../services/mana-core-auth/src/referrals/)
|
||||
- [Credit Operations Registry](../packages/credit-operations/)
|
||||
|
|
|
|||
|
|
@ -17,12 +17,6 @@
|
|||
backToLogin: string;
|
||||
showPassword: string;
|
||||
hidePassword: string;
|
||||
// Referral
|
||||
referralCodePlaceholder: string;
|
||||
referralCodeValid: string;
|
||||
referralCodeInvalid: string;
|
||||
referralCodeValidating: string;
|
||||
referralBonusCredits: string;
|
||||
// Error messages
|
||||
emailRequired: string;
|
||||
passwordRequired: string;
|
||||
|
|
@ -40,14 +34,6 @@
|
|||
checkYourEmail?: string;
|
||||
}
|
||||
|
||||
/** Referral code validation result */
|
||||
interface ReferralValidation {
|
||||
valid: boolean;
|
||||
referrerName?: string;
|
||||
bonusCredits?: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/** Default English translations */
|
||||
const defaultTranslations: RegisterTranslations = {
|
||||
title: 'Create Account',
|
||||
|
|
@ -61,12 +47,6 @@
|
|||
backToLogin: 'Back to Login',
|
||||
showPassword: 'Show password',
|
||||
hidePassword: 'Hide password',
|
||||
// Referral
|
||||
referralCodePlaceholder: 'Referral Code (optional)',
|
||||
referralCodeValid: 'Valid code!',
|
||||
referralCodeInvalid: 'Invalid code',
|
||||
referralCodeValidating: 'Checking...',
|
||||
referralBonusCredits: 'bonus credits',
|
||||
// Error messages
|
||||
emailRequired: 'Email is required',
|
||||
passwordRequired: 'Password is required',
|
||||
|
|
@ -91,8 +71,8 @@
|
|||
logo: Component<{ size?: number; color?: string }>;
|
||||
/** Primary color (hex) */
|
||||
primaryColor: string;
|
||||
/** Sign up function (with optional referral code) */
|
||||
onSignUp: (email: string, password: string, referralCode?: string) => Promise<AuthResult>;
|
||||
/** Sign up function */
|
||||
onSignUp: (email: string, password: string) => Promise<AuthResult>;
|
||||
/** Navigation function */
|
||||
goto: (path: string) => void;
|
||||
/** Resend verification email function */
|
||||
|
|
@ -111,12 +91,6 @@
|
|||
headerControls?: Snippet;
|
||||
/** Translations for i18n support */
|
||||
translations?: Partial<RegisterTranslations>;
|
||||
/** Referral code validation function */
|
||||
onValidateReferralCode?: (code: string) => Promise<ReferralValidation>;
|
||||
/** Initial referral code (e.g., from URL) */
|
||||
initialReferralCode?: string;
|
||||
/** Base signup credits (shown to user) */
|
||||
baseSignupCredits?: number;
|
||||
}
|
||||
|
||||
let {
|
||||
|
|
@ -133,9 +107,6 @@
|
|||
appSlider,
|
||||
headerControls,
|
||||
translations = {},
|
||||
onValidateReferralCode,
|
||||
initialReferralCode = '',
|
||||
baseSignupCredits = 25,
|
||||
}: Props = $props();
|
||||
|
||||
// Merge provided translations with defaults
|
||||
|
|
@ -153,17 +124,6 @@
|
|||
let resendingVerification = $state(false);
|
||||
let verificationEmailSent = $state(false);
|
||||
|
||||
// Referral state
|
||||
let referralCode = $state(initialReferralCode);
|
||||
let referralValidation = $state<ReferralValidation | null>(null);
|
||||
let validatingReferral = $state(false);
|
||||
let referralDebounceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
// Calculate total credits (base + bonus)
|
||||
let totalCredits = $derived(
|
||||
baseSignupCredits + (referralValidation?.valid ? referralValidation.bonusCredits || 0 : 0)
|
||||
);
|
||||
|
||||
// Theme state - can be toggled manually, defaults to system preference
|
||||
let userThemePreference = $state<'light' | 'dark' | null>(null);
|
||||
let systemIsDark = $state(
|
||||
|
|
@ -195,44 +155,6 @@
|
|||
}
|
||||
}
|
||||
|
||||
// Referral code validation
|
||||
async function validateReferralCode(code: string) {
|
||||
if (!code || code.length < 3 || !onValidateReferralCode) {
|
||||
referralValidation = null;
|
||||
return;
|
||||
}
|
||||
|
||||
validatingReferral = true;
|
||||
try {
|
||||
const result = await onValidateReferralCode(code);
|
||||
referralValidation = result;
|
||||
} catch {
|
||||
referralValidation = { valid: false, error: 'Validation failed' };
|
||||
} finally {
|
||||
validatingReferral = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleReferralCodeChange(value: string) {
|
||||
referralCode = value.toUpperCase().replace(/[^A-Z0-9]/g, '');
|
||||
|
||||
// Clear previous timer
|
||||
if (referralDebounceTimer) {
|
||||
clearTimeout(referralDebounceTimer);
|
||||
}
|
||||
|
||||
// Reset validation if empty
|
||||
if (!referralCode) {
|
||||
referralValidation = null;
|
||||
return;
|
||||
}
|
||||
|
||||
// Debounce validation
|
||||
referralDebounceTimer = setTimeout(() => {
|
||||
validateReferralCode(referralCode);
|
||||
}, 500);
|
||||
}
|
||||
|
||||
// Password validation
|
||||
let passwordRequirements = $derived.by(() => {
|
||||
if (!password) {
|
||||
|
|
@ -306,9 +228,7 @@
|
|||
return;
|
||||
}
|
||||
|
||||
// Pass referral code if valid
|
||||
const validReferralCode = referralValidation?.valid ? referralCode : undefined;
|
||||
const result = await onSignUp(email, password, validReferralCode);
|
||||
const result = await onSignUp(email, password);
|
||||
|
||||
loading = false;
|
||||
|
||||
|
|
@ -625,58 +545,6 @@
|
|||
{t.passwordRequirements}
|
||||
</p>
|
||||
|
||||
<!-- Referral Code Input -->
|
||||
{#if onValidateReferralCode}
|
||||
<div>
|
||||
<div class="relative">
|
||||
<input
|
||||
type="text"
|
||||
value={referralCode}
|
||||
oninput={(e) => handleReferralCodeChange((e.target as HTMLInputElement).value)}
|
||||
placeholder={t.referralCodePlaceholder}
|
||||
maxlength={12}
|
||||
class="h-14 w-full rounded-xl border px-4 pr-24 text-lg transition-colors focus:outline-none focus:ring-2 uppercase tracking-wider"
|
||||
style="background-color: {isDark
|
||||
? 'rgba(0, 0, 0, 0.2)'
|
||||
: 'rgba(255, 255, 255, 0.8)'}; border-color: {referralValidation?.valid
|
||||
? '#22c55e'
|
||||
: referralValidation && !referralValidation.valid
|
||||
? '#ef4444'
|
||||
: isDark
|
||||
? 'rgba(255, 255, 255, 0.2)'
|
||||
: 'rgba(0, 0, 0, 0.1)'}; color: {isDark
|
||||
? '#ffffff'
|
||||
: '#000000'}; --tw-ring-color: {primaryColor};"
|
||||
/>
|
||||
<!-- Validation indicator -->
|
||||
<div class="absolute inset-y-0 right-0 flex items-center pr-4">
|
||||
{#if validatingReferral}
|
||||
<span
|
||||
class="text-sm"
|
||||
style="color: {isDark ? 'rgba(255, 255, 255, 0.6)' : 'rgba(0, 0, 0, 0.6)'};"
|
||||
>
|
||||
{t.referralCodeValidating}
|
||||
</span>
|
||||
{:else if referralValidation?.valid}
|
||||
<span class="text-sm text-green-500">✓ {t.referralCodeValid}</span>
|
||||
{:else if referralValidation && !referralValidation.valid}
|
||||
<span class="text-sm text-red-500">✗ {t.referralCodeInvalid}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<!-- Bonus info -->
|
||||
{#if referralValidation?.valid && referralValidation.bonusCredits}
|
||||
<p class="mt-2 text-sm text-green-500">
|
||||
+{referralValidation.bonusCredits}
|
||||
{t.referralBonusCredits}
|
||||
{#if referralValidation.referrerName}
|
||||
(von {referralValidation.referrerName})
|
||||
{/if}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
|
|
|
|||
|
|
@ -109,20 +109,11 @@ export function createAuthService(config: AuthServiceConfig) {
|
|||
* Sign up with email and password
|
||||
* @param email User email
|
||||
* @param password User password
|
||||
* @param referralCode Optional referral code for bonus credits
|
||||
* @param sourceAppUrl Optional URL of the app where the user is registering
|
||||
*/
|
||||
async signUp(
|
||||
email: string,
|
||||
password: string,
|
||||
referralCode?: string,
|
||||
sourceAppUrl?: string
|
||||
): Promise<AuthResult> {
|
||||
async signUp(email: string, password: string, sourceAppUrl?: string): Promise<AuthResult> {
|
||||
try {
|
||||
const body: Record<string, string> = { email, password };
|
||||
if (referralCode) {
|
||||
body.referralCode = referralCode;
|
||||
}
|
||||
if (sourceAppUrl) {
|
||||
body.sourceAppUrl = sourceAppUrl;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,5 +2,5 @@ import { createDrizzleConfig } from '@manacore/shared-drizzle-config';
|
|||
|
||||
export default createDrizzleConfig({
|
||||
dbName: 'manacore',
|
||||
schemaFilter: ['auth', 'credits', 'gifts', 'referrals', 'subscriptions', 'public'],
|
||||
schemaFilter: ['auth', 'credits', 'gifts', 'subscriptions', 'public'],
|
||||
});
|
||||
|
|
|
|||
|
|
@ -11,7 +11,6 @@ import { CreditsModule } from './credits/credits.module';
|
|||
import { FeedbackModule } from './feedback/feedback.module';
|
||||
import { GiftsModule } from './gifts/gifts.module';
|
||||
import { HealthModule } from './health/health.module';
|
||||
import { ReferralsModule } from './referrals/referrals.module';
|
||||
import { SettingsModule } from './settings/settings.module';
|
||||
import { StorageModule } from './storage/storage.module';
|
||||
import { TagsModule } from './tags/tags.module';
|
||||
|
|
@ -46,7 +45,6 @@ import { LoggerModule } from './common/logger';
|
|||
FeedbackModule,
|
||||
GiftsModule,
|
||||
HealthModule,
|
||||
ReferralsModule,
|
||||
SettingsModule,
|
||||
StorageModule,
|
||||
TagsModule,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { Module, forwardRef } from '@nestjs/common';
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AuthController } from './auth.controller';
|
||||
import { BetterAuthPassthroughController } from './better-auth-passthrough.controller';
|
||||
import { OidcController } from './oidc.controller';
|
||||
|
|
@ -6,10 +6,8 @@ import { OidcLoginController } from './oidc-login.controller';
|
|||
import { MatrixSessionController } from './matrix-session.controller';
|
||||
import { BetterAuthService } from './services/better-auth.service';
|
||||
import { MatrixSessionService } from './services/matrix-session.service';
|
||||
import { ReferralsModule } from '../referrals/referrals.module';
|
||||
|
||||
@Module({
|
||||
imports: [forwardRef(() => ReferralsModule)],
|
||||
controllers: [
|
||||
AuthController,
|
||||
BetterAuthPassthroughController,
|
||||
|
|
|
|||
|
|
@ -20,9 +20,6 @@ import { silentError } from '../../__tests__/utils/silent-error.decorator';
|
|||
|
||||
// Mock services that are not yet implemented
|
||||
const SecurityEventsService = jest.fn();
|
||||
const ReferralCodeService = jest.fn();
|
||||
const ReferralTierService = jest.fn();
|
||||
const ReferralTrackingService = jest.fn();
|
||||
|
||||
// Mock nanoid before importing factories
|
||||
jest.mock('nanoid', () => ({
|
||||
|
|
@ -57,18 +54,6 @@ const mockSecurityEventsService = {
|
|||
logEvent: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
|
||||
const mockReferralCodeService = {
|
||||
createAutoCode: jest.fn().mockResolvedValue({ id: 'code-123', code: 'ABC123' }),
|
||||
};
|
||||
|
||||
const mockReferralTierService = {
|
||||
initializeUserTier: jest.fn().mockResolvedValue({ id: 'tier-123', tier: 'bronze' }),
|
||||
};
|
||||
|
||||
const mockReferralTrackingService = {
|
||||
applyReferral: jest.fn().mockResolvedValue({ success: true }),
|
||||
};
|
||||
|
||||
const mockLoggerService = {
|
||||
setContext: jest.fn().mockReturnThis(),
|
||||
log: jest.fn(),
|
||||
|
|
@ -114,18 +99,6 @@ describe('BetterAuthService', () => {
|
|||
provide: SecurityEventsService,
|
||||
useValue: mockSecurityEventsService,
|
||||
},
|
||||
{
|
||||
provide: ReferralCodeService,
|
||||
useValue: mockReferralCodeService,
|
||||
},
|
||||
{
|
||||
provide: ReferralTierService,
|
||||
useValue: mockReferralTierService,
|
||||
},
|
||||
{
|
||||
provide: ReferralTrackingService,
|
||||
useValue: mockReferralTrackingService,
|
||||
},
|
||||
{
|
||||
provide: LoggerService,
|
||||
useValue: mockLoggerService,
|
||||
|
|
|
|||
|
|
@ -29,9 +29,6 @@ import { createBetterAuth } from '../better-auth.config';
|
|||
import type { BetterAuthInstance } from '../better-auth.config';
|
||||
import { getDb } from '../../db/connection';
|
||||
import { balances } from '../../db/schema/credits.schema';
|
||||
import { ReferralCodeService } from '../../referrals/services/referral-code.service';
|
||||
import { ReferralTierService } from '../../referrals/services/referral-tier.service';
|
||||
import { ReferralTrackingService } from '../../referrals/services/referral-tracking.service';
|
||||
import { GiftCodeService } from '../../gifts/services/gift-code.service';
|
||||
import { hasUser, hasToken, hasMember, hasMembers, hasSession } from '../types/better-auth.types';
|
||||
import { sourceAppStore } from '../stores/source-app.store';
|
||||
|
|
@ -114,15 +111,6 @@ export class BetterAuthService {
|
|||
constructor(
|
||||
private configService: ConfigService,
|
||||
@Optional()
|
||||
@Inject(forwardRef(() => ReferralCodeService))
|
||||
private referralCodeService: ReferralCodeService,
|
||||
@Optional()
|
||||
@Inject(forwardRef(() => ReferralTierService))
|
||||
private referralTierService: ReferralTierService,
|
||||
@Optional()
|
||||
@Inject(forwardRef(() => ReferralTrackingService))
|
||||
private referralTrackingService: ReferralTrackingService,
|
||||
@Optional()
|
||||
@Inject(forwardRef(() => GiftCodeService))
|
||||
private giftCodeService: GiftCodeService,
|
||||
loggerService: LoggerService
|
||||
|
|
@ -186,9 +174,6 @@ export class BetterAuthService {
|
|||
}
|
||||
}
|
||||
|
||||
// Initialize referral system for new user
|
||||
await this.initializeUserReferrals(user.id, dto.referralCode, dto.sourceAppId);
|
||||
|
||||
return {
|
||||
user: {
|
||||
id: user.id,
|
||||
|
|
@ -1686,58 +1671,6 @@ export class BetterAuthService {
|
|||
.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize referral system for a new user
|
||||
*
|
||||
* This method:
|
||||
* 1. Creates an automatic referral code for the new user
|
||||
* 2. Initializes the user's tier (bronze)
|
||||
* 3. If a referral code was used, applies the referral relationship
|
||||
*
|
||||
* @param userId - The new user's ID
|
||||
* @param referralCode - Optional referral code used during signup
|
||||
* @param sourceAppId - Optional app ID where the user registered
|
||||
* @private
|
||||
*/
|
||||
private async initializeUserReferrals(
|
||||
userId: string,
|
||||
referralCode?: string,
|
||||
sourceAppId?: string
|
||||
): Promise<void> {
|
||||
// Skip if referral services are not available
|
||||
if (!this.referralCodeService || !this.referralTierService) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// 1. Create automatic referral code for the new user
|
||||
await this.referralCodeService.createAutoCode(userId);
|
||||
|
||||
// 2. Initialize user's tier (starts at bronze)
|
||||
await this.referralTierService.initializeUserTier(userId);
|
||||
|
||||
// 3. If a referral code was provided, apply the referral relationship
|
||||
if (referralCode && this.referralTrackingService) {
|
||||
// The applyReferral method handles validation internally
|
||||
const result = await this.referralTrackingService.applyReferral({
|
||||
refereeId: userId,
|
||||
code: referralCode,
|
||||
sourceAppId: sourceAppId || 'manacore',
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
this.logger.warn('Failed to apply referral code', { error: result.error, referralCode });
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// Log but don't fail registration if referral setup fails
|
||||
this.logger.error(
|
||||
'Error setting up referrals',
|
||||
error instanceof Error ? error.stack : undefined
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// OIDC Provider Methods
|
||||
// =========================================================================
|
||||
|
|
|
|||
|
|
@ -374,7 +374,6 @@ export interface RegisterB2CDto {
|
|||
email: string;
|
||||
password: string;
|
||||
name: string;
|
||||
referralCode?: string;
|
||||
sourceAppId?: string;
|
||||
sourceAppUrl?: string;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,5 @@ export * from './credits.schema';
|
|||
export * from './feedback.schema';
|
||||
export * from './gifts.schema';
|
||||
export * from './organizations.schema';
|
||||
export * from './referrals.schema';
|
||||
export * from './subscriptions.schema';
|
||||
export * from './tags.schema';
|
||||
|
|
|
|||
|
|
@ -1,522 +0,0 @@
|
|||
/**
|
||||
* 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;
|
||||
|
|
@ -1,191 +0,0 @@
|
|||
/**
|
||||
* 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;
|
||||
};
|
||||
}
|
||||
|
|
@ -1,193 +0,0 @@
|
|||
/**
|
||||
* Referrals Admin Controller
|
||||
*
|
||||
* Admin-only endpoints for managing the referral system:
|
||||
* - Review queue management
|
||||
* - Fraud pattern management
|
||||
* - Statistics and reporting
|
||||
*/
|
||||
|
||||
import { Controller, Get, Post, Body, Param, Query, HttpCode, HttpStatus } from '@nestjs/common';
|
||||
import { FraudDetectionService } from './services/fraud-detection.service';
|
||||
import { ReferralTrackingService } from './services/referral-tracking.service';
|
||||
|
||||
// DTOs for admin endpoints
|
||||
class ProcessReviewDto {
|
||||
decision: 'approved' | 'rejected';
|
||||
reviewerId: string;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
class AddFraudPatternDto {
|
||||
patternType: 'email_domain' | 'ip_range' | 'device_pattern';
|
||||
patternValue: string;
|
||||
severity: 'low' | 'medium' | 'high' | 'critical';
|
||||
scoreImpact: number;
|
||||
description: string;
|
||||
createdBy: string;
|
||||
}
|
||||
|
||||
class PaginationQuery {
|
||||
limit?: string;
|
||||
offset?: string;
|
||||
}
|
||||
|
||||
// Note: In production, add proper auth guard
|
||||
// @UseGuards(AdminAuthGuard)
|
||||
@Controller('referrals/admin')
|
||||
export class ReferralsAdminController {
|
||||
constructor(
|
||||
private fraudDetectionService: FraudDetectionService,
|
||||
private trackingService: ReferralTrackingService
|
||||
) {}
|
||||
|
||||
// ===================================
|
||||
// REVIEW QUEUE ENDPOINTS
|
||||
// ===================================
|
||||
|
||||
/**
|
||||
* Get pending review items
|
||||
* GET /referrals/admin/reviews
|
||||
*/
|
||||
@Get('reviews')
|
||||
async getPendingReviews(@Query() query: PaginationQuery) {
|
||||
const limit = parseInt(query.limit || '50', 10);
|
||||
const offset = parseInt(query.offset || '0', 10);
|
||||
|
||||
const reviews = await this.fraudDetectionService.getPendingReviews(limit, offset);
|
||||
|
||||
return {
|
||||
items: reviews,
|
||||
pagination: {
|
||||
limit,
|
||||
offset,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a review decision
|
||||
* POST /referrals/admin/reviews/:id/process
|
||||
*/
|
||||
@Post('reviews/:id/process')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async processReview(@Param('id') reviewId: string, @Body() dto: ProcessReviewDto) {
|
||||
await this.fraudDetectionService.processReview(
|
||||
reviewId,
|
||||
dto.decision,
|
||||
dto.reviewerId,
|
||||
dto.notes
|
||||
);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `Review ${dto.decision}`,
|
||||
};
|
||||
}
|
||||
|
||||
// ===================================
|
||||
// FRAUD PATTERN ENDPOINTS
|
||||
// ===================================
|
||||
|
||||
/**
|
||||
* Add a new fraud pattern
|
||||
* POST /referrals/admin/fraud-patterns
|
||||
*/
|
||||
@Post('fraud-patterns')
|
||||
@HttpCode(HttpStatus.CREATED)
|
||||
async addFraudPattern(@Body() dto: AddFraudPatternDto) {
|
||||
await this.fraudDetectionService.addFraudPattern(
|
||||
dto.patternType,
|
||||
dto.patternValue,
|
||||
dto.severity,
|
||||
dto.scoreImpact,
|
||||
dto.description,
|
||||
dto.createdBy
|
||||
);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'Fraud pattern added',
|
||||
};
|
||||
}
|
||||
|
||||
// ===================================
|
||||
// STATISTICS ENDPOINTS
|
||||
// ===================================
|
||||
|
||||
/**
|
||||
* Get fraud statistics
|
||||
* GET /referrals/admin/stats/fraud
|
||||
*/
|
||||
@Get('stats/fraud')
|
||||
async getFraudStats() {
|
||||
return this.fraudDetectionService.getFraudStats();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get overall referral statistics
|
||||
* GET /referrals/admin/stats/overview
|
||||
*/
|
||||
@Get('stats/overview')
|
||||
async getOverviewStats() {
|
||||
return {
|
||||
message: 'Overview stats endpoint - to be implemented with aggregated data',
|
||||
};
|
||||
}
|
||||
|
||||
// ===================================
|
||||
// USER MANAGEMENT ENDPOINTS
|
||||
// ===================================
|
||||
|
||||
/**
|
||||
* Get referral details for a specific user
|
||||
* GET /referrals/admin/users/:userId/referrals
|
||||
*/
|
||||
@Get('users/:userId/referrals')
|
||||
async getUserReferrals(
|
||||
@Param('userId') userId: string,
|
||||
@Query('status') status: string | undefined,
|
||||
@Query() query: PaginationQuery
|
||||
) {
|
||||
const limit = parseInt(query.limit || '50', 10);
|
||||
const offset = parseInt(query.offset || '0', 10);
|
||||
|
||||
const result = await this.trackingService.getReferredUsers(userId, status, limit, offset);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get referral stats for a specific user
|
||||
* GET /referrals/admin/users/:userId/stats
|
||||
*/
|
||||
@Get('users/:userId/stats')
|
||||
async getUserStats(@Param('userId') userId: string) {
|
||||
return this.trackingService.getReferralStats(userId);
|
||||
}
|
||||
|
||||
// ===================================
|
||||
// MANUAL ACTIONS
|
||||
// ===================================
|
||||
|
||||
/**
|
||||
* Manually trigger stage update (for support/admin use)
|
||||
* POST /referrals/admin/manual/stage-update
|
||||
*/
|
||||
@Post('manual/stage-update')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async manualStageUpdate(
|
||||
@Body() dto: { userId: string; stage: 'activated' | 'qualified'; appId?: string }
|
||||
) {
|
||||
if (dto.stage === 'activated') {
|
||||
await this.trackingService.checkActivation(dto.userId);
|
||||
} else if (dto.stage === 'qualified') {
|
||||
await this.trackingService.checkQualification(dto.userId);
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `Stage update processed for user ${dto.userId}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -1,264 +0,0 @@
|
|||
/**
|
||||
* 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -1,40 +0,0 @@
|
|||
/**
|
||||
* Referrals Module
|
||||
*
|
||||
* NestJS module for the referral system.
|
||||
* Provides services for:
|
||||
* - Referral code management
|
||||
* - Referral tracking and stage progression
|
||||
* - Tier calculation and bonus multipliers
|
||||
* - Fraud detection and prevention
|
||||
*/
|
||||
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { ScheduleModule } from '@nestjs/schedule';
|
||||
import { ReferralsController } from './referrals.controller';
|
||||
import { ReferralsAdminController } from './referrals-admin.controller';
|
||||
import { ReferralCodeService } from './services/referral-code.service';
|
||||
import { ReferralTierService } from './services/referral-tier.service';
|
||||
import { ReferralTrackingService } from './services/referral-tracking.service';
|
||||
import { FraudDetectionService } from './services/fraud-detection.service';
|
||||
import { ReferralCronService } from './services/referral-cron.service';
|
||||
|
||||
@Module({
|
||||
imports: [ConfigModule, ScheduleModule.forRoot()],
|
||||
controllers: [ReferralsController, ReferralsAdminController],
|
||||
providers: [
|
||||
ReferralCodeService,
|
||||
ReferralTierService,
|
||||
ReferralTrackingService,
|
||||
FraudDetectionService,
|
||||
ReferralCronService,
|
||||
],
|
||||
exports: [
|
||||
ReferralCodeService,
|
||||
ReferralTierService,
|
||||
ReferralTrackingService,
|
||||
FraudDetectionService,
|
||||
],
|
||||
})
|
||||
export class ReferralsModule {}
|
||||
|
|
@ -1,642 +0,0 @@
|
|||
/**
|
||||
* Fraud Detection Service
|
||||
*
|
||||
* Handles fraud detection for the referral system:
|
||||
* - Device fingerprinting and tracking
|
||||
* - IP address analysis
|
||||
* - Pattern detection (velocity, clusters)
|
||||
* - Fraud scoring
|
||||
* - Auto-hold and review queue management
|
||||
*/
|
||||
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { eq, and, sql, gte, count, desc, or } from 'drizzle-orm';
|
||||
import { getDb } from '../../db/connection';
|
||||
import {
|
||||
fingerprints,
|
||||
userFingerprints,
|
||||
fraudPatterns,
|
||||
rateLimits,
|
||||
reviewQueue,
|
||||
relationships,
|
||||
FRAUD_THRESHOLDS,
|
||||
FRAUD_SIGNALS,
|
||||
RATE_LIMITS,
|
||||
type ReviewQueueItem,
|
||||
} from '../../db/schema/referrals.schema';
|
||||
import * as crypto from 'crypto';
|
||||
|
||||
/**
|
||||
* Fraud check input data
|
||||
*/
|
||||
export interface FraudCheckInput {
|
||||
userId: string;
|
||||
referrerId?: string;
|
||||
ipAddress?: string;
|
||||
userAgent?: string;
|
||||
deviceFingerprint?: string;
|
||||
email?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fraud check result
|
||||
*/
|
||||
export interface FraudCheckResult {
|
||||
score: number;
|
||||
signals: string[];
|
||||
action: 'allow' | 'hold' | 'reject';
|
||||
holdReason?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fingerprint data for storage
|
||||
*/
|
||||
export interface FingerprintData {
|
||||
ipAddress: string;
|
||||
deviceHash?: string;
|
||||
userAgent?: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class FraudDetectionService {
|
||||
private readonly logger = new Logger(FraudDetectionService.name);
|
||||
|
||||
constructor(private configService: ConfigService) {}
|
||||
|
||||
private getDb() {
|
||||
const databaseUrl = this.configService.get<string>('database.url');
|
||||
return getDb(databaseUrl!);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hash a value for privacy (GDPR compliance)
|
||||
*/
|
||||
private hashValue(value: string): string {
|
||||
return crypto.createHash('sha256').update(value).digest('hex');
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform comprehensive fraud check for a referral
|
||||
*/
|
||||
async checkFraud(input: FraudCheckInput): Promise<FraudCheckResult> {
|
||||
const signals: string[] = [];
|
||||
let score = 0;
|
||||
|
||||
try {
|
||||
// 1. Check IP-based signals
|
||||
if (input.ipAddress) {
|
||||
const ipSignals = await this.checkIpSignals(input.ipAddress, input.referrerId);
|
||||
signals.push(...ipSignals.signals);
|
||||
score += ipSignals.score;
|
||||
}
|
||||
|
||||
// 2. Check device fingerprint signals
|
||||
if (input.deviceFingerprint) {
|
||||
const fpSignals = await this.checkFingerprintSignals(
|
||||
input.deviceFingerprint,
|
||||
input.referrerId
|
||||
);
|
||||
signals.push(...fpSignals.signals);
|
||||
score += fpSignals.score;
|
||||
}
|
||||
|
||||
// 3. Check referrer velocity (too many referrals too fast)
|
||||
if (input.referrerId) {
|
||||
const velocitySignals = await this.checkReferrerVelocity(input.referrerId);
|
||||
signals.push(...velocitySignals.signals);
|
||||
score += velocitySignals.score;
|
||||
}
|
||||
|
||||
// 4. Check email patterns
|
||||
if (input.email) {
|
||||
const emailSignals = this.checkEmailPatterns(input.email);
|
||||
signals.push(...emailSignals.signals);
|
||||
score += emailSignals.score;
|
||||
}
|
||||
|
||||
// 5. Check for known fraud patterns
|
||||
const patternSignals = await this.checkKnownPatterns(input);
|
||||
signals.push(...patternSignals.signals);
|
||||
score += patternSignals.score;
|
||||
|
||||
// Determine action based on score
|
||||
let action: 'allow' | 'hold' | 'reject' = 'allow';
|
||||
let holdReason: string | undefined;
|
||||
|
||||
if (score >= FRAUD_THRESHOLDS.critical) {
|
||||
action = 'reject';
|
||||
} else if (score >= FRAUD_THRESHOLDS.highRisk) {
|
||||
action = 'hold';
|
||||
holdReason = signals.join(', ');
|
||||
}
|
||||
|
||||
this.logger.debug(
|
||||
`Fraud check for user ${input.userId}: score=${score}, action=${action}, signals=${signals.join(', ')}`
|
||||
);
|
||||
|
||||
return { score, signals, action, holdReason };
|
||||
} catch (error) {
|
||||
this.logger.error('Error during fraud check:', error);
|
||||
// On error, allow but flag for review
|
||||
return {
|
||||
score: FRAUD_THRESHOLDS.highRisk,
|
||||
signals: ['check_error'],
|
||||
action: 'hold',
|
||||
holdReason: 'Fraud check encountered an error',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check IP-based fraud signals
|
||||
*/
|
||||
private async checkIpSignals(
|
||||
ipAddress: string,
|
||||
referrerId?: string
|
||||
): Promise<{ score: number; signals: string[] }> {
|
||||
const db = this.getDb();
|
||||
const signals: string[] = [];
|
||||
let score = 0;
|
||||
const ipHash = this.hashValue(ipAddress);
|
||||
|
||||
// Check how many users registered from this IP
|
||||
const [ipCount] = await db
|
||||
.select({ count: count() })
|
||||
.from(fingerprints)
|
||||
.where(eq(fingerprints.ipHash, ipHash));
|
||||
|
||||
if (ipCount.count >= 5) {
|
||||
signals.push('same_ip');
|
||||
score += FRAUD_SIGNALS.same_ip;
|
||||
}
|
||||
|
||||
// Check if IP was used by referrer
|
||||
if (referrerId) {
|
||||
const [referrerIP] = await db
|
||||
.select()
|
||||
.from(userFingerprints)
|
||||
.innerJoin(fingerprints, eq(userFingerprints.fingerprintId, fingerprints.id))
|
||||
.where(and(eq(userFingerprints.userId, referrerId), eq(fingerprints.ipHash, ipHash)))
|
||||
.limit(1);
|
||||
|
||||
if (referrerIP) {
|
||||
signals.push('same_ip');
|
||||
score += FRAUD_SIGNALS.same_ip;
|
||||
}
|
||||
}
|
||||
|
||||
// Check if IP is from known proxy/VPN ranges
|
||||
if (this.isProxyIP(ipAddress)) {
|
||||
signals.push('vpn_proxy');
|
||||
score += FRAUD_SIGNALS.vpn_proxy;
|
||||
}
|
||||
|
||||
return { score, signals };
|
||||
}
|
||||
|
||||
/**
|
||||
* Check device fingerprint signals
|
||||
*/
|
||||
private async checkFingerprintSignals(
|
||||
deviceHash: string,
|
||||
referrerId?: string
|
||||
): Promise<{ score: number; signals: string[] }> {
|
||||
const db = this.getDb();
|
||||
const signals: string[] = [];
|
||||
let score = 0;
|
||||
|
||||
// Check how many users share this device
|
||||
const [fpCount] = await db
|
||||
.select({ count: count() })
|
||||
.from(userFingerprints)
|
||||
.innerJoin(fingerprints, eq(userFingerprints.fingerprintId, fingerprints.id))
|
||||
.where(eq(fingerprints.deviceHash, deviceHash));
|
||||
|
||||
if (fpCount.count >= 3) {
|
||||
signals.push('same_device');
|
||||
score += FRAUD_SIGNALS.same_device;
|
||||
}
|
||||
|
||||
// Check if device was used by referrer
|
||||
if (referrerId) {
|
||||
const [referrerDevice] = await db
|
||||
.select()
|
||||
.from(userFingerprints)
|
||||
.innerJoin(fingerprints, eq(userFingerprints.fingerprintId, fingerprints.id))
|
||||
.where(
|
||||
and(eq(userFingerprints.userId, referrerId), eq(fingerprints.deviceHash, deviceHash))
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (referrerDevice) {
|
||||
signals.push('same_device');
|
||||
score += FRAUD_SIGNALS.same_device;
|
||||
}
|
||||
}
|
||||
|
||||
return { score, signals };
|
||||
}
|
||||
|
||||
/**
|
||||
* Check referrer velocity (too many referrals too fast)
|
||||
*/
|
||||
private async checkReferrerVelocity(
|
||||
referrerId: string
|
||||
): Promise<{ score: number; signals: string[] }> {
|
||||
const db = this.getDb();
|
||||
const signals: string[] = [];
|
||||
let score = 0;
|
||||
|
||||
const oneDayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000);
|
||||
|
||||
// Check referrals in last day
|
||||
const [dailyCount] = await db
|
||||
.select({ count: count() })
|
||||
.from(relationships)
|
||||
.where(
|
||||
and(eq(relationships.referrerId, referrerId), gte(relationships.createdAt, oneDayAgo))
|
||||
);
|
||||
|
||||
if (dailyCount.count >= RATE_LIMITS.registrationsPerReferrer.limit) {
|
||||
signals.push('rapid_registration');
|
||||
score += FRAUD_SIGNALS.rapid_registration;
|
||||
}
|
||||
|
||||
if (dailyCount.count >= 10) {
|
||||
signals.push('bulk_registrations');
|
||||
score += FRAUD_SIGNALS.bulk_registrations;
|
||||
}
|
||||
|
||||
return { score, signals };
|
||||
}
|
||||
|
||||
/**
|
||||
* Check email patterns for fraud indicators
|
||||
*/
|
||||
private checkEmailPatterns(email: string): { score: number; signals: string[] } {
|
||||
const signals: string[] = [];
|
||||
let score = 0;
|
||||
|
||||
const lowerEmail = email.toLowerCase();
|
||||
|
||||
// Check for disposable email domains
|
||||
const disposableDomains = [
|
||||
'tempmail.com',
|
||||
'throwaway.com',
|
||||
'guerrillamail.com',
|
||||
'10minutemail.com',
|
||||
'mailinator.com',
|
||||
'yopmail.com',
|
||||
'fakeinbox.com',
|
||||
'trashmail.com',
|
||||
];
|
||||
|
||||
const domain = lowerEmail.split('@')[1];
|
||||
if (disposableDomains.some((d) => domain?.includes(d))) {
|
||||
signals.push('disposable_email');
|
||||
score += FRAUD_SIGNALS.disposable_email;
|
||||
}
|
||||
|
||||
// Check for plus-addressing pattern abuse (test+1@gmail.com)
|
||||
if (lowerEmail.includes('+') && /\+\d+@/.test(lowerEmail)) {
|
||||
signals.push('similar_email');
|
||||
score += FRAUD_SIGNALS.similar_email;
|
||||
}
|
||||
|
||||
return { score, signals };
|
||||
}
|
||||
|
||||
/**
|
||||
* Check for known fraud patterns in database
|
||||
*/
|
||||
private async checkKnownPatterns(
|
||||
input: FraudCheckInput
|
||||
): Promise<{ score: number; signals: string[] }> {
|
||||
const db = this.getDb();
|
||||
const signals: string[] = [];
|
||||
let score = 0;
|
||||
|
||||
if (!input.email) {
|
||||
return { score, signals };
|
||||
}
|
||||
|
||||
const domain = input.email.split('@')[1];
|
||||
if (!domain) {
|
||||
return { score, signals };
|
||||
}
|
||||
|
||||
// Check for known bad email domains
|
||||
const patterns = await db
|
||||
.select()
|
||||
.from(fraudPatterns)
|
||||
.where(
|
||||
and(
|
||||
eq(fraudPatterns.isActive, true),
|
||||
eq(fraudPatterns.patternType, 'email_domain'),
|
||||
eq(fraudPatterns.patternValue, domain)
|
||||
)
|
||||
);
|
||||
|
||||
for (const pattern of patterns) {
|
||||
signals.push(`known_pattern_${pattern.patternType}`);
|
||||
score += pattern.scoreImpact;
|
||||
}
|
||||
|
||||
return { score, signals };
|
||||
}
|
||||
|
||||
/**
|
||||
* Simple check for proxy/VPN IPs
|
||||
*/
|
||||
private isProxyIP(_ip: string): boolean {
|
||||
// In production, use services like IPQualityScore, MaxMind, or IP2Proxy
|
||||
// For now, return false (disabled)
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Store device fingerprint for a user
|
||||
*/
|
||||
async storeFingerprint(userId: string, data: FingerprintData): Promise<void> {
|
||||
const db = this.getDb();
|
||||
|
||||
try {
|
||||
const ipHash = this.hashValue(data.ipAddress);
|
||||
const deviceHash = data.deviceHash || null;
|
||||
const userAgentHash = data.userAgent ? this.hashValue(data.userAgent) : null;
|
||||
|
||||
// Check if fingerprint already exists
|
||||
let [existingFp] = await db
|
||||
.select()
|
||||
.from(fingerprints)
|
||||
.where(
|
||||
and(
|
||||
eq(fingerprints.ipHash, ipHash),
|
||||
deviceHash
|
||||
? eq(fingerprints.deviceHash, deviceHash)
|
||||
: sql`${fingerprints.deviceHash} IS NULL`
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (!existingFp) {
|
||||
// Create new fingerprint
|
||||
[existingFp] = await db
|
||||
.insert(fingerprints)
|
||||
.values({
|
||||
ipHash,
|
||||
deviceHash,
|
||||
userAgentHash,
|
||||
firstSeenAt: new Date(),
|
||||
lastSeenAt: new Date(),
|
||||
registrationCount: 1,
|
||||
})
|
||||
.returning();
|
||||
} else {
|
||||
// Update existing
|
||||
await db
|
||||
.update(fingerprints)
|
||||
.set({
|
||||
lastSeenAt: new Date(),
|
||||
registrationCount: sql`${fingerprints.registrationCount} + 1`,
|
||||
})
|
||||
.where(eq(fingerprints.id, existingFp.id));
|
||||
}
|
||||
|
||||
// Link fingerprint to user (check if exists first)
|
||||
const [existingLink] = await db
|
||||
.select()
|
||||
.from(userFingerprints)
|
||||
.where(
|
||||
and(
|
||||
eq(userFingerprints.userId, userId),
|
||||
eq(userFingerprints.fingerprintId, existingFp.id)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (!existingLink) {
|
||||
await db.insert(userFingerprints).values({
|
||||
userId,
|
||||
fingerprintId: existingFp.id,
|
||||
seenAt: new Date(),
|
||||
context: 'registration',
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.error('Error storing fingerprint:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add item to review queue
|
||||
*/
|
||||
async addToReviewQueue(
|
||||
relationshipId: string,
|
||||
fraudScore: number,
|
||||
signals: string[],
|
||||
_reason: string
|
||||
): Promise<void> {
|
||||
const db = this.getDb();
|
||||
|
||||
try {
|
||||
const priority =
|
||||
fraudScore >= FRAUD_THRESHOLDS.critical
|
||||
? 'critical'
|
||||
: fraudScore >= FRAUD_THRESHOLDS.highRisk
|
||||
? 'high'
|
||||
: fraudScore >= FRAUD_THRESHOLDS.mediumRisk
|
||||
? 'medium'
|
||||
: 'low';
|
||||
|
||||
await db.insert(reviewQueue).values({
|
||||
relationshipId,
|
||||
fraudScore,
|
||||
fraudSignals: JSON.stringify(signals),
|
||||
priority,
|
||||
status: 'pending',
|
||||
createdAt: new Date(),
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.error('Error adding to review queue:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get pending review items
|
||||
*/
|
||||
async getPendingReviews(limit = 50, offset = 0): Promise<ReviewQueueItem[]> {
|
||||
const db = this.getDb();
|
||||
|
||||
return db
|
||||
.select()
|
||||
.from(reviewQueue)
|
||||
.where(eq(reviewQueue.status, 'pending'))
|
||||
.orderBy(desc(reviewQueue.fraudScore), reviewQueue.createdAt)
|
||||
.limit(limit)
|
||||
.offset(offset);
|
||||
}
|
||||
|
||||
/**
|
||||
* Process review decision
|
||||
*/
|
||||
async processReview(
|
||||
reviewId: string,
|
||||
decision: 'approved' | 'rejected',
|
||||
_reviewerId: string,
|
||||
notes?: string
|
||||
): Promise<void> {
|
||||
const db = this.getDb();
|
||||
|
||||
await db
|
||||
.update(reviewQueue)
|
||||
.set({
|
||||
status: decision,
|
||||
reviewedAt: new Date(),
|
||||
notes,
|
||||
})
|
||||
.where(eq(reviewQueue.id, reviewId));
|
||||
|
||||
// Get review to find relationship
|
||||
const [review] = await db
|
||||
.select()
|
||||
.from(reviewQueue)
|
||||
.where(eq(reviewQueue.id, reviewId))
|
||||
.limit(1);
|
||||
|
||||
if (!review) return;
|
||||
|
||||
if (decision === 'approved') {
|
||||
// Reset fraud score
|
||||
await db
|
||||
.update(relationships)
|
||||
.set({ fraudScore: 0, isFlagged: false })
|
||||
.where(eq(relationships.id, review.relationshipId));
|
||||
} else if (decision === 'rejected') {
|
||||
// Mark as fraudulent
|
||||
await db
|
||||
.update(relationships)
|
||||
.set({ fraudScore: 100, isFlagged: true })
|
||||
.where(eq(relationships.id, review.relationshipId));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a fraud pattern to the database
|
||||
*/
|
||||
async addFraudPattern(
|
||||
patternType: 'email_domain' | 'ip_range' | 'device_pattern',
|
||||
patternValue: string,
|
||||
severity: 'low' | 'medium' | 'high' | 'critical',
|
||||
scoreImpact: number,
|
||||
description: string,
|
||||
createdBy: string
|
||||
): Promise<void> {
|
||||
const db = this.getDb();
|
||||
|
||||
await db.insert(fraudPatterns).values({
|
||||
patternType,
|
||||
patternValue,
|
||||
severity,
|
||||
scoreImpact,
|
||||
description,
|
||||
createdBy,
|
||||
isActive: true,
|
||||
createdAt: new Date(),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check rate limit for an action
|
||||
*/
|
||||
async checkRateLimit(
|
||||
identifier: string,
|
||||
identifierType: string,
|
||||
action: string,
|
||||
limit: number,
|
||||
windowMinutes: number
|
||||
): Promise<{ allowed: boolean; remaining: number }> {
|
||||
const db = this.getDb();
|
||||
|
||||
const windowStart = new Date(Date.now() - windowMinutes * 60 * 1000);
|
||||
const windowEnd = new Date(Date.now() + windowMinutes * 60 * 1000);
|
||||
|
||||
const [record] = await db
|
||||
.select()
|
||||
.from(rateLimits)
|
||||
.where(
|
||||
and(
|
||||
eq(rateLimits.identifier, identifier),
|
||||
eq(rateLimits.identifierType, identifierType),
|
||||
eq(rateLimits.action, action),
|
||||
gte(rateLimits.windowStart, windowStart)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (!record) {
|
||||
// Create new rate limit record
|
||||
await db.insert(rateLimits).values({
|
||||
identifier,
|
||||
identifierType,
|
||||
action,
|
||||
count: 1,
|
||||
windowStart: new Date(),
|
||||
windowEnd,
|
||||
});
|
||||
|
||||
return { allowed: true, remaining: limit - 1 };
|
||||
}
|
||||
|
||||
if (record.count >= limit) {
|
||||
return { allowed: false, remaining: 0 };
|
||||
}
|
||||
|
||||
// Increment count
|
||||
await db
|
||||
.update(rateLimits)
|
||||
.set({ count: record.count + 1 })
|
||||
.where(eq(rateLimits.id, record.id));
|
||||
|
||||
return { allowed: true, remaining: limit - record.count - 1 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get fraud statistics for admin dashboard
|
||||
*/
|
||||
async getFraudStats(): Promise<{
|
||||
pendingReviews: number;
|
||||
rejectedToday: number;
|
||||
flaggedReferrals: number;
|
||||
}> {
|
||||
const db = this.getDb();
|
||||
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
|
||||
// Count pending reviews
|
||||
const [pendingCount] = await db
|
||||
.select({ count: count() })
|
||||
.from(reviewQueue)
|
||||
.where(eq(reviewQueue.status, 'pending'));
|
||||
|
||||
// Count rejected today
|
||||
const [rejectedCount] = await db
|
||||
.select({ count: count() })
|
||||
.from(reviewQueue)
|
||||
.where(and(eq(reviewQueue.status, 'rejected'), gte(reviewQueue.reviewedAt, today)));
|
||||
|
||||
// Count flagged referrals
|
||||
const [flaggedCount] = await db
|
||||
.select({ count: count() })
|
||||
.from(relationships)
|
||||
.where(eq(relationships.isFlagged, true));
|
||||
|
||||
return {
|
||||
pendingReviews: pendingCount.count,
|
||||
rejectedToday: rejectedCount.count,
|
||||
flaggedReferrals: flaggedCount.count,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
export { ReferralCodeService } from './referral-code.service';
|
||||
export { ReferralTierService } from './referral-tier.service';
|
||||
export { ReferralTrackingService } from './referral-tracking.service';
|
||||
export { FraudDetectionService } from './fraud-detection.service';
|
||||
export { ReferralCronService } from './referral-cron.service';
|
||||
|
|
@ -1,376 +0,0 @@
|
|||
/**
|
||||
* 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -1,327 +0,0 @@
|
|||
/**
|
||||
* Referral Cron Service
|
||||
*
|
||||
* Handles scheduled tasks for the referral system:
|
||||
* - Retention checking (30-day mark)
|
||||
* - Daily statistics aggregation
|
||||
* - Cleanup of expired rate limits
|
||||
*/
|
||||
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { Cron, CronExpression } from '@nestjs/schedule';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { eq, and, sql, lte, gte, count, isNull } from 'drizzle-orm';
|
||||
import { getDb } from '../../db/connection';
|
||||
import {
|
||||
relationships,
|
||||
bonusEvents,
|
||||
dailyStats,
|
||||
rateLimits,
|
||||
userTiers,
|
||||
BONUS_AMOUNTS,
|
||||
TIMING_RULES,
|
||||
} from '../../db/schema/referrals.schema';
|
||||
import { balances } from '../../db/schema/credits.schema';
|
||||
import { ReferralTierService } from './referral-tier.service';
|
||||
|
||||
@Injectable()
|
||||
export class ReferralCronService {
|
||||
private readonly logger = new Logger(ReferralCronService.name);
|
||||
|
||||
constructor(
|
||||
private configService: ConfigService,
|
||||
private tierService: ReferralTierService
|
||||
) {}
|
||||
|
||||
private getDb() {
|
||||
const databaseUrl = this.configService.get<string>('database.url');
|
||||
return getDb(databaseUrl!);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check for retained referrals (30 days after qualification)
|
||||
* Runs every hour
|
||||
*/
|
||||
@Cron(CronExpression.EVERY_HOUR)
|
||||
async processRetentionCheck(): Promise<void> {
|
||||
this.logger.log('Starting retention check...');
|
||||
const db = this.getDb();
|
||||
|
||||
try {
|
||||
const retentionDays = TIMING_RULES.retentionCheckDays;
|
||||
const retentionDate = new Date(Date.now() - retentionDays * 24 * 60 * 60 * 1000);
|
||||
|
||||
// Find qualified referrals that are now retained
|
||||
const eligibleReferrals = await db
|
||||
.select()
|
||||
.from(relationships)
|
||||
.where(
|
||||
and(
|
||||
eq(relationships.status, 'qualified'),
|
||||
lte(relationships.qualifiedAt, retentionDate),
|
||||
isNull(relationships.retainedAt)
|
||||
)
|
||||
)
|
||||
.limit(100);
|
||||
|
||||
let processedCount = 0;
|
||||
let errorCount = 0;
|
||||
|
||||
for (const referral of eligibleReferrals) {
|
||||
try {
|
||||
await this.processRetention(referral);
|
||||
processedCount++;
|
||||
} catch (error) {
|
||||
errorCount++;
|
||||
this.logger.error(`Error processing retention for referral ${referral.id}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
this.logger.log(
|
||||
`Retention check complete: ${processedCount} processed, ${errorCount} errors`
|
||||
);
|
||||
} catch (error) {
|
||||
this.logger.error('Error in retention check:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a single retention transition
|
||||
*/
|
||||
private async processRetention(referral: typeof relationships.$inferSelect): Promise<void> {
|
||||
const db = this.getDb();
|
||||
|
||||
// Get referrer's tier for multiplier
|
||||
const multiplier = await this.tierService.getMultiplier(referral.referrerId);
|
||||
const baseBonus = BONUS_AMOUNTS.retained.referrer;
|
||||
const finalBonus = Math.round(baseBonus * multiplier);
|
||||
|
||||
// Get referrer's current tier
|
||||
const tierInfo = await this.tierService.getUserTier(referral.referrerId);
|
||||
|
||||
// Update relationship to retained
|
||||
await db
|
||||
.update(relationships)
|
||||
.set({
|
||||
status: 'retained',
|
||||
retainedAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(relationships.id, referral.id));
|
||||
|
||||
// Award retention bonus to referrer (if not held for fraud)
|
||||
if (referral.fraudScore < 50) {
|
||||
await db
|
||||
.update(balances)
|
||||
.set({
|
||||
balance: sql`${balances.balance} + ${finalBonus}`,
|
||||
totalEarned: sql`${balances.totalEarned} + ${finalBonus}`,
|
||||
})
|
||||
.where(eq(balances.userId, referral.referrerId));
|
||||
|
||||
// Record bonus event
|
||||
await db.insert(bonusEvents).values({
|
||||
relationshipId: referral.id,
|
||||
userId: referral.referrerId,
|
||||
eventType: 'retained',
|
||||
creditsBase: baseBonus,
|
||||
tierMultiplier: multiplier,
|
||||
creditsFinal: finalBonus,
|
||||
tierAtTime: tierInfo.current,
|
||||
status: 'paid',
|
||||
createdAt: new Date(),
|
||||
});
|
||||
} else {
|
||||
// Record as held due to fraud score
|
||||
await db.insert(bonusEvents).values({
|
||||
relationshipId: referral.id,
|
||||
userId: referral.referrerId,
|
||||
eventType: 'retained',
|
||||
creditsBase: baseBonus,
|
||||
tierMultiplier: multiplier,
|
||||
creditsFinal: finalBonus,
|
||||
tierAtTime: tierInfo.current,
|
||||
status: 'held',
|
||||
holdReason: `High fraud score: ${referral.fraudScore}`,
|
||||
createdAt: new Date(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregate daily statistics
|
||||
* Runs at midnight every day
|
||||
*/
|
||||
@Cron(CronExpression.EVERY_DAY_AT_MIDNIGHT)
|
||||
async aggregateDailyStats(): Promise<void> {
|
||||
this.logger.log('Starting daily stats aggregation...');
|
||||
const db = this.getDb();
|
||||
|
||||
try {
|
||||
const yesterday = new Date();
|
||||
yesterday.setDate(yesterday.getDate() - 1);
|
||||
yesterday.setHours(0, 0, 0, 0);
|
||||
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
|
||||
// Count registrations
|
||||
const [registrationsResult] = await db
|
||||
.select({ count: count() })
|
||||
.from(relationships)
|
||||
.where(and(gte(relationships.createdAt, yesterday), lte(relationships.createdAt, today)));
|
||||
|
||||
// Count activations
|
||||
const [activationsResult] = await db
|
||||
.select({ count: count() })
|
||||
.from(relationships)
|
||||
.where(
|
||||
and(gte(relationships.activatedAt, yesterday), lte(relationships.activatedAt, today))
|
||||
);
|
||||
|
||||
// Count qualifications
|
||||
const [qualificationsResult] = await db
|
||||
.select({ count: count() })
|
||||
.from(relationships)
|
||||
.where(
|
||||
and(gte(relationships.qualifiedAt, yesterday), lte(relationships.qualifiedAt, today))
|
||||
);
|
||||
|
||||
// Count retentions
|
||||
const [retentionsResult] = await db
|
||||
.select({ count: count() })
|
||||
.from(relationships)
|
||||
.where(and(gte(relationships.retainedAt, yesterday), lte(relationships.retainedAt, today)));
|
||||
|
||||
// Sum credits paid
|
||||
const [creditsPaidResult] = await db
|
||||
.select({ total: sql<number>`COALESCE(SUM(${bonusEvents.creditsFinal}), 0)` })
|
||||
.from(bonusEvents)
|
||||
.where(
|
||||
and(
|
||||
eq(bonusEvents.status, 'paid'),
|
||||
gte(bonusEvents.createdAt, yesterday),
|
||||
lte(bonusEvents.createdAt, today)
|
||||
)
|
||||
);
|
||||
|
||||
// Sum credits held
|
||||
const [creditsHeldResult] = await db
|
||||
.select({ total: sql<number>`COALESCE(SUM(${bonusEvents.creditsFinal}), 0)` })
|
||||
.from(bonusEvents)
|
||||
.where(
|
||||
and(
|
||||
eq(bonusEvents.status, 'held'),
|
||||
gte(bonusEvents.createdAt, yesterday),
|
||||
lte(bonusEvents.createdAt, today)
|
||||
)
|
||||
);
|
||||
|
||||
// Count fraud blocked
|
||||
const [fraudBlockedResult] = await db
|
||||
.select({ count: count() })
|
||||
.from(relationships)
|
||||
.where(
|
||||
and(
|
||||
gte(relationships.fraudScore, 90),
|
||||
gte(relationships.createdAt, yesterday),
|
||||
lte(relationships.createdAt, today)
|
||||
)
|
||||
);
|
||||
|
||||
// Insert daily stats
|
||||
await db.insert(dailyStats).values({
|
||||
date: yesterday,
|
||||
registrations: registrationsResult.count,
|
||||
activations: activationsResult.count,
|
||||
qualifications: qualificationsResult.count,
|
||||
retentions: retentionsResult.count,
|
||||
creditsPaid: creditsPaidResult.total || 0,
|
||||
creditsHeld: creditsHeldResult.total || 0,
|
||||
fraudBlocked: fraudBlockedResult.count,
|
||||
});
|
||||
|
||||
this.logger.log('Daily stats aggregation complete');
|
||||
} catch (error) {
|
||||
this.logger.error('Error aggregating daily stats:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup expired rate limits
|
||||
* Runs every 6 hours
|
||||
*/
|
||||
@Cron(CronExpression.EVERY_6_HOURS)
|
||||
async cleanupRateLimits(): Promise<void> {
|
||||
this.logger.log('Cleaning up expired rate limits...');
|
||||
const db = this.getDb();
|
||||
|
||||
try {
|
||||
await db.delete(rateLimits).where(lte(rateLimits.windowEnd, new Date()));
|
||||
|
||||
this.logger.log('Rate limit cleanup complete');
|
||||
} catch (error) {
|
||||
this.logger.error('Error cleaning up rate limits:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Recalculate tier standings for all users
|
||||
* Runs weekly on Sunday at 3am
|
||||
*/
|
||||
@Cron('0 3 * * 0')
|
||||
async recalculateTiers(): Promise<void> {
|
||||
this.logger.log('Recalculating all user tiers...');
|
||||
const db = this.getDb();
|
||||
|
||||
try {
|
||||
// Get all user tiers
|
||||
const allTiers = await db.select().from(userTiers);
|
||||
|
||||
let updatedCount = 0;
|
||||
|
||||
for (const userTier of allTiers) {
|
||||
// Recalculate qualified count from relationships
|
||||
const [actualCount] = await db
|
||||
.select({ count: count() })
|
||||
.from(relationships)
|
||||
.where(
|
||||
and(
|
||||
eq(relationships.referrerId, userTier.userId),
|
||||
eq(relationships.status, 'qualified')
|
||||
)
|
||||
);
|
||||
|
||||
// Add retained counts too
|
||||
const [retainedCount] = await db
|
||||
.select({ count: count() })
|
||||
.from(relationships)
|
||||
.where(
|
||||
and(eq(relationships.referrerId, userTier.userId), eq(relationships.status, 'retained'))
|
||||
);
|
||||
|
||||
const totalQualified = actualCount.count + retainedCount.count;
|
||||
|
||||
// Update if different
|
||||
if (totalQualified !== userTier.qualifiedCount) {
|
||||
const newTier = this.tierService.calculateTierFromCount(totalQualified);
|
||||
|
||||
await db
|
||||
.update(userTiers)
|
||||
.set({
|
||||
qualifiedCount: totalQualified,
|
||||
tier: newTier,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(userTiers.userId, userTier.userId));
|
||||
|
||||
updatedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
this.logger.log(`Tier recalculation complete: ${updatedCount} users updated`);
|
||||
} catch (error) {
|
||||
this.logger.error('Error recalculating tiers:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,269 +0,0 @@
|
|||
/**
|
||||
* 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 name
|
||||
*/
|
||||
getMultiplierForTier(tier: TierName): number {
|
||||
return TIER_CONFIG[tier]?.multiplier || 1.0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the multiplier for a user by their ID
|
||||
*/
|
||||
async getMultiplier(userId: string): Promise<number> {
|
||||
const db = this.getDb();
|
||||
|
||||
const [tier] = await db.select().from(userTiers).where(eq(userTiers.userId, userId)).limit(1);
|
||||
|
||||
if (!tier) {
|
||||
return 1.0; // Default bronze multiplier
|
||||
}
|
||||
|
||||
return this.getMultiplierForTier(tier.tier as TierName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate bonus credits with tier multiplier
|
||||
*/
|
||||
calculateBonus(
|
||||
eventType: keyof typeof BONUS_AMOUNTS,
|
||||
tier: TierName,
|
||||
isReferrer = true
|
||||
): { base: number; multiplier: number; final: number } {
|
||||
const bonusConfig = BONUS_AMOUNTS[eventType];
|
||||
const base = isReferrer ? bonusConfig.referrer : bonusConfig.referee;
|
||||
const multiplier = isReferrer ? this.getMultiplierForTier(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
|
||||
*/
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -1,832 +0,0 @@
|
|||
/**
|
||||
* 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 = 20,
|
||||
offset = 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 newBalance = currentBalance.balance + amount;
|
||||
const newTotalEarned = currentBalance.totalEarned + amount;
|
||||
|
||||
// Update balance (add to main balance, not free credits)
|
||||
await db
|
||||
.update(balances)
|
||||
.set({
|
||||
balance: newBalance,
|
||||
totalEarned: newTotalEarned,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(balances.userId, userId));
|
||||
|
||||
// Create transaction record (using 'gift' type for referral bonuses)
|
||||
const [transaction] = await db
|
||||
.insert(transactions)
|
||||
.values({
|
||||
userId,
|
||||
type: 'gift',
|
||||
status: 'completed',
|
||||
amount,
|
||||
balanceBefore: currentBalance.balance,
|
||||
balanceAfter: newBalance,
|
||||
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
|
||||
const 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