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 "unique" | Deduplication verdict (unique | duplicate) |
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 |
extensionRequestedAt | timestamp, nullable | Set by requestExtension — see Deal extension |
extensionGrantedAt | timestamp, nullable | Set by grantExtension — pushes expectedCloseDate +30 days |
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 route at /opportunities/new is a thin server shell: it resolves the partner session and hands clerkUserId to NewOpportunityForm, the client component that submits to the registerOpportunity server action in lib/actions/opportunities.ts via React useActionState.
Why the split: the form needs the Clerk user id to namespace its saved draft. Reading it server-side rather than calling
useUser()in the client keeps the route free of Clerk context — a Clerk hook here makes the page unprerenderable and failsnext buildwhenever no valid publishable key is present at build time.
Fields are controlled, held by useOpportunityDraft over the pure lib/opportunity-draft.ts.
This exists because React 19 automatically resets uncontrolled <form action={fn}> elements once the action settles. The fields previously read only defaultValue, so a single validation error — one malformed email — wiped the entire registration, checkbox groups included.
| Event | Draft survives? |
|---|---|
| Validation error | Yes |
| Hard refresh | Yes |
| Navigate away and back | Yes |
| Tab close / browser crash | Yes |
| Older than the 7-day TTL | No — discarded on read |
| Written by an older field schema | No — discarded on read |
| Reset form button (confirms first) | No — cleared |
| Successful submit | No — cleared |
Storage is localStorage, not sessionStorage: the latter is destroyed when the tab closes, which would fail the main case worth protecting. The DRAFT_TTL_MS (7 days) bounds the staleness localStorage would otherwise allow, and the key is namespaced per Clerk user (pm:draft:opportunity:new:<userId>) so a shared machine never restores one partner's draft into another's form. Every payload carries a version; a mismatch discards rather than hydrates a stale shape. All storage access is wrapped in try/catch (Safari private mode throws on write) and degrades to in-memory state.
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 | Optional. union([literal(""), coerce.number().int().positive()]).optional() → null. "ARR must be positive" when supplied |
| 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, plus Expected ARR, 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.
Expected ARR became optional because partners routinely do not know the budget at registration. opportunities.expected_arr_usd dropped its NOT NULL constraint (a db:push step recorded in PENDING_TASKS.md §11). An untouched number input submits "", which resolves to null — deliberately distinct from a stored 0, since "unknown budget" and "zero budget" are different facts. Every read surface renders unknown as an em dash via formatArrUsd in lib/format.ts, admin aggregates COALESCE to 0, and the SF upsert omits mt_Budget__c entirely rather than sending null.
Required-field convention: the form carries no (optional) labelling anywhere. A single legend — "Fields marked * are required." — sits above the first section, and requirement is signalled only by the * marker.
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";
All of the above — plus USE_CASES, COMPETING_PRODUCTS, and SPECIALIZATIONS — are exported from lib/constants/partner-forms.ts as the single source. OpportunitySchema, UpdateStageSchema, OpportunityFields, and StageUpdateForm all import them, so the form and its validation cannot drift.
DEAL_STAGESwas previously duplicated: a six-value list insideOpportunityFieldsand a four-value list insideStageUpdateForm. A deal registered at Discovery or POC/Pilot could therefore never be re-staged. One exported constant now feeds all three consumers.
Both checkbox groups carry business-approved options rather than the generic AI-automation list they originally shipped with. Because both serialise to Salesforce as free text (below), the lists can be revised without a migration — historical deals keep whatever strings they were saved with and render verbatim.
USE_CASES is the business-approved list (2026-08-11), superseding the earlier DDR-derived draft, in approved order: Agentic AI Remediation · Compliance Readiness · Dynamic Risk Prioritization · DPDP Compliance · Data Lineage and Flow · On-Prem Support · Insider Data Risk & Behaviour Detection · Endpoint Data Visibility & DLP Enrichment · AI Asset and Risk Intelligence · Real-Time AI Interaction Control · AI-Aware Discovery and Contextual Intelligence · Database Activity Monitoring (DAM) · Other.
COMPETING_PRODUCTS is likewise the business-approved list (2026-08-11): Forcepoint · Sentra · Securiti.ai · Varonis · Guardium · Microsoft Purview · Symmetry · None / Greenfield · Other. (The approved list named Sentra twice — deduplicated. "None / Greenfield" and "Other" are kept as terminators so a partner facing no competitor, or an unlisted one, has an honest answer.) A unit test pins both lists exactly, so a drive-by edit fails the suite.
SPECIALIZATIONS (partner profile, rendered as chips on the public directory) is a different axis — the partner's delivery capability, not the customer's use case — so it has its own list.
No Salesforce migration was needed. Both fields serialise to SF as free text (mt_Use_Case_Details__c, a 255-char TextArea, and the Lead Description), not picklists. Historical rows keep whatever strings they were saved with and render verbatim; only the selectable options changed.
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.
PartnerAccountIddegradation (seen live 2026-08-11): the org rejectsLead.PartnerAccountIdfor the integration user (INVALID_FIELD_FOR_INSERT_UPDATE— no Edit FLS, or platform-managed likeIsPartner). Rather than losing the entire Lead over one attribution lookup, the upsert retries once without it (lib/sf/error-fields.ts, strictly limited to an explicit droppable list) and logs the dropped fields; attribution still flows viamt_Partner_Company_Name__c, which the same response did not reject. If the SF admin grants Edit FLS on the field (PENDING_TASKS §14), later re-upserts populate the standard lookup automatically.
Once a deal exists, the updateOpportunity server action (same file) lets it be completed — not rewritten. Editing is append-only: a field that was saved with a value is locked, while a field still empty stays fillable, so a deal can be enriched over time without any submitted fact ever changing. 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).buildUpdateSet(row, parsed, { bypassLocks: clearance.ok }) decides what may actually be written. See Append-only field locking.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) and the post-filter values — pushing the raw submission would let a rejected overwrite reach the Lead anyway. 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, plus a separate opportunity.admin_field_override entry when a super admin changed an already-submitted value.{ success: true }, plus ignoredFields when locked fields were dropped (no redirect — the form collapses in place and the refreshed row renders above it).lib/opportunity-fields.ts is the single source for what is editable. It is a plain module, not "use server" — action files may only export async functions, so a sync helper exported from one would pass tsc, run in dev, then fail the production build.
What locks. lockedFields(row) walks LOCKABLE_FIELDS and locks any scalar where isFieldPopulated is true. Zero counts as an answer; null, undefined, "", whitespace, and [] do not.
What never locks.
dealStage — a progression, owned by updateDealStage.notes — an append surface; the opportunity_notes timeline already exists for history.Multi-selects (primaryUseCases, competingProducts) lock per option: an already-chosen option renders checked and disabled while unchosen options stay selectable. Server-side, mergeMulti computes persisted ∪ submitted, so additions land and attempted removals are ignored.
Enforcement is server-side. buildUpdateSet drops locked columns from the SET clause entirely, so a forged POST cannot overwrite a submitted fact regardless of what the request body contained. The read-only inputs are a convenience, never the boundary.
Partial success, not failure. A request touching a locked field still saves its legitimate portion and returns the rejected names in ignoredFields, surfaced as a non-blocking notice. A locked field resubmitted with its current value is a no-op, not a rejection.
Accessibility. Locked scalars render readOnly rather than disabled — a disabled field is dropped from FormData and removed from the accessibility tree. <select> cannot be readOnly, so a locked one is disabled and shadowed by a hidden input carrying its value; the same trick re-supplies locked checkbox options. All locked controls point aria-describedby at one shared explainer: "Submitted values cannot be changed. Contact your partner manager to correct an error."
Super-admin override. When getSuperAdminClearance().ok, locks are bypassed and the edit panel renders an explicit Admin override banner. The UI condition and the action's bypassLocks are the same clearance.ok expression, so the UI can never display locks the server does not enforce. Every overridden field is written to activity_log as opportunity.admin_field_override with a { field: [before, after] } diff. Partner-org admins get no override — the boundary stays creator-or-Matters.AI-super-admin.
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 "unique"; vocabulary unique | duplicate. Deduplication is enforced pre-insert — registerOpportunity rejects a registration when a matching non-cancelled/non-rejected deal for the same customer already exists — so every row that actually persists is "unique", which is why the column default matches the only value the writer sets.
/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 (COALESCE(SUM(expectedArrUsd), 0), rendered in $k — the COALESCE matters now that ARR is nullable).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.Field-level locking makes "which deals still need qualification detail?" an operationally useful question — internal staff need to see which registrations are missing enrichment so they can chase the partner.
The column shows n/10: how many of the ten OPTIONAL_FIELDS are populated. The denominator comes from the same exported list the locking logic uses, so the listing and the edit form cannot disagree about what counts as filled. The count is a SQL CASE expression added to the existing select, so it costs no extra round trip and transfers no extra columns. Text columns count as blank when NULL or ''; numeric columns only when NULL.
The ?completeness=incomplete chip narrows the list to deals with any unfilled optional field. It toggles independently of the status chips and survives search and pagination.
Precedence: the predicate is an
ORchain, explicitly parenthesised as a whole.ANDbinds tighter thanORin SQL, so an unwrapped chain would leak out of the surroundingand()and match rows the status filter had excluded.
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.
A partner can request a 30-day extension on a deal's expectedCloseDate, which an internal reviewer grants. Two timestamp columns back it: extensionRequestedAt and extensionGrantedAt.
Partner side — requestExtension(opportunityId) in lib/actions/opportunities.ts: requirePartnerSession(), org-ownership check, refuse if the deal is cancelled, then stamp extensionRequestedAt. Surfaced by the ExtensionCard in the deal-detail aside, which renders one of three states — request (a button), pending (after a request), or granted (with the new close date).
Admin side — grantExtension(opportunityId) in lib/actions/admin.ts: requireAdminWriteSession(), requires a pending extensionRequestedAt and a non-cancelled deal, then pushes expectedCloseDate out 30 days and stamps extensionGrantedAt. It re-asserts extensionRequestedAt IS NOT NULL AND extensionGrantedAt IS NULL in the UPDATE ... WHERE so a double-submit can't grant twice (which would push the date +60 days and re-fire the email). It then notifies the partner (deal_status), emails the submitter via sendExtensionGrantedEmail, and audits. Surfaced as a Grant button in the Extension column of /admin/opportunities.
Both the in-portal notification link and the
revalidatePathtarget the deal detail route byopportunities.id(a UUID) — notportalLeadId, which is what the route resolves by; using the wrong id would 404 the notification click.
Rendered through @matters/email (see Email Design System) by the pure builders in lib/email-templates.ts, 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.
Internal alerts. These two templates are the partner-facing emails. Deal registration additionally fires the
deal.registeredevent through the Notification Engine, which alerts Matters.AI staff over in-app, email, and Slack according to super-admin-configured routing — closing the "notify the internal channel/sales team of new registrations" requirement WEB-1 had carried unmet since 2026-06-01. Extension requests, MDF submissions, and failed Salesforce sync retries route the same way.
| 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.admin_field_override | updateOpportunity | targetId = portalLeadId | { diff: { field: [before, after] } } — emitted only when a super admin changes an already-submitted value |
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