Files
worldmonitor/server/_shared/referral-code.ts
Elie Habib b5824d0512 feat(brief): Phase 9 / Todo #223 — share button + referral attribution (#3175)
* feat(brief): Phase 9 / Todo #223 — share button + referral attribution

Adds a Share button to the dashboard Brief panel so PRO users can
spread WorldMonitor virally. Built on the existing referral plumbing
(registrations.referralCode + referredBy fields; api/register-interest
already passes referredBy through) — this PR fills in the last mile:
a stable referral code for signed-in Clerk users, a share URL, and
a client-side share sheet.

Files:

  server/_shared/referral-code.ts (new)
    Deterministic 8-char hex code: HMAC(BRIEF_URL_SIGNING_SECRET,
    'referral:v1:' + userId). Same Clerk userId always produces the
    same code. No DB write on login, no schema migration, stable for
    the life of the account.

  api/referral/me.ts (new)
    GET -> { code, shareUrl, invitedCount }. Bearer-auth via Clerk.
    Reuses BRIEF_URL_SIGNING_SECRET to avoid another Railway env
    var. Stats fail gracefully to 0 on Convex outage.

  convex/registerInterest.ts + convex/http.ts
    New internal query getReferralStatsByCode({referralCode}) counts
    registrations rows that named this code as their referredBy.
    Exposed via POST /relay/referral-stats (RELAY_SHARED_SECRET auth).

  src/services/referral.ts (new)
    getReferralProfile: 5-min cache, profile is effectively immutable
    shareReferral: Web Share API primary (mobile native sheet),
    clipboard fallback on desktop. Returns 'shared'/'copied'/'blocked'
    /'error'. AbortError is treated as 'blocked', not failure.
    clearReferralCache for account-switch hygiene.

  src/components/LatestBriefPanel.ts + src/styles/panels.css
    New share row below the brief cover card. Button disabled until
    /api/referral/me resolves; if fetch fails the row removes itself.
    invitedCount > 0 renders as 'N invited' next to the button.
    Referral cache invalidated alongside Clerk token cache on account
    switch (otherwise user B would see user A's share link for 5 min).

Tests: 10 new cases in tests/brief-referral-code.test.mjs
  - getReferralCodeForUser: hex shape, determinism, uniqueness,
    secret-rotation invalidates, input guards
  - buildShareUrl: path shape, trailing-slash trim, URL-encoding

153/153 brief + deploy tests pass. Both tsconfigs typecheck clean.

Attribution flow (already working end-to-end):
  1. Share button -> worldmonitor.app/pro?ref={code}
  2. /pro landing page already reads ?ref= and passes to
     /api/register-interest as referredBy
  3. convex registerInterest:register increments the referrer's
     referralCount and stores referredBy on the new row
  4. /api/referral/me reads the count back via the relay query
  5. 'N invited' updates on next 5-min cache refresh

Scope boundaries (deferred):
  - Convex conversion tracking (invited -> PRO subscribed). Needs a
    join from registrations.referredBy to subscriptions.userId via
    email. Surface 'N converted' in a follow-up.
  - Referral-credit / reward system: viral loop works today, reward
    logic is a separate product decision.

* fix(brief): address three P2 review findings on #3175

- api/referral/me.ts JSDoc said '503 if REFERRAL_SIGNING_SECRET is
  not configured' but the handler actually reads BRIEF_URL_SIGNING_SECRET.
  Updated the docstring so an operator chasing a 503 doesn't look for
  an env var that doesn't exist.

- server/_shared/referral-code.ts carried a RESERVED_CODES Set to
  avoid collisions with URL-path keywords ('index', 'robots', 'admin').
  The guard is dead code: the code alphabet is [0-9a-f] (hex output
  of the HMAC) so none of those non-hex keywords can ever appear.
  Removed the Set + the while loop; left a comment explaining why it
  was unnecessary so nobody re-adds it.

- src/components/LatestBriefPanel.ts passed disabled: 'true' (string)
  to the h() helper. DOM-utils' h() calls setAttribute for unknown
  props, which does disable the button — but it's inconsistent with
  the later .disabled = false property write. Fixed to the boolean
  disabled: true so the attribute and the IDL property agree.

10/10 referral-code tests pass. Both tsconfigs typecheck clean.

* fix(brief): address two review findings on #3175 — drop misleading count + fix user-agnostic cache

P1: invitedCount wired to the wrong attribution store.
The share URL is /pro?ref=<code>. On /pro the 'ref' feeds
Dodopayments checkout metadata (affonso_referral), NOT
registrations.referredBy. /api/referral/me counted only the
waitlist path, so the panel would show 0 invited for anyone who
converted direct-to-checkout — misleading.

Rather than ship a count that measures only one of two attribution
paths (and the less-common one at that), the count is removed
entirely. The share button itself still works. A proper metric
requires unifying the waitlist + Dodo-metadata paths into a single
attribution store, which is a follow-up.

Changes:
  - api/referral/me.ts: response shape is { code, shareUrl } — no
    invitedCount / convertedCount
  - convex/registerInterest.ts: removed getReferralStatsByCode
    internal query
  - convex/http.ts: removed /relay/referral-stats route
  - src/services/referral.ts: ReferralProfile interface no longer
    has invitedCount; fetch call unchanged in behaviour
  - src/components/LatestBriefPanel.ts: dropped the 'N invited'
    render branch

P2: referral cache was user-agnostic.
Module-global _cached had no userId key, so a stale cache primed by
user A would hand user B user A's share link for up to 5 min after
an account switch — if no panel is mounted at the transition moment
to call clearReferralCache(). Per the reviewer's point, this is a
real race.

Fix: two-part.
  (a) Cache entry carries the userId it was computed for; reads
      check the current Clerk userId and only accept hits when they
      match. Mismatch → drop + re-fetch.
  (b) src/services/referral.ts self-subscribes to auth-state at
      module load (ensureAuthSubscription). On any id transition
      _cached is dropped. Module-level subscription means the
      invalidation works even when no panel is currently mounted.
  (c) Belt-and-suspenders: post-fetch, re-check the current user
      before caching. Protects against account switches that happen
      mid-flight between 'read cache → ask network → write cache'.

Panel's local clearReferralCache() call removed — module now
self-invalidates.

10/10 referral-code tests pass. Both tsconfigs typecheck clean.

* fix(referral): address P1 review finding — share codes now actually credit the sharer

The earlier head generated 8-char Clerk-derived HMAC codes for the
share button, but the waitlist register mutation only looked up
registrations.by_referral_code (6-char email-generated codes).
Codes issued by the share button NEVER resolved to a sharer — the
'referral attribution' half of the feature was non-functional.

Fix (schema-level, honest attribution path):

  convex/schema.ts
    - userReferralCodes   { userId, code, createdAt }  + by_code, by_user
    - userReferralCredits { referrerUserId, refereeEmail, createdAt }
                          + by_referrer, by_referrer_email

  convex/registerInterest.ts
    - register mutation: after the existing registrations.by_referral_code
      lookup, falls through to userReferralCodes.by_code. On match,
      inserts a userReferralCredits row (the Clerk user has no
      registrations row to increment, so credit needs its own
      table). Dedupes by (referrer, refereeEmail) so returning
      visitors can't double-credit.
    - new internalMutation registerUserReferralCode({userId, code})
      idempotent binding of a code to a userId. Collisions logged
      and ignored (keeps first writer).

  convex/http.ts
    - new POST /relay/register-referral-code (RELAY_SHARED_SECRET auth)
      that runs the mutation above.

  api/referral/me.ts
    - signature gains a ctx.waitUntil handle
    - after generating the user's code, fire-and-forget POSTs to
      /relay/register-referral-code so the binding is live by the
      time anyone clicks a shared link. Idempotent — a failure just
      means the NEXT call re-registers.

Still deferred: display of 'N credited' / 'N converted' in the
LatestBriefPanel. The waitlist side now resolves correctly, but the
Dodopayments checkout path (/pro?ref=<code> → affonso_referral) is
tracked in Dodo, not Convex. Surfacing a unified count requires a
separate follow-up to pull Dodo metadata into Convex.

Regression tests (3 new cases in tests/brief-referral-code.test.mjs):
  - register mutation extends to userReferralCodes + inserts credits
  - schema declares both tables with the right indexes
  - /api/referral/me registers the binding via waitUntil

13/13 referral tests pass. Both tsconfigs typecheck clean.

* fix(referral): address two P1 review findings — checkout attribution + dead-link prevention

P1: share URL didn't credit on the /pro?ref= checkout path.
The earlier PR wired Clerk codes into the waitlist path
(/api/register-interest -> userReferralCodes -> userReferralCredits)
but a visitor landing on /pro?ref=<code> and going straight to
Dodo checkout forwarded the code only into Dodo metadata
(affonso_referral). Nothing on our side credited the sharer.

Fix: convex/payments/subscriptionHelpers.ts handleSubscriptionActive
now reads data.metadata.affonso_referral when inserting a NEW
subscription row. If the code resolves in userReferralCodes, a
userReferralCredits row crediting the sharer is inserted (deduped
by (referrer, refereeEmail) so webhook replays don't double-credit).
The credit only lands on first-activation — the else-branch of the
existing/new split guards against replays.

P1: /api/referral/me returned 200 + share link even when the
(code, userId) binding failed.
ctx.waitUntil(registerReferralCodeInConvex(...)) ran the binding
asynchronously, swallowing missing env + non-2xx + network errors.
Users got a share URL that the waitlist lookup could never
resolve — dead link.

Fix: registerReferralCodeInConvex is now BLOCKING (throws on any
failure) and the handler awaits it before returning. On failure
the endpoint responds 503 service_unavailable rather than a 200
with a non-functional URL. Mutation is idempotent so client
retries are safe.

Regression tests (2 updated/new in tests/brief-referral-code.test.mjs):
  - asserts the binding is awaited, not ctx.waitUntil'd; asserts
    the failure path returns 503
  - asserts subscriptionHelpers reads affonso_referral, resolves via
    userReferralCodes.by_code, inserts a userReferralCredits row,
    and dedupes by (referrer, refereeEmail)

14/14 referral tests pass. Both tsconfigs typecheck clean.

Net effect: /pro?ref=<code> visitors who convert (direct checkout)
now credit the sharer on webhook receipt, same as waitlist
signups. The share button is no longer a dead-end UI.
2026-04-18 20:39:55 +04:00

74 lines
2.8 KiB
TypeScript

/**
* Deterministic referral code generation (Phase 9 / Todo #223).
*
* Signed-in Clerk users don't have a storage-backed referral code the
* way the pre-signup `registrations` table does — Clerk userId is the
* only stable identifier we have. Rather than provisioning a new
* `userReferrals` table, we derive an 8-char code from
* HMAC(secret, userId) so:
*
* - the code is stable for the life of the Clerk account
* - the same user sees the same code across devices with no sync
* - nothing needs to be written on login
* - a guessed code can only fish for a user's invite count, not
* anything identifying — the reverse lookup is a Convex query
* keyed on email, not on the code itself
*
* Collision risk is negligible at our scale (8 hex chars = 4B slots)
* but we reject the 3 codes that conflict with reserved keywords to
* keep the landing-page share URLs clean.
*
* NOTE: this is a DIFFERENT code space than the `registrations` table's
* referralCode column. Those are 6-char codes keyed to an email row
* (pre-signup). Clerk codes are 8-char (to separate the namespaces at
* a glance). The register-interest.js endpoint already accepts
* `referredBy` as an arbitrary string, so the two code spaces merge
* cleanly at attribution time.
*/
function hexHmac(secret: string, message: string): Promise<string> {
return (async () => {
const enc = new TextEncoder();
const key = await crypto.subtle.importKey(
'raw',
enc.encode(secret),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign'],
);
const sig = await crypto.subtle.sign('HMAC', key, enc.encode(message));
const bytes = new Uint8Array(sig);
let hex = '';
for (let i = 0; i < bytes.length; i++) hex += bytes[i]!.toString(16).padStart(2, '0');
return hex;
})();
}
export async function getReferralCodeForUser(
userId: string,
secret: string,
): Promise<string> {
if (typeof userId !== 'string' || userId.length === 0) {
throw new Error('invalid_user_id');
}
if (typeof secret !== 'string' || secret.length === 0) {
throw new Error('missing_secret');
}
const hex = await hexHmac(secret, `referral:v1:${userId}`);
// 8 hex chars = 32-bit namespace. Plenty for the lifetime of the
// product; rotate the secret + migrate if we ever approach the
// birthday-collision zone (~65k users).
//
// Note: the output alphabet is [0-9a-f] so there's no risk of the
// code colliding with URL-path keywords like 'admin' / 'robots'
// — those contain non-hex characters. (An earlier draft carried
// a RESERVED_CODES guard for this case; it was dead code and was
// removed after review.)
return hex.slice(0, 8);
}
export function buildShareUrl(baseUrl: string, code: string): string {
const trimmed = baseUrl.replace(/\/+$/, '');
return `${trimmed}/pro?ref=${encodeURIComponent(code)}`;
}