An external user whose email domain is not in the seeded partner data lands as pending and has no portal access. The access-request flow is how they get in: they submit a request, a Super Admin reviews it at /admin/access-requests, and approval provisions everything needed for them to sign in as a partner. The whole approval is an all-or-nothing saga — it either fully completes or fully reverts, with no half-provisioned state.
Source: the public request action lib/actions/access-request.ts, the admin review actions lib/actions/access-request-admin.ts, the page app/(admin)/admin/access-requests/page.tsx + AccessRequestRow.tsx, the email templates emails/AccessStatusEmail.tsx, and the partner_access_requests table (Data Model).
unknown partner → /request-access (public form)
→ partner_access_requests row (status: pending)
→ Super Admin at /admin/access-requests
├─ Approve → Clerk org + admin invite → status: approved
│ + account_type = partner (one DB transaction)
│ + audit rows + approval email
└─ Reject → status: rejected
+ account_type = rejected (one DB transaction)
+ audit rows + rejection email
/request-access is a public route (allowed in middleware.ts). The RequestAccessForm posts to a server action that validates input with Zod and inserts a partner_access_requests row: email, fullName, companyName, website, optional message, status = "pending". Known-domain requests may auto-approve; unknown ones wait for review. The /pending and /access-denied interstitials tell the user what state they're in (see Auth & Gates).
/admin/access-requests#Super-Admin-only (requireSuperAdminSession). The page lists requests split into Pending Review (with ✓ Approve / ✕ Reject controls) and Actioned (read-only history). Each requestId is validated as a UUID before any work, and every transition re-asserts status = 'pending' in its WHERE clause so concurrent clicks can't double-approve or double-email.
True distributed ACID across Postgres and the external Clerk API isn't possible, so the approval is ordered so that a failure at any step leaves the request untouched and retryable:
approveAccessRequest(requestId):
1. load request; guard status === "pending" and a valid email domain
2. provisionPartnerAccount(domain) // idempotent: marks the domain an active partner
3. createOrgWithAdminInvite(...) // REVERSIBLE external step, done FIRST
└─ on failure → return error, request stays pending (full revert; nothing committed)
4. db().transaction(tx => { // ATOMIC
update status → approved (WHERE status = pending) // 0 rows ⇒ AlreadyActioned ⇒ rollback
assign account_type = partner (pre-login row, source: assigned)
recordActivity("access_request.approved")
recordActivity("account_type.changed")
})
└─ on tx failure → compensate: revoke the Clerk invite; request stays pending
5. sendAccessApprovedEmail(...) // best-effort, after commit
The key change from the portal's earlier behavior: there is no "partial approval." Previously a Clerk failure could mark the request approved without sending the invite (and the pending-guard then blocked retry). Now Clerk provisioning happens first and the request is marked approved only inside the transaction — so the outcome is always either fully approved (Clerk org + invite + partner type + audit + email) or still pending and retryable. If the DB transaction throws after the invite was created, the saga compensates by revoking that invite so a retry starts clean.
Because provisionPartnerAccount makes the domain a known active partner, the approved user resolves to partner on first login even via auto-derive — the explicit setAccountType(partner) inside the transaction makes it assigned (auditable) and is belt-and-suspenders.
Reject is DB-only (no external side effects), so it's a single atomic transaction: flip status → rejected (re-asserting pending), set account_type = rejected, write access_request.rejected + account_type.changed audit rows, then best-effort send the rejection email (which includes the reviewer's note if one was given). Rejected users appear in the Rejected list on /admin/accounts with a "Move to Pending" action to reconsider.
AccessStatusEmail.tsx is one React-email template with an approved boolean — blue header + "Go to Partner Portal" CTA for approvals; red header + the optional rejection reason for rejections. Sent via Resend (RESEND_API_KEY, RESEND_FROM_EMAIL=partners@matters.ai). Email sending is after the DB commit and best-effort (.catch() logs failure) — a mail hiccup never rolls back an approval that already happened. See Operations for Resend setup.
| Artifact | Where |
|---|---|
| Clerk organization for the partner company | Clerk (createOrgWithAdminInvite) |
| Clerk admin invitation to the requester's email | Clerk → email → /accept-invite |
partner_accounts row for the domain (status active) | Neon — lets the login gate admit the domain |
account_type = partner (assigned) | partner_sessions (pre-login pending:<email> row, adopted on first login) |
access_request.approved + account_type.changed events | activity_log (Audit Log) |
| Approval email | Resend |
partner_accounts drives auto-classification