Files
twenty/packages/twenty-apps/internal
Rashad Karanouh fea06bdd4b v1.5.1 — partners: Discord notification for client briefs + referring-partner attribution (#23344)
**Merge after #23295.** Targets `main`, but must land second: #23295
bumps `1.3.2 → 1.4.0`, and this bumps `1.4.0 → 1.5.1`. Merging this
first would leave `main` at 1.5.1 and make #23295's bump conflict and
regress the version. `package.json` is the only file the two branches
share.

App version: **v1.5.1**.

## What this does

Posts a Discord notification when a client brief is submitted through
the public marketplace form, and records which partner's profile page
the brief came from.

A visitor can reach the brief form from the marketplace listing page or
from a specific partner's profile. Until now that context was lost. This
adds a `referredByPartner` relation on Opportunity so the attribution is
a queryable CRM fact rather than a line in a chat message.

## How it works

`submitClientBrief` resolves the incoming `partnerSlug` to a Partner,
sets the relation on create, then posts the embed inline.

Inline rather than an `opportunity.created` database trigger, because
that event cannot distinguish a brief from a TFT import — both are
created by logic functions and both carry `createdBy.source ===
'APPLICATION'`. A trigger would need a discriminator like "source is
APPLICATION and `tftOpportunityId` is empty", which silently breaks the
day a third logic function creates an Opportunity.

The cost of going inline is that the Discord call sits in the visitor's
request, so it uses a 3s timeout rather than the trigger path's 8s, and
every failure is swallowed — a dead webhook can never turn a submitted
brief into a failed one.

## Notable decisions

- **Slug resolution ignores `validationStage` and `availability`**,
unlike the marketplace profile query. If someone submitted a brief from
a partner's page, that partner referred it, even if they go unavailable
a minute later. Filtering would silently drop real attribution.
- **An unresolved slug never fails the brief.** It logs a warning,
leaves the relation unset, and still notifies. A brief is a sales lead;
losing one over an attribution field the visitor never saw would be a
bad trade.
- **`referredByPartner` is separate from the existing `partner` field.**
One is who sent the lead, the other is who works it.
- **The Discord connector moved to `modules/shared/connector/`.** Two
domains now need it, and `AGENTS.md` forbids importing logic sideways
between domains. `postWebhook` gained `label` and `timeoutMs`
parameters; the transport is otherwise unchanged.
- Reuses the existing `DISCORD_WEBHOOK_URL` and
`PARTNER_APP_FRONTEND_URL` variables — no new configuration to set on
prod.

## Permissions

`partner.role.ts` locks the new Opportunity field.
`configure-partner-rls.ts` treats its skip-list as a closed allowlist of
system columns, so an unlocked new field is reported as a discrepancy.

Note that Opportunity RLS for partners is `(partnerUser IS me) OR
(isListed = true)`, so on a **listed** brief any partner can read
`referredByPartner` — i.e. see that a competitor referred it. Called out
deliberately; happy to restrict it if that's not wanted.

## Testing

8 unit tests for the embed mapper (partner present/absent, truncation,
absent optionals, no email in the payload, inline-row padding) and 4 for
the schema. Full suite: 188 passing, lint clean.

Verified end to end against a local workspace with a real Discord
webhook. All three paths return `ok: true`; the persisted relation was
confirmed via GraphQL rather than inferred from the status code:

| Submission | `referredByPartner` |
|---|---|
| valid slug | linked to the partner |
| no slug | `null`, embed reads "Marketplace listing" |
| unknown slug | `null`, brief still succeeds |

## Follow-up, not in this PR

`yarn rls:configure` fails before reaching its field-lock check — its
retry path strips `predicateGroups` but the predicates still carry
`rowLevelPermissionPredicateGroupId`, so the retry fails identically.
Pre-existing and unrelated to this change (`configure-partner-rls.ts` is
untouched here), but it means the script cannot currently verify the
lock on a fresh workspace.

The website side that sends `partnerSlug` is #23351. Until it ships,
this is inert: no caller sends the field, and briefs behave exactly as
before. Merge this one first — #23351 is the sender, this is the
receiver.


<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23344?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-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-07-28 09:42:23 +00:00
..