diff --git a/migrations/add_referral_system.sql b/migrations/add_referral_system.sql index 9f67d3c..9dec6aa 100644 --- a/migrations/add_referral_system.sql +++ b/migrations/add_referral_system.sql @@ -1,9 +1,9 @@ -- Migration: Referral system (Issue #2) --- Idempotent +-- Idempotent: safe to re-run 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 UNIQUE NOT NULL DEFAULT upper(substring(replace(gen_random_uuid()::text, '-', ''), 1, 8)), owner_id TEXT NOT NULL, uses INT DEFAULT 0, credits_awarded INT DEFAULT 0, @@ -18,30 +18,101 @@ CREATE TABLE IF NOT EXISTS referral_conversions ( UNIQUE(referral_code, new_user_id) ); +CREATE TABLE IF NOT EXISTS user_credit_balances ( + user_id TEXT PRIMARY KEY, + free_credits INT NOT NULL DEFAULT 0, + updated_at TIMESTAMPTZ DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS user_notifications ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id TEXT NOT NULL, + message TEXT NOT NULL, + created_at TIMESTAMPTZ DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS system_events ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + event_type TEXT NOT NULL, + payload JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_referral_codes_owner + ON referral_codes (owner_id); + +CREATE INDEX IF NOT EXISTS idx_referral_conversions_user + ON referral_conversions (new_user_id); + 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; + v_norm TEXT; BEGIN + IF p_code IS NULL OR btrim(p_code) = '' OR p_new_user_id IS NULL OR btrim(p_new_user_id) = '' THEN + RETURN jsonb_build_object('status', 'invalid_input'); + END IF; + + v_norm := upper(btrim(p_code)); + -- Idempotency check - IF EXISTS (SELECT 1 FROM referral_conversions WHERE referral_code = p_code AND new_user_id = p_new_user_id) THEN + IF EXISTS ( + SELECT 1 FROM referral_conversions + WHERE referral_code = v_norm 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; + SELECT owner_id INTO v_owner_id FROM referral_codes WHERE code = v_norm; + IF NOT FOUND THEN + RETURN jsonb_build_object('status', 'invalid_code'); + END IF; + + IF v_owner_id = p_new_user_id THEN + RETURN jsonb_build_object('status', 'self_referral'); + END IF; -- Log conversion - INSERT INTO referral_conversions (referral_code, new_user_id) VALUES (p_code, p_new_user_id); + INSERT INTO referral_conversions (referral_code, new_user_id) + VALUES (v_norm, p_new_user_id); - -- Award credits - UPDATE referral_codes SET uses = uses + 1, credits_awarded = credits_awarded + v_credits WHERE code = p_code; + -- Award credits on code + owner balance + UPDATE referral_codes + SET uses = uses + 1, + credits_awarded = credits_awarded + v_credits + WHERE code = v_norm; + + INSERT INTO user_credit_balances (user_id, free_credits, updated_at) + VALUES (v_owner_id, v_credits, NOW()) + ON CONFLICT (user_id) DO UPDATE + SET free_credits = user_credit_balances.free_credits + EXCLUDED.free_credits, + updated_at = NOW(); -- 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()); + VALUES ( + 'referral_conversion', + jsonb_build_object( + 'code', v_norm, + 'new_user', p_new_user_id, + 'owner_id', v_owner_id, + 'credits', v_credits + ), + NOW() + ); + + -- Notify both users + INSERT INTO user_notifications (user_id, message) + VALUES + (v_owner_id, format('Referral success: +%s free credits (code %s).', v_credits, v_norm)), + (p_new_user_id, format('Welcome — you joined via referral %s.', v_norm)); - RETURN jsonb_build_object('status', 'ok', 'credits_awarded', v_credits, 'owner_id', v_owner_id); + RETURN jsonb_build_object( + 'status', 'ok', + 'credits_awarded', v_credits, + 'owner_id', v_owner_id + ); END; $$ LANGUAGE plpgsql; diff --git a/src/growth/referral.ts b/src/growth/referral.ts new file mode 100644 index 0000000..4750d52 --- /dev/null +++ b/src/growth/referral.ts @@ -0,0 +1,177 @@ +/** + * Referral reward loop — Issue #2 + * When user B signs up with A's code and makes their first paid call, + * A receives 5 free credits once (idempotent per code+new_user). + */ + +export const REFERRAL_CREDITS = 5; +export const EVENT_TYPE = 'referral_conversion'; + +export interface ReferralCode { + code: string; + ownerId: string; + uses: number; + creditsAwarded: number; + createdAt: string; +} + +export interface ReferralConversion { + referralCode: string; + newUserId: string; + convertedAt: string; +} + +export interface SystemEvent { + eventType: string; + payload: Record; + createdAt: string; +} + +export interface Notification { + userId: string; + message: string; + createdAt: string; +} + +export type ProcessStatus = + | 'ok' + | 'already_processed' + | 'invalid_code' + | 'self_referral' + | 'invalid_input'; + +export interface ProcessReferralResult { + status: ProcessStatus; + creditsAwarded?: number; + ownerId?: string; + event?: SystemEvent; + notifications?: Notification[]; +} + +function nowIso(): string { + return new Date().toISOString(); +} + +function normalizeCode(code: string): string { + return code.trim().toUpperCase(); +} + +/** In-memory mirror of referral_codes + referral_conversions + system_events. */ +export class ReferralStore { + codes = new Map(); + /** key = `${code}::${newUserId}` */ + conversions = new Map(); + /** owner_id → free credit balance */ + balances = new Map(); + events: SystemEvent[] = []; + notifications: Notification[] = []; + + createCode(ownerId: string, code?: string): ReferralCode { + if (!ownerId?.trim()) throw new Error('ownerId required'); + const raw = code?.trim() || crypto.randomUUID().replace(/-/g, '').slice(0, 8); + const normalized = normalizeCode(raw); + if (this.codes.has(normalized)) throw new Error('code already exists'); + const row: ReferralCode = { + code: normalized, + ownerId: ownerId.trim(), + uses: 0, + creditsAwarded: 0, + createdAt: nowIso(), + }; + this.codes.set(normalized, row); + if (!this.balances.has(row.ownerId)) this.balances.set(row.ownerId, 0); + return row; + } + + getCode(code: string): ReferralCode | undefined { + return this.codes.get(normalizeCode(code)); + } + + getBalance(userId: string): number { + return this.balances.get(userId) ?? 0; + } + + clear(): void { + this.codes.clear(); + this.conversions.clear(); + this.balances.clear(); + this.events = []; + this.notifications = []; + } +} + +const defaultStore = new ReferralStore(); + +/** + * process_referral(referral_code, new_user_id) + * Awards REFERRAL_CREDITS to the code owner on first paid conversion. + * Idempotent: same (code, new_user) pair cannot convert twice. + */ +export function processReferral( + referralCode: string, + newUserId: string, + store: ReferralStore = defaultStore, +): ProcessReferralResult { + if (!referralCode?.trim() || !newUserId?.trim()) { + return { status: 'invalid_input' }; + } + + const code = normalizeCode(referralCode); + const userId = newUserId.trim(); + const row = store.codes.get(code); + if (!row) return { status: 'invalid_code' }; + + if (row.ownerId === userId) { + return { status: 'self_referral' }; + } + + const convKey = `${code}::${userId}`; + if (store.conversions.has(convKey)) { + return { status: 'already_processed', ownerId: row.ownerId }; + } + + const convertedAt = nowIso(); + store.conversions.set(convKey, { + referralCode: code, + newUserId: userId, + convertedAt, + }); + + row.uses += 1; + row.creditsAwarded += REFERRAL_CREDITS; + store.balances.set(row.ownerId, store.getBalance(row.ownerId) + REFERRAL_CREDITS); + + const event: SystemEvent = { + eventType: EVENT_TYPE, + payload: { + code, + new_user: userId, + owner_id: row.ownerId, + credits: REFERRAL_CREDITS, + }, + createdAt: convertedAt, + }; + store.events.push(event); + + const notifications: Notification[] = [ + { + userId: row.ownerId, + message: `Referral success: +${REFERRAL_CREDITS} free credits (code ${code}).`, + createdAt: convertedAt, + }, + { + userId, + message: `Welcome — you joined via referral ${code}.`, + createdAt: convertedAt, + }, + ]; + store.notifications.push(...notifications); + + return { + status: 'ok', + creditsAwarded: REFERRAL_CREDITS, + ownerId: row.ownerId, + event, + notifications, + }; +} diff --git a/tests/referral.test.ts b/tests/referral.test.ts new file mode 100644 index 0000000..67d070f --- /dev/null +++ b/tests/referral.test.ts @@ -0,0 +1,74 @@ +import { assertEquals, assertExists } from 'https://deno.land/std@0.224.0/assert/mod.ts'; +import { + REFERRAL_CREDITS, + EVENT_TYPE, + ReferralStore, + processReferral, +} from '../src/growth/referral.ts'; + +Deno.test('happy path awards 5 credits and logs event + notifications', () => { + const store = new ReferralStore(); + store.createCode('alice', 'ALICE01'); + const r = processReferral('alice01', 'bob', store); + assertEquals(r.status, 'ok'); + assertEquals(r.creditsAwarded, REFERRAL_CREDITS); + assertEquals(r.ownerId, 'alice'); + assertEquals(store.getBalance('alice'), 5); + assertEquals(store.getCode('ALICE01')?.uses, 1); + assertEquals(store.getCode('ALICE01')?.creditsAwarded, 5); + assertEquals(store.events.length, 1); + assertEquals(store.events[0].eventType, EVENT_TYPE); + assertEquals(store.notifications.length, 2); +}); + +Deno.test('idempotent: duplicate conversion for same user is rejected', () => { + const store = new ReferralStore(); + store.createCode('alice', 'ALICE02'); + const first = processReferral('ALICE02', 'bob', store); + assertEquals(first.status, 'ok'); + const second = processReferral('ALICE02', 'bob', store); + assertEquals(second.status, 'already_processed'); + assertEquals(store.getBalance('alice'), 5); + assertEquals(store.getCode('ALICE02')?.uses, 1); + assertEquals(store.events.length, 1); +}); + +Deno.test('same code can convert different users', () => { + const store = new ReferralStore(); + store.createCode('alice', 'SHARE01'); + assertEquals(processReferral('SHARE01', 'bob', store).status, 'ok'); + assertEquals(processReferral('SHARE01', 'carol', store).status, 'ok'); + assertEquals(store.getBalance('alice'), 10); + assertEquals(store.getCode('SHARE01')?.uses, 2); +}); + +Deno.test('invalid code returns invalid_code', () => { + const store = new ReferralStore(); + const r = processReferral('NOPE', 'bob', store); + assertEquals(r.status, 'invalid_code'); +}); + +Deno.test('self-referral is blocked', () => { + const store = new ReferralStore(); + store.createCode('alice', 'SELF01'); + const r = processReferral('SELF01', 'alice', store); + assertEquals(r.status, 'self_referral'); + assertEquals(store.getBalance('alice'), 0); +}); + +Deno.test('invalid inputs are safe no-ops', () => { + const store = new ReferralStore(); + assertEquals(processReferral('', 'bob', store).status, 'invalid_input'); + assertEquals(processReferral('X', '', store).status, 'invalid_input'); +}); + +Deno.test('event payload includes code, new_user, credits', () => { + const store = new ReferralStore(); + store.createCode('owner', 'EVT001'); + const r = processReferral('EVT001', 'newbie', store); + assertEquals(r.status, 'ok'); + assertExists(r.event); + assertEquals(r.event!.payload.code, 'EVT001'); + assertEquals(r.event!.payload.new_user, 'newbie'); + assertEquals(r.event!.payload.credits, 5); +});