Skip to main content

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.

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.
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. 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.

Component, provider, and auth architecture

app/partner-portal/layout.tsx composed four portal-only providers, all under components/partner-portal/:
  • 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.tsPOST 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).

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.
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.