From 5fc1ce2807271ec8eef4f275492e57ec0ca1b297 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Fri, 20 Feb 2026 16:51:26 +0000
Subject: [PATCH 1/9] Initial plan
From 381f9b63f15ebb7d61e6429478046f8bcd9e2877 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Fri, 20 Feb 2026 17:11:49 +0000
Subject: [PATCH 2/9] feat: enforce referral username change limit via
REFERRAL_SYSTEM_USERNAME_CHANGE env var
Co-authored-by: thinkdj <688055+thinkdj@users.noreply.github.com>
---
REFERRAL_SYSTEM.md | 14 ++
.../.env.example | 4 +
.../src/components/ReferralDashboard.tsx | 97 ++++++++++----
.../vitest.config.ts | 5 +
.../__tests__/referrals-username.test.ts | 125 ++++++++++++++++++
.../worker/routes/referrals.ts | 23 ++++
packages/ottaorm/src/models/User.schema.ts | 1 +
packages/ottaorm/src/models/User.ts | 14 ++
8 files changed, 254 insertions(+), 29 deletions(-)
create mode 100644 apps/ottabase-template-app-tanstack/worker/routes/__tests__/referrals-username.test.ts
diff --git a/REFERRAL_SYSTEM.md b/REFERRAL_SYSTEM.md
index 69c743eb6..d396cef67 100644
--- a/REFERRAL_SYSTEM.md
+++ b/REFERRAL_SYSTEM.md
@@ -85,6 +85,7 @@ Added to `packages/ottaorm/src/models/User.ts`:
{
referralUsername: text("referral_username").unique(),
referredById: text("referred_by_id"),
+ referralUsernameChanges: integer("referral_username_changes").default(0).notNull(),
}
```
@@ -216,6 +217,8 @@ Response: 200
- Letters, numbers, underscores only
- Must be unique
- Returns 400 with error if validation fails
+- Returns 400 with `USERNAME_CHANGE_LIMIT_REACHED` code if the user has already changed their username the maximum
+ number of times (configurable via `REFERRAL_SYSTEM_USERNAME_CHANGE` env var, default: 1)
### Register with Referral Attribution
@@ -309,6 +312,16 @@ features: {
- **Behavior:** Expired codes are automatically cleared from localStorage
- **Common values:** 30, 60, 90, 180, 365
+### Environment Variables
+
+#### `REFERRAL_SYSTEM_USERNAME_CHANGE` (default: `1`)
+
+- **Type:** `string` (parsed as integer)
+- **Description:** How many times a user can change their referral username **after initial setup**
+- **Default:** `"1"` — users may set the username once and change it one more time
+- **`"0"`** — username is locked after initial setup (no changes allowed)
+- **Set in:** `wrangler.jsonc` `vars` section or as a Worker secret
+
### Example Configurations
**Minimal tracking (conversions only):**
@@ -604,6 +617,7 @@ When a user changes their referral username:
- Pending referrals with old code may not convert
- A warning is shown in the UI
- Completed conversions remain linked
+- **Change limit is enforced** (configurable via `REFERRAL_SYSTEM_USERNAME_CHANGE` env var, default: 1)
## Testing Checklist
diff --git a/apps/ottabase-template-app-tanstack/.env.example b/apps/ottabase-template-app-tanstack/.env.example
index dd85ab90e..aefcf4837 100644
--- a/apps/ottabase-template-app-tanstack/.env.example
+++ b/apps/ottabase-template-app-tanstack/.env.example
@@ -127,3 +127,7 @@ KILLSWITCH_LOCKDOWN=false
# By default destructive migrations are disabled. Set to '1' or 'true' to enable.
MIGRATION_ALLOW_DESTRUCTIVE=0
+# Referral system: number of times a user can change their referral username after initial setup.
+# Set to '0' to disallow any changes after first set. Default is 1.
+REFERRAL_SYSTEM_USERNAME_CHANGE=1
+
diff --git a/apps/ottabase-template-app-tanstack/src/components/ReferralDashboard.tsx b/apps/ottabase-template-app-tanstack/src/components/ReferralDashboard.tsx
index c8440e07a..859cd785a 100644
--- a/apps/ottabase-template-app-tanstack/src/components/ReferralDashboard.tsx
+++ b/apps/ottabase-template-app-tanstack/src/components/ReferralDashboard.tsx
@@ -42,11 +42,13 @@ interface ReferralUser {
email?: string;
referralUsername?: string;
referredById?: string;
+ referralUsernameChanges?: number;
}
interface ReferralData {
user: ReferralUser;
stats: ReferralStats;
+ usernameChangeLimit?: number;
}
interface TrackingData {
@@ -242,35 +244,72 @@ export function ReferralDashboard({ userId }: ReferralDashboardProps) {
Choose a unique username for your referral links
-
+ Warning: Changing your username will invalidate your
+ old referral links and may affect pending conversions.
+
+
+
+ )}
+ >
+ );
+ })()}
diff --git a/apps/ottabase-template-app-tanstack/vitest.config.ts b/apps/ottabase-template-app-tanstack/vitest.config.ts
index f8aed5374..1cbdd003a 100644
--- a/apps/ottabase-template-app-tanstack/vitest.config.ts
+++ b/apps/ottabase-template-app-tanstack/vitest.config.ts
@@ -33,6 +33,7 @@ export default defineConfig({
'src/**/*.{test,spec}.{ts,tsx}',
'__tests__/**/*.{test,spec}.{ts,tsx}',
'ottabase/**/*.{test,spec}.{ts,tsx}',
+ 'worker/**/*.{test,spec}.{ts,tsx}',
],
testTimeout: 10000,
},
@@ -41,8 +42,12 @@ export default defineConfig({
'@ottabase/cf-realtime/server': path.resolve(__dirname, './src/test-mocks/cf-realtime-server.ts'),
'@ottabase/ottaorm/models': path.resolve(__dirname, '../../packages/ottaorm/src/models'),
'@ottabase/auth/backend': path.resolve(__dirname, '../../packages/auth/src/backend-handler'),
+ '@ottabase/cf/cache-keys': path.resolve(__dirname, '../../packages/cf/src/cache-keys'),
'@ottabase/utils/http-response': path.resolve(__dirname, '../../packages/utils/src/http-response'),
'@ottabase/utils/http-errors': path.resolve(__dirname, '../../packages/utils/src/http-errors'),
+ '@ottabase/utils/pagination': path.resolve(__dirname, '../../packages/utils/src/pagination'),
+ '@ottabase/analytics/query': path.resolve(__dirname, '../../packages/analytics/src/query'),
+ '@ottabase/analytics/track': path.resolve(__dirname, '../../packages/analytics/src/track'),
'@ottabase/rbac/admin-guard': path.resolve(__dirname, '../../packages/rbac/src/admin-guard.ts'),
'@ottabase/rbac/request-context': path.resolve(__dirname, '../../packages/rbac/src/request-context.ts'),
},
diff --git a/apps/ottabase-template-app-tanstack/worker/routes/__tests__/referrals-username.test.ts b/apps/ottabase-template-app-tanstack/worker/routes/__tests__/referrals-username.test.ts
new file mode 100644
index 000000000..2cc968dc3
--- /dev/null
+++ b/apps/ottabase-template-app-tanstack/worker/routes/__tests__/referrals-username.test.ts
@@ -0,0 +1,125 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { handleReferralUsernameUpdate } from '../referrals';
+
+vi.mock('@ottabase/db/drizzle-d1', () => ({ createD1Driver: vi.fn() }));
+vi.mock('@ottabase/ottaorm', () => ({ registerConnection: vi.fn() }));
+vi.mock('@ottabase/auth/backend', () => ({ getSession: vi.fn() }));
+vi.mock('../../lib/auth-utils', () => ({ getAuthOptions: vi.fn(() => ({})) }));
+vi.mock('../../lib/utils', () => ({ readJson: vi.fn() }));
+vi.mock('@ottabase/analytics/query', () => ({
+ AnalyticsQueryError: class {},
+ queryEvents: vi.fn(),
+ validateAnalyticsConfig: vi.fn(),
+}));
+vi.mock('@ottabase/analytics/track', () => ({ trackEvent: vi.fn() }));
+vi.mock('@ottabase/utils/pagination', () => ({
+ parsePaginationParams: vi.fn(),
+ paginatedJsonResponse: vi.fn(),
+}));
+vi.mock('@ottabase/referrals', () => ({
+ validateReferralUsername: vi.fn(() => ({ valid: true })),
+ ReferralTracking: { getStats: vi.fn(), forUser: vi.fn() },
+}));
+vi.mock('@ottabase/ottaorm/models', () => ({
+ User: { findByReferralUsername: vi.fn(), find: vi.fn() },
+}));
+
+const { getSession } = await import('@ottabase/auth/backend');
+const { readJson } = await import('../../lib/utils');
+const { User } = await import('@ottabase/ottaorm/models');
+
+function makeContext(envOverrides: Record = {}, requestBody?: any) {
+ const request = new Request('https://example.com/api/referrals/username', { method: 'PUT' });
+ vi.mocked(readJson).mockResolvedValue(requestBody ?? { referralUsername: 'newname' });
+ return {
+ request,
+ env: { OBCF_D1: {}, ...envOverrides } as any,
+ url: new URL(request.url),
+ };
+}
+
+function makeUser(overrides: Record = {}) {
+ const data: Record = {
+ id: 'user-1',
+ referralUsername: null,
+ referralUsernameChanges: 0,
+ ...overrides,
+ };
+ return {
+ get: vi.fn((key: string) => data[key]),
+ set: vi.fn((key: string, value: any) => {
+ data[key] = value;
+ }),
+ save: vi.fn().mockResolvedValue(undefined),
+ toJson: vi.fn(() => data),
+ };
+}
+
+describe('handleReferralUsernameUpdate – username change limit', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ vi.mocked(getSession).mockResolvedValue({ user: { id: 'user-1' } } as any);
+ vi.mocked(User.findByReferralUsername).mockResolvedValue(null);
+ });
+
+ it('allows first-time username setup without incrementing counter', async () => {
+ const user = makeUser({ referralUsername: null, referralUsernameChanges: 0 });
+ vi.mocked(User.find).mockResolvedValue(user as any);
+
+ const res = await handleReferralUsernameUpdate(makeContext());
+ expect(res.status).toBe(200);
+ // counter should NOT be incremented for initial setup
+ const setCallsForChanges = user.set.mock.calls.filter(([k]: [string]) => k === 'referralUsernameChanges');
+ expect(setCallsForChanges).toHaveLength(0);
+ expect(user.set).toHaveBeenCalledWith('referralUsername', 'newname');
+ });
+
+ it('allows a change when user has a username and is under the default limit (1)', async () => {
+ const user = makeUser({ referralUsername: 'oldname', referralUsernameChanges: 0 });
+ vi.mocked(User.find).mockResolvedValue(user as any);
+
+ const res = await handleReferralUsernameUpdate(makeContext());
+ expect(res.status).toBe(200);
+ expect(user.set).toHaveBeenCalledWith('referralUsernameChanges', 1);
+ expect(user.set).toHaveBeenCalledWith('referralUsername', 'newname');
+ });
+
+ it('blocks a change when the default limit (1) is reached', async () => {
+ const user = makeUser({ referralUsername: 'oldname', referralUsernameChanges: 1 });
+ vi.mocked(User.find).mockResolvedValue(user as any);
+
+ const res = await handleReferralUsernameUpdate(makeContext());
+ expect(res.status).toBe(400);
+ const body = await res.json();
+ expect(body.code).toBe('USERNAME_CHANGE_LIMIT_REACHED');
+ });
+
+ it('respects a custom limit set via REFERRAL_SYSTEM_USERNAME_CHANGE=3', async () => {
+ const user = makeUser({ referralUsername: 'oldname', referralUsernameChanges: 2 });
+ vi.mocked(User.find).mockResolvedValue(user as any);
+
+ const res = await handleReferralUsernameUpdate(makeContext({ REFERRAL_SYSTEM_USERNAME_CHANGE: '3' }));
+ expect(res.status).toBe(200);
+ expect(user.set).toHaveBeenCalledWith('referralUsernameChanges', 3);
+ });
+
+ it('blocks when custom limit (3) is exactly reached', async () => {
+ const user = makeUser({ referralUsername: 'oldname', referralUsernameChanges: 3 });
+ vi.mocked(User.find).mockResolvedValue(user as any);
+
+ const res = await handleReferralUsernameUpdate(makeContext({ REFERRAL_SYSTEM_USERNAME_CHANGE: '3' }));
+ expect(res.status).toBe(400);
+ const body = await res.json();
+ expect(body.code).toBe('USERNAME_CHANGE_LIMIT_REACHED');
+ });
+
+ it('blocks all changes when limit is 0 (REFERRAL_SYSTEM_USERNAME_CHANGE=0)', async () => {
+ const user = makeUser({ referralUsername: 'oldname', referralUsernameChanges: 0 });
+ vi.mocked(User.find).mockResolvedValue(user as any);
+
+ const res = await handleReferralUsernameUpdate(makeContext({ REFERRAL_SYSTEM_USERNAME_CHANGE: '0' }));
+ expect(res.status).toBe(400);
+ const body = await res.json();
+ expect(body.code).toBe('USERNAME_CHANGE_LIMIT_REACHED');
+ });
+});
diff --git a/apps/ottabase-template-app-tanstack/worker/routes/referrals.ts b/apps/ottabase-template-app-tanstack/worker/routes/referrals.ts
index 88bd12318..eaf3985d1 100644
--- a/apps/ottabase-template-app-tanstack/worker/routes/referrals.ts
+++ b/apps/ottabase-template-app-tanstack/worker/routes/referrals.ts
@@ -18,6 +18,11 @@ export interface ReferralRouteContext {
url: URL;
}
+/** Returns the maximum number of post-setup username changes allowed (default: 1). */
+function getMaxUsernameChanges(env: CloudflareEnv): number {
+ return parseInt((env as any).REFERRAL_SYSTEM_USERNAME_CHANGE ?? '1', 10);
+}
+
export async function handleReferralTrack(context: ReferralRouteContext): Promise {
const { request, env } = context;
if (!env.OBCF_D1) {
@@ -125,7 +130,9 @@ export async function handleReferralUser(context: ReferralRouteContext): Promise
email: user.get('email'),
referralUsername: user.get('referralUsername'),
referredById: user.get('referredById'),
+ referralUsernameChanges: (user.get('referralUsernameChanges') as number) ?? 0,
},
+ usernameChangeLimit: getMaxUsernameChanges(env),
stats,
tracking: trackingRecords.map((t) => t.toJson()),
});
@@ -173,6 +180,22 @@ export async function handleReferralUsernameUpdate(context: ReferralRouteContext
return errorResponse('User not found', 404);
}
+ // Enforce change limit: first-time setting is free; subsequent changes are limited.
+ const maxChanges = getMaxUsernameChanges(env);
+ const currentUsername = user.get('referralUsername');
+ if (currentUsername) {
+ // This is a change (not initial setup)
+ const changesMade = (user.get('referralUsernameChanges') as number) ?? 0;
+ if (changesMade >= maxChanges) {
+ return errorResponse(
+ `Referral username can only be changed ${maxChanges} time${maxChanges === 1 ? '' : 's'} after initial setup`,
+ 400,
+ { code: 'USERNAME_CHANGE_LIMIT_REACHED' },
+ );
+ }
+ user.set('referralUsernameChanges', changesMade + 1);
+ }
+
user.set('referralUsername', body.referralUsername);
await user.save();
diff --git a/packages/ottaorm/src/models/User.schema.ts b/packages/ottaorm/src/models/User.schema.ts
index e4cfe6625..3c0632ce7 100644
--- a/packages/ottaorm/src/models/User.schema.ts
+++ b/packages/ottaorm/src/models/User.schema.ts
@@ -19,6 +19,7 @@ export const usersTable = sqliteTable('users', {
// Referral fields
referralUsername: text('referral_username').unique(),
referredById: text('referred_by_id'),
+ referralUsernameChanges: integer('referral_username_changes').default(0).notNull(),
// App identifier for multi-app database sharing (nullable, opt-in)
appId: text('app_id'),
createdAt: integer('created_at')
diff --git a/packages/ottaorm/src/models/User.ts b/packages/ottaorm/src/models/User.ts
index 952dc7ff2..155b34e49 100644
--- a/packages/ottaorm/src/models/User.ts
+++ b/packages/ottaorm/src/models/User.ts
@@ -166,6 +166,20 @@ export class User extends BaseModel {
visible: true,
},
},
+ referralUsernameChanges: {
+ type: 'number',
+ editable: false,
+ uiConfig: {
+ label: 'Referral Username Changes',
+ description: 'Number of times the referral username has been changed after initial setup',
+ },
+ formConfig: {
+ visible: false,
+ },
+ tableConfig: {
+ visible: false,
+ },
+ },
referredById: {
type: 'string',
editable: false,
From b33e0465e1bdf77a1a5da6a69e829116af08fc44 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Fri, 20 Feb 2026 18:26:15 +0000
Subject: [PATCH 3/9] docs: add REFERRAL_FEATURES_ROADMAP.md with 10 simple + 5
high-level feature ideas
Co-authored-by: thinkdj <688055+thinkdj@users.noreply.github.com>
---
REFERRAL_FEATURES_ROADMAP.md | 258 +++++++++++++++++++++++++++++++++++
REFERRAL_SYSTEM.md | 29 ++--
2 files changed, 278 insertions(+), 9 deletions(-)
create mode 100644 REFERRAL_FEATURES_ROADMAP.md
diff --git a/REFERRAL_FEATURES_ROADMAP.md b/REFERRAL_FEATURES_ROADMAP.md
new file mode 100644
index 000000000..67e84466b
--- /dev/null
+++ b/REFERRAL_FEATURES_ROADMAP.md
@@ -0,0 +1,258 @@
+# Referral System — Feature Roadmap
+
+This document lists candidate features for the referral system, split into two tiers.
+Everything already shipped is marked ✅. Everything below is a candidate — decide which ones
+to build next.
+
+---
+
+## What's already live (summary)
+
+| Feature | Notes |
+|---|---|
+| ✅ First-touch attribution | localStorage, 90-day expiry |
+| ✅ Click tracking | IP, user-agent, UTM, referer → D1 |
+| ✅ WAE analytics | Click counts by country / code / day |
+| ✅ Conversion tracking | pending → completed on signup |
+| ✅ User-managed referral username | Set once, change limited by `REFERRAL_SYSTEM_USERNAME_CHANGE` |
+| ✅ Referral dashboard | Stats, activity feed, copy link |
+| ✅ Admin tracking page | All-user conversion list |
+| ✅ RESTful API | `/api/referrals/*` |
+
+---
+
+## Tier 1 — Simple, Good-to-Have (10 ideas)
+
+These are self-contained, low-risk additions that fit naturally into the existing
+architecture. Each one can be built in a single PR.
+
+---
+
+### 1. Auto-generate referral username on signup
+
+**What:** When a new user registers and no referral username is set, automatically derive a
+username from their display name or email prefix (`john.doe@` → `johndoe`) and save it.
+
+**Why:** Users get a share-ready link immediately; zero friction.
+
+**Where:** `processReferralAttribution` / Auth.js sign-in callback.
+New helper `generateReferralUsername(user)` in `@ottabase/referrals/validation`.
+Add a uniqueness-suffix loop (`johndoe2`, `johndoe3` …) if taken.
+
+---
+
+### 2. Conversion rate display in the dashboard
+
+**What:** Add a "Conversion rate" stat card next to Total / Conversions / Pending:
+
+```
+Conversion rate = completed / (completed + pending) × 100
+```
+
+**Why:** The most useful KPI for any referral programme — it's one arithmetic expression on
+data that's already returned by `/api/referrals/user`.
+
+**Where:** Pure UI change in `ReferralDashboard.tsx`. No schema or API change needed.
+
+---
+
+### 3. One-click social sharing buttons
+
+**What:** Pre-formatted share URLs for Twitter/X, LinkedIn, and WhatsApp directly in the
+dashboard, next to the "Copy" button.
+
+```
+Twitter: https://twitter.com/intent/tweet?text=Join+via+my+link:+{link}
+LinkedIn: https://www.linkedin.com/shareArticle?url={link}
+WhatsApp: https://wa.me/?text={link}
+```
+
+**Why:** Dramatically lowers the effort to share. No backend work; pure UI.
+
+**Where:** `ReferralDashboard.tsx` — Referral Link card.
+
+---
+
+### 4. Referral source label in the activity feed
+
+**What:** Parse the stored `referer` header into a human-readable label ("Twitter", "Facebook",
+"Reddit", "Direct", "Other") and show it in the tracking table.
+
+**Why:** Users want to know _where_ their clicks came from without decoding raw URLs.
+
+**Where:** Pure display utility in `ReferralDashboard.tsx` / `ReferralTracking.getBrowserInfo()`
+style helper. No schema change.
+
+---
+
+### 5. QR code for the referral link
+
+**What:** A "Show QR Code" button in the Referral Link card that renders a QR code using the
+browser-native `window.QRCode` API or a tiny canvas-based lib (e.g. `qrcode` npm, ~7 KB).
+
+**Why:** Great for offline use, printed materials, and conference name-badges.
+
+**Where:** `ReferralDashboard.tsx` — Referral Link card. Optional dep added only to the app,
+not shared packages.
+
+---
+
+### 6. Referred-by display on the user's own profile/settings
+
+**What:** If `referredById` is set on the user, show a small "Referred by: @username" note
+on the user's settings or profile page.
+
+**Why:** Nice social acknowledgement; confirms the attribution is working.
+
+**Where:** Add `GET /api/referrals/referrer` (returns `{ referralUsername }` of the referrer),
+then display in the profile UI.
+
+---
+
+### 7. Referral milestone badges / in-app notifications
+
+**What:** When a user crosses a referral count milestone (1st, 5th, 10th, 25th, 50th
+conversion), show a toast/banner in the dashboard celebrating it.
+
+**Why:** Gamification keeps top referrers engaged. Pure client-side calculation on data
+already loaded.
+
+**Where:** `ReferralDashboard.tsx` — compute `milestoneMessage` from `stats.completed` on
+mount, pop a `toast.success()`.
+
+---
+
+### 8. Duplicate-click deduplication (basic fraud prevention)
+
+**What:** In `handleReferralTrack`, skip creating a new WAE event if the same IP has already
+fired for the same `referralCode` within the last N minutes (tracked in KV with a TTL).
+
+**Why:** Prevents a single user from inflating click counts by refreshing the page.
+
+**Where:** `worker/routes/referrals.ts` — `handleReferralTrack`. Use `OBCF_KV` (already
+bound) with key `ref_dedup:{ip}:{code}` and 15-min TTL. Config flag
+`REFERRAL_DEDUP_WINDOW_MINUTES` (default `15`, `0` = disabled).
+
+---
+
+### 9. Export referral data as CSV
+
+**What:** A "Download CSV" button in the activity feed that calls
+`GET /api/referrals/export?format=csv` and downloads the user's tracking records as a
+comma-separated file.
+
+**Why:** Power users want their data. Requested feature in many SaaS products.
+
+**Where:** New route handler `handleReferralExport` in `worker/routes/referrals.ts`.
+Generates CSV in memory from `ReferralTracking.forUser(userId)`.
+
+---
+
+### 10. Referral link preview / custom `/r/{username}` vanity URL
+
+**What:** Add a route `/r/:username` that redirects to `/?ref=:username` with a proper
+`302` and injects OG meta tags (`og:title`, `og:description`, `og:image`) so link previews
+on social media show a personalised card rather than the generic homepage preview.
+
+**Why:** `?ref=` params look spammy; `/r/johndoe` is clean and memorable.
+
+**Where:** New catch-all worker route `/r/:username` → read user record → redirect with
+meta-injected HTML (reuse the existing `brand-html-inject` pattern).
+
+---
+
+## Tier 2 — High-Level / Larger Features (5 ideas)
+
+These require more planning (schema changes, multi-step flows, or new packages) but would
+significantly elevate the referral programme.
+
+---
+
+### A. Rewards & Incentives Engine
+
+**Vision:** Define configurable rewards that are automatically granted when a referral
+converts — account credits, coupon codes, feature unlocks, or custom callback webhooks.
+Both the referrer _and_ the new user can receive rewards (double-sided referral).
+
+**Key pieces:**
+- `rewards` config table: `{ trigger: 'conversion', grantType: 'credit', amount: 10 }`
+- `referral_rewards` table: `{ userId, trackingId, grantType, amount, status, grantedAt }`
+- Queue job `referral.reward.grant` dispatched on conversion
+- Dashboard: "You earned $10 credit" banner
+
+---
+
+### B. Multi-Tier / Chain Referrals
+
+**Vision:** Support referral chains where A referred B who referred C, so A gets a partial
+reward for C's conversion (configurable depth and split percentages).
+
+**Key pieces:**
+- `referralChain` JSON column on `referral_tracking`: `['userId-A', 'userId-B']`
+- Attribution walker that climbs the chain up to `REFERRAL_MAX_DEPTH` levels
+- Per-tier reward config: `[{ depth: 1, pct: 100 }, { depth: 2, pct: 20 }]`
+
+---
+
+### C. Campaign Management
+
+**Vision:** Admins create named referral campaigns (e.g. "Black Friday 2025") with custom
+expiry dates, unique campaign-scoped tracking URLs, per-campaign conversion goals, and
+campaign-specific reward overrides.
+
+**Key pieces:**
+- New `referral_campaigns` table: `{ id, name, startsAt, endsAt, goal, rewardConfig }`
+- Campaign-scoped referral links: `/?ref=johndoe&campaign=blackfriday`
+- Admin campaign CRUD page
+- Dashboard: campaign selector + per-campaign stats
+
+---
+
+### D. Fraud Detection & Risk Scoring
+
+**Vision:** Automatically flag suspicious referral activity with a risk score per tracking
+record — VPN/datacenter IP detection, velocity checks (too many conversions from the same /24
+subnet in 24 h), disposable email detection on the referred user.
+
+**Key pieces:**
+- `riskScore` integer column on `referral_tracking` (0–100)
+- `status: 'suspicious'` in addition to existing `pending/completed/invalid`
+- Background queue job `referral.risk.score` runs after each conversion
+- Admin UI: filter by status=suspicious, one-click approve/invalidate
+
+---
+
+### E. White-Label Public Invite Page (`/invite/{username}`)
+
+**Vision:** A fully branded, publicly accessible landing page at `/invite/{username}` that
+shows the inviter's name, avatar, a personalised headline ("John Doe invites you to join!"),
+and a sign-up CTA — all themed with the app's brand engine. Ideal for email campaigns and
+direct links.
+
+**Key pieces:**
+- Worker SSR route `/invite/:username` → fetches user record → renders branded HTML
+- Extend `brand-html-inject` to accept per-page OG meta overrides
+- Optional: `referralBio` text field on the User model for a custom tagline
+- Optional: integration with `@ottabase/ui-shadcn` for the client-side component after hydration
+
+---
+
+## Decision Matrix
+
+| # | Feature | Effort | Impact | Dependencies |
+|---|---|---|---|---|
+| 1 | Auto-generate username | Low | High | None |
+| 2 | Conversion rate display | Very Low | Medium | None |
+| 3 | Social sharing buttons | Very Low | High | None |
+| 4 | Source label | Very Low | Medium | None |
+| 5 | QR code | Low | Medium | Small npm dep |
+| 6 | Referred-by on profile | Low | Low | New API endpoint |
+| 7 | Milestone badges | Very Low | Medium | None |
+| 8 | Dedup / fraud prevention | Low | High | KV (already bound) |
+| 9 | CSV export | Low | Medium | None |
+| 10 | `/r/{username}` vanity URL | Medium | High | Worker route |
+| A | Rewards engine | High | Very High | Schema + Queue |
+| B | Multi-tier referrals | High | High | Schema changes |
+| C | Campaign management | High | High | New tables + Admin UI |
+| D | Fraud detection | Medium | High | Queue + scoring logic |
+| E | White-label invite page | Medium | High | Worker SSR |
diff --git a/REFERRAL_SYSTEM.md b/REFERRAL_SYSTEM.md
index d396cef67..154555902 100644
--- a/REFERRAL_SYSTEM.md
+++ b/REFERRAL_SYSTEM.md
@@ -714,15 +714,26 @@ When a user changes their referral username:
## Future Enhancements
-- [ ] Email notifications for conversions
-- [ ] Reward/incentive system
-- [ ] Admin analytics dashboard
-- [ ] Referral leaderboard
-- [ ] Custom referral link URLs (e.g., `/r/{username}`)
-- [ ] Multi-level referrals (referral of referral)
-- [ ] Export referral data (CSV/JSON)
-- [ ] Webhook notifications for conversions
-- [ ] A/B testing for referral campaigns
+See **[REFERRAL_FEATURES_ROADMAP.md](./REFERRAL_FEATURES_ROADMAP.md)** for a full list of
+candidate features, split into:
+
+- **Tier 1 — Simple, good-to-have** (10 ideas, each buildable in a single PR)
+- **Tier 2 — High-level / larger features** (5 strategic ideas)
+
+Quick reference of items not yet started:
+
+| Tier 1 (simple) | Tier 2 (high-level) |
+|---|---|
+| Auto-generate referral username on signup | Rewards & incentives engine |
+| Conversion rate stat in dashboard | Multi-tier / chain referrals |
+| One-click social sharing buttons | Campaign management |
+| Source label in activity feed | Fraud detection & risk scoring |
+| QR code for referral link | White-label `/invite/{username}` page |
+| Referred-by on user profile | |
+| Milestone badges / in-app notifications | |
+| Duplicate-click deduplication | |
+| CSV export | |
+| `/r/{username}` vanity URL | |
## License
From e805f16e8d7b55c0a6c15a78572c45090c11fb2a Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Fri, 20 Feb 2026 18:48:41 +0000
Subject: [PATCH 4/9] feat(referrals): auto-generate username, click dedup, CSV
export, /r/{username} vanity URL
Co-authored-by: thinkdj <688055+thinkdj@users.noreply.github.com>
---
REFERRAL_FEATURES_ROADMAP.md | 183 ++++++------
.../.env.example | 5 +
.../cloudflare-worker.ts | 11 +
.../src/components/ReferralDashboard.tsx | 30 +-
.../vitest.config.ts | 2 +
.../__tests__/referrals-new-features.test.ts | 278 ++++++++++++++++++
.../__tests__/referrals-username.test.ts | 8 +
.../worker/routes/auth.ts | 17 ++
.../worker/routes/referrals.ts | 142 +++++++++
.../worker/routes/router.ts | 5 +
packages/referrals/src/validation.ts | 30 ++
11 files changed, 617 insertions(+), 94 deletions(-)
create mode 100644 apps/ottabase-template-app-tanstack/worker/routes/__tests__/referrals-new-features.test.ts
diff --git a/REFERRAL_FEATURES_ROADMAP.md b/REFERRAL_FEATURES_ROADMAP.md
index 67e84466b..c4ab23f69 100644
--- a/REFERRAL_FEATURES_ROADMAP.md
+++ b/REFERRAL_FEATURES_ROADMAP.md
@@ -1,43 +1,45 @@
# Referral System — Feature Roadmap
-This document lists candidate features for the referral system, split into two tiers.
-Everything already shipped is marked ✅. Everything below is a candidate — decide which ones
-to build next.
+This document lists candidate features for the referral system, split into two tiers. Everything already shipped is
+marked ✅. Everything below is a candidate — decide which ones to build next.
---
## What's already live (summary)
-| Feature | Notes |
-|---|---|
-| ✅ First-touch attribution | localStorage, 90-day expiry |
-| ✅ Click tracking | IP, user-agent, UTM, referer → D1 |
-| ✅ WAE analytics | Click counts by country / code / day |
-| ✅ Conversion tracking | pending → completed on signup |
-| ✅ User-managed referral username | Set once, change limited by `REFERRAL_SYSTEM_USERNAME_CHANGE` |
-| ✅ Referral dashboard | Stats, activity feed, copy link |
-| ✅ Admin tracking page | All-user conversion list |
-| ✅ RESTful API | `/api/referrals/*` |
+| Feature | Notes |
+| -------------------------------------------- | ------------------------------------------------------------------------ |
+| ✅ First-touch attribution | localStorage, 90-day expiry |
+| ✅ Click tracking | IP, user-agent, UTM, referer → D1 |
+| ✅ WAE analytics | Click counts by country / code / day |
+| ✅ Conversion tracking | pending → completed on signup |
+| ✅ User-managed referral username | Set once, change limited by `REFERRAL_SYSTEM_USERNAME_CHANGE` |
+| ✅ Auto-generate referral username on signup | Derived from email prefix with uniqueness suffix loop |
+| ✅ Duplicate-click deduplication | KV `ref:dedup:{ip}:{code}`, 20-min TTL (`REFERRAL_DEDUP_WINDOW_MINUTES`) |
+| ✅ CSV export | `GET /api/referrals/export?format=csv` + Download button in dashboard |
+| ✅ `/r/{username}` vanity URL | HTML + OG meta + JS redirect — registered in `cloudflare-worker.ts` |
+| ✅ Referral dashboard | Stats, activity feed, copy link, Download CSV |
+| ✅ Admin tracking page | All-user conversion list |
+| ✅ RESTful API | `/api/referrals/*` |
---
## Tier 1 — Simple, Good-to-Have (10 ideas)
-These are self-contained, low-risk additions that fit naturally into the existing
-architecture. Each one can be built in a single PR.
+These are self-contained, low-risk additions that fit naturally into the existing architecture. Each one can be built in
+a single PR.
---
### 1. Auto-generate referral username on signup
-**What:** When a new user registers and no referral username is set, automatically derive a
-username from their display name or email prefix (`john.doe@` → `johndoe`) and save it.
+**What:** When a new user registers and no referral username is set, automatically derive a username from their display
+name or email prefix (`john.doe@` → `johndoe`) and save it.
**Why:** Users get a share-ready link immediately; zero friction.
-**Where:** `processReferralAttribution` / Auth.js sign-in callback.
-New helper `generateReferralUsername(user)` in `@ottabase/referrals/validation`.
-Add a uniqueness-suffix loop (`johndoe2`, `johndoe3` …) if taken.
+**Where:** `processReferralAttribution` / Auth.js sign-in callback. New helper `generateReferralUsername(user)` in
+`@ottabase/referrals/validation`. Add a uniqueness-suffix loop (`johndoe2`, `johndoe3` …) if taken.
---
@@ -49,8 +51,8 @@ Add a uniqueness-suffix loop (`johndoe2`, `johndoe3` …) if taken.
Conversion rate = completed / (completed + pending) × 100
```
-**Why:** The most useful KPI for any referral programme — it's one arithmetic expression on
-data that's already returned by `/api/referrals/user`.
+**Why:** The most useful KPI for any referral programme — it's one arithmetic expression on data that's already returned
+by `/api/referrals/user`.
**Where:** Pure UI change in `ReferralDashboard.tsx`. No schema or API change needed.
@@ -58,8 +60,8 @@ data that's already returned by `/api/referrals/user`.
### 3. One-click social sharing buttons
-**What:** Pre-formatted share URLs for Twitter/X, LinkedIn, and WhatsApp directly in the
-dashboard, next to the "Copy" button.
+**What:** Pre-formatted share URLs for Twitter/X, LinkedIn, and WhatsApp directly in the dashboard, next to the "Copy"
+button.
```
Twitter: https://twitter.com/intent/tweet?text=Join+via+my+link:+{link}
@@ -75,106 +77,103 @@ WhatsApp: https://wa.me/?text={link}
### 4. Referral source label in the activity feed
-**What:** Parse the stored `referer` header into a human-readable label ("Twitter", "Facebook",
-"Reddit", "Direct", "Other") and show it in the tracking table.
+**What:** Parse the stored `referer` header into a human-readable label ("Twitter", "Facebook", "Reddit", "Direct",
+"Other") and show it in the tracking table.
**Why:** Users want to know _where_ their clicks came from without decoding raw URLs.
-**Where:** Pure display utility in `ReferralDashboard.tsx` / `ReferralTracking.getBrowserInfo()`
-style helper. No schema change.
+**Where:** Pure display utility in `ReferralDashboard.tsx` / `ReferralTracking.getBrowserInfo()` style helper. No schema
+change.
---
### 5. QR code for the referral link
-**What:** A "Show QR Code" button in the Referral Link card that renders a QR code using the
-browser-native `window.QRCode` API or a tiny canvas-based lib (e.g. `qrcode` npm, ~7 KB).
+**What:** A "Show QR Code" button in the Referral Link card that renders a QR code using the browser-native
+`window.QRCode` API or a tiny canvas-based lib (e.g. `qrcode` npm, ~7 KB).
**Why:** Great for offline use, printed materials, and conference name-badges.
-**Where:** `ReferralDashboard.tsx` — Referral Link card. Optional dep added only to the app,
-not shared packages.
+**Where:** `ReferralDashboard.tsx` — Referral Link card. Optional dep added only to the app, not shared packages.
---
### 6. Referred-by display on the user's own profile/settings
-**What:** If `referredById` is set on the user, show a small "Referred by: @username" note
-on the user's settings or profile page.
+**What:** If `referredById` is set on the user, show a small "Referred by: @username" note on the user's settings or
+profile page.
**Why:** Nice social acknowledgement; confirms the attribution is working.
-**Where:** Add `GET /api/referrals/referrer` (returns `{ referralUsername }` of the referrer),
-then display in the profile UI.
+**Where:** Add `GET /api/referrals/referrer` (returns `{ referralUsername }` of the referrer), then display in the
+profile UI.
---
### 7. Referral milestone badges / in-app notifications
-**What:** When a user crosses a referral count milestone (1st, 5th, 10th, 25th, 50th
-conversion), show a toast/banner in the dashboard celebrating it.
+**What:** When a user crosses a referral count milestone (1st, 5th, 10th, 25th, 50th conversion), show a toast/banner in
+the dashboard celebrating it.
-**Why:** Gamification keeps top referrers engaged. Pure client-side calculation on data
-already loaded.
+**Why:** Gamification keeps top referrers engaged. Pure client-side calculation on data already loaded.
-**Where:** `ReferralDashboard.tsx` — compute `milestoneMessage` from `stats.completed` on
-mount, pop a `toast.success()`.
+**Where:** `ReferralDashboard.tsx` — compute `milestoneMessage` from `stats.completed` on mount, pop a
+`toast.success()`.
---
### 8. Duplicate-click deduplication (basic fraud prevention)
-**What:** In `handleReferralTrack`, skip creating a new WAE event if the same IP has already
-fired for the same `referralCode` within the last N minutes (tracked in KV with a TTL).
+**What:** In `handleReferralTrack`, skip creating a new WAE event if the same IP has already fired for the same
+`referralCode` within the last N minutes (tracked in KV with a TTL).
**Why:** Prevents a single user from inflating click counts by refreshing the page.
-**Where:** `worker/routes/referrals.ts` — `handleReferralTrack`. Use `OBCF_KV` (already
-bound) with key `ref_dedup:{ip}:{code}` and 15-min TTL. Config flag
-`REFERRAL_DEDUP_WINDOW_MINUTES` (default `15`, `0` = disabled).
+**Where:** `worker/routes/referrals.ts` — `handleReferralTrack`. Use `OBCF_KV` (already bound) with key
+`ref_dedup:{ip}:{code}` and 15-min TTL. Config flag `REFERRAL_DEDUP_WINDOW_MINUTES` (default `15`, `0` = disabled).
---
### 9. Export referral data as CSV
-**What:** A "Download CSV" button in the activity feed that calls
-`GET /api/referrals/export?format=csv` and downloads the user's tracking records as a
-comma-separated file.
+**What:** A "Download CSV" button in the activity feed that calls `GET /api/referrals/export?format=csv` and downloads
+the user's tracking records as a comma-separated file.
**Why:** Power users want their data. Requested feature in many SaaS products.
-**Where:** New route handler `handleReferralExport` in `worker/routes/referrals.ts`.
-Generates CSV in memory from `ReferralTracking.forUser(userId)`.
+**Where:** New route handler `handleReferralExport` in `worker/routes/referrals.ts`. Generates CSV in memory from
+`ReferralTracking.forUser(userId)`.
---
### 10. Referral link preview / custom `/r/{username}` vanity URL
-**What:** Add a route `/r/:username` that redirects to `/?ref=:username` with a proper
-`302` and injects OG meta tags (`og:title`, `og:description`, `og:image`) so link previews
-on social media show a personalised card rather than the generic homepage preview.
+**What:** Add a route `/r/:username` that redirects to `/?ref=:username` with a proper `302` and injects OG meta tags
+(`og:title`, `og:description`, `og:image`) so link previews on social media show a personalised card rather than the
+generic homepage preview.
**Why:** `?ref=` params look spammy; `/r/johndoe` is clean and memorable.
-**Where:** New catch-all worker route `/r/:username` → read user record → redirect with
-meta-injected HTML (reuse the existing `brand-html-inject` pattern).
+**Where:** New catch-all worker route `/r/:username` → read user record → redirect with meta-injected HTML (reuse the
+existing `brand-html-inject` pattern).
---
## Tier 2 — High-Level / Larger Features (5 ideas)
-These require more planning (schema changes, multi-step flows, or new packages) but would
-significantly elevate the referral programme.
+These require more planning (schema changes, multi-step flows, or new packages) but would significantly elevate the
+referral programme.
---
### A. Rewards & Incentives Engine
-**Vision:** Define configurable rewards that are automatically granted when a referral
-converts — account credits, coupon codes, feature unlocks, or custom callback webhooks.
-Both the referrer _and_ the new user can receive rewards (double-sided referral).
+**Vision:** Define configurable rewards that are automatically granted when a referral converts — account credits,
+coupon codes, feature unlocks, or custom callback webhooks. Both the referrer _and_ the new user can receive rewards
+(double-sided referral).
**Key pieces:**
+
- `rewards` config table: `{ trigger: 'conversion', grantType: 'credit', amount: 10 }`
- `referral_rewards` table: `{ userId, trackingId, grantType, amount, status, grantedAt }`
- Queue job `referral.reward.grant` dispatched on conversion
@@ -184,10 +183,11 @@ Both the referrer _and_ the new user can receive rewards (double-sided referral)
### B. Multi-Tier / Chain Referrals
-**Vision:** Support referral chains where A referred B who referred C, so A gets a partial
-reward for C's conversion (configurable depth and split percentages).
+**Vision:** Support referral chains where A referred B who referred C, so A gets a partial reward for C's conversion
+(configurable depth and split percentages).
**Key pieces:**
+
- `referralChain` JSON column on `referral_tracking`: `['userId-A', 'userId-B']`
- Attribution walker that climbs the chain up to `REFERRAL_MAX_DEPTH` levels
- Per-tier reward config: `[{ depth: 1, pct: 100 }, { depth: 2, pct: 20 }]`
@@ -196,11 +196,11 @@ reward for C's conversion (configurable depth and split percentages).
### C. Campaign Management
-**Vision:** Admins create named referral campaigns (e.g. "Black Friday 2025") with custom
-expiry dates, unique campaign-scoped tracking URLs, per-campaign conversion goals, and
-campaign-specific reward overrides.
+**Vision:** Admins create named referral campaigns (e.g. "Black Friday 2025") with custom expiry dates, unique
+campaign-scoped tracking URLs, per-campaign conversion goals, and campaign-specific reward overrides.
**Key pieces:**
+
- New `referral_campaigns` table: `{ id, name, startsAt, endsAt, goal, rewardConfig }`
- Campaign-scoped referral links: `/?ref=johndoe&campaign=blackfriday`
- Admin campaign CRUD page
@@ -210,11 +210,12 @@ campaign-specific reward overrides.
### D. Fraud Detection & Risk Scoring
-**Vision:** Automatically flag suspicious referral activity with a risk score per tracking
-record — VPN/datacenter IP detection, velocity checks (too many conversions from the same /24
-subnet in 24 h), disposable email detection on the referred user.
+**Vision:** Automatically flag suspicious referral activity with a risk score per tracking record — VPN/datacenter IP
+detection, velocity checks (too many conversions from the same /24 subnet in 24 h), disposable email detection on the
+referred user.
**Key pieces:**
+
- `riskScore` integer column on `referral_tracking` (0–100)
- `status: 'suspicious'` in addition to existing `pending/completed/invalid`
- Background queue job `referral.risk.score` runs after each conversion
@@ -224,12 +225,12 @@ subnet in 24 h), disposable email detection on the referred user.
### E. White-Label Public Invite Page (`/invite/{username}`)
-**Vision:** A fully branded, publicly accessible landing page at `/invite/{username}` that
-shows the inviter's name, avatar, a personalised headline ("John Doe invites you to join!"),
-and a sign-up CTA — all themed with the app's brand engine. Ideal for email campaigns and
-direct links.
+**Vision:** A fully branded, publicly accessible landing page at `/invite/{username}` that shows the inviter's name,
+avatar, a personalised headline ("John Doe invites you to join!"), and a sign-up CTA — all themed with the app's brand
+engine. Ideal for email campaigns and direct links.
**Key pieces:**
+
- Worker SSR route `/invite/:username` → fetches user record → renders branded HTML
- Extend `brand-html-inject` to accept per-page OG meta overrides
- Optional: `referralBio` text field on the User model for a custom tagline
@@ -239,20 +240,20 @@ direct links.
## Decision Matrix
-| # | Feature | Effort | Impact | Dependencies |
-|---|---|---|---|---|
-| 1 | Auto-generate username | Low | High | None |
-| 2 | Conversion rate display | Very Low | Medium | None |
-| 3 | Social sharing buttons | Very Low | High | None |
-| 4 | Source label | Very Low | Medium | None |
-| 5 | QR code | Low | Medium | Small npm dep |
-| 6 | Referred-by on profile | Low | Low | New API endpoint |
-| 7 | Milestone badges | Very Low | Medium | None |
-| 8 | Dedup / fraud prevention | Low | High | KV (already bound) |
-| 9 | CSV export | Low | Medium | None |
-| 10 | `/r/{username}` vanity URL | Medium | High | Worker route |
-| A | Rewards engine | High | Very High | Schema + Queue |
-| B | Multi-tier referrals | High | High | Schema changes |
-| C | Campaign management | High | High | New tables + Admin UI |
-| D | Fraud detection | Medium | High | Queue + scoring logic |
-| E | White-label invite page | Medium | High | Worker SSR |
+| # | Feature | Effort | Impact | Dependencies | Status |
+| --- | -------------------------- | -------- | --------- | --------------------- | ------- |
+| 1 | Auto-generate username | Low | High | None | ✅ Done |
+| 2 | Conversion rate display | Very Low | Medium | None | |
+| 3 | Social sharing buttons | Very Low | High | None | |
+| 4 | Source label | Very Low | Medium | None | |
+| 5 | QR code | Low | Medium | Small npm dep | |
+| 6 | Referred-by on profile | Low | Low | New API endpoint | |
+| 7 | Milestone badges | Very Low | Medium | None | |
+| 8 | Dedup / fraud prevention | Low | High | KV (already bound) | ✅ Done |
+| 9 | CSV export | Low | Medium | None | ✅ Done |
+| 10 | `/r/{username}` vanity URL | Medium | High | Worker route | ✅ Done |
+| A | Rewards engine | High | Very High | Schema + Queue | |
+| B | Multi-tier referrals | High | High | Schema changes | |
+| C | Campaign management | High | High | New tables + Admin UI | |
+| D | Fraud detection | Medium | High | Queue + scoring logic | |
+| E | White-label invite page | Medium | High | Worker SSR | |
diff --git a/apps/ottabase-template-app-tanstack/.env.example b/apps/ottabase-template-app-tanstack/.env.example
index aefcf4837..466d0e505 100644
--- a/apps/ottabase-template-app-tanstack/.env.example
+++ b/apps/ottabase-template-app-tanstack/.env.example
@@ -131,3 +131,8 @@ MIGRATION_ALLOW_DESTRUCTIVE=0
# Set to '0' to disallow any changes after first set. Default is 1.
REFERRAL_SYSTEM_USERNAME_CHANGE=1
+# Referral click deduplication window (minutes). Within this window a second click from the
+# same IP+referral-code pair is silently ignored (not counted in analytics).
+# Set to '0' to disable deduplication. Default is 20.
+REFERRAL_DEDUP_WINDOW_MINUTES=20
+
diff --git a/apps/ottabase-template-app-tanstack/cloudflare-worker.ts b/apps/ottabase-template-app-tanstack/cloudflare-worker.ts
index cb205b13d..45260d905 100644
--- a/apps/ottabase-template-app-tanstack/cloudflare-worker.ts
+++ b/apps/ottabase-template-app-tanstack/cloudflare-worker.ts
@@ -6,6 +6,7 @@ import { handleBootstrapRoute, interceptIfNotReady, resolvePlatformState } from
import { injectBrandCriticalCSS } from './worker/lib/brand-html-inject';
import { initDbConnection } from './worker/lib/db-utils';
import { checkKillSwitches } from './worker/lib/killswitch';
+import { handleReferralVanityRedirect } from './worker/routes/referrals';
import { resolveApiRoute } from './worker/routes/router';
import { handleShortlinkFallback } from './worker/routes/shortlinks';
@@ -118,6 +119,16 @@ export default {
return shortlinkFallbackResponse;
}
+ // /r/{username} vanity referral redirect
+ const vanityMatch = normalizedPathname.match(/^\/r\/([^/]+)$/);
+ if (vanityMatch) {
+ const vanityRes = await handleReferralVanityRedirect(
+ { request, env, url },
+ decodeURIComponent(vanityMatch[1]),
+ );
+ if (vanityRes) return vanityRes;
+ }
+
if (!env.OBCF_ASSETS) {
return errorResponse('Assets binding not configured', 500, {
code: 'CONFIG_ERROR',
diff --git a/apps/ottabase-template-app-tanstack/src/components/ReferralDashboard.tsx b/apps/ottabase-template-app-tanstack/src/components/ReferralDashboard.tsx
index 859cd785a..5925719c2 100644
--- a/apps/ottabase-template-app-tanstack/src/components/ReferralDashboard.tsx
+++ b/apps/ottabase-template-app-tanstack/src/components/ReferralDashboard.tsx
@@ -26,7 +26,7 @@ import {
CardTitle,
Input,
} from '@ottabase/ui-shadcn';
-import { Copy, X } from 'lucide-react';
+import { Copy, Download, X } from 'lucide-react';
import { useEffect, useState } from 'react';
import { toast } from 'sonner';
@@ -174,6 +174,22 @@ export function ReferralDashboard({ userId }: ReferralDashboardProps) {
window.location.reload();
};
+ const handleDownloadCsv = async () => {
+ try {
+ const res = await fetch('/api/referrals/export?format=csv', { credentials: 'include' });
+ if (!res.ok) throw new Error('Export failed');
+ const blob = await res.blob();
+ const today = new Date().toISOString().slice(0, 10);
+ const a = document.createElement('a');
+ a.href = URL.createObjectURL(blob);
+ a.download = `referrals-${today}.csv`;
+ a.click();
+ URL.revokeObjectURL(a.href);
+ } catch {
+ toast.error('Failed to download CSV');
+ }
+ };
+
if (loading) {
return (
@@ -382,8 +398,16 @@ export function ReferralDashboard({ userId }: ReferralDashboardProps) {
{/* Recent Tracking with Pagination */}
- Recent Activity
- Your referral click and conversion history
+
+
+ Recent Activity
+ Your referral click and conversion history
+