-
Notifications
You must be signed in to change notification settings - Fork 6
feat(referrals): invite links via GET /api/v1/referrals and dashboard UX #420
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Changes from all commits
Commits
Show all changes
22 commits
Select commit
Hold shift + click to select a range
b4be8ca
feat(referrals): invite links via GET /api/v1/referrals and dashboard UX
odilitime ad8db68
Referrals UX and API hardening; fix Web UI URL tests
odilitime 24d7200
Harden referrals UX, tests, and CI-safe mocks
odilitime 009e52c
fix(tests): satisfy Bun 1.3.9 mock.module exports; tighten Milady URL…
odilitime 9ebc2fc
packages: String branch missing safe integer check unlike bigint branch
odilitime 2db387d
packages: improve this (use-dashboard-referral-me.ts)
odilitime c3c6799
docs: Potential issue | 🟡 Minor (referrals.md)
odilitime b90c5cd
packages: add tests for milady-web-ui (milady-web-ui.ts)
odilitime fdd69fa
packages: Potential issue | 🟡 Minor (copy-to-clipboard.ts)
odilitime 2d4be30
packages: Potential issue | 🟠 Major (bun-partial-module-shims.ts)
odilitime 58593de
packages: add tests for milady-billing-route.test
odilitime a926ea6
packages: add tests for milady-web-ui.test (milady-web-ui.test.ts)
odilitime f33e4f3
packages: Potential issue | 🟠 Major (header-invite-button.tsx)
odilitime ff2b739
packages: add tests for bun-partial-module-shims
odilitime 03f85a7
app: add tests for route (route.ts)
odilitime 32e4e32
packages: improve a guard (affiliates-page-client.tsx)
odilitime 5658068
packages: add tests for affiliates.test (affiliates.test.ts)
odilitime 4e7914c
packages: fix issues in packages (affiliates-page-client.tsx)
odilitime 2ccf5a7
packages: Minor: `refetch` is never exposed on the affiliates page
odilitime daaa26c
packages: 4. `fetchReferralMe` discards HTTP status code
odilitime 7a7da38
packages: improve performance (use-dashboard-referral-me.ts)
odilitime 6ad1791
packages: refactor for clarity (compat-envelope.ts)
odilitime File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,139 @@ | ||
| /** | ||
| * Authenticated "my referral code" endpoint for dashboard clients. | ||
| * | ||
| * WHY GET (not POST) for creation: One idempotent read that ensures `referral_codes` has a row—no | ||
| * separate "create" step in the UI. Duplicate calls from header + Affiliates page are safe. | ||
| * | ||
| * WHY flat JSON (`ReferralMeResponse`): Nested `{ code: { code } }` shapes confuse parsers and | ||
| * low-context clients; the share URL is always built client-side with `encodeURIComponent`. | ||
| * | ||
| * WHY `force-dynamic`: This handler may insert on first hit; caching would be wrong. | ||
| * | ||
| * WHY `ForbiddenError` before auth mapping: Missing org / feature gate returns 403 instead of 500. | ||
| * | ||
| * REST note — GET performs `getOrCreateCode` (may INSERT): This intentionally trades strict | ||
| * HTTP safety for one round-trip UX. Callers must be authenticated and rate-limited; we do not | ||
| * rely on CDN cache. Automated clients with a valid session could create a row; risk is bounded | ||
| * by auth + `referral_codes` unique(user_id). A stricter design would be POST to create + GET read-only. | ||
| */ | ||
| import { type NextRequest, NextResponse } from "next/server"; | ||
| import { AuthenticationError, ForbiddenError, getErrorStatusCode } from "@/lib/api/errors"; | ||
| import { requireAuthOrApiKeyWithOrg } from "@/lib/auth"; | ||
| import { RateLimitPresets, withRateLimit } from "@/lib/middleware/rate-limit"; | ||
| import { referralsService } from "@/lib/services/referrals"; | ||
| import { coerceNonNegativeIntegerCount, type ReferralMeResponse } from "@/lib/types/referral-me"; | ||
| import { getCorsHeaders } from "@/lib/utils/cors"; | ||
| import { logger } from "@/lib/utils/logger"; | ||
|
|
||
| export const dynamic = "force-dynamic"; | ||
|
|
||
| /** | ||
| * Known wallet authentication failure message patterns. | ||
| * Note: These string patterns are fragile—if wallet auth error messages change upstream, | ||
| * this will incorrectly return 500 instead of 401. Track as technical debt. | ||
| */ | ||
| const WALLET_AUTH_FAILURE_PATTERNS = [ | ||
| "Invalid wallet signature", | ||
| "Wallet authentication failed", | ||
| ] as const; | ||
|
|
||
| /** | ||
| * Checks if error indicates wallet authentication failure via message patterns. | ||
| * Used to convert untyped wallet errors to {@link AuthenticationError}. | ||
| */ | ||
| function isWalletAuthFailure(error: Error): boolean { | ||
| return WALLET_AUTH_FAILURE_PATTERNS.some((pattern) => error.message.includes(pattern)); | ||
| } | ||
|
|
||
| /** | ||
| * Wraps auth call and converts known wallet auth failures to typed {@link AuthenticationError}. | ||
| * This contains the fragile message-matching in one place and ensures downstream code | ||
| * only needs to check for typed errors. | ||
| */ | ||
| async function requireAuthWithTypedErrors(request: NextRequest) { | ||
| try { | ||
| return await requireAuthOrApiKeyWithOrg(request); | ||
| } catch (error) { | ||
| // Convert wallet auth failures to typed AuthenticationError | ||
| if (error instanceof Error && isWalletAuthFailure(error)) { | ||
| throw new AuthenticationError(error.message); | ||
| } | ||
| throw error; | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Maps thrown errors to "treat as 401" for this route. | ||
| * Uses {@link AuthenticationError} for auth failures, then `getErrorStatusCode` for other | ||
| * `ApiError` shapes with 401 status. | ||
| */ | ||
| function isUnauthorizedError(error: unknown): boolean { | ||
| if (error instanceof AuthenticationError) return true; | ||
| if (getErrorStatusCode(error) === 401) return true; | ||
| return false; | ||
| } | ||
|
|
||
| export async function OPTIONS(request: NextRequest) { | ||
| const origin = request.headers.get("origin"); | ||
| return new NextResponse(null, { | ||
| status: 204, | ||
| headers: getCorsHeaders(origin), | ||
| }); | ||
| } | ||
|
|
||
| /** | ||
| * GET /api/v1/referrals | ||
| * Returns the current user's referral code (creates one if missing). | ||
| */ | ||
| async function handleGET(request: NextRequest) { | ||
| const origin = request.headers.get("origin"); | ||
| const corsHeaders = getCorsHeaders(origin); | ||
|
|
||
| try { | ||
| const { user } = await requireAuthWithTypedErrors(request); | ||
| const row = await referralsService.getOrCreateCode(user.id); | ||
|
|
||
| if (row == null || typeof row !== "object") { | ||
| throw new Error("Referrals API: getOrCreateCode returned no referral row"); | ||
| } | ||
| if (typeof row.code !== "string" || row.code.length === 0) { | ||
| throw new Error("Referrals API: referral row missing code"); | ||
| } | ||
| if (typeof row.is_active !== "boolean") { | ||
| throw new Error("Referrals API: referral row missing is_active"); | ||
| } | ||
|
|
||
| const totalReferrals = coerceNonNegativeIntegerCount(row.total_referrals); | ||
| if (totalReferrals === null) { | ||
| throw new Error( | ||
| `Referrals API: total_referrals is not a valid non-negative integer (row.total_referrals=${String(row.total_referrals)})`, | ||
| ); | ||
| } | ||
|
|
||
| const body: ReferralMeResponse = { | ||
| code: row.code, | ||
| total_referrals: totalReferrals, | ||
| is_active: row.is_active, | ||
| }; | ||
|
|
||
| return NextResponse.json(body, { headers: corsHeaders }); | ||
| } catch (error) { | ||
| if (error instanceof ForbiddenError) { | ||
| return NextResponse.json({ error: error.message }, { status: 403, headers: corsHeaders }); | ||
| } | ||
|
|
||
| if (isUnauthorizedError(error)) { | ||
| return NextResponse.json({ error: "Unauthorized" }, { status: 401, headers: corsHeaders }); | ||
| } | ||
|
|
||
| logger.error("[Referrals API] Error getting referral code", { | ||
| error: error instanceof Error ? error.message : String(error), | ||
| }); | ||
| return NextResponse.json( | ||
| { error: "Internal server error" }, | ||
| { status: 500, headers: corsHeaders }, | ||
| ); | ||
| } | ||
| } | ||
|
|
||
| export const GET = withRateLimit(handleGET, RateLimitPresets.STANDARD); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,14 @@ | ||
| # Affiliate vs referral — quick comparison | ||
|
|
||
| Use this table when documenting, debugging payouts, or designing UI copy. Full detail and APIs: [referrals.md](./referrals.md). | ||
|
|
||
| | | **Referral** | **Affiliate** | | ||
| |---|-------------|---------------| | ||
| | **Primary goal** | Attribute signups and share **purchase revenue** (50/40/10) plus signup/qualified **bonuses** | Charge a **markup** on specific usage; affiliate earns the markup | | ||
| | **Typical share URL** | `/login?ref=CODE` (also `referral_code=`) | `/login?affiliate=CODE` | | ||
| | **Apply / link API** | `POST /api/v1/referrals/apply` | `POST /api/v1/affiliates/link` (and header `X-Affiliate-Code` on API calls) | | ||
| | **“My code / link” API** | `GET /api/v1/referrals` (flat JSON: `code`, `total_referrals`, `is_active`) | `GET` / `POST` / `PUT` `/api/v1/affiliates` | | ||
| | **Revenue source** | Stripe checkout + x402 purchases (splits) | Auto top-up + MCP (markup) | | ||
| | **Double-dip** | **No** — same transaction does not run referral splits and affiliate markup | Same rule | | ||
|
|
||
| **Why two programs:** Referral economics are baked into **purchase price distribution** (must sum to 100%). Affiliate economics are **optional markup** on certain flows so cost is visible to the end customer and we never over-allocate platform revenue. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,125 @@ | ||
| # Referrals and invite links | ||
|
|
||
| This document describes the **referral program** (signup attribution, bonuses, 50/40/10 purchase splits), how it differs from **affiliates**, and how users obtain and share invite links. It includes API details and design **WHYs**. | ||
|
|
||
| --- | ||
|
|
||
| ## Concepts | ||
|
|
||
| ### Referral program | ||
|
|
||
| - **What:** A referred user signs up with someone’s **referral code**. We store a `referral_signups` row linking referrer and referred user. On signup, both sides can receive **bonus credits** (minted as marketing spend, not taken from purchase revenue). When the referred user buys credits (Stripe checkout or x402), **100%** of that purchase is split **50% Eliza Cloud / 40% app owner / 10% creator** (with optional multi-tier rules for creator vs editor). See `REFERRAL_REVENUE_SPLITS` in [`packages/lib/services/referrals.ts`](../packages/lib/services/referrals.ts). | ||
|
|
||
| - **Why separate from affiliates:** Referrals attach to **purchase revenue** and one-time/qualified **bonuses**. Affiliates attach a **markup** on specific flows (auto top-up, MCP). Same transaction must not apply both (see README). **Why:** Predictable economics—referral splits always sum to 100% of the purchase; affiliate cost is passed through to the customer. | ||
|
|
||
| ### Affiliate program | ||
|
|
||
| - **What:** Users share an **affiliate** link (`?affiliate=CODE`) or API clients send `X-Affiliate-Code`. Linked users pay a **markup** on marked-up flows; the affiliate earns from that markup. | ||
|
|
||
| - **Why not call affiliates “referrals” in the UI:** The word “referral” in this codebase means the **50/40/10 + signup bonus** program. Affiliate signups are a **different** ledger and product surface (`/dashboard/affiliates`). **Why:** Prevents users and integrators from confusing two URLs (`?ref=` vs `?affiliate=`) and two payout models. | ||
|
|
||
| --- | ||
|
|
||
| ## How users get and share an invite link | ||
|
|
||
| ### Automatic code creation | ||
|
|
||
| - Each full account can have at most one row in `referral_codes`. The string `code` is generated by `referralsService.getOrCreateCode` (prefix from user id + random suffix) unless you add custom tooling. | ||
|
|
||
| - **`GET /api/v1/referrals`** calls `getOrCreateCode` on first success. **Why GET creates a row:** One round trip for the dashboard—no separate “create my code” step. The operation is **idempotent**; duplicate calls are safe. | ||
|
|
||
| - **REST / safety:** Strictly speaking, GET should not mutate state; here we trade that for UX. Mitigations: **auth required**, **rate limiting**, **`force-dynamic`** (no CDN cache). A caller with a valid session could create a row; `user_id` is **UNIQUE** so at most one row per user. Concurrent first requests hit **Postgres unique violations**—handled inside `getOrCreateCode` by re-reading `user_id`. A purist alternative is `POST` to create + `GET` read-only. | ||
|
|
||
| ### Where the link appears | ||
|
|
||
| 1. **Dashboard header — “Invite”** | ||
| - Visible only when the user is **not** anonymous and **not** in `authGraceActive` (session still settling). **Why:** Avoids 401s and confusing errors during Privy grace; keeps anonymous users on sign-up CTAs only. | ||
|
|
||
| 2. **`/dashboard/affiliates` — “Invite friends” card** | ||
| - Placed **above** the affiliate link card, with a distinct visual treatment. **Why:** Both programs live under Monetization, but mixing orange “affiliate” styling with “invite friends” caused low-signal UIs to merge them mentally. | ||
|
|
||
| ### Share URL shape | ||
|
|
||
| - **Referral invite:** Use `buildReferralInviteLoginUrl(origin, code)` from `packages/lib/utils/referral-invite-url.ts` so all surfaces stay aligned. Equivalent: `{origin}/login?ref={encodeURIComponent(code)}`. Login also accepts `referral_code` as a query param for the same purpose. | ||
|
|
||
| - **Why `encodeURIComponent`:** Codes are alphanumeric + hyphen; encoding stays correct if the format ever changes and matches browser URL rules. | ||
|
|
||
| ### Inactive codes | ||
|
|
||
| - If `referral_codes.is_active` is `false`, `POST /api/v1/referrals/apply` rejects the code. The **GET** endpoint still returns the row so the owner can see status. | ||
|
|
||
| - **UI:** Header **Invite** does not copy an inactive link (toast explains). The Affiliates page shows an **inactive** state with read-only URL and no Copy. **Why:** Stops sharing links that will fail at apply time; still lets support/debug see which code is paused. | ||
|
|
||
| - **Invite button freshness:** Each header **Invite** click calls `GET /api/v1/referrals` again (concurrent clicks dedupe on one in-flight request). `is_active` reflects the server at click time, not a stale page-load cache. | ||
|
|
||
| - **Clipboard:** Copy helpers use the Clipboard API when available, then fall back to `document.execCommand('copy')` for plain HTTP or older browsers. **Production dashboard should use HTTPS** (or localhost); some environments still block clipboard access entirely. | ||
|
|
||
| --- | ||
|
|
||
| ## API reference | ||
|
|
||
| ### `GET /api/v1/referrals` | ||
|
|
||
| - **Auth:** Session cookie or API key via `requireAuthOrApiKeyWithOrg` (same family as `GET /api/v1/affiliates`). | ||
|
|
||
| - **Success (200)** — flat JSON (do **not** nest under `{ code: { ... } }`; **Why:** Fewer client parser bugs and matches TypeScript `ReferralMeResponse`): | ||
|
|
||
| ```json | ||
| { | ||
| "code": "ABCD-A1B2C3", | ||
| "total_referrals": 0, | ||
| "is_active": true | ||
| } | ||
| ``` | ||
|
|
||
| - `code` — The user's unique referral code string. | ||
| - `total_referrals` — Count of successful referral signups (rows in `referral_signups` where this user is the referrer). | ||
| - `is_active` — Whether the code can be used for new signups. | ||
|
|
||
| - **Errors** | ||
| - **401** — Not authenticated. The route uses `getErrorStatusCode` from `@/lib/api/errors` (typed `ApiError` / legacy message heuristics) plus explicit handling for wallet **plain `Error`** strings (`Invalid wallet signature`, `Wallet authentication failed`) that `requireAuthOrApiKey` still throws outside `AuthenticationError`. | ||
| - **403** — Authenticated but forbidden (e.g. missing org / `ForbiddenError`). **Why:** Distinguishes “who are you?” from “you can’t use this feature yet.” | ||
| - **500** — Unexpected server error (logged). | ||
|
|
||
| - **CORS / rate limit:** `OPTIONS` + `getCorsHeaders`; `withRateLimit(..., STANDARD)`. **Why:** Same cross-origin and abuse posture as other authenticated GET v1 routes. | ||
|
|
||
| ### `POST /api/v1/referrals/apply` | ||
|
|
||
| - **Auth:** Required (session or API key with org). | ||
|
|
||
| - **Body:** `{ "code": "..." }` (trimmed and uppercased server-side). | ||
|
|
||
| - **WHYs:** One referral signup per referred user; idempotent replay of the **same** code returns success; self-referral and inactive codes are rejected. See implementation in `referralsService.applyReferralCode`. | ||
|
|
||
| ### Applying a code at login | ||
|
|
||
| - Query params `ref` or `referral_code` on `/login` are stored and, after authentication, posted to `/api/v1/referrals/apply`. **Why:** Lets marketing links be simple; attribution survives OAuth redirects via `sessionStorage`. | ||
|
|
||
| --- | ||
|
|
||
| ## Related code (for maintainers) | ||
|
|
||
| | Area | Path | | ||
| |------|------| | ||
| | Referral service (splits, apply, getOrCreateCode) | `packages/lib/services/referrals.ts` | | ||
| | Schemas | `packages/db/schemas/referrals.ts` | | ||
| | GET me + OPTIONS | `app/api/v1/referrals/route.ts` | | ||
| | Apply | `app/api/v1/referrals/apply/route.ts` | | ||
| | Response typing / parse helper | `packages/lib/types/referral-me.ts` | | ||
| | Share URL helper | `packages/lib/utils/referral-invite-url.ts` | | ||
| | Header invite | `packages/ui/src/components/layout/header-invite-button.tsx` | | ||
| | Affiliates + invite card | `packages/ui/src/components/affiliates/affiliates-page-client.tsx` | | ||
|
|
||
| --- | ||
|
|
||
| ## Signup codes (distinct) | ||
|
|
||
| Campaign codes (`SIGNUP_CODES_JSON`, `POST /api/signup-code/redeem`) are **not** referral codes. **Why:** Signup codes are one-off org bonuses; referrals drive ongoing split + referral bonuses. See [signup-codes.md](./signup-codes.md). | ||
|
|
||
| --- | ||
|
|
||
| ## Roadmap / out of scope (see [ROADMAP.md](./ROADMAP.md)) | ||
|
|
||
| - Vanity / custom referral strings (needs validation, uniqueness, and possibly admin). | ||
| - Optional `intent=signup` on invite URLs for analytics or UX. | ||
| - Centralized client cache (SWR/React context) to dedupe GET across header + page—currently two GETs are acceptable because `getOrCreateCode` is idempotent. | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.