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:
Till-JS 2026-02-17 13:43:08 +01:00
parent 03d90f2bda
commit 1d44f918c5
33 changed files with 19 additions and 5538 deletions

File diff suppressed because it is too large Load diff

View file

@ -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 | ✅ |

View file

@ -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

View file

@ -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;
}
},
};

View file

@ -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,

View file

@ -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>

View file

@ -101,10 +101,6 @@
"recent": "Kürzlich",
"empty": "Keine Dateien",
"open": "Storage öffnen"
},
"referral": {
"title": "Empfehlungen",
"description": "Teile und verdiene Credits"
}
}
},

View file

@ -101,10 +101,6 @@
"recent": "Recent",
"empty": "No files",
"open": "Open Storage"
},
"referral": {
"title": "Referrals",
"description": "Share and earn credits"
}
}
},

View file

@ -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
*/

View file

@ -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',

View file

@ -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"

View file

@ -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

View file

@ -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/)

View file

@ -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}

View file

@ -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;
}

View file

@ -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'],
});

View file

@ -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,

View file

@ -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,

View file

@ -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,

View file

@ -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
// =========================================================================

View file

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

View file

@ -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';

View file

@ -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;

View file

@ -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;
};
}

View file

@ -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}`,
};
}
}

View file

@ -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,
};
}
}

View file

@ -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 {}

View file

@ -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,
};
}
}

View file

@ -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';

View file

@ -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,
});
}
}

View file

@ -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);
}
}
}

View file

@ -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,
};
}
}

View file

@ -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}.`;
}
}