Files
twenty/packages/twenty-apps/internal/twenty-partners
Rashad Karanouh 24533b510c feat(twenty-partners): marketplace v2 — Application-driven matching workspace (#21816)
## Summary

Restructures the Twenty Partners app into an **Application-driven
matching workspace**: leads post briefs (Opportunities), partners browse
and **self-apply**, admins review applications and assign a winner. The
candidacy funnel lives on `Application.state`; the deal lifecycle lives
on the stock Opportunity `stage`.

Version **1.0.0** — **breaking**: removes the legacy `matchStatus` field
and the auto-match flow. Prod upgrade path is **uninstall → deploy →
install** (not an in-place upgrade).

## How "Apply" works — no workflow, no special permission

Partners apply by **creating an Application directly** from a listed
brief (a normal record write, governed by the Application object
permission). The `on-application-created` logic function (shipped in the
manifest) then resolves the partner from `createdBy`, sets `state =
APPLIED`, stamps `partner`/`partnerUser`/`lastActivityAt`, and dedupes
by (opportunity, partner).

- **No `WORKFLOWS` permission flag.** An earlier iteration used a manual
"Apply" workflow, but running a manual workflow requires the `WORKFLOWS`
flag, which **cannot be granted on an app-owned role** (the manifest
sync drops `role → permissionFlag` links, and the metadata API rejects
out-of-band grants on app roles). Self-apply via record-create sidesteps
this entirely and is prod-viable as-is.
- `Application.state` **defaults to `APPLIED`** so a partner never sees
a misleading "Invited" flicker while the async handler runs. Admin
invites set `INVITED` explicitly.

## ⚠️ Manual setup after install (per workspace)

1. **`yarn rls:configure`** — applies the partner row-level predicates
and verifies field-locks (predicates can't ship in the manifest).
Required for partner scoping.
2. **"Mark as Winner" workflow** — one manual-trigger workflow on
**Application** → *Update Record* that sets `Opportunity.partner`, which
drives the WON/BACKUP cascade. Admins run it (admins bypass the flag via
`canUpdateAllSettings`); equivalent to editing the Opportunity's
`partner` field directly. Steps in `src/workflows/README.md` (the
**Apply** section there is superseded by self-apply).

## What's included

- **Data model:** new `BACKUP` Application state; symmetric cascade
owned by `on-opportunity-partner-won` — assign → winner `WON`, other
applicants `BACKUP`; unassign → all reopen to `APPLIED`.
`Opportunity.partner` is the single source of truth.
- **Removed:** `matchStatus` field + `on-opportunity-auto-match` (dead).
Deal lifecycle now on the stock `stage`.
- **Partner row-level security (B7):** RLS predicates scope partners to
their own `Partner`/`Person`/`Company`/`Application` rows; `Opportunity`
is `(partnerUser IS me) OR (isListed = true)` so listed briefs are
visible to all partners; `Application` is `(partnerUser IS me) OR
(lastActivityAt IS EMPTY)` — the IS-EMPTY branch lets a partner's own
insert pass (partnerUser is stamped just after insert). Field-locks make
Opportunity `stage`/`amount` and most Application fields read-only for
partners (pitch stays editable). Applied via `yarn rls:configure`.
- *Trade-off:* an unstamped application (lastActivityAt null) is briefly
readable by any partner — sub-second window, permanent only if the
handler fails to stamp. Acceptable for an internal marketplace; the
front-component Apply path (below) would remove it.
- **Idempotency:** `on-application-created` dedupes duplicate
applications by (opportunity, partner).
- **Views & navigation**, reorganized into sections:
- **Partner Workspace:** Open Briefs · My Applications · My Profile · My
Deals
- **Matching Admin:** Briefs to Match · Deals (board) · Applications ·
Applications by Opportunity · All Opportunities · Follow-up Applications
· Follow-up Briefs
- **Partners:** per Stage · per Country · Partner Applications ·
Validated (per-group COUNT)
- Opportunity & Partner record **side panels** via FIELDS_WIDGET views
(surface relations incl. `applications`, so a brief shows all its
applications).

## Known issues / follow-ups

- **Pre-existing failing unit test (not introduced here):**
`on-partner-application-created › "posts a Discord embed when an
APPLICATION-sourced partner is created"` — the handler/test are
byte-identical to base; tracked separately.
- **Follow-up views** lack the "older than 7 days" staleness filter — no
confirmed relative date operand in this Twenty version (TODOs left in
the views).
- **Apply UX (future):** a front-component "Apply" button on the brief,
calling an authenticated `/s/apply-to-brief` logic function (runs as the
app), would replace the "create a record" entry point — nicer UX, and it
removes the RLS IS-EMPTY trade-off. Not required to ship.

## Testing

- Partner self-apply verified end-to-end as a partner (create
application from a brief → lands on `APPLIED`).
- Unit tests for the WON/BACKUP cascade + application handlers pass (the
one failing test above is the pre-existing, unrelated Discord handler).
- Lint clean (`oxlint`).

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/21816?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-light.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-06-23 05:53:00 +00:00
..

twenty-partners

A Twenty app that turns the CRM into the operating system for the Twenty partner program: intake partner-eligible deals, match them to vetted marketplace partners, and track the matching pipeline end-to-end.

Built on Twenty with twenty-sdk v2.5.

What's inside

  • Custom object: Partner — slug, status, availability, served geos, languages spoken, deployment expertise, Calendly link, last-match timestamp. See src/objects/partner.object.ts.
  • Opportunity extensionsmatchStatus, designDocStatus, introSentAt, lastRelanceSentAt, tftId, plus a partner relation.
  • Logic functions
    • on-opportunity-auto-match — fires when matchStatus is set to AUTO_MATCH. Assigns the longest-idle available partner and flips status to MATCHED. If no partner is available, hands off to MANUAL_MATCH with an audit Note explaining why.
    • list-available-partners — surfaces matchable partners for a given opportunity.
    • post-install — first-run setup.
  • Roles (src/roles/)
    • Twenty Partner Ops — internal team role, full CRUD on Partner/Company/Person/Opportunity.
    • Partner — placeholder external-partner role. Do not assign until Twenty ships row-level permissions — it currently grants access to every record.
  • Views (src/views/)
    • Waiting for match — opportunities awaiting human action (matchStatus is TO_BE_MATCHED or MANUAL_MATCH).
    • Matches overview — full matching funnel grouped by matchStatus (configure Kanban grouping manually in the UI).
    • Opportunities — replacement of the native opportunities view with the partner columns.
    • Partners and All matched deals — partner-side index and deal log.
  • Sidebar nav — surfaced in workflow order: Waiting for match, All partner deals, Matches overview, Partners, Opportunities.
  • Seed scripts (src/scripts/) — populate a fresh workspace with realistic demo data.

Match status pipeline

matchStatus is a non-nullable SELECT field with a default of TO_BE_MATCHED. The 10 states follow the deal lifecycle:

Status Meaning
TO_BE_MATCHED Default — deal entered, awaiting assignment
MANUAL_MATCH Needs a human to pick a partner
AUTO_MATCH Triggers automatic partner assignment
MATCHED Partner assigned
INTRODUCED_TO_A_PARTNER Customer intro sent
WORKING_WITH_A_PARTNER Engagement underway
IMPLEMENTING Active implementation
WON Deal closed won
RECONNECT_LATER Paused — reconnect in future
LOST Deal closed lost

Getting started

Requires a local Twenty server at http://localhost:2020 and Node ^24.5.

yarn install
yarn twenty dev

Default dev credentials: tim@apple.dev / tim@apple.dev.

Run yarn twenty help for the full CLI reference.

Common commands

Command What it does
yarn twenty dev Start the dev server and sync the app on file changes
yarn twenty server status Check the local Twenty server
yarn lint / yarn lint:fix Run oxlint
yarn test Run integration tests (vitest.config.ts)

Seeding demo data

Two idempotent seed scripts. Both run via the vitest.seed.config.ts config that skips the global app uninstall/reinstall.

# 1. Marketplace partners (run first — pipeline seed wires opportunities to these by slug)
yarn vitest run --config vitest.seed.config.ts src/scripts/seed-marketplace-partners.ts

# 2. Pipeline demo: 3 companies, 3 people, 15 opportunities spread across matchStatus values
yarn vitest run --config vitest.seed.config.ts src/scripts/seed-pipeline-demo.ts

Both scripts skip records that already exist (by slug, name, or firstName+lastName), so they are safe to re-run.

Known limitations

Current SDK gaps blocking further polish:

  • Custom Partner record page layout (RECORD_TABLE has no relation scoping).
  • Native Opportunities view column-order override.
  • Kanban view configuration from app code (ViewType.KANBAN is currently ignored).
  • App and field descriptions.

Learn more