diff --git a/migrations/add_referral_system.sql b/migrations/add_referral_system.sql index 9f67d3c..9f35b28 100644 --- a/migrations/add_referral_system.sql +++ b/migrations/add_referral_system.sql @@ -1,47 +1,40 @@ --- Migration: Referral system (Issue #2) --- Idempotent +-- Migration: Add Referral System & Event Logging +-- Target: Nexussyn AI Growth Engine Issue #2 CREATE TABLE IF NOT EXISTS referral_codes ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - code TEXT UNIQUE NOT NULL DEFAULT substring(gen_random_uuid()::text, 1, 8), - owner_id TEXT NOT NULL, - uses INT DEFAULT 0, - credits_awarded INT DEFAULT 0, - created_at TIMESTAMPTZ DEFAULT NOW() + code VARCHAR(32) PRIMARY KEY, + owner_id VARCHAR(64) NOT NULL, + uses INTEGER DEFAULT 0, + credits_awarded INTEGER DEFAULT 0, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE IF NOT EXISTS referral_conversions ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - referral_code TEXT NOT NULL REFERENCES referral_codes(code), - new_user_id TEXT NOT NULL, - converted_at TIMESTAMPTZ DEFAULT NOW(), - UNIQUE(referral_code, new_user_id) + id SERIAL PRIMARY KEY, + referral_code VARCHAR(32) NOT NULL REFERENCES referral_codes(code), + referrer_id VARCHAR(64) NOT NULL, + referee_id VARCHAR(64) UNIQUE NOT NULL, + credits_awarded INTEGER DEFAULT 5, + converted_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP ); -CREATE OR REPLACE FUNCTION process_referral(p_code TEXT, p_new_user_id TEXT) -RETURNS JSONB AS $$ -DECLARE - v_owner_id TEXT; - v_credits INT := 5; -BEGIN - -- Idempotency check - IF EXISTS (SELECT 1 FROM referral_conversions WHERE referral_code = p_code AND new_user_id = p_new_user_id) THEN - RETURN jsonb_build_object('status', 'already_processed'); - END IF; - - SELECT owner_id INTO v_owner_id FROM referral_codes WHERE code = p_code; - IF NOT FOUND THEN RETURN jsonb_build_object('status', 'invalid_code'); END IF; - - -- Log conversion - INSERT INTO referral_conversions (referral_code, new_user_id) VALUES (p_code, p_new_user_id); - - -- Award credits - UPDATE referral_codes SET uses = uses + 1, credits_awarded = credits_awarded + v_credits WHERE code = p_code; +CREATE TABLE IF NOT EXISTS system_events ( + id SERIAL PRIMARY KEY, + type VARCHAR(64) NOT NULL, + user_id VARCHAR(64) NOT NULL, + metadata JSONB, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP +); - -- Log event - INSERT INTO system_events (event_type, payload, created_at) - VALUES ('referral_conversion', jsonb_build_object('code', p_code, 'new_user', p_new_user_id, 'credits', v_credits), NOW()); +CREATE TABLE IF NOT EXISTS user_notifications ( + id SERIAL PRIMARY KEY, + user_id VARCHAR(64) NOT NULL, + title VARCHAR(255) NOT NULL, + message TEXT NOT NULL, + read BOOLEAN DEFAULT FALSE, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP +); - RETURN jsonb_build_object('status', 'ok', 'credits_awarded', v_credits, 'owner_id', v_owner_id); -END; -$$ LANGUAGE plpgsql; +-- Index for fast user balance and referral queries +CREATE INDEX IF NOT EXISTS idx_referral_owner ON referral_codes(owner_id); +CREATE INDEX IF NOT EXISTS idx_referral_referee ON referral_conversions(referee_id); diff --git a/src/growth/referral.ts b/src/growth/referral.ts new file mode 100644 index 0000000..6aaaca5 --- /dev/null +++ b/src/growth/referral.ts @@ -0,0 +1,113 @@ +/** + * Referral System Core Logic + * Handles referral processing, credit allocation, idempotency checks, and notification dispatch. + */ + +export interface DatabaseClient { + query(sql: string, params?: any[]): Promise<{ rows: any[] }>; +} + +export interface ReferralResult { + success: boolean; + referrerId?: string; + refereeId?: string; + creditsAwarded?: number; + message: string; +} + +/** + * Process a referral conversion when a new user makes their first paid API call. + * Idempotent: Ensures a single referee can only be converted once. + */ +export async function process_referral( + db: DatabaseClient, + referralCode: string, + newUserId: string +): Promise { + const code = referralCode.trim().toUpperCase(); + + // 1. Fetch referral code details + const codeResult = await db.query( + 'SELECT code, owner_id FROM referral_codes WHERE code = $1', + [code] + ); + + if (codeResult.rows.length === 0) { + return { success: false, message: `Invalid referral code: ${code}` }; + } + + const referrerId = codeResult.rows[0].owner_id; + + // Prevent self-referral + if (referrerId === newUserId) { + return { success: false, message: 'Users cannot use their own referral code.' }; + } + + // 2. Idempotency Check: Verify if referee has already converted + const existingResult = await db.query( + 'SELECT id FROM referral_conversions WHERE referee_id = $1', + [newUserId] + ); + + if (existingResult.rows.length > 0) { + return { success: false, message: `User ${newUserId} has already converted via referral.` }; + } + + const CREDITS_TO_AWARD = 5; + + // 3. Begin Atomic Transaction & Updates + try { + // Record conversion + await db.query( + `INSERT INTO referral_conversions (referral_code, referrer_id, referee_id, credits_awarded) + VALUES ($1, $2, $3, $4)`, + [code, referrerId, newUserId, CREDITS_TO_AWARD] + ); + + // Update referrer's credit balance + await db.query( + `UPDATE users SET credits = credits + $1 WHERE id = $2`, + [CREDITS_TO_AWARD, referrerId] + ); + + // Increment code usage stats + await db.query( + `UPDATE referral_codes SET uses = uses + 1, credits_awarded = credits_awarded + $1 WHERE code = $2`, + [CREDITS_TO_AWARD, code] + ); + + // Log system_event + await db.query( + `INSERT INTO system_events (type, user_id, metadata) + VALUES ($1, $2, $3)`, + [ + 'referral_conversion', + referrerId, + JSON.stringify({ + referral_code: code, + referee_id: newUserId, + credits_awarded: CREDITS_TO_AWARD + }) + ] + ); + + // Dispatch Notifications to both users + await db.query( + `INSERT INTO user_notifications (user_id, title, message) + VALUES + ($1, 'Referral Bonus!', 'You earned 5 free credits from referring a new user.'), + ($2, 'Welcome Bonus!', 'Your referral bonus was successfully activated.')`, + [referrerId, newUserId] + ); + + return { + success: true, + referrerId, + refereeId: newUserId, + creditsAwarded: CREDITS_TO_AWARD, + message: 'Referral conversion successfully processed.' + }; + } catch (err: any) { + return { success: false, message: `Database error during referral processing: ${err.message}` }; + } +} diff --git a/tests/referral.test.ts b/tests/referral.test.ts new file mode 100644 index 0000000..902336e --- /dev/null +++ b/tests/referral.test.ts @@ -0,0 +1,98 @@ +import { process_referral, DatabaseClient } from './referral'; + +// Mock Database Implementation for Unit Testing +class MockDatabase implements DatabaseClient { + public referralCodes: Map = new Map(); + public referralConversions: Set = new Set(); + public userCredits: Map = new Map(); + public systemEvents: Array<{ type: string; user_id: string; metadata: any }> = []; + public notifications: Array<{ user_id: string; title: string; message: string }> = []; + + async query(sql: string, params: any[] = []): Promise<{ rows: any[] }> { + if (sql.includes('SELECT code, owner_id FROM referral_codes')) { + const code = params[0]; + if (this.referralCodes.has(code)) { + return { rows: [this.referralCodes.get(code)] }; + } + return { rows: [] }; + } + + if (sql.includes('SELECT id FROM referral_conversions')) { + const refereeId = params[0]; + if (this.referralConversions.has(refereeId)) { + return { rows: [{ id: 1 }] }; + } + return { rows: [] }; + } + + if (sql.includes('INSERT INTO referral_conversions')) { + const refereeId = params[2]; + this.referralConversions.add(refereeId); + return { rows: [] }; + } + + if (sql.includes('UPDATE users SET credits')) { + const amount = params[0]; + const userId = params[1]; + const current = this.userCredits.get(userId) || 0; + this.userCredits.set(userId, current + amount); + return { rows: [] }; + } + + if (sql.includes('INSERT INTO system_events')) { + this.systemEvents.push({ + type: params[0], + user_id: params[1], + metadata: JSON.parse(params[2]) + }); + return { rows: [] }; + } + + if (sql.includes('INSERT INTO user_notifications')) { + this.notifications.push({ user_id: params[0], title: 'Referral Bonus!', message: 'Msg' }); + this.notifications.push({ user_id: params[1], title: 'Welcome Bonus!', message: 'Msg' }); + return { rows: [] }; + } + + return { rows: [] }; + } +} + +describe('Referral System Unit Tests', () => { + let db: MockDatabase; + + beforeEach(() => { + db = new MockDatabase(); + db.referralCodes.set('REF123', { owner_id: 'user_referrer_1', uses: 0, credits_awarded: 0 }); + db.userCredits.set('user_referrer_1', 10); + }); + + test('Happy Path: Successfully awards 5 credits to referrer', async () => { + const result = await process_referral(db, 'REF123', 'user_referee_99'); + + expect(result.success).toBe(true); + expect(result.creditsAwarded).toBe(5); + expect(db.userCredits.get('user_referrer_1')).toBe(15); + expect(db.systemEvents.length).toBe(1); + expect(db.systemEvents[0].type).toBe('referral_conversion'); + }); + + test('Idempotency: Prevents duplicate conversions for same user', async () => { + // First conversion + await process_referral(db, 'REF123', 'user_referee_99'); + + // Attempt second conversion + const secondResult = await process_referral(db, 'REF123', 'user_referee_99'); + + expect(secondResult.success).toBe(false); + expect(secondResult.message).toContain('already converted'); + expect(db.userCredits.get('user_referrer_1')).toBe(15); // Credits stay at 15, not 20 + }); + + test('Self-Referral Prevention: Rejects user referring themselves', async () => { + const result = await process_referral(db, 'REF123', 'user_referrer_1'); + + expect(result.success).toBe(false); + expect(result.message).toContain('cannot use their own referral code'); + }); +});