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 “removeapp/partner-portal/*andcomponents/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.createbinds a caller-suppliedlocationIdwith 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.
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 underapp/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/:
PartnerPortalShellrendered children bare on the auth/onboarding routes and otherwise wrapped them inBrandedPortalLayout, which set--brand-*hex vars on:root(so Radix portals inherit them) and scoped Tailwind--primary/--accentHSL tokens to the portal subtree.BrandingProvideralso exportedhexToHSLandgetContrastColor, plus the defaults#0f172a(primary) and#2563eb(accent).PartnerSidebarContextheld only the collapsed/expanded sidebar state.PartnerPortalSidebarrendered Clerk’sOrganizationSwitcherandUserButton, a collapsible desktop rail plus a mobile Sheet, and the nav list Dashboard / Referrals / Earnings / Team / Branding (admin-only) / Settings.
proxy.tsmatched/partner-portal(.*), Clerk-protected it (auth.protectwithunauthenticatedUrl→/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 inclerkMiddlewareonly because of this. Note the historical gap: whenCLERK_SECRET_KEYwas unset the wrapper was skipped and the Portal passed through unauthenticated.components/auth/AuthGuard.tsxtreated any/partner-portalpath as a bypassed route.components/RouteRestorer.tsxexcluded/partner-portalfrom saved-route restoration.
Portal-only API and backend dependencies
Removed with the UI:app/api/partner-portal/referral-links/route.ts—POSTcreated a Dub.co short link (createShortLinkFull,externalId: partner_<orgId>_<timestamp>,?ref=<orgId>appended);DELETEverified ownership viapartnerReferralLinks.getByDubLinkIdbeforedeleteShortLink. Both gated on Clerkauth()userId+orgId. The portal referrals page was its only caller.
partnerOrganizations:getByClerkOrg,getDashboardMetrics,getBranding,updateBranding,checkSlugAvailability,createpartnerEarnings:getEarningsSummary,getEarningsTimeseries,getCommissionHistory(all derived at query time fromsalesCommissionsfiltered by the org’s boundlocationId; there is nopartnerEarningstable)partnerReferralLinks:listByOrg,create,toggleStatus,remove,getByDubLinkId
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
partnerOrganizationsandpartnerReferralLinks(convex/schemas/agencyOps.ts),organizationMemberships(convex/schemas/core.ts), andsalesCommissions(convex/schemas/commissions.ts).salesCommissionsis 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-syncinvercel.json), which readspartnerReferralLinks.listAllActiveand writes back viaupdateStats. 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/dubIdare still captured from CRM custom fields inlib/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/nextjsdependency was not retained — it was the Portal’s only consumer and is removed frompackage.json/bun.lockby this change. A rebuild must reinstall it (see Git history guidance).
Cautions before future reuse
- Do not restore
partnerOrganizations.createas-is. It inserts a caller-suppliedlocationIdwith 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 inghlInstallations, admin/verified approval) and enforce it on the read path inpartnerEarnings.getAuthorizedPartnerOrganization, not just on write.checkSlugAvailabilityis also unauthenticated (slug enumeration). - The earnings numbers were misleading.
salesCommissionshas 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. - Never join
salesCommissionswithghlAffiliateCommissions, and never equateclerkOrgIdwithghlAffiliateId. Different money, different parties, different keyspaces (ADR §5). - Do not revive canceled scope: custom domains / DNS (FUND-1183), affiliate-domain linking (FUND-1084), the data-import tool (FUND-1182).
- 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 toproxy.ts, components/auth/AuthGuard.tsx,
components/RouteRestorer.tsx, and on the @clerk/nextjs dependency. Take all of them
from the same parent commit.
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.
