The Opportunities module is the deal-registration system of the Matters.AI Partners Portal. A partner registers a deal (an end customer + an expected ARR + a sales stage); the portal persists it to the partners Neon database, fires a confirmation email, best-effort pushes a Lead into Salesforce, and records an audit event. From then on the deal moves through a portal status lifecycle, accrues a notes thread, and — if it goes quiet for 90 days — is automatically cancelled by a cron sweep.
Every row is scoped to the partner's Clerk organisation (clerkOrgId). Partners see and act only on their own org's deals; internal admins see all of them.
| Route | Audience | Description |
|---|---|---|
/opportunities | Partner | List of the org's deals — search, status filter chips, paginated table, "Active Deals" carousel, Export CSV, Register Deal |
/opportunities/new | Partner | Deal-registration form (the registerOpportunity server action) |
/opportunities/[id] | Partner | Deal detail — fields, SF sync indicator, stage updater, progress timeline, notes/activity log |
/admin/opportunities | Internal admin | All partners' deals — search across company/customer/org ID, status filter, pipeline ARR totals, SF link indicators, Export CSV |
/api/export/opportunities | Partner | CSV of the calling org's deals |
/api/export/admin/opportunities | Internal admin (write tier) | CSV of all deals (capped at 5000 rows) |
/api/cron/lead-lifecycle-sweep | Cron (Bearer secret) | 90-day inactivity cancellation + 83-day warning marker |
Source: app/(portal)/opportunities/, app/(admin)/admin/opportunities/page.tsx.
Two tables back this module, defined in lib/db/schema.ts. See Data Model for the full schema.
opportunities#| Column | Type | Notes |
|---|---|---|
id | uuid PK | defaultRandom() — the public deal identifier used in URLs |
portalLeadId | uuid, unique | Generated with randomUUID() at registration; the cross-system reference ID (shown in emails, pushed to SF as Portal_Lead_Id__c, used as the CSV "Deal ID") |
submittedByClerkUserId | text | Clerk user who registered the deal |
clerkOrgId | text | Owning partner org — every query filters on this |
sfAccountId | text | Partner's Salesforce account id (session.sfAccountId ?? "") |
endCustomerName / endCustomerEmail / endCustomerPhone | text | The prospect contact |
companyName | text | End customer's company |
companyType | text | One of the companyType enum (below) |
companySize | text | One of the companySize enum |
primaryUseCases | text[] | At least one use case |
expectedArrUsd | integer | Positive integer USD |
expectedCloseDate | timestamp | Stored as a Date parsed from the form string |
dealStage | text | One of the dealStage enum |
region | text | One of the region enum |
competingProducts | text[] | Defaults to [] |
notes | text, nullable | Free-text, max 2000 chars |
endCustomerTitle | text, nullable | Contact job title → SF Lead.Title (max 128) |
endCustomerWebsite | text, nullable | Company website URL → SF Lead.Website |
endCustomerLinkedin | text, nullable | Contact LinkedIn URL → SF Lead.mt_LinkedIn_URL__c |
whyNow | text, nullable | Qualification: why now → SF Lead.mt_Why_Now__c (max 255) |
whyMattersAi | text, nullable | Qualification: why Matters.AI → SF Lead.mt_Why_Matters_AI__c (max 255) |
whyAnything | text, nullable | Qualification: why do anything → SF Lead.mt_Why_Anything__c (max 255) |
endCustomerEmployees | integer, nullable | Headcount → SF Lead.NumberOfEmployees |
endCustomerAnnualRevenueUsd | bigint, nullable | Annual revenue USD → SF Lead.AnnualRevenue |
sfLeadId / sfOpportunityId | text, nullable | Salesforce object ids, filled after sync |
sfStatus | text, default "pending" | Salesforce sync state |
portalStatus | text, default "registered" | Portal lifecycle state (below) |
dedupStatus | text, default "approved" | Deduplication verdict |
lastActivityAt | timestamp, default now | Drives the inactivity sweep; bumped on stage change and note add |
inactivityWarningAt | timestamp, nullable | Set by the sweep at 83 days idle |
cancelledAt | timestamp, nullable | Set by the sweep at 90 days idle |
extensionGrantedAt | timestamp, nullable | Reserved for a manual deadline extension |
createdAt / updatedAt | timestamp |
Indexes: portalLeadId, clerkOrgId, portalStatus, lastActivityAt.
opportunity_notes#| Column | Type | Notes |
|---|---|---|
id | uuid PK | |
opportunityId | uuid | FK-by-convention to opportunities.id (indexed) |
authorClerkUserId | text | Clerk user who wrote the note |
body | text | 1–2000 chars |
sfTaskId | text, nullable | Reserved for mirroring the note as a Salesforce Task |
createdAt | timestamp |
The form lives at /opportunities/new and submits to the registerOpportunity server action in lib/actions/opportunities.ts via React useActionState.
Every submission is parsed with OpportunitySchema.safeParse. On failure, flattened field errors are returned and rendered inline (no DB write occurs).
| Field | Form name | Validation |
|---|---|---|
| End customer name | endCustomerName | string().min(2) — "Name required" |
| End customer email | endCustomerEmail | string().email() — "Valid email required" |
| End customer phone | endCustomerPhone | string().min(7) — "Phone required" |
| Company name | companyName | string().min(2) — "Company name required" |
| Company type | companyType | enum (see below) |
| Company size | companySize | enum (see below) |
| Primary use cases | primaryUseCases | array(string()).min(1) — "Select at least one use case" |
| Expected ARR (USD) | expectedArrUsd | coerce.number().int().positive() — "ARR must be positive" |
| Expected close date | expectedCloseDate | string().min(1) — parsed into a Date |
| Deal stage | dealStage | enum (see below) |
| Region | region | enum (see below) |
| Competing products | competingProducts | array(string()).default([]) (optional) |
| Notes | notes | string().max(2000).optional() |
| Contact title | endCustomerTitle | string().max(128).optional().or("") |
| Website | endCustomerWebsite | string().url().optional().or("") |
endCustomerLinkedin | string().url().optional().or("") | |
| Why now | whyNow | string().max(255).optional().or("") |
| Why Matters.AI | whyMattersAi | string().max(255).optional().or("") |
| Why anything | whyAnything | string().max(255).optional().or("") |
| Headcount | endCustomerEmployees | coerce.number().int().positive().max(99999999).optional() |
| Annual revenue (USD) | endCustomerAnnualRevenueUsd | coerce.number().int().positive().optional() |
The final six are optional enrichment fields: they may be left blank at registration and completed later through the edit flow. The form and the edit form share a single OpportunityFields client component so the two can never drift.
companyType: "BFSI" |
"Healthcare & Life Sciences" |
"Manufacturing" |
"Retail & CPG" |
"Technology" |
"Government & Public Sector" |
"Energy & Utilities" |
"Education" |
"Other";
companySize: "1-50" | "51-200" | "201-1000" | "1001-5000" | "5000+";
dealStage: "Prospecting" | "Qualification" | "Discovery" | "POC/Pilot" | "Proposal" | "Negotiation";
region: "APAC" |
"EMEA" |
"Americas" |
"Global" |
"India" |
"Middle East" |
"Southeast Asia" |
"ANZ" |
"UK & Ireland" |
"DACH" |
"Benelux";
primaryUseCases and competingProducts are checkbox groups whose option lists are exported from lib/constants/partner-forms.ts (USE_CASES, COMPETING_PRODUCTS, REGIONS) so the form and validation stay in sync.
requirePartnerSession() resolves the partner session (org, user, email, sfAccountId).OpportunitySchema.safeParse(raw); abort with field errors on failure.opportunities with a fresh portalLeadId = randomUUID(). portalStatus defaults to "registered", sfStatus to "pending", dedupStatus to "approved". lastActivityAt is set to now.sendDealRegisteredEmail({ toEmail: session.email, … }). Errors are swallowed (.catch(() => undefined)) so email never blocks registration.syncOpportunityToSalesforce upserts a Lead by the external id Portal_Lead_Id__c (so a retry never creates a duplicate). The end-customer name is split into FirstName / LastName (Lead requires LastName). It maps Email, MobilePhone (the org labels this "Primary Mobile Number"; the single portal phone is the contact's primary), Company, LeadSource: "Partner Sourced", Status: "New", mt_Budget__c (ARR), mt_Use_Case_Details__c (use cases joined with ;, 255-capped), mt_Anticipated_Closure__c (close date, YYYY-MM-DD), mt_ByPass_Validation__c: true, and — when the partner has an SF account — PartnerAccountId + mt_Partner_Company_Name__c (which back-fill the read-only Partner Email/Phone/Company formula fields). The enrichment fields map to Title, Website, mt_LinkedIn_URL__c, mt_Why_Now__c, mt_Why_Matters_AI__c, mt_Why_Anything__c, NumberOfEmployees, AnnualRevenue — each omitted when blank so a later edit fills gaps without clobbering existing SF values. On success the returned id is written back as sfLeadId. On failure the catch is silent — the row stays in Neon with sfLeadId = null for ops to retry. See Salesforce sync.recordActivity({ action: "opportunity.registered", … }) is recorded before the redirect (since redirect() throws to control flow).revalidatePath("/opportunities") and /dashboard, then redirect("/opportunities").The portal populates every Lead field it can source from a partner deal-registration. The Lead object has 62 fields; the rest are intentionally not written, for concrete reasons:
| Bucket | Fields | Why not written |
|---|---|---|
| Mapped | FirstName/LastName, Email, MobilePhone, Company, Title, Website, mt_LinkedIn_URL__c, mt_Budget__c, mt_Use_Case_Details__c, mt_Anticipated_Closure__c, mt_Why_Now__c, mt_Why_Matters_AI__c, mt_Why_Anything__c, NumberOfEmployees, AnnualRevenue, PartnerAccountId, mt_Partner_Company_Name__c, Portal_Lead_Id__c, LeadSource, Status, mt_ByPass_Validation__c, Description | — |
| Formula / read-only | mt_Partner_Email__c, mt_Partner_Phone__c, mt_Number_of_Open_Days__c, mt_Previous_Owner_Name__c, mt_ICP_Tier__c | No Edit FLS — SF computes them (the Partner Email/Phone/Company back-fill from PartnerAccountId). |
| Lookup (needs an SF record id) | mt_Champion__c (Contact), mt_Campaign__c, mt_Parent_Lead__c, mt_Parent_Contact__c, mt_Previous_Owner__c | Require a Salesforce record id the portal doesn't hold. |
| Picklist — no partner-supplied source | Rating, Industry, mt_Account_Type__c, Country_Phone_Code__c | Verified against the live org: Rating and Industry have no active picklist values on Lead (nothing to write); mt_Account_Type__c is a relationship classification the SDR owns (values Prospect / Customer / Partner / Investor / Competition / Others — a new partner-sourced lead is implicitly Prospect), not the end customer's industry, so companyType doesn't map to it; Country_Phone_Code__c is a restricted dialing-code picklist the portal doesn't collect. companyType and region are preserved in Description. Note an unlisted value on a restricted picklist would 400 the whole upsert, so nothing is sent speculatively. |
| Internal / SDR-owned | SDR_Updates_and_Next_Steps__c, mt_Rejection_Reason__c, mt_Is_Renurture_Lead__c | Owned by the internal sales motion, not the partner. |
| Not captured in portal | Address, Salutation, Pronouns, Fax | No portal input; low value for partner-sourced leads. |
Phone slot: the org maps standard
MobilePhone→ "Primary Mobile Number" and standardPhone→ "Alternate Mobile Number". The portal captures one phone (the contact's primary), so it writesMobilePhone.
Once a deal exists, the updateOpportunity server action (same file) lets it be completed or corrected — typically to fill the optional enrichment fields that were skipped at registration. The edit UI (OpportunityEditForm) is a collapsible form on the deal detail page that re-uses the shared OpportunityFields component, pre-filled from the current row.
Editing is restricted to exactly two principals:
opportunities.submittedByClerkUserId === session.clerkUserId), andgetSuperAdminClearance().ok).No other org member, no partner-org admin, nobody else. The boundary is enforced in two places:
canEdit = opp.submittedByClerkUserId === session.clerkUserId || clearance.ok server-side and only renders OpportunityEditForm when it's true.updateOpportunity re-loads the row by portalLeadId, recomputes the same predicate, and returns a form error ("Only the partner who registered this deal (or a Matters.AI super admin) can edit it.") if it fails. The UI check is a convenience; the action is the security boundary.requirePartnerSession().portalLeadId (uuid) from the form; load the row (return "Deal not found." if missing).OpportunitySchema.safeParse (the same schema as registration, so all rules incl. the enrichment fields apply).null. lastActivityAt + updatedAt are bumped (resetting the inactivity clock).syncOpportunityToSalesforce upserts the same SF Lead by Portal_Lead_Id__c, using the row's sfAccountId (so a super-admin edit never rebinds the deal to a different partner account). If the Lead had no sfLeadId yet and the upsert now returns one, it's written back.opportunity.updated with metadata.bySuperAdmin set when a super admin edited a deal they didn't register.{ success: true } (no redirect — the form collapses in place and the refreshed row renders above it).Clearing vs filling: an emptied optional field is stored as
nullin Neon (source of truth) but is omitted from the SF upsert rather than nulled — the sync is fill-only, so it never blanks a value a Salesforce user may have set. Neon remains authoritative for the portal-owned fields.
Three independent status columns describe a deal.
portalStatus — the partner-facing lifecycle#Default "registered". The six real values and their UI labels (consistent across the list, admin list, detail, and email templates):
portalStatus | Label | Badge class | Treated as |
|---|---|---|---|
registered | Registered | pm-badge-pending | open |
under_review | Under Review | pm-badge-pending | open |
opportunity_active | Active | pm-badge-active | open |
closed_won | Closed Won | pm-badge-active | terminal |
closed_lost | Closed Lost | pm-badge-inactive | terminal |
cancelled | Cancelled | pm-badge-inactive | terminal |
The detail-page progress timeline (OpportunityTimeline.tsx) walks the happy-path sequence registered → under_review → opportunity_active → closed_won, lighting up steps through the current status. closed_lost and cancelled are off-path terminal states (rendered via the badge, not as timeline steps). The list page's "Active Deals" carousel is populated from the three most recent opportunity_active rows.
The
registered → under_review → opportunity_active → closed_*transitions are driven by internal pipeline operations (admins / Salesforce sync), not by a partner-facing button. The partner-facing write surface is the deal stage updater (below) and thecancelledtransition is automated by the sweep.
dealStage — the sales stage (partner-editable)#Distinct from portalStatus. One of Prospecting | Qualification | Proposal | Negotiation. Partners change it from the deal detail page; see "Updating the deal stage".
sfStatus — Salesforce sync state#Default "pending". Reflects the mirror state in Salesforce. The admin list shows a compact SF indicator from sfLeadId / sfOpportunityId ("L ✓" once a Lead exists, " O ✓" once converted to an Opportunity). See Salesforce sync.
dedupStatus — deduplication verdict#Default "approved". Records whether the registered deal collides with an existing pipeline entry (deal-protection / channel-conflict).
/opportunities/[id] validates the UUID format and 404s on a malformed id, then loads the row scoped to the caller's clerkOrgId (404 if it belongs to another org).
Layout. The page is full-width and responsive (.pm-deal-* classes in styles/globals.css), not a narrow single column: a header row, a metric-tile strip, then a two-column split (main detail + sticky aside) that collapses to one column below 960px. It renders:
OpportunityStatusBadge (with a glowing effect for opportunity_active / closed_won).OpportunityEditForm, rendered only when canEdit (creator or super admin — see Editing a registered deal). Full width: collapses to a right-aligned button, expands in place to the full edit form.auto-fit strip).StageUpdateForm, omitted once the deal is in a terminal status), Deal Progress (the status timeline), and Details (registered timestamp, and the sfLeadId / sfOpportunityId references when present).The stage updater posts to updateDealStage in lib/actions/update-opportunity.ts:
requirePartnerSession(), then validate { opportunityId: uuid, dealStage: enum } with UpdateStageSchema.id and clerkOrgId; return "Opportunity not found." if missing.portalStatus is closed_won, closed_lost, or cancelled, return "Cannot update stage on a closed or cancelled deal." The form also hides itself client-side for those statuses.dealStage, and bump lastActivityAt + updatedAt (resetting the inactivity clock).sendNotification, type deal_status) linking to the deal.opportunity.status_changed with metadata: { from, to }.Note: this action changes the sales stage and emits the
opportunity.status_changedaudit action. TheportalStatuslifecycle transitions (and the deal-status email) are driven by internal/admin pipeline operations and the inactivity sweep, not by this partner-facing stage button.
The add-note form posts to addOpportunityNote in lib/actions/opportunity-notes.ts:
requirePartnerSession(), validate { body: string(1..2000), opportunityId: uuid }.opp.clerkOrgId === session.clerkOrgId; otherwise _form: ["Opportunity not found or access denied."].opportunity_notes (authorClerkUserId = session.clerkUserId).lastActivityAt + updatedAt (so notes keep a deal "alive" against the sweep).opportunity.note_added./admin/opportunities is gated by requireAdminSession() (read tier). It is cross-org: no clerkOrgId filter. It shows:
count and pipeline ARR (SUM(expectedArrUsd), rendered in $k).companyName, endCustomerName, and clerkOrgId; the same status filter chips; page size 30.partnerProfiles (joined in a second query keyed by clerkOrgId), plus the compact SF link indicator.See RBAC for the admin session tiers.
The cron route /api/cron/lead-lifecycle-sweep (GET) enforces a 90-day deal-protection window. It is authorised by a constant-time Bearer comparison against CRON_SECRET (timingSafeEqual); a missing or mismatched token returns 401.
On each run (now):
lastActivityAt < now − 90d and cancelledAt IS NULL is set to portalStatus = "cancelled", cancelledAt = now.lastActivityAt < now − 83d, inactivityWarningAt IS NULL, and cancelledAt IS NULL gets inactivityWarningAt = now (a one-time warning marker, ~7 days before cancellation).It responds with { cancelled, warned } counts. lastActivityAt is reset to now whenever a partner updates the stage or adds a note, so any genuine activity restarts the 90-day clock. The schema carries an extensionGrantedAt column reserved for a manual deadline extension.
Rendered with @react-email/components and sent via Resend from lib/email.ts.
| Trigger | Helper | Template | Subject |
|---|---|---|---|
| Deal registered | sendDealRegisteredEmail | DealRegisteredEmail.tsx | Deal registered: {companyName} |
| Portal status changed | sendDealStatusChangedEmail | DealStatusEmail.tsx | Deal update: {companyName} is now {label} |
DealRegisteredEmail summarises company / stage / expected ARR, a "View in Portal" button to ${APP_URL}/opportunities, and the portalLeadId as a reference id. DealStatusEmail shows the old → new status (mapped through the same label table as the UI). The registration email is dispatched fire-and-forget from registerOpportunity; failures never block the write path.
| Route | Gate | Scope | Columns |
|---|---|---|---|
/api/export/opportunities | requirePartnerSession() | Calling org only | Deal ID, Company, End Customer, Email, Phone, ARR, Stage, Region, Status, SF Lead ID, SF Opportunity ID, Submitted, Last Activity |
/api/export/admin/opportunities | requireAdminWriteSession() | All orgs (max 5000 rows) | Org ID, Deal ID, Company, End Customer, Email, ARR, Stage, Region, Status, SF Lead ID, SF Opportunity ID, Submitted |
Both serialise cells through escapeCsvCell (CSV-injection safe), set Content-Type: text/csv, an attachment filename stamped with the date, and Cache-Control: no-store.
Tier gating: the partner export uses the partner session (own-org data). The admin export is gated by
requireAdminWriteSession()— the write tier, not the read tier that backs the admin list page. Read-tier admins can browse all deals but cannot bulk-export them. See RBAC.
Each mutation calls recordActivity (org-scoped, with actor + target) — see the Audit Log.
| Action | Emitted by | Target | Metadata |
|---|---|---|---|
opportunity.registered | registerOpportunity | targetId = portalLeadId | { companyName, expectedArrUsd, dealStage } |
opportunity.updated | updateOpportunity | targetId = portalLeadId | { bySuperAdmin } |
opportunity.status_changed | updateDealStage | targetId = opportunityId | { from, to } (deal stage) |
opportunity.note_added | addOpportunityNote | targetId = opportunityId | — |
session.clerkOrgId. The detail page and note action both re-check ownership and 404 / deny cross-org access.updateOpportunity may be run only by the deal's creator or a Matters.AI super admin — enforced in the action itself, not just in the UI.updateDealStage refuses changes once portalStatus is closed_won / closed_lost / cancelled.CRON_SECRET Bearer token.opportunities / opportunity_notes schemarecordActivity and the event catalogsfStatus, retries