"Enablement" is the cluster of Partners Portal modules that arm a partner to sell, deliver, and be discovered: the Content Library, Agreements, Tiers, the Company Profile, the Team roster, and the public Partner Directory. This page documents each one against the real implementation in apps/partners.
Two recurring patterns underpin everything here:
requireAdminWriteSession() / the admin.write capability. Read-tier internal users (internal_team_member) see the same admin pages but with every form and control hidden — see RBAC.Related: Data Model · RBAC · Audit Log · MDF.
The Content Library is the central repository of partner-facing collateral — sales decks, technical docs, training material, legal templates, and marketing assets. Partners browse it at /content; admins manage it at /admin/content.
Every document carries exactly one category. The category vocabulary is fixed by a Zod enum in lib/actions/content.ts:
| Category | Partner-facing label | Typical contents |
|---|---|---|
sales | Sales | Pitch decks, battlecards, pricing one-pagers |
technical | Technical | Architecture guides, integration docs |
training | Training | Enablement courses, onboarding material |
legal | Legal | Templates, programme terms, policy docs |
marketing | Marketing | Brand assets, campaign kits, co-marketing |
The upload presign route (app/api/upload/content/route.ts) accepts a slightly wider category allowlist — it adds general and agreements and falls back to general for anything unrecognised — but the persisted content_documents.category is constrained to the five values above by the server action's schema.
fileType is one of pdf, pptx, docx, xlsx, video. The upload route never trusts a client-supplied extension: it derives the extension and validates the MIME type server-side. The accepted MIME types and the 100 MB cap are:
// app/api/upload/content/route.ts
const ALLOWED = [
"application/pdf",
"application/vnd.openxmlformats-officedocument.presentationml.presentation", // pptx
"application/vnd.openxmlformats-officedocument.wordprocessingml.document", // docx
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", // xlsx
"video/mp4",
];
const MAX = 100 * 1024 * 1024; // 100 MB
visibleToTiers#Each document carries a visibleToTiers: string[] array. The visibility rule is monotonic and lives in lib/tiers.ts as isContentVisibleToTier:
registered = 1, silver = 2, gold = 3.So a doc targeting ["silver"] is seen by Silver and Gold but not Registered; a doc targeting ["registered"] is seen by everyone. A higher tier always sees lower-tier content — never the reverse.
// lib/tiers.ts
export function isContentVisibleToTier(visibleToTiers, partnerTier) {
if (!visibleToTiers || visibleToTiers.length === 0) return true;
const rank = TIER_RANK[normalizeTier(partnerTier)] ?? 1;
const minTargeted = Math.min(...visibleToTiers.map((t) => TIER_RANK[normalizeTier(t)] ?? 99));
return rank >= minTargeted;
}
This rule is enforced twice: once when the /content page filters the list it renders, and again at download time in app/api/content/download/[id]/route.ts — a partner cannot reach a hidden document by guessing its ID (the route returns 403 if the tier check fails).
Files live in the partners R2 bucket under the content/ prefix (see lib/r2.ts). The upload is a presigned-PUT flow followed by a metadata-save server action:
{ contentType, size, category } to /api/upload/content.content/<category>/<uuid>.<ext>, and returns a presigned PUT URL (valid 600 s) plus the key.r2Key, fileType, fileSizeBytes, visibleToTiers) to the saveContentDocument server action, which inserts a content_documents row.Downloads never expose the bucket. /api/content/download/[id] requires a partner session, rate-limits per user, re-checks tier visibility, then issues a 60-second presigned GET URL and 302-redirects to it.
All three content mutations call requireAdminWriteSession(), so only write-tier internal users can run them (lib/actions/content.ts):
| Action | What it does | Notes |
|---|---|---|
saveContentDocument | Inserts a new doc row after the file is uploaded to R2 | Rejects any r2Key not under content/; records content.uploaded to the audit log |
updateContentDocument | Edits title, description, category, visibleToTiers (metadata only) | The file / r2Key is immutable — replacing a file means a re-upload, not an edit |
deleteContentDocument | Deletes the R2 object (best-effort) and the DB row | R2 delete failure is logged and the DB row is still removed so no orphan rows persist |
On the admin page /admin/content, the upload form and per-row edit/delete controls are wrapped in {canWrite && …} where canWrite = can(accountType, "admin.write"). Read-tier users see the document list but no controls — consistent with RBAC.
Agreements are the per-partner legal documents — signed NDAs, partner agreements, programme terms, order forms. Admins attach them to a specific partner org at /admin/agreements; the partner sees only their own at /agreements.
agreementType is a fixed enum (lib/actions/agreements.ts). The partner_agreements table (lib/db/schema.ts) holds:
| Field | Type | Notes |
|---|---|---|
clerkOrgId | text, required, must start with org_ | The owning partner org — agreements are org-scoped |
title | text, min 3 chars | Display name |
agreementType | nda · partner_agreement · programme_terms · order_form · other | Drives the partner-facing label and icon |
r2Key | text, must start with content/agreements/ | The stored file |
fileSizeBytes | integer, optional | |
signedDate | timestamp, optional | Shown as "Signed <date>" |
expiresAt | timestamp, optional | Drives the "Expired" badge once past |
uploadedByEmail | text | The admin who attached it |
Admin upload mirrors the content flow: presign → PUT → savePartnerAgreement server action. The action (write-tier only) validates that clerkOrgId looks like a real Clerk org (org_…) and that the r2Key sits under content/agreements/, inserts the row, and records agreement.uploaded to the audit log. deletePartnerAgreement removes the R2 object (best-effort) and the row.
The partner page (app/(portal)/agreements/page.tsx) lists only agreements where clerkOrgId matches the partner's own org, sorted by type then creation date. Each card shows the type label, the signed/expiry dates, and an Expired badge when expiresAt < now. The download link points at /api/agreements/download/[id], which double-scopes the lookup by both agreement ID and the caller's clerkOrgId — a partner can never download another org's agreement even with a valid ID — then redirects to a 60-second presigned GET URL.
The partner programme has exactly three tiers, ranked: Registered → Silver → Gold. There is no Platinum tier. The canonical slug vocabulary ["registered", "silver", "gold"] is defined in lib/tiers.ts and shared with partnerSessions.partnerTier (which is synced from Salesforce Partner_Tier__c). A partner's tier is therefore read-only and assigned by Matters.AI — the portal never lets a partner change their own tier.
partner_tiers table#Tier presentation data (the descriptions, benefits, requirements shown on /tier-benefits) lives in the partner_tiers table, keyed by the unique slug:
| Column | Type | Notes |
|---|---|---|
slug | text, unique | registered · silver · gold |
displayName | text | e.g. "Gold" |
description | text, nullable | |
arrRange | text, nullable | Free-text ARR range typed by an admin — never computed or invented |
mdfQuarterlyLimitUsd | integer, nullable | Per-quarter MDF cap (USD) shown as a stat |
dealProtection | text, nullable | Free-text deal-protection note shown as a stat |
benefits | text[], default {} | Bulleted list |
requirements | text[], default {} | Bulleted list |
sortOrder | integer, default 0 | Controls left-to-right card order on /tier-benefits |
/admin/tiers exposes a TierForm (write-tier only). The saveTier server action (lib/actions/tiers.ts) upserts by slug (onConflictDoUpdate on partner_tiers.slug), so saving an existing slug edits it in place. benefits and requirements are entered as a free-text textarea and parsed by parseList, which splits on newlines or commas and trims empties. deleteTier removes a tier by slug. Both revalidate /admin/tiers, /tier-benefits, and /dashboard.
pnpm seed:tiers#The three tiers are seeded idempotently by scripts/seed-tiers.ts:
pnpm --filter partners seed:tiers
The script upserts by slug, so re-running it updates the three rows rather than duplicating them. It targets PARTNERS_NEON_DATABASE_URL. Per the PRD, the seed leaves arrRange, mdfQuarterlyLimitUsd, and dealProtection null — margin and MDF are configured per deal (see MDF), not per tier — and seeds these benefit/requirement lists:
Below the tier cards, /tier-benefits renders a second grid: Capabilities by Partner Type. Partner types are an orthogonal axis from tiers — fixed ecosystem categories synced from Salesforce Partner_Type__c, defined as a typed constant in lib/partner-types.ts (not in the DB). The four types and their key portal features:
| Partner type | Use case | Key portal features |
|---|---|---|
| Cloud Provider | Technology alliance, co-sell, marketplace listings | Company Profile, Content Library, Opportunity Registration, Account Mapping |
| VAR (Value Added Reseller) | Resell Matters.AI licenses, add professional services | Full feature access, MDF Funds, Certifications |
| Regional Distributor | Sub-reseller management, regional coverage | Margin Tracking, MDF Funds, Sub-partner Analytics |
| MSSP | Managed service delivery on top of Matters.AI | Content Library, Certifications, Opportunity Registration, MDF Funds |
matchPartnerType maps a raw Salesforce/CSV value to one of these four via id, exact name, or alias substring (e.g. aws, gcp, azure → Cloud Provider). The page highlights both the partner's current tier ("Your Tier") and current type ("Your Type"). Internal staff (partnerTier === "internal") read "Internal" in the header. If no tier rows exist yet, the page shows an empty-state card pointing the partner to their partner manager.
The Company Profile (/profile) is what populates a partner's public Directory card. It is editable by any partner-session user — it uses requirePartnerSession() (not the admin gate) — and is stored one row per org in partner_profiles, upserted by clerkOrgId.
From the ProfileSchema in lib/actions/profile.ts:
| Field | Validation | Notes |
|---|---|---|
companyName | required, min 2 | |
tagline | optional, max 160 | Shown on the directory card |
websiteUrl | optional, valid URL or empty | |
primaryContactName | required, min 2 | |
primaryContactEmail | required, valid email | |
primaryContactPhone | required, min 7 | |
headquartersCity | optional | Combined with country into "City, Country" |
country | optional | |
regionsServed | string[] | Choices: APAC, EMEA, Americas, Global |
specializations | string[] | Nine fixed choices (AI Workflow Automation, Document Intelligence, Customer Service AI, Sales Intelligence, HR & People Analytics, Finance Automation, Custom LLM Integration, Enterprise Integration, Cloud Infrastructure) |
certifiedStaffCount | optional, non-negative integer | Shown as "N certified staff" |
gstTaxId | optional | See note below |
logoR2Key | optional, must start with logos/ | The company logo object |
regionsServed and specializations are exported as the REGIONS and SPECIALIZATIONS constants from lib/actions/profile.ts. Saving revalidates both /profile and /directory.
The logo flows through /api/upload/logo (presigned PUT, 120 s expiry, per-org rate limit). Allowed types are JPEG, PNG, WebP only — SVG is deliberately rejected as an XSS/SSRF vector — capped at 2 MB. The key pattern is logos/<orgId>/<uuid>.<ext>, and the updateProfile action rejects any logoR2Key not under logos/. The logo is the one R2 asset served publicly: the Directory renders it from ${R2_PUBLIC_URL}/<logoR2Key> (default host https://partners-assets.matters.ai).
gstTaxId note#gstTaxId is flagged in the schema as a regulated identifier: the column comment recommends application-level encryption before storing real GST/tax IDs. Treat it as sensitive PII. Today it is a plain text column with no portal-side encryption — anyone wiring real tax IDs in should add encryption first.
If the profile's directoryApproved flag is true, /profile shows a "✓ Directory Approved" badge confirming the listing is live. The partner cannot toggle this themselves — approval is an admin action (next section).
The Team page (/team) lists the partner organisation's Clerk members and lets org admins invite or remove people. It is gated by requirePartnerSession(); the management controls additionally require the caller's Clerk org role to be org:admin (session.orgRole === "org:admin"). Note this is the partner's own Clerk org-admin role — distinct from the internal portal account-type RBAC in RBAC.
inviteMember (lib/actions/team.ts) validates an email and a role (org:member or org:admin — the role keys must match this Clerk instance exactly; the legacy org:basic_member 404s as "Organization role not found"), then creates a Clerk organization invitation with a redirect to /accept-invite. A welcome email is sent fire-and-forget via sendInviteEmail so it never blocks the request. On success it writes a team.invited entry to the audit log (targetType: "user", targetId: <email>) and revalidates /team.
removeMember deletes the Clerk organization membership. Guards: the caller must be org:admin, and a member cannot remove themselves (userId === session.clerkUserId is rejected). Raw Clerk error messages are never surfaced to the client — failures are logged and returned as a generic message (MED-7).
The Partner Directory is the only public, unauthenticated Enablement surface — a SEO-indexed page at /directory (robots: index, follow, revalidate = 3600) that markets the certified partner network.
A profile appears in the directory only when partner_profiles.directoryApproved = true. This flag is controlled solely by write-tier admins on /admin/partners via the DirectoryToggle component, which calls the toggleDirectoryApproval server action (lib/actions/admin.ts):
requireAdminWriteSession(), flips directoryApproved, and sets directoryApprovedAt to now (or null when un-approving), then revalidates /admin/partners and /directory.canWrite is false (read-tier internal_team_member), it renders a static "Listed" / "Not listed" status label instead of a clickable button. If the org has no profile yet, it shows "No profile". See RBAC.The directory query selects only approved profiles and exposes a curated subset of fields — never the contact name/email/phone or gstTaxId. Each card shows:
headquartersCity, country)Tier is resolved per org from partner_sessions: an org can have multiple member sessions with divergent tiers, so the page dedupes to the highest (most generous) tier rather than whichever row came last. Cards are sorted Gold → Silver → Registered. The page also carries hero and footer CTAs ("Become a Partner", "Partner Sign In").
Across every admin Enablement surface, the pattern is identical: read-tier internal users (internal_team_member) can view the data but every authoring control is hidden behind canWrite = can(accountType, "admin.write").
| Admin surface | Write-tier sees | Read-tier sees |
|---|---|---|
/admin/content | Upload form + per-row edit/delete | Document list only |
/admin/agreements | Upload form + per-row delete | Agreement list only |
/admin/tiers | Tier editor form + per-row edit/delete | Tier list only |
/admin/partners | Directory Approve / ✓ Approved toggle | Static "Listed / Not listed" label |
Partner-session surfaces (/content, /agreements, /tier-benefits, /profile, /team, and the public /directory) follow their own scoping rules described above and are unaffected by internal account-type tiers.
content_documents, partner_agreements, partner_tiers, partner_profiles.admin.write capability, account types, and the read-tier read-only behaviour.content.uploaded, agreement.uploaded, and team.invited events.