diff --git a/migrations/add_referral_system.sql b/migrations/add_referral_system.sql index 9f67d3c..8f1d03d 100644 --- a/migrations/add_referral_system.sql +++ b/migrations/add_referral_system.sql @@ -1,47 +1,25 @@ --- Migration: Referral system (Issue #2) --- Idempotent - +-- Migration: Add Referral System 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), + code TEXT PRIMARY KEY, owner_id TEXT NOT NULL, - uses INT DEFAULT 0, - credits_awarded INT DEFAULT 0, - created_at TIMESTAMPTZ DEFAULT NOW() + uses INTEGER NOT NULL DEFAULT 0, + credits_awarded INTEGER NOT NULL 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(), + id TEXT PRIMARY KEY, 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) + referrer_id TEXT NOT NULL, + referred_user_id TEXT NOT NULL UNIQUE, + credits_granted INTEGER NOT NULL 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; - - -- 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()); - - RETURN jsonb_build_object('status', 'ok', 'credits_awarded', v_credits, 'owner_id', v_owner_id); -END; -$$ LANGUAGE plpgsql; +CREATE TABLE IF NOT EXISTS system_events ( + id TEXT PRIMARY KEY, + event_type TEXT NOT NULL, + user_id TEXT NOT NULL, + metadata JSONB NOT NULL, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP +); diff --git a/src/growth/referral.ts b/src/growth/referral.ts new file mode 100644 index 0000000..c903df6 --- /dev/null +++ b/src/growth/referral.ts @@ -0,0 +1,123 @@ +export interface ReferralCode { + code: string; + ownerId: string; + uses: number; + creditsAwarded: number; + createdAt: string; +} + +export interface ReferralConversion { + id: string; + referralCode: string; + referrerId: string; + referredUserId: string; + creditsGranted: number; + convertedAt: string; +} + +export interface SystemEvent { + id: string; + eventType: string; + userId: string; + metadata: Record; + createdAt: string; +} + +export interface ReferralResult { + success: boolean; + creditsAwarded?: number; + referrerId?: string; + referredUserId?: string; + error?: string; +} + +export class ReferralEngine { + private codes = new Map(); + private conversions = new Map(); // Keyed by referredUserId + private userCredits = new Map(); + private systemEvents: SystemEvent[] = []; + + public createReferralCode(ownerId: string, customCode?: string): ReferralCode { + const code = customCode || `ref_${ownerId.slice(0, 6)}_${Math.random().toString(36).slice(2, 6)}`; + const record: ReferralCode = { + code, + ownerId, + uses: 0, + creditsAwarded: 0, + createdAt: new Date().toISOString(), + }; + this.codes.set(code, record); + return record; + } + + public getUserCredits(userId: string): number { + return this.userCredits.get(userId) || 0; + } + + public addCredits(userId: string, amount: number): void { + const current = this.getUserCredits(userId); + this.userCredits.set(userId, current + amount); + } + + public getEvents(): SystemEvent[] { + return [...this.systemEvents]; + } + + public processReferral(referralCode: string, newUserId: string): ReferralResult { + const codeRecord = this.codes.get(referralCode); + if (!codeRecord) { + return { success: false, error: 'invalid_referral_code' }; + } + + if (codeRecord.ownerId === newUserId) { + return { success: false, error: 'self_referral_forbidden' }; + } + + if (this.conversions.has(newUserId)) { + return { success: false, error: 'referral_already_claimed' }; + } + + const creditsAwarded = 5; + const now = new Date().toISOString(); + + // 1. Record conversion + const conversion: ReferralConversion = { + id: `cnv_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`, + referralCode, + referrerId: codeRecord.ownerId, + referredUserId: newUserId, + creditsGranted: creditsAwarded, + convertedAt: now, + }; + this.conversions.set(newUserId, conversion); + + // 2. Update referral code metrics + codeRecord.uses += 1; + codeRecord.creditsAwarded += creditsAwarded; + + // 3. Award credits to referrer + this.addCredits(codeRecord.ownerId, creditsAwarded); + + // 4. Log system event + this.systemEvents.push({ + id: `evt_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`, + eventType: 'referral_conversion', + userId: codeRecord.ownerId, + metadata: { + referredUserId: newUserId, + referralCode, + creditsGranted: creditsAwarded, + }, + createdAt: now, + }); + + return { + success: true, + creditsAwarded, + referrerId: codeRecord.ownerId, + referredUserId: newUserId, + }; + } +} + +export const defaultReferralEngine = new ReferralEngine(); diff --git a/tests/referral.test.ts b/tests/referral.test.ts new file mode 100644 index 0000000..7f8c712 --- /dev/null +++ b/tests/referral.test.ts @@ -0,0 +1,62 @@ +import { assertEquals } from 'https://deno.land/std@0.224.0/assert/mod.ts'; +import { ReferralEngine } from '../src/growth/referral.ts'; + +Deno.test('Referral Happy Path: awards 5 credits to referrer and logs system event', () => { + const engine = new ReferralEngine(); + const code = engine.createReferralCode('user_alice', 'REF_ALICE'); + + assertEquals(code.code, 'REF_ALICE'); + assertEquals(engine.getUserCredits('user_alice'), 0); + + const result = engine.processReferral('REF_ALICE', 'user_bob'); + assertEquals(result.success, true); + assertEquals(result.creditsAwarded, 5); + assertEquals(result.referrerId, 'user_alice'); + assertEquals(result.referredUserId, 'user_bob'); + + // Verify credit balance + assertEquals(engine.getUserCredits('user_alice'), 5); + + // Verify system event logged + const events = engine.getEvents(); + assertEquals(events.length, 1); + assertEquals(events[0].eventType, 'referral_conversion'); + assertEquals(events[0].userId, 'user_alice'); + assertEquals(events[0].metadata.referredUserId, 'user_bob'); +}); + +Deno.test('Referral Idempotency: prevents duplicate claim by same referred user', () => { + const engine = new ReferralEngine(); + engine.createReferralCode('user_alice', 'REF_ALICE'); + + // First claim succeeds + const first = engine.processReferral('REF_ALICE', 'user_bob'); + assertEquals(first.success, true); + assertEquals(engine.getUserCredits('user_alice'), 5); + + // Second claim fails idempotently + const second = engine.processReferral('REF_ALICE', 'user_bob'); + assertEquals(second.success, false); + assertEquals(second.error, 'referral_already_claimed'); + + // Credits are NOT doubled + assertEquals(engine.getUserCredits('user_alice'), 5); + assertEquals(engine.getEvents().length, 1); +}); + +Deno.test('Referral Self-Referral Prevention: rejects user referring themselves', () => { + const engine = new ReferralEngine(); + engine.createReferralCode('user_alice', 'REF_ALICE'); + + const result = engine.processReferral('REF_ALICE', 'user_alice'); + assertEquals(result.success, false); + assertEquals(result.error, 'self_referral_forbidden'); + assertEquals(engine.getUserCredits('user_alice'), 0); +}); + +Deno.test('Referral Invalid Code: rejects non-existent referral code', () => { + const engine = new ReferralEngine(); + const result = engine.processReferral('NON_EXISTENT_CODE', 'user_bob'); + assertEquals(result.success, false); + assertEquals(result.error, 'invalid_referral_code'); +});