Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 17 additions & 39 deletions migrations/add_referral_system.sql
Original file line number Diff line number Diff line change
@@ -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
);
123 changes: 123 additions & 0 deletions src/growth/referral.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;
createdAt: string;
}

export interface ReferralResult {
success: boolean;
creditsAwarded?: number;
referrerId?: string;
referredUserId?: string;
error?: string;
}

export class ReferralEngine {
private codes = new Map<string, ReferralCode>();
private conversions = new Map<string, ReferralConversion>(); // Keyed by referredUserId
private userCredits = new Map<string, number>();
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();
62 changes: 62 additions & 0 deletions tests/referral.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});