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 -
-
- setNewUsername(e.target.value)} - placeholder="e.g., johndoe" - className="flex-1" - /> - -
- {usernameError &&

{usernameError}

} -

- 3-20 characters, letters/numbers/underscore only -

-
- - {data.user.referralUsername && ( - - -

- Warning: Changing your username will invalidate your old referral - links and may affect pending conversions. -

-
-
- )} + {(() => { + // usernameChangeLimit is always included in the API response; + // the fallback of 1 matches the server-side default (REFERRAL_SYSTEM_USERNAME_CHANGE). + const maxChanges = data.usernameChangeLimit ?? 1; + const changesMade = data.user.referralUsernameChanges ?? 0; + const hasUsername = !!data.user.referralUsername; + const changesRemaining = hasUsername ? Math.max(0, maxChanges - changesMade) : null; + const atLimit = hasUsername && changesRemaining === 0; + + return ( + <> +
+
+ setNewUsername(e.target.value)} + placeholder="e.g., johndoe" + className="flex-1" + disabled={atLimit} + /> + +
+ {usernameError &&

{usernameError}

} +

+ 3-20 characters, letters/numbers/underscore only +

+ {hasUsername && changesRemaining !== null && ( +

+ {atLimit ? ( + + Username change limit reached. You cannot change your username + again. + + ) : ( + <> + You have{' '} + + {changesRemaining} change + {changesRemaining !== 1 ? 's' : ''} + {' '} + remaining. + + )} +

+ )} +
+ + {hasUsername && !atLimit && ( + + +

+ 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 +
+ +
{trackingLoading ? ( diff --git a/apps/ottabase-template-app-tanstack/vitest.config.ts b/apps/ottabase-template-app-tanstack/vitest.config.ts index 1cbdd003a..17e61ba58 100644 --- a/apps/ottabase-template-app-tanstack/vitest.config.ts +++ b/apps/ottabase-template-app-tanstack/vitest.config.ts @@ -43,6 +43,8 @@ export default defineConfig({ '@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/cf/kv-cache': path.resolve(__dirname, '../../packages/cf/src/kv-cache'), + '@ottabase/referrals/validation': path.resolve(__dirname, '../../packages/referrals/src/validation'), '@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'), diff --git a/apps/ottabase-template-app-tanstack/worker/routes/__tests__/referrals-new-features.test.ts b/apps/ottabase-template-app-tanstack/worker/routes/__tests__/referrals-new-features.test.ts new file mode 100644 index 000000000..4c269591f --- /dev/null +++ b/apps/ottabase-template-app-tanstack/worker/routes/__tests__/referrals-new-features.test.ts @@ -0,0 +1,278 @@ +/** + * Tests for the 4 new referral features: + * 1. generateReferralUsername (packages/referrals) + * 2. Duplicate-click deduplication in handleReferralTrack + * 3. CSV export via handleReferralExport + * 4. /r/{username} vanity redirect via handleReferralVanityRedirect + */ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +// ───────────────────────────────────────────────────────────────────────────── +// Shared mocks (all needed for referrals.ts top-level import resolution) +// ───────────────────────────────────────────────────────────────────────────── +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(), + create: vi.fn(), + }, +})); +vi.mock('@ottabase/ottaorm/models', () => ({ + User: { findByReferralUsername: vi.fn(), find: vi.fn() }, +})); +vi.mock('@ottabase/email', () => ({ + createResendMailer: vi.fn(), + createSESMailer: vi.fn(), + sendTemplatedEmail: vi.fn(), +})); +vi.mock('@ottabase/email/providers/nodemailer', () => ({ createNodemailerMailer: vi.fn() })); +vi.mock('@ottabase/cf/kv-cache', () => ({ invalidateCacheByPrefix: vi.fn() })); + +import { generateReferralUsername } from '@ottabase/referrals/validation'; +import { handleReferralExport, handleReferralTrack, handleReferralVanityRedirect } from '../referrals'; + +const { getSession } = await import('@ottabase/auth/backend'); +const { readJson } = await import('../../lib/utils'); +const { User } = await import('@ottabase/ottaorm/models'); +const { ReferralTracking } = await import('@ottabase/referrals'); + +// ───────────────────────────────────────────────────────────────────────────── +// Helpers +// ───────────────────────────────────────────────────────────────────────────── +function makeContext(overrides: Record = {}, body?: any, headers?: Record) { + const request = new Request('https://example.com/api/referrals/track', { + method: 'POST', + headers: { 'CF-Connecting-IP': '1.2.3.4', ...headers }, + }); + if (body !== undefined) vi.mocked(readJson).mockResolvedValue(body); + return { + request, + env: { OBCF_D1: {}, ...overrides } as any, + url: new URL(request.url), + }; +} + +function makeUser(id: string, referralUsername: string, name?: string) { + return { + get: vi.fn((key: string) => ({ id, referralUsername, name: name ?? null })[key] ?? null), + set: vi.fn(), + save: vi.fn().mockResolvedValue(undefined), + toJson: vi.fn(() => ({ id, referralUsername, name })), + }; +} + +// ───────────────────────────────────────────────────────────────────────────── +// Feature 1: generateReferralUsername +// ───────────────────────────────────────────────────────────────────────────── +describe('generateReferralUsername', () => { + it('extracts the email prefix', () => { + expect(generateReferralUsername('john.doe@example.com')).toBe('john_doe'); + }); + + it('lowercases the result', () => { + expect(generateReferralUsername('JohnDoe@example.com')).toBe('johndoe'); + }); + + it('replaces non-alphanumeric chars with underscores and collapses them', () => { + expect(generateReferralUsername('hello+world@test.com')).toBe('hello_world'); + }); + + it('pads a short prefix to the minimum length', () => { + const result = generateReferralUsername('ab@x.com'); // prefix = 'ab' (length 2) + expect(result.length).toBeGreaterThanOrEqual(3); + }); + + it('truncates a long prefix to 15 characters', () => { + const result = generateReferralUsername('verylongemailprefix@example.com'); + expect(result.length).toBeLessThanOrEqual(15); + }); + + it('strips leading and trailing underscores', () => { + const result = generateReferralUsername('+test+@example.com'); // prefix = '+test+' + expect(result).not.toMatch(/^_|_$/); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Feature 2: Duplicate-click deduplication +// ───────────────────────────────────────────────────────────────────────────── +describe('handleReferralTrack – deduplication', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(User.findByReferralUsername).mockResolvedValue(makeUser('ref-1', 'johndoe') as any); + }); + + it('counts the click when KV is not configured', async () => { + vi.mocked(readJson).mockResolvedValue({ referralCode: 'johndoe' }); + const ctx = makeContext({ OBCF_D1: {} }); // no OBCF_KV + const res = await handleReferralTrack(ctx); + const body = await res.json(); + expect(res.status).toBe(200); + expect(body.tracking.recorded).toBe(true); + }); + + it('deduplicates when the same IP+code appears within the window', async () => { + vi.mocked(readJson).mockResolvedValue({ referralCode: 'johndoe' }); + const mockKv = { get: vi.fn().mockResolvedValue('1'), put: vi.fn().mockResolvedValue(undefined) }; + const ctx = makeContext({ OBCF_D1: {}, OBCF_KV: mockKv }); + const res = await handleReferralTrack(ctx); + const body = await res.json(); + expect(res.status).toBe(200); + expect(body.tracking.deduplicated).toBe(true); + expect(body.tracking.recorded).toBe(false); + }); + + it('counts a first click and stores the dedup key', async () => { + vi.mocked(readJson).mockResolvedValue({ referralCode: 'johndoe' }); + const putSpy = vi.fn().mockResolvedValue(undefined); + const mockKv = { get: vi.fn().mockResolvedValue(null), put: putSpy }; + const ctx = makeContext({ OBCF_D1: {}, OBCF_KV: mockKv }); + const res = await handleReferralTrack(ctx); + const body = await res.json(); + expect(res.status).toBe(200); + expect(body.tracking.recorded).toBe(true); + // dedup key should have been stored + await vi.waitFor(() => expect(putSpy).toHaveBeenCalled()); + const [key, , opts] = putSpy.mock.calls[0]; + expect(key).toContain('ref:dedup:'); + expect(opts.expirationTtl).toBe(20 * 60); + }); + + it('disables dedup when REFERRAL_DEDUP_WINDOW_MINUTES=0', async () => { + vi.mocked(readJson).mockResolvedValue({ referralCode: 'johndoe' }); + const getSpy = vi.fn(); + const mockKv = { get: getSpy, put: vi.fn().mockResolvedValue(undefined) }; + const ctx = makeContext({ OBCF_D1: {}, OBCF_KV: mockKv, REFERRAL_DEDUP_WINDOW_MINUTES: '0' }); + const res = await handleReferralTrack(ctx); + expect(res.status).toBe(200); + expect(getSpy).not.toHaveBeenCalled(); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Feature 3: CSV export +// ───────────────────────────────────────────────────────────────────────────── +describe('handleReferralExport', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(getSession).mockResolvedValue({ user: { id: 'user-1' } } as any); + }); + + it('returns 401 without a session', async () => { + vi.mocked(getSession).mockResolvedValue(null); + const req = new Request('https://example.com/api/referrals/export?format=csv'); + const res = await handleReferralExport({ request: req, env: { OBCF_D1: {} } as any, url: new URL(req.url) }); + expect(res.status).toBe(401); + }); + + it('returns CSV with correct headers and rows', async () => { + vi.mocked(ReferralTracking.forUser).mockResolvedValue([ + { + toJson: () => ({ + id: 'track-1', + referralCode: 'johndoe', + referredUserId: 'user-2', + status: 'completed', + ipAddress: '1.2.3.4', + userAgent: 'Chrome', + referer: 'https://twitter.com', + createdAt: '2025-01-01', + conversionAt: '2025-01-02', + }), + } as any, + ]); + + const req = new Request('https://example.com/api/referrals/export?format=csv'); + const res = await handleReferralExport({ request: req, env: { OBCF_D1: {} } as any, url: new URL(req.url) }); + expect(res.status).toBe(200); + expect(res.headers.get('Content-Type')).toContain('text/csv'); + expect(res.headers.get('Content-Disposition')).toContain('attachment'); + const text = await res.text(); + expect(text).toContain('id,referralCode'); + expect(text).toContain('track-1'); + expect(text).toContain('johndoe'); + }); + + it('escapes commas in field values', async () => { + vi.mocked(ReferralTracking.forUser).mockResolvedValue([ + { + toJson: () => ({ + id: '1', + referralCode: 'code,with,commas', + referredUserId: null, + status: 'pending', + ipAddress: null, + userAgent: null, + referer: null, + createdAt: null, + conversionAt: null, + }), + } as any, + ]); + const req = new Request('https://example.com/api/referrals/export?format=csv'); + const res = await handleReferralExport({ request: req, env: { OBCF_D1: {} } as any, url: new URL(req.url) }); + const text = await res.text(); + expect(text).toContain('"code,with,commas"'); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Feature 4: /r/{username} vanity redirect +// ───────────────────────────────────────────────────────────────────────────── +describe('handleReferralVanityRedirect', () => { + beforeEach(() => vi.clearAllMocks()); + + it('returns null when user not found', async () => { + vi.mocked(User.findByReferralUsername).mockResolvedValue(null); + const url = new URL('https://example.com/r/unknownuser'); + const req = new Request(url); + const res = await handleReferralVanityRedirect( + { request: req, env: { OBCF_D1: {} } as any, url }, + 'unknownuser', + ); + expect(res).toBeNull(); + }); + + it('returns an HTML page with OG meta and redirect', async () => { + vi.mocked(User.findByReferralUsername).mockResolvedValue(makeUser('u-1', 'johndoe', 'John Doe') as any); + const url = new URL('https://example.com/r/johndoe'); + const req = new Request(url); + const res = await handleReferralVanityRedirect({ request: req, env: { OBCF_D1: {} } as any, url }, 'johndoe'); + expect(res).not.toBeNull(); + expect(res!.status).toBe(200); + expect(res!.headers.get('Content-Type')).toContain('text/html'); + const html = await res!.text(); + expect(html).toContain('og:title'); + expect(html).toContain('John Doe'); + expect(html).toContain('/?ref=johndoe'); + expect(html).toContain('window.location.replace'); + }); + + it('escapes HTML-special characters in the username/name', async () => { + vi.mocked(User.findByReferralUsername).mockResolvedValue( + makeUser('u-2', 'safe', '') as any, + ); + const url = new URL('https://example.com/r/safe'); + const req = new Request(url); + const res = await handleReferralVanityRedirect({ request: req, env: { OBCF_D1: {} } as any, url }, 'safe'); + const html = await res!.text(); + // Raw '); + }); +}); 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 index 2cc968dc3..5469cc4f4 100644 --- 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 @@ -23,6 +23,14 @@ vi.mock('@ottabase/referrals', () => ({ vi.mock('@ottabase/ottaorm/models', () => ({ User: { findByReferralUsername: vi.fn(), find: vi.fn() }, })); +// Prevent Vite from trying to resolve email sub-path exports during test transform +vi.mock('@ottabase/email', () => ({ + createResendMailer: vi.fn(), + createSESMailer: vi.fn(), + sendTemplatedEmail: vi.fn(), +})); +vi.mock('@ottabase/email/providers/nodemailer', () => ({ createNodemailerMailer: vi.fn() })); +vi.mock('@ottabase/cf/kv-cache', () => ({ invalidateCacheByPrefix: vi.fn() })); const { getSession } = await import('@ottabase/auth/backend'); const { readJson } = await import('../../lib/utils'); diff --git a/apps/ottabase-template-app-tanstack/worker/routes/auth.ts b/apps/ottabase-template-app-tanstack/worker/routes/auth.ts index a57890384..d3e92679e 100644 --- a/apps/ottabase-template-app-tanstack/worker/routes/auth.ts +++ b/apps/ottabase-template-app-tanstack/worker/routes/auth.ts @@ -514,6 +514,23 @@ export async function handleAuthRegister(context: AuthRouteContext): Promise 0 && env.OBCF_KV) { + const ip = request.headers.get('CF-Connecting-IP') ?? request.headers.get('X-Forwarded-For') ?? 'unknown'; + const dedupKey = `ref:dedup:${ip}:${body.referralCode}`; + try { + const existing = await env.OBCF_KV.get(dedupKey); + if (existing !== null) { + // Duplicate within window – return success silently (don't count the click twice) + return jsonResponse({ + success: true, + tracking: { referralCode: body.referralCode, recorded: false, deduplicated: true }, + }); + } + // Store dedup marker – fire-and-forget, don't let KV failure block tracking + env.OBCF_KV.put(dedupKey, '1', { expirationTtl: dedupWindowMin * 60 }).catch((e) => { + console.warn('ref:dedup KV write failed:', e); + }); + } catch { + // KV unavailable – proceed without dedup + } + } + // Analytics Engine: write click event (non-blocking, no D1 write per click) const meta = body.meta as Record | undefined; const utm = meta?.utm as Record | undefined; @@ -245,6 +268,63 @@ export async function handleReferralTrackingList(context: ReferralRouteContext): }); } +/** Escape a CSV field (quote if it contains comma, newline or double-quote). */ +function csvField(value: unknown): string { + const str = value == null ? '' : String(value); + if (str.includes(',') || str.includes('"') || str.includes('\n') || str.includes('\r')) { + return `"${str.replace(/"/g, '""')}"`; + } + return str; +} + +/** + * Handle GET /api/referrals/export?format=csv + * Downloads all of the authenticated user's referral tracking records as a CSV file. + */ +export async function handleReferralExport(context: ReferralRouteContext): Promise { + const { request, env } = context; + if (!env.OBCF_D1) { + return errorResponse('D1 database binding not configured', 500, { code: 'CONFIG_ERROR' }); + } + + registerConnection('default', createD1Driver(env.OBCF_D1)); + + const session = await getSession(request, env as any, getAuthOptions(env)); + const userId = session?.user?.id; + + if (!userId) { + return errorResponse('Unauthorized', 401, { code: 'UNAUTHORIZED' }); + } + + const records = await ReferralTracking.forUser(userId); + + const header = 'id,referralCode,referredUserId,status,ipAddress,userAgent,referer,createdAt,conversionAt\r\n'; + const rows = records.map((t) => { + const d = t.toJson() as Record; + return [ + csvField(d.id), + csvField(d.referralCode), + csvField(d.referredUserId), + csvField(d.status), + csvField(d.ipAddress), + csvField(d.userAgent), + csvField(d.referer), + csvField(d.createdAt), + csvField(d.conversionAt), + ].join(','); + }); + + const csv = header + rows.join('\r\n'); + const today = new Date().toISOString().slice(0, 10); + return new Response(csv, { + status: 200, + headers: { + 'Content-Type': 'text/csv; charset=utf-8', + 'Content-Disposition': `attachment; filename="referrals-${today}.csv"`, + }, + }); +} + /** * Handle GET /api/referrals/analytics - query WAE for referral click analytics * Requires auth. Params: referralCode (optional), days (default 7), groupBy (country|referralCode|day) @@ -306,3 +386,65 @@ export async function handleReferralsAnalytics(context: ReferralRouteContext): P throw e; } } + +/** + * Handle GET /r/{username} — vanity referral redirect + * + * Returns a lightweight HTML page with: + * - Open Graph meta tags (for social link previews) + * - redirect + * - JavaScript redirect fallback + * + * This makes `/r/johndoe` work like `/?ref=johndoe` while giving clean, + * shareable URLs with proper social preview cards. + */ +export async function handleReferralVanityRedirect( + context: { request: Request; env: CloudflareEnv; url: URL }, + username: string, +): Promise { + const { env, url } = context; + + if (!username || !env.OBCF_D1) return null; + + registerConnection('default', createD1Driver(env.OBCF_D1)); + + const user = await User.findByReferralUsername(username); + if (!user) return null; // fall through to 404 / SPA + + const displayName: string = (user.get('name') as string | null) || username; + const origin = url.origin; + const destination = `${origin}/?ref=${encodeURIComponent(username)}`; + + // Escape helper – avoid XSS in injected content + const esc = (s: string) => + s.replace(/&/g, '&').replace(/"/g, '"').replace(//g, '>'); + + const title = esc(`Join ${displayName}'s referral`); + const description = esc(`${displayName} invited you. Sign up using their referral link.`); + const escapedDest = esc(destination); + + const html = ` + + + +${title} + + + + + + + + + + +

Redirecting… Click here if not redirected

+ + +`; + + return new Response(html, { + status: 200, + headers: { 'Content-Type': 'text/html; charset=utf-8' }, + }); +} diff --git a/apps/ottabase-template-app-tanstack/worker/routes/router.ts b/apps/ottabase-template-app-tanstack/worker/routes/router.ts index ab68bcbb5..cb78cc5ea 100644 --- a/apps/ottabase-template-app-tanstack/worker/routes/router.ts +++ b/apps/ottabase-template-app-tanstack/worker/routes/router.ts @@ -67,6 +67,7 @@ import { handleEmailProviders, handleEmailTest } from './email'; import { handleOttaormCrud } from './ottaorm-crud'; import { handleModelsMetadata, handleOttaormInit } from './ottaorm-init'; import { + handleReferralExport, handleReferralStats, handleReferralTrack, handleReferralTrackingList, @@ -195,6 +196,10 @@ async function handleGetRoutes(context: ApiRouteContext): Promise Date: Fri, 20 Feb 2026 20:43:54 +0000 Subject: [PATCH 5/9] feat: add user username field with auto-generation on signup and editable in profile Co-authored-by: thinkdj <688055+thinkdj@users.noreply.github.com> --- .../src/pages/user/UserProfilePage.tsx | 72 ++++++++++++++++--- .../worker/routes/auth.ts | 48 ++++++++++--- packages/ottaorm/src/models/User.schema.ts | 2 + packages/ottaorm/src/models/User.ts | 28 +++++++- 4 files changed, 131 insertions(+), 19 deletions(-) diff --git a/apps/ottabase-template-app-tanstack/src/pages/user/UserProfilePage.tsx b/apps/ottabase-template-app-tanstack/src/pages/user/UserProfilePage.tsx index bad617215..fdd330afe 100644 --- a/apps/ottabase-template-app-tanstack/src/pages/user/UserProfilePage.tsx +++ b/apps/ottabase-template-app-tanstack/src/pages/user/UserProfilePage.tsx @@ -24,7 +24,7 @@ import { Label, Separator, } from '@ottabase/ui-shadcn'; -import { Calendar, Check, Loader2, Mail, User } from 'lucide-react'; +import { AtSign, Calendar, Check, Loader2, Mail, User } from 'lucide-react'; import { useCallback, useEffect, useState } from 'react'; interface LinkedAccountRecord { @@ -40,8 +40,15 @@ export function UserProfilePage() { const [formData, setFormData] = useState({ name: user?.name || '', email: user?.email || '', + username: userUsername || '', }); + const [usernameError, setUsernameError] = useState(null); + + // Convenience accessor — the auth User type uses an index signature so extra + // properties like `username` are present at runtime but not statically typed. + const userUsername: string = user?.username ?? ''; + const [linkedAccounts, setLinkedAccounts] = useState([]); const [isAccountsLoading, setIsAccountsLoading] = useState(true); @@ -53,21 +60,28 @@ export function UserProfilePage() { const normalize = useCallback((value: string) => value.trim(), []); const computeHasChanges = useCallback( - (next: { name: string; email: string }) => { + (next: { name: string; email: string; username: string }) => { const currentName = normalize(user?.name ?? ''); const currentEmail = normalize(user?.email ?? ''); - return normalize(next.name) !== currentName || normalize(next.email) !== currentEmail; + const currentUsername = normalize(userUsername ?? ''); + return ( + normalize(next.name) !== currentName || + normalize(next.email) !== currentEmail || + normalize(next.username) !== currentUsername + ); }, - [normalize, user?.email, user?.name], + [normalize, user?.email, user?.name, userUsername], ); useEffect(() => { setFormData({ name: user?.name || '', email: user?.email || '', + username: userUsername || '', }); setHasChanges(false); - }, [user?.name, user?.email]); + setUsernameError(null); + }, [user?.name, user?.email, userUsername]); useEffect(() => { let cancelled = false; @@ -104,12 +118,13 @@ export function UserProfilePage() { .toUpperCase() : user?.email?.[0]?.toUpperCase() || '?'; - const handleChange = (field: 'name' | 'email', value: string) => { + const handleChange = (field: 'name' | 'email' | 'username', value: string) => { setFormData((prev) => { const next = { ...prev, [field]: value }; setHasChanges(computeHasChanges(next)); return next; }); + if (field === 'username') setUsernameError(null); }; const handleSave = async () => { @@ -122,12 +137,19 @@ export function UserProfilePage() { const trimmedName = normalize(formData.name); const trimmedEmail = normalize(formData.email); + const trimmedUsername = normalize(formData.username); if (!trimmedName) { toast.error('Name is required', 'Please enter your full name.'); return; } + // Basic client-side username validation (same rules as server) + if (trimmedUsername && !/^[a-zA-Z0-9_]{3,20}$/.test(trimmedUsername)) { + setUsernameError('Username must be 3-20 characters: letters, numbers, underscores only'); + return; + } + const updates: Record = {}; if (trimmedName !== normalize(user.name ?? '')) { @@ -137,10 +159,16 @@ export function UserProfilePage() { if (trimmedEmail !== normalize(user.email ?? '')) { toast.warning('Email changes are disabled', 'Contact support to update your login email.'); setFormData((prev) => ({ ...prev, email: user.email ?? '' })); - setHasChanges(computeHasChanges({ name: trimmedName, email: user.email ?? '' })); + setHasChanges( + computeHasChanges({ name: trimmedName, email: user.email ?? '', username: trimmedUsername }), + ); return; } + if (trimmedUsername !== normalize(userUsername ?? '')) { + updates.username = trimmedUsername; + } + if (Object.keys(updates).length === 0) { toast.info('No changes to save'); setHasChanges(false); @@ -165,6 +193,7 @@ export function UserProfilePage() { if (updatedUser?.emailVerified !== undefined) safeUpdates.emailVerified = updatedUser.emailVerified; if (updatedUser?.createdAt !== undefined) safeUpdates.createdAt = updatedUser.createdAt; if (updatedUser?.updatedAt !== undefined) safeUpdates.updatedAt = updatedUser.updatedAt; + if (updatedUser?.username !== undefined) safeUpdates.username = updatedUser.username; if (Object.keys(safeUpdates).length > 0) { updateUser(safeUpdates); @@ -173,6 +202,7 @@ export function UserProfilePage() { setFormData({ name: updatedUser?.name ?? user.name ?? '', email: updatedUser?.email ?? user.email ?? '', + username: updatedUser?.username ?? userUsername ?? '', }); if (updatedUser?.linkedAccounts) { setLinkedAccounts(updatedUser.linkedAccounts); @@ -182,8 +212,13 @@ export function UserProfilePage() { } setHasChanges(false); toast.success('Profile updated', 'Your profile has been updated successfully'); - } catch (error) { - toast.error('Update failed', 'Failed to update profile'); + } catch (error: any) { + const fieldErrors = error?.fieldErrors; + if (fieldErrors?.username) { + setUsernameError(fieldErrors.username[0]); + } else { + toast.error('Update failed', 'Failed to update profile'); + } } finally { setIsSaving(false); } @@ -264,6 +299,25 @@ export function UserProfilePage() { />
+ {/* Username */} +
+ + handleChange('username', e.target.value)} + placeholder="e.g. johndoe" + disabled={isSaving} + /> + {usernameError &&

{usernameError}

} +

+ 3–20 characters: letters, numbers and underscores only. Can be changed at any time. +

+
+ {/* Email */}
diff --git a/apps/ottabase-template-app-tanstack/worker/routes/auth.ts b/apps/ottabase-template-app-tanstack/worker/routes/auth.ts index d3e92679e..8bc584598 100644 --- a/apps/ottabase-template-app-tanstack/worker/routes/auth.ts +++ b/apps/ottabase-template-app-tanstack/worker/routes/auth.ts @@ -362,7 +362,7 @@ export async function handleUserProfile(context: AuthRouteContext): Promise(request); + const body = await readJson<{ name?: string; image?: string | null; username?: string }>(request); const updates: Record = {}; const fieldErrors: Record = {}; @@ -389,6 +389,23 @@ export async function handleUserProfile(context: AuthRouteContext): Promise 0) { return errorResponse('Validation failed', 400, { code: 'VALIDATION_ERROR', @@ -514,21 +531,36 @@ export async function handleAuthRegister(context: AuthRouteContext): Promise Date: Sat, 21 Feb 2026 03:51:22 +0000 Subject: [PATCH 6/9] refactor: rename validateReferralUsername, extract findUniqueHandle, add ProfilePhotoUploader Co-authored-by: thinkdj <688055+thinkdj@users.noreply.github.com> --- .../src/components/ProfilePhotoUploader.tsx | 238 ++++++++++++++++++ .../src/components/ReferralDashboard.tsx | 4 +- .../src/pages/user/UserProfilePage.tsx | 47 +++- .../__tests__/referrals-new-features.test.ts | 1 + .../__tests__/referrals-username.test.ts | 1 + .../worker/routes/auth.ts | 50 ++-- .../worker/routes/referrals.ts | 4 +- packages/referrals/src/validation.ts | 20 +- 8 files changed, 324 insertions(+), 41 deletions(-) create mode 100644 apps/ottabase-template-app-tanstack/src/components/ProfilePhotoUploader.tsx diff --git a/apps/ottabase-template-app-tanstack/src/components/ProfilePhotoUploader.tsx b/apps/ottabase-template-app-tanstack/src/components/ProfilePhotoUploader.tsx new file mode 100644 index 000000000..3f8af8b88 --- /dev/null +++ b/apps/ottabase-template-app-tanstack/src/components/ProfilePhotoUploader.tsx @@ -0,0 +1,238 @@ +/** + * ProfilePhotoUploader + * + * Lets the user pick an image, crop it to a circle (1:1), and upload the result + * to the configured storage backend via POST /api/upload. + * + * Uses: + * - @ottabase/cropper — zero-React vanilla image cropper + * - POST /api/upload — existing upload endpoint (R2 / Cloudflare Images) + */ + +import { Avatar, AvatarFallback, AvatarImage, Button } from '@ottabase/ui-shadcn'; +import type { Cropper } from '@ottabase/cropper'; +import { Loader2, Pencil, Upload, X } from 'lucide-react'; +import { useCallback, useEffect, useRef, useState } from 'react'; + +export interface ProfilePhotoUploaderProps { + /** Current avatar URL shown before any upload. */ + currentImageUrl?: string | null; + /** User initials for the fallback avatar. */ + initials?: string; + /** Called with the uploaded image URL on success. */ + onUploaded: (url: string) => void; + /** Optionally override the upload endpoint (default: /api/upload). */ + uploadEndpoint?: string; + /** Disable the component. */ + disabled?: boolean; +} + +type Stage = 'idle' | 'cropping' | 'uploading'; + +export function ProfilePhotoUploader({ + currentImageUrl, + initials = '?', + onUploaded, + uploadEndpoint = '/api/upload', + disabled = false, +}: ProfilePhotoUploaderProps) { + const fileInputRef = useRef(null); + const cropContainerRef = useRef(null); + const cropperRef = useRef(null); + // Pending file waiting for cropContainerRef to mount + const pendingFileRef = useRef(null); + + const [stage, setStage] = useState('idle'); + const [error, setError] = useState(null); + + // Destroy cropper on unmount + useEffect(() => { + return () => { + cropperRef.current?.destroy(); + cropperRef.current = null; + }; + }, []); + + const openFilePicker = () => { + setError(null); + fileInputRef.current?.click(); + }; + + const handleFileChange = useCallback(async (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + + // Reset input so picking the same file again triggers the event + e.target.value = ''; + + if (!file.type.startsWith('image/')) { + setError('Please select an image file (PNG, JPEG, WebP).'); + return; + } + if (file.size > 10 * 1024 * 1024) { + setError('Image must be smaller than 10 MB.'); + return; + } + + // Destroy any previous cropper instance + cropperRef.current?.destroy(); + cropperRef.current = null; + + // Store the file; the ref callback on the crop container will initialise + // the cropper once the div is in the DOM. + pendingFileRef.current = file; + setStage('cropping'); + }, []); + + /** + * Ref callback for the crop container div. + * Called with the element when it mounts (stage === 'cropping') and with null on unmount. + */ + const handleCropContainerMount = useCallback(async (el: HTMLDivElement | null) => { + // Store for later use (confirm/cancel) + (cropContainerRef as React.MutableRefObject).current = el; + + if (!el || !pendingFileRef.current) return; + + const { Cropper: CropperClass } = await import('@ottabase/cropper'); + + // Guard: component might have been unmounted by the time the import resolves + if (!pendingFileRef.current) return; + + const file = pendingFileRef.current; + pendingFileRef.current = null; + + const cropper = new CropperClass(el, { + aspectRatio: 1, + shape: 'circle', + maxHeight: 320, + }); + cropperRef.current = cropper; + await cropper.loadFile(file); + }, []); + + const handleCancelCrop = () => { + cropperRef.current?.destroy(); + cropperRef.current = null; + setStage('idle'); + setError(null); + }; + + const handleConfirmCrop = useCallback(async () => { + const cropper = cropperRef.current; + if (!cropper) return; + + setError(null); + setStage('uploading'); + + try { + const blob = await cropper.getBlob('image/jpeg', 0.9); + + const formData = new FormData(); + formData.append('file', blob, 'avatar.jpg'); + + const res = await fetch(uploadEndpoint, { method: 'POST', body: formData }); + + if (!res.ok) { + const text = await res.text().catch(() => ''); + throw new Error(text || `Upload failed (${res.status})`); + } + + const json = (await res.json()) as { url?: string; key?: string; success?: boolean }; + const url = json.url; + + if (!url) throw new Error('Upload succeeded but no URL was returned.'); + + cropper.destroy(); + cropperRef.current = null; + + onUploaded(url); + setStage('idle'); + } catch (err) { + setError(err instanceof Error ? err.message : 'Upload failed. Please try again.'); + setStage('cropping'); // Return to crop stage so user can retry + } + }, [onUploaded, uploadEndpoint]); + + return ( +
+ {/* Avatar preview + edit button */} +
+ + + {initials} + + + {stage === 'idle' && !disabled && ( + + )} +
+ + {/* Hidden file input */} + + + {/* Cropper stage */} + {stage === 'cropping' && ( +
+

+ Drag to reposition · scroll to zoom · use handles to resize +

+ + {/* The cropper mounts here; ref callback initialises it once in DOM */} +
+ +
+ + +
+
+ )} + + {/* Uploading stage */} + {stage === 'uploading' && ( +
+ + Uploading… +
+ )} + + {/* Idle change-photo link */} + {stage === 'idle' && !disabled && ( + + )} + + {/* Error */} + {error &&

{error}

} +
+ ); +} diff --git a/apps/ottabase-template-app-tanstack/src/components/ReferralDashboard.tsx b/apps/ottabase-template-app-tanstack/src/components/ReferralDashboard.tsx index 5925719c2..3513b0dda 100644 --- a/apps/ottabase-template-app-tanstack/src/components/ReferralDashboard.tsx +++ b/apps/ottabase-template-app-tanstack/src/components/ReferralDashboard.tsx @@ -6,7 +6,7 @@ import { api } from '@/lib/api'; import { clearStoredReferralCode, getReferralExpiryInfo, getStoredReferralCode } from '@/lib/referrals'; -import { validateReferralUsername } from '@ottabase/referrals'; +import { validateUsername } from '@ottabase/referrals'; import { AlertDialog, AlertDialogAction, @@ -134,7 +134,7 @@ export function ReferralDashboard({ userId }: ReferralDashboardProps) { const handleUpdateUsername = async () => { const trimmed = newUsername.trim(); // Validate - const validation = validateReferralUsername(trimmed); + const validation = validateUsername(trimmed); if (!validation.valid) { setUsernameError(validation.error || 'Invalid username'); return; diff --git a/apps/ottabase-template-app-tanstack/src/pages/user/UserProfilePage.tsx b/apps/ottabase-template-app-tanstack/src/pages/user/UserProfilePage.tsx index fdd330afe..1d3033d2f 100644 --- a/apps/ottabase-template-app-tanstack/src/pages/user/UserProfilePage.tsx +++ b/apps/ottabase-template-app-tanstack/src/pages/user/UserProfilePage.tsx @@ -10,9 +10,6 @@ import { api } from '@/lib/api'; import { useSession } from '@/lib/auth'; import { requestEmailVerification } from '@/lib/auth-api'; import { - Avatar, - AvatarFallback, - AvatarImage, Badge, Button, Card, @@ -26,6 +23,7 @@ import { } from '@ottabase/ui-shadcn'; import { AtSign, Calendar, Check, Loader2, Mail, User } from 'lucide-react'; import { useCallback, useEffect, useState } from 'react'; +import { ProfilePhotoUploader } from '@/components/ProfilePhotoUploader'; interface LinkedAccountRecord { provider: string; @@ -44,6 +42,8 @@ export function UserProfilePage() { }); const [usernameError, setUsernameError] = useState(null); + const [currentImage, setCurrentImage] = useState(user?.image ?? null); + const [isPhotoUploading, setIsPhotoUploading] = useState(false); // Convenience accessor — the auth User type uses an index signature so extra // properties like `username` are present at runtime but not statically typed. @@ -127,6 +127,22 @@ export function UserProfilePage() { if (field === 'username') setUsernameError(null); }; + const handlePhotoUploaded = async (url: string) => { + setIsPhotoUploading(true); + setCurrentImage(url); + // Persist to the user profile immediately; this only updates `image`, + // which is separate from the name/username save below. + try { + await api('/api/users/me', { method: 'PATCH', body: { image: url } }); + updateUser({ image: url }); + toast.success('Photo updated', 'Your profile photo has been updated successfully.'); + } catch { + toast.error('Photo update failed', 'Could not save the new photo to your profile.'); + } finally { + setIsPhotoUploading(false); + } + }; + const handleSave = async () => { setIsSaving(true); try { @@ -198,6 +214,9 @@ export function UserProfilePage() { if (Object.keys(safeUpdates).length > 0) { updateUser(safeUpdates); } + if (updatedUser?.image !== undefined) { + setCurrentImage(updatedUser.image ?? null); + } setFormData({ name: updatedUser?.name ?? user.name ?? '', @@ -270,13 +289,15 @@ export function UserProfilePage() { Your profile information visible to others - {/* Avatar */} -
- - - {userInitials} - -
+ {/* Avatar + photo uploader */} +
+ +

{formData.name || 'No name set'}

@@ -338,7 +359,11 @@ export function UserProfilePage() { {/* Save Button */} {hasChanges && (
-