> ## Documentation Index
> Fetch the complete documentation index at: https://docs.myfundingmachine.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Partner portal

# Archive: Partner Portal (`/partner-portal`) — removed UI

Status: **Removed** (UI only) · Removed: 2026-07-26 · Built: FUND-1038 → FUND-1296 (Mar–May 2026)

This document preserves what the Partner Portal was, so it can be reconstructed or
redesigned later without archaeology. The exact code lives in git history — see
[Git history guidance](#git-history-guidance).

## Why it was removed

* The Portal's paid subscription was canceled for lack of use (FUND-1847, May 2026).
  It shipped in every build with no feature gate but had effectively no users.
* `docs/design/partner-systems-adr.md` (Accepted, 2026-07-12) declared it legacy /
  maintenance-only and laid out a staged decommission path; step 5 of that path is
  "remove `app/partner-portal/*` and `components/partner-portal/*` … in its own PR".
  This removal is that step, taken **out of order and with explicit approval**: the
  earlier ADR steps (backend retirement, the security fixes on the Convex functions,
  and the Dub link/cron disposition) are still incomplete.
* It carried a known cross-tenant P0 (`partnerOrganizations.create` binds a
  caller-supplied `locationId` with no ownership verification) and zero test coverage
  across its Convex functions. **That exposure is not retired.** The Convex functions
  are still deployed, still unverified, and still callable by anything holding a Clerk
  identity against this deployment; removing the UI only removes this app's own caller.
  See [Cautions before future reuse](#cautions-before-future-reuse).

Brock explicitly approved this out-of-order UI removal and asked that the Portal's
information be saved for a possible future implementation.

### This is NOT the Partner module at `/partners`

Two different products share the word "partner". Only the Portal was removed.

|              | **Partner module** (retained, go-forward)                                                                              | **Partner Portal** (removed here)                                                                                                          |
| ------------ | ---------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ |
| Routes       | `/partners`, `/admin/partners`                                                                                         | `/partner-portal/*`                                                                                                                        |
| Auth         | CRM SSO / session actor (marketplace iframe)                                                                           | Clerk (external login)                                                                                                                     |
| Identity key | `ghlAffiliateId`                                                                                                       | `clerkOrgId` / `partnerOrganizations._id`                                                                                                  |
| Data         | `ghlAffiliates`, `ghlAffiliateCampaigns`, `ghlAffiliateCommissions`, `ghlAffiliatePayouts`, `partnerReportingSettings` | `partnerOrganizations`, `partnerReferralLinks`, `salesCommissions` (read through the Portal's location binding), `organizationMemberships` |
| Audience     | CRM affiliate partners surfaced to operators/admins inside the CRM                                                     | External referral partners with their own white-label login                                                                                |

Everything in the Partner module column — affiliate sync, enrollment, attribution,
commissions, reporting, partner list/detail UI, admin tools — is untouched by this
removal. A third system, prospecting partners (`convex/prospecting/*`,
`prospectingPartners`), is also unrelated and untouched, including its own operator
copy that says "partner portal".

## Route and page inventory

All routes were under `app/partner-portal/` and rendered inside the portal shell.

| Route                                                | Capability                                                                                                                                                                                                          |
| ---------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `/partner-portal`                                    | Dashboard: four metric cards (Total Referrals, Earnings, Team Members, Conversion Rate), quick actions, recent-activity feed. Redirected to `getting-started` when the Clerk org had no `partnerOrganizations` row. |
| `/partner-portal/getting-started`                    | 4-step onboarding: Company Information → Connect Your Account (CRM `locationId`, validated client-side only as `^[a-zA-Z0-9]{10,}$`) → Brand Your Portal → Review & Launch. Called `partnerOrganizations.create`.   |
| `/partner-portal/referrals`                          | Dub.co short-link management: create (with optional custom slug), copy, activate/deactivate, delete. Tracking `?ref=<clerkOrgId>` was appended to the destination URL.                                              |
| `/partner-portal/earnings`                           | Commission summary cards, timeseries chart, and a commission history list. The history was not paginated: `getCommissionHistory` returned the latest 100 rows (optionally date-filtered).                           |
| `/partner-portal/branding`                           | White-label config: primary/accent color (with presets), logo URL, company name. Admin-only in the sidebar (`org:admin`).                                                                                           |
| `/partner-portal/team`                               | Team management built purely on Clerk organization APIs (memberships, invitations, roles). No Convex data.                                                                                                          |
| `/partner-portal/settings`                           | Thin page: Clerk org name/id plus subscription status derived from the Clerk feature flag `org:partner_portal_access`.                                                                                              |
| `/partner-portal/pricing`                            | Delegated entirely to Clerk `<PricingTable for="organization" />`.                                                                                                                                                  |
| `/partner-portal/sign-in`, `/partner-portal/sign-up` | Clerk catch-all auth pages (`[[...sign-in]]` / `[[...sign-up]]`).                                                                                                                                                   |
| `error.tsx`, `loading.tsx`                           | Portal-scoped error boundary and skeleton.                                                                                                                                                                          |

## Component, provider, and auth architecture

`app/partner-portal/layout.tsx` composed four portal-only providers, all under
`components/partner-portal/`:

```
PartnerClerkProvider     → <ClerkProvider> with portal sign-in/up/after-sign-out URLs
  PartnerConvexProvider  → ConvexProviderWithClerk (Clerk identity, not the SSO session)
    BrandingProvider     → reads api.partnerOrganizations.getBranding by Clerk orgId
      PartnerPortalShell → sidebar + branded layout
```

* `PartnerPortalShell` rendered children bare on the auth/onboarding routes and
  otherwise wrapped them in `BrandedPortalLayout`, which set `--brand-*` hex vars on
  `:root` (so Radix portals inherit them) and scoped Tailwind `--primary` / `--accent`
  HSL tokens to the portal subtree.
* `BrandingProvider` also exported `hexToHSL` and `getContrastColor`, plus the
  defaults `#0f172a` (primary) and `#2563eb` (accent).
* `PartnerSidebarContext` held only the collapsed/expanded sidebar state.
* `PartnerPortalSidebar` rendered Clerk's `OrganizationSwitcher` and `UserButton`, a
  collapsible desktop rail plus a mobile Sheet, and the nav list Dashboard / Referrals
  / Earnings / Team / Branding (admin-only) / Settings.

Global auth exceptions that existed only for the Portal, all removed with it:

* `proxy.ts` matched `/partner-portal(.*)`, Clerk-protected it (`auth.protect` with
  `unauthenticatedUrl` → `/partner-portal/sign-in`) except for the sign-in/sign-up
  subtrees, and returned early so the Lead Built SSO flow never ran. The whole
  middleware was conditionally wrapped in `clerkMiddleware` only because of this.
  Note the historical gap: when `CLERK_SECRET_KEY` was unset the wrapper was skipped
  and the Portal passed through unauthenticated.
* `components/auth/AuthGuard.tsx` treated any `/partner-portal` path as a bypassed
  route.
* `components/RouteRestorer.tsx` excluded `/partner-portal` from saved-route
  restoration.

## Portal-only API and backend dependencies

Removed with the UI:

* `app/api/partner-portal/referral-links/route.ts` — `POST` created a Dub.co short link
  (`createShortLinkFull`, `externalId: partner_<orgId>_<timestamp>`, `?ref=<orgId>`
  appended); `DELETE` verified ownership via `partnerReferralLinks.getByDubLinkId`
  before `deleteShortLink`. Both gated on Clerk `auth()` `userId` + `orgId`. The portal
  referrals page was its only caller.

Convex functions the Portal called (all **retained**, now with no in-app caller):

* `partnerOrganizations`: `getByClerkOrg`, `getDashboardMetrics`, `getBranding`,
  `updateBranding`, `checkSlugAvailability`, `create`
* `partnerEarnings`: `getEarningsSummary`, `getEarningsTimeseries`,
  `getCommissionHistory` (all derived at query time from `salesCommissions` filtered by
  the org's bound `locationId`; there is no `partnerEarnings` table)
* `partnerReferralLinks`: `listByOrg`, `create`, `toggleStatus`, `remove`,
  `getByDubLinkId`

External services: Clerk (auth, organizations, billing/`PricingTable`, the
`org:partner_portal_access` feature) and Dub.co (short links + analytics).

## Preserved backend structures

Nothing backend was deleted. Still present after this removal:

* Convex modules `convex/partnerOrganizations.ts`, `convex/partnerEarnings.ts`,
  `convex/partnerReferralLinks.ts`.
* Schema tables `partnerOrganizations` and `partnerReferralLinks`
  (`convex/schemas/agencyOps.ts`), `organizationMemberships`
  (`convex/schemas/core.ts`), and `salesCommissions` (`convex/schemas/commissions.ts`).
  `salesCommissions` is FM's own commission ledger with independent accounting value;
  schema deletions are Out of Scope without Brock approval regardless.
* The Dub analytics sync cron (`/api/cron/dub-analytics-sync` in `vercel.json`), which
  reads `partnerReferralLinks.listAllActive` and writes back via `updateStats`. It
  still runs every 15 minutes. Stopping it and deciding the disposition of live Dub
  links is a separate, deliberate decision (ADR step 3).
* Referral attribution at lead intake: `leadEvents.referralOrgId` / `dubId` are still
  captured from CRM custom fields in `lib/webhooks/contact-create-handler.ts`.
* Clerk configuration on the Convex side: `convex/auth.config.ts`
  (`CLERK_JWT_ISSUER_DOMAIN`, still required for the Convex push to succeed) and the
  Clerk application itself. The ADR says retire Clerk last, after confirming no other
  surface depends on it.
  The `@clerk/nextjs` dependency was **not** retained — it was the Portal's only
  consumer and is removed from `package.json` / `bun.lock` by this change. A rebuild
  must reinstall it (see [Git history guidance](#git-history-guidance)).

## Cautions before future reuse

1. **Do not restore `partnerOrganizations.create` as-is.** It inserts a caller-supplied
   `locationId` with no ownership verification and no uniqueness guard, which lets any
   authenticated org read another tenant's commission aggregates. Any revival must
   verify the binding server-side (location exists in `ghlInstallations`, admin/verified
   approval) and enforce it on the read path in
   `partnerEarnings.getAuthorizedPartnerOrganization`, not just on write.
   `checkSlugAvailability` is also unauthenticated (slug enumeration).
2. **The earnings numbers were misleading.** `salesCommissions` has writers with no
   in-repo callers, so the Portal generally rendered empty or legacy data. Wiring a
   future portal to a real ledger is a product-comms event, not just a code change.
3. **Never join `salesCommissions` with `ghlAffiliateCommissions`,** and never equate
   `clerkOrgId` with `ghlAffiliateId`. Different money, different parties, different
   keyspaces (ADR §5).
4. **Do not revive canceled scope:** custom domains / DNS (FUND-1183),
   affiliate-domain linking (FUND-1084), the data-import tool (FUND-1182).
5. Auth, schema, and billing math are Out of Scope surfaces — a rebuild needs explicit
   human approval per `VISION.md`.

## Further reading

* `docs/design/partner-systems-adr.md` — Partner module vs Partner Portal ownership,
  and the staged retirement path this removal follows.
* `docs/partner-portal-payouts-plan.md` — deep current-state audit of the Portal,
  commissions, and a (proposal-only) payouts architecture.
* `docs/design/fund-1921-subpartner-commissions.md` — design-only multi-level
  commission chains that referenced the Portal earnings page.

## Git history guidance

The removal commit / PR that deleted these files is the boundary: check out its parent
to get the complete, working implementation.

Restoring the deleted directories alone is not enough: the Portal also depended on
edits made in place to `proxy.ts`, `components/auth/AuthGuard.tsx`,
`components/RouteRestorer.tsx`, and on the `@clerk/nextjs` dependency. Take all of them
from the same parent commit.

```bash theme={null}
# find the removal commit
git log --oneline --diff-filter=D -- 'app/partner-portal/*'

# browse the full portal at the commit before removal
git show <removal-sha>^:app/partner-portal/page.tsx

# restore the deleted portal trees
git checkout <removal-sha>^ -- app/partner-portal components/partner-portal app/api/partner-portal

# restore the in-place routing/auth exceptions and the Clerk dependency
git checkout <removal-sha>^ -- proxy.ts components/auth/AuthGuard.tsx components/RouteRestorer.tsx package.json bun.lock
bun install
```

Reinstating `clerkMiddleware` in `proxy.ts` also reinstates the historical gap noted
above (unauthenticated pass-through when `CLERK_SECRET_KEY` is unset) — fix it rather
than restoring it verbatim.

Full prior history for these paths: `git log --follow -- app/partner-portal`.
