snapshot of https://github.com/twentyhq/twenty.git (main, 2026-09-17; blobs > 131072B omitted)
Some checks failed
App Prod-Parity E2E Dispatch / dispatch (push) Failing after 10s
CD Storybook UI / build (push) Failing after 3m25s
CD Storybook UI / dispatch (push) Has been skipped
CD deploy main / website-changed-files (push) Successful in 18m8s
CI Agent Skills / changed-files-check (push) Successful in 18m9s
CI Create App E2E minimal / changed-files-check (push) Successful in 24s
CI Create App / changed-files-check (push) Successful in 26s
CI Docs / changed-files-check (push) Successful in 26s
CI Emails / changed-files-check (push) Successful in 28s
CI Example App Hello World / changed-files-check (push) Successful in 23s
CI Front / changed-files-check (push) Has been skipped
CI Example App Postcard / changed-files-check (push) Successful in 27s
CI UI / changed-files-check (push) Has been skipped
CI Utils Test / changed-files-check (push) Successful in 45s
CI Twenty Apps / discover (push) Successful in 1m3s
CD deploy main / deploy-main (push) Failing after 3s
CI Agent Skills / agent-skills-validate (push) Has been skipped
CI Create App / create-app-test (lint) (push) Has been skipped
CI Emails / emails-test (push) Has been skipped
CI Front / front-task (lingui:compile) (push) Has been skipped
CI Front / front-task (lint) (push) Has been skipped
CI Example App Postcard / example-app-postcard (push) Has been skipped
CI UI / ui-task (lint) (push) Has been skipped
CI UI / ui-task (pack-size) (push) Has been skipped
CI UI / ui-task (size) (push) Has been skipped
CI UI / ui-task (test) (push) Has been skipped
CI UI / ui-task (typecheck) (push) Has been skipped
CI Utils Test / utils-test (push) Has been skipped
CI Twenty Apps / ${{ matrix.app.name }} (push) Has been skipped
CI Emails / ci-emails-status-check (push) Successful in 2s
CI Example App Hello World / ci-example-app-hello-world-status-check (push) Successful in 2s
CI Example App Postcard / ci-example-app-postcard-status-check (push) Successful in 3s
CI UI / ui-sb-test (push) Has been skipped
CI Utils Test / ci-utils-test-status-check (push) Successful in 2s
CI Twenty Apps / ci-twenty-apps-status-check (push) Successful in 2s
CI UI / ci-ui-status-check (push) Successful in 2s
CI Desktop / desktop (push) Failing after 1m8s
Push docs to Crowdin / Push documentation to Crowdin (push) Failing after 55s
Push translations to Crowdin / Extract and upload translations (push) Failing after 5s
Push website translations to Crowdin / Extract and upload website translations (push) Failing after 5s
CI Create App / create-app-test (test) (push) Has been skipped
CI Create App / create-app-test (typecheck) (push) Has been skipped
CI Docs / docs-lint (push) Has been skipped
CI Example App Hello World / example-app-hello-world (push) Has been skipped
CI Front / front-task (lingui:extract) (push) Has been skipped
CI Front / front-task (test) (push) Has been skipped
CI Front / front-task (typecheck) (push) Has been skipped
CI UI / ui-sb-build (push) Failing after 1m32s
CI Twenty Apps / ${{ matrix.app.name }} (${{ matrix.server-source }}) (push) Has been skipped
CI Agent Skills / agent-skills-publish (push) Has been skipped
CI Create App / ci-create-app-status-check (push) Successful in 2s
CI Agent Skills / agent-skills-remote-source (push) Has been skipped
CI Create App E2E minimal / create-app-e2e-minimal (push) Has been cancelled
CI E2E Main / e2e-test (push) Has been cancelled
CI Front / front-sb-build (push) Has been cancelled
CI Front / front-build (push) Has been cancelled
CI Create App E2E minimal / ci-create-app-e2e-minimal-status-check (push) Has been cancelled
CI E2E Main / ci-e2e-main-status-check (push) Has been cancelled
CI E2E Main / notify-main-ci-failure (push) Has been cancelled
CI E2E Main / qa-scout-comment (push) Has been cancelled
CI Front / front-sb-test (push) Has been cancelled
CI Front / front-sb-screenshots (push) Has been cancelled
CI Front / ci-front-status-check (push) Has been cancelled
DPA Sub-Processor Sync / sync-subprocessors (push) Failing after 3s
AI Catalog Sync / sync-catalog (push) Failing after 3s

This commit is contained in:
upstream-mirror
2026-09-17 23:28:13 +05:00
commit e7da97194a
32688 changed files with 4208681 additions and 0 deletions

View File

@@ -0,0 +1,6 @@
# Credentials for integration tests (vitest.config.ts) and seed scripts
# (vitest.seed.config.ts). Copy this file to .env.local and fill in the key.
# Get an API key from the Twenty UI: Settings -> APIs & Webhooks.
# .env.local is gitignored; never commit a real key.
TWENTY_API_URL=http://localhost:2020
TWENTY_API_KEY=

View File

@@ -0,0 +1,49 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn
# codegen
generated
# testing
/coverage
# dev
/dist/
.twenty
# production
/build
# misc
.DS_Store
# internal planning docs
docs/superpowers/
**/docs/superpowers/
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
!.env.example
# typescript
*.tsbuildinfo
*.d.ts
# local graphify index (never commit — run `graphify update .` locally)
graphify-out/
.superpowers/
docs/

View File

@@ -0,0 +1 @@
24.5.0

View File

@@ -0,0 +1,58 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"extends": ["../../.oxlintrc.base.json"],
"plugins": ["react", "typescript", "import", "unicorn"],
"categories": {
"correctness": "off"
},
"ignorePatterns": ["node_modules", "dist"],
"rules": {
"func-style": ["error", "declaration", { "allowArrowFunctions": true }],
"no-console": "off",
"no-control-regex": "off",
"no-debugger": "error",
"no-duplicate-imports": "error",
"no-undef": "off",
"no-unused-vars": "off",
"no-redeclare": "off",
"import/no-duplicates": "error",
"typescript/no-redeclare": "error",
"typescript/ban-ts-comment": "error",
"typescript/consistent-type-imports": [
"error",
{
"prefer": "type-imports",
"fixStyle": "inline-type-imports"
}
],
"typescript/explicit-function-return-type": "off",
"typescript/explicit-module-boundary-types": "off",
"typescript/no-empty-object-type": [
"error",
{
"allowInterfaces": "with-single-extends"
}
],
"typescript/no-empty-function": "off",
"typescript/no-explicit-any": "off",
"typescript/no-unused-vars": [
"warn",
{
"vars": "all",
"varsIgnorePattern": "^_",
"args": "after-used",
"argsIgnorePattern": "^_"
}
],
"react/no-unescaped-entities": "off",
"react/prop-types": "off",
"react/jsx-key": "off",
"react/display-name": "off",
"react/jsx-uses-react": "off",
"react/react-in-jsx-scope": "off",
"react/jsx-no-useless-fragment": "off",
"react/jsx-props-no-spreading": ["error", { "explicitSpread": "ignore" }],
"react-hooks/rules-of-hooks": "error",
"react-hooks/exhaustive-deps": "warn"
}
}

View File

@@ -0,0 +1 @@
nodeLinker: node-modules

View File

@@ -0,0 +1,149 @@
# Architecture — twenty-partners
Vertical-slice modules, one folder per domain. Adapted from `twentyhq/twenty-eng`
(the largest Twenty SDK app), which migrated flat → modular as it grew. Repo-wide
conventions (kebab-case files, named exports, no `any`, `types` over `interface`,
short `//` comments) live in the root `CLAUDE.md` and still apply — this file only
adds the partners-specific structure.
> **Status:** migration complete — every domain lives under `src/modules/`. The only
> top-level `src/` entries left are `constants/universal-identifiers.ts`, `scripts/`,
> `roles/`, `skills/`, `workflows/`, `__tests__/`, and the two root config files. Add new
> code to a domain module, never a flat top-level `src/<primitive>/` folder.
## Layout
```
src/
application-config.ts # app + application variables — unchanged
default-role.ts # unchanged
constants/universal-identifiers.ts # ⚠️ MUST NOT MOVE (see Invariants)
scripts/ # ⚠️ MUST NOT MOVE — seed/purge/rls/slugify
roles/ # app-wide RLS — stays at root (spans every object)
skills/ # bundled Claude skills — unchanged
workflows/ # bundled workflows — unchanged
__tests__/helpers/ # shared test setup (client, fixtures, cleanup)
modules/
shared/ # cross-cutting: secret-guard (http/), find-or-create, paginate,
# utils, front-components, cross-domain nav folders
<domain>/ # partner · application · opportunity · … (large domains add a <feature>/ tier)
<name>.logic-function.ts # thin SDK entrypoints — sit at the domain/feature root, NOT in a subfolder
objects/ fields/ view-fields/ views/ navigation-menu-items/ page-layouts/ # declarations
constants/ # value lists + declaration UUID maps (NOT types)
services/ # business logic (testable)
graphql/{queries,mutations}/ # the only place raw queries/mutations live
connector/ # OUTBOUND third-party API per folder (<name>.connector.ts · config · types)
mappers/ utils/ types/ front-components/
```
A domain is a self-contained slice: its declarations (`objects`, `fields`, `views`, …)
**and** its logic (`logic-functions` → `services` → `graphql`/`connector`) live together.
Adding a feature means adding a folder, not scattering files across ten flat piles.
**Feature tier for large domains:** when a domain holds several distinct features, insert a
`<feature>/` level — `modules/<domain>/<feature>/<primitive>/` (the twenty-eng standard,
e.g. `modules/code-build/build-task/services/`). It keeps `graphql/` and `services/` from
becoming flat piles. Partner uses `partner/{self-service,marketplace,application-intake,
directory}/…`; opportunity uses `opportunity/{intake,matching}/…`. A small single-feature
domain (application) may keep `<domain>/<primitive>/` directly.
## The dependency rule (the one that matters — never import upward)
```
logic-functions → services → { graphql, connector, mappers } → shared · utils · types
```
- **Entrypoints (`*.logic-function.ts`, at the domain/feature root)** — SDK entrypoint
ONLY. Parse input, call one service, return. No business logic, no raw GraphQL, no
external HTTP. >~40 lines means it's doing a service's job — extract it.
- **`services/`** — all business logic. Testable, no SDK/transport coupling. Must **not**
import a `logic-function`.
- **`graphql/`** — the ONLY place raw queries/mutations live (named typed operations,
not inline `client.query(...)`).
- **`connector/`** — the ONLY place **outbound** third-party APIs are called; one folder
per API (`<name>.connector.ts` / `config.ts` / `types.ts`). **Inbound webhooks are NOT connectors** —
a webhook the app *receives* is an ordinary `logic-functions/` entrypoint guarded by the
shared secret-guard util. (Today the one real connector is Discord; TFT/client-brief/
partner-application are inbound.)
- Never import **logic** sideways between domains — share via `modules/shared/`. (A relation
field naturally referencing its target object's ID constant is a schema reference, not a
logic dependency — that cross-module import is allowed.)
## Naming
`<name>.<primitive>.ts` — e.g. `submit-application.logic-function.ts`,
`resolve-candidacy.service.ts`, `partner.object.ts`. Files are kebab-case (no PascalCase,
even for React components: `profile-picture-upload.tsx`).
Tests co-locate with their subject and split by kind: `<name>.test.ts` = unit (mocked
client, fast, no infra); `<name>.integration-test.ts` = real workspace (needs a live
server + global setup; excluded by tsconfig). The split is a selectable vitest **project**
(`--project unit`), not a file-count mandate — one `vitest.config.ts` with two projects is
preferred. Don't rename a mocked unit test to `.integration-test.ts`. Shared test setup
lives in `src/__tests__/helpers/`.
## Invariants (moving code must not break these)
- The set of `universalIdentifier` UUIDs must stay **byte-identical** across a move —
the SDK tracks primitives by identifier, not path, so a pure `git mv` is safe but a
changed/dropped UUID re-registers or orphans an object.
- **Do not move** `src/constants/universal-identifiers.ts` — the env-toolkit rewrites it
by path per bundle.
- **Do not move** `src/scripts/*` — `package.json` invokes them by path.
- **Keep importers of `src/constants/universal-identifiers.ts` pointing at the root file** —
don't relocate its UUID exports into a module.
- **Never split a relation across domains with a dangling ID import.** Paired relation
fields export/import each other's field-ID constant; keep both sides of a relation in one
domain, or hoist the shared ID into `shared/`.
- One domain per commit when migrating (`git mv` only, imports fixed in the same commit).
## Application review playbook
The Matching Admin Workspace playbook is a standalone page in
`src/modules/partner/application-intake/` (front-component + page layout +
nav item). It must stay in lockstep with real behaviour. When you change any
of the following, update the playbook in the same change:
- Website form fields that land on Partner
- The Partner Applications view (filter, columns, sort)
- `validationStage` values used as the review decision
- Intake side effects the reviewer needs to know (Discord on create;
welcome email on `VALIDATED`, linked from
`WELCOME_EMAIL_WORKFLOW_URL`)
- Marketplace ranking bonuses in
`packages/twenty-website/src/partners-marketplace/completeness-score.ts`
(case study, cover, introduction, service, picture, calendar, rate,
category, ghost rule)
Facts on the page come from the same constants the view and intake use. Do
not leave stale steps. Do not keep a second copy on twenty.com or in a Note.
## Opportunity process playbooks
How to process (Matching Admin) and How to apply (Partner Workspace) are
standalone pages under `src/modules/opportunity/how-to-process/`. They must
stay in lockstep with real operator behaviour. Update the matching page and
its copy test in the same change when you change any of:
- Intake (marketplace brief already Listed + Discord; Twenty Internal
unlisted; `/twenty-lead-brief` writes Design Doc URL)
- Listing (`Listed` flip → Discord; daily digest; Open Briefs membership)
- Intro (`Intro Sent At` turns Listed off; Invited applications)
- Win (set Partner → Won / Declined; Backup stays; unassign reopens Won
to Applied; Deals stages New → Screening → Meeting → Proposal → Customer)
- Apply (Open Briefs listed-only; pitch minimum; one pitch; state meaning)
- Bundled skills (`twenty-lead-brief`, `twenty-partner-shortlist`,
`twenty-partner-intro`): local run, expected output, GitHub URL of
`SKILL.md`
- Match via MCP: the operator can ask the agent to write CRM changes
Pages stay operator language. Skill names, slash triggers, and GitHub URLs
of those bundled skills are allowed on How to process. Open Briefs and
My Applications links are required on How to apply. Do not open Apply
from the standalone page — Apply is pinned on the brief record.
## Where does new code go?
External API call → `connector/`. Raw query/mutation → `graphql/`. Any logic →
`services/`. New SDK trigger/function → a thin `*.logic-function.ts` at the domain/feature
root that calls a service. Shared across domains → `modules/shared/`.

View File

@@ -0,0 +1 @@
@AGENTS.md

View File

@@ -0,0 +1,27 @@
# Twenty Partners
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.
## What's inside
- **Partner object** — slug, status, availability, served geographies, languages spoken, deployment expertise, Calendly link, and last-match timestamp.
- **Opportunity extensions** — match status, design-doc status, intro and relance timestamps, and a relation to the matched partner.
- **Automatic matching** — when an opportunity is set to auto-match, the longest-idle available partner is assigned and the deal is marked matched; if none is available it is handed off for manual matching with an explanatory note.
- **Views** — a waiting-for-match queue, a matching-funnel overview grouped by status, the partner index, and a log of matched deals, all surfaced in the sidebar.
## Match status pipeline
`matchStatus` follows 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 |

View File

@@ -0,0 +1,47 @@
{
"name": "twenty-partners",
"version": "1.8.3",
"license": "MIT",
"engines": {
"node": "^24.5.0",
"npm": "please-use-yarn",
"yarn": ">=4.0.2",
"twenty": ">=2.31.0"
},
"keywords": [],
"packageManager": "yarn@4.13.0",
"scripts": {
"twenty": "twenty",
"lint": "oxlint -c .oxlintrc.json .",
"lint:fix": "oxlint --fix -c .oxlintrc.json .",
"test": "vitest run",
"test:unit": "vitest run --project unit",
"test:integration": "vitest run --project integration",
"test:watch": "vitest",
"seed": "tsx src/scripts/seed.ts",
"seed:prod": "ENV_FILE=.env.prod tsx src/scripts/seed.ts",
"purge": "tsx src/scripts/purge-soft-deleted.ts",
"purge:prod": "ENV_FILE=.env.prod tsx src/scripts/purge-soft-deleted.ts",
"rls:configure": "tsx src/scripts/configure-partner-rls.ts",
"rls:configure:prod": "ENV_FILE=.env.prod tsx src/scripts/configure-partner-rls.ts"
},
"dependencies": {
"react-markdown": "^10.1.0",
"twenty-client-sdk": "2.31.0",
"twenty-sdk": "2.31.0",
"zod": "^4.1.11"
},
"devDependencies": {
"@types/node": "^24.7.2",
"@types/react": "^19.0.0",
"@types/react-dom": "^19",
"dotenv": "^16.0.0",
"oxlint": "^0.16.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"tsx": "^4.0.0",
"typescript": "^5.9.3",
"vite-tsconfig-paths": "^4.2.1",
"vitest": "^4.0.0"
}
}

View File

@@ -0,0 +1,74 @@
// Seed file: ensures at least one ACTIVE + AVAILABLE partner exists before
// the matching integration tests run. Idempotent — skips if already seeded.
import { CoreApiClient } from 'twenty-client-sdk/core';
import { beforeAll, describe, it } from 'vitest';
describe('seed: marketplace partners', () => {
let client: CoreApiClient;
beforeAll(() => {
client = new CoreApiClient();
});
it('ensures at least one ACTIVE AVAILABLE partner exists', async () => {
const existingResult = await client.query({
partners: {
__args: {
filter: { validationStage: { eq: 'VALIDATED' }, availability: { eq: 'AVAILABLE' } },
first: 1,
},
edges: { node: { id: true } },
},
} as any);
const existing = (existingResult as any).partners.edges;
if (existing.length > 0) {
console.log('[seed] partner already exists, skipping');
return;
}
const PARTNERS = [
{
slug: 'nine-dots-ventures',
name: 'Nine Dots Ventures',
introduction: 'Boutique CRM implementer specialising in real-estate workflows.',
calendlyLink: 'https://calendly.com/placeholder',
deploymentExpertise: ['CLOUD', 'SELF_HOST'],
servedGeos: ['EUROPE', 'MENA'],
languagesSpoken: ['ENGLISH', 'FRENCH'],
},
{
slug: 'elevate-consulting',
name: 'Elevate Consulting',
introduction: 'Revenue-operations partner for B2B SaaS teams.',
calendlyLink: 'https://calendly.com/placeholder',
deploymentExpertise: ['CLOUD'],
servedGeos: ['US', 'LATAM'],
languagesSpoken: ['ENGLISH', 'SPANISH'],
},
];
for (const p of PARTNERS) {
const r = await client.mutation({
createPartner: {
__args: {
data: {
name: p.name,
slug: p.slug,
introduction: p.introduction,
calendarLink: { primaryLinkUrl: p.calendlyLink },
deploymentExpertise: p.deploymentExpertise,
region: p.servedGeos,
languagesSpoken: p.languagesSpoken,
validationStage: 'VALIDATED',
availability: 'AVAILABLE',
},
},
id: true,
name: true,
},
} as any);
console.log('[seed] created', (r as any).createPartner.name);
}
});
});

View File

@@ -0,0 +1,87 @@
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { appDevOnce, appUninstall } from 'twenty-sdk/cli';
const APP_PATH = process.cwd();
const CONFIG_DIR = path.join(os.homedir(), '.twenty');
function validateEnv(): { apiUrl: string; apiKey: string } {
const apiUrl = process.env.TWENTY_API_URL;
const apiKey = process.env.TWENTY_API_KEY;
if (!apiUrl || !apiKey) {
throw new Error(
'TWENTY_API_URL and TWENTY_API_KEY must be set.\n' +
'Start a local server: yarn twenty server start\n' +
'Or set them in vitest env config.',
);
}
return { apiUrl, apiKey };
}
async function checkServer(apiUrl: string) {
let response: Response;
try {
response = await fetch(`${apiUrl}/healthz`);
} catch {
throw new Error(
`Twenty server is not reachable at ${apiUrl}. ` +
'Make sure the server is running before executing integration tests.',
);
}
if (!response.ok) {
throw new Error(`Server at ${apiUrl} returned ${response.status}`);
}
}
function writeConfig(apiUrl: string, apiKey: string) {
const payload = JSON.stringify(
{
remotes: {
local: { apiUrl, apiKey, accessToken: apiKey },
},
defaultRemote: 'local',
},
null,
2,
);
fs.mkdirSync(CONFIG_DIR, { recursive: true });
fs.writeFileSync(path.join(CONFIG_DIR, 'config.test.json'), payload);
}
export async function setup() {
const { apiUrl, apiKey } = validateEnv();
await checkServer(apiUrl);
writeConfig(apiUrl, apiKey);
await appUninstall({ appPath: APP_PATH }).catch(() => {});
const result = await appDevOnce({
appPath: APP_PATH,
onProgress: (message: string) => console.log(`[dev] ${message}`),
});
if (!result.success) {
throw new Error(
`Dev sync failed: ${result.error?.message ?? 'Unknown error'}`,
);
}
}
export async function teardown() {
const uninstallResult = await appUninstall({ appPath: APP_PATH });
if (!uninstallResult.success) {
console.warn(
`App uninstall failed: ${uninstallResult.error?.message ?? 'Unknown error'}`,
);
}
}

View File

@@ -0,0 +1,56 @@
import { CoreApiClient } from 'twenty-client-sdk/core';
// Integration tests authenticate with the workspace API key, which the server
// rate-limits per workspace (API_RATE_LIMITING_LONG_LIMIT requests per window).
// The full suite issues more API calls than one window allows, so later calls
// would fail with "Limit reached". The server refuses a throttled request
// without consuming a token and its bucket refills continuously, so waiting
// briefly frees a token. Transparently retry rate-limited SDK calls after a
// short delay — patching the client prototype once covers every test.
const RATE_LIMIT_MAX_RETRIES = 30;
const RATE_LIMIT_RETRY_DELAY_MS = 700;
const sleep = (ms: number): Promise<void> =>
new Promise((resolve) => setTimeout(resolve, ms));
const isRateLimitError = (error: unknown): boolean => {
const message = error instanceof Error ? error.message : String(error);
return /limit reached|tokens per/i.test(message);
};
type AsyncMethod = (...args: unknown[]) => Promise<unknown>;
const RATE_LIMIT_PATCHED = Symbol.for('twenty-partners.rateLimitRetryPatched');
const patchMethod = (methodName: 'query' | 'mutation' | 'uploadFile'): void => {
const prototype = CoreApiClient.prototype as unknown as Record<
string,
AsyncMethod
>;
const original = prototype[methodName];
if (typeof original !== 'function') return;
prototype[methodName] = async function rateLimitRetry(...args: unknown[]) {
let attempt = 0;
for (;;) {
try {
return await original.apply(this, args);
} catch (error) {
if (!isRateLimitError(error) || attempt >= RATE_LIMIT_MAX_RETRIES) {
throw error;
}
attempt += 1;
await sleep(RATE_LIMIT_RETRY_DELAY_MS);
}
}
};
};
const clientConstructor = CoreApiClient as unknown as Record<symbol, boolean>;
if (!clientConstructor[RATE_LIMIT_PATCHED]) {
patchMethod('query');
patchMethod('mutation');
patchMethod('uploadFile');
clientConstructor[RATE_LIMIT_PATCHED] = true;
}

View File

@@ -0,0 +1,46 @@
import { CoreApiClient } from 'twenty-client-sdk/core';
import { MetadataApiClient } from 'twenty-client-sdk/metadata';
import { APPLICATION_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
import { describe, expect, it } from 'vitest';
describe('App installation', () => {
it('should find the installed app in the applications list', async () => {
const client = new MetadataApiClient();
const result = await client.query({
findManyApplications: {
id: true,
name: true,
universalIdentifier: true,
},
});
const app = result.findManyApplications.find(
(a: { universalIdentifier: string }) =>
a.universalIdentifier === APPLICATION_UNIVERSAL_IDENTIFIER,
);
expect(app).toBeDefined();
});
});
describe('CoreApiClient', () => {
it('should support CRUD on standard objects', async () => {
const client = new CoreApiClient();
const created = await client.mutation({
createNote: {
__args: { data: { title: 'Integration test note' } },
id: true,
},
});
expect(created.createNote.id).toBeDefined();
await client.mutation({
destroyNote: {
__args: { id: created.createNote.id },
id: true,
},
});
});
});

View File

@@ -0,0 +1,33 @@
import { defineApplication } from 'twenty-sdk/define';
import {
APP_DESCRIPTION,
APP_DISPLAY_NAME,
APPLICATION_UNIVERSAL_IDENTIFIER,
} from 'src/constants/universal-identifiers';
export default defineApplication({
universalIdentifier: APPLICATION_UNIVERSAL_IDENTIFIER,
displayName: APP_DISPLAY_NAME,
description: APP_DESCRIPTION,
applicationVariables: {
PARTNER_APPLICATION_SECRET: {
universalIdentifier: '2026a052-9f01-4d18-b6a7-31c3a5b1c7d2',
description:
"Shared secret required in the X-Application-Secret header on POST /partner-applications. Must match the website route's PARTNER_APPLICATION_SECRET env var. Set per-workspace in Settings → Apps → Twenty Partners → Variables.",
isSecret: true,
},
DISCORD_WEBHOOK_URL: {
universalIdentifier: '7056c98a-e7e1-4dba-8a40-b578f30b3479',
description:
'Discord incoming webhook URL. When set, a notification is posted to this channel each time the application form creates a new Partner, each time the public marketplace form submits a client brief (brief details, contact name and company, and the referring partner), and each time a brief is hand-listed on the marketplace. Leave empty to disable all three. Set per-workspace in Settings → Apps → Twenty Partners → Variables.',
isSecret: true,
},
PARTNER_APP_FRONTEND_URL: {
universalIdentifier: '746e7bd8-8934-414e-95f5-cc266a624616',
description:
'Workspace front-end base URL (e.g. https://partners.twenty.com), used to build the clickable Partner and Opportunity record links in Discord notifications. When empty, notifications are still sent but carry no links. Set per-workspace in Settings → Apps → Twenty Partners → Variables.',
isSecret: false,
},
},
});

View File

@@ -0,0 +1,78 @@
export const APP_DISPLAY_NAME = 'Twenty partners';
export const APP_DESCRIPTION = '';
export const APPLICATION_UNIVERSAL_IDENTIFIER = 'e662fc1f-02c1-41ff-b8ba-c95a447b3965';
export const DEFAULT_ROLE_UNIVERSAL_IDENTIFIER = 'ee18c3f3-ebe7-4c56-ad6d-aad555cc32db';
export const PARTNER_OBJECT_UNIVERSAL_IDENTIFIER = '39101b39-1c16-4148-9e82-45dc271bb90d';
export const PARTNER_CONTENT_OBJECT_UNIVERSAL_IDENTIFIER = '65172140-d377-41c1-a2ae-190e96fb79dd';
export const PARTNER_LINK_OBJECT_UNIVERSAL_IDENTIFIER =
'91c1bf51-f977-4ca5-9974-954fe23d1c1c';
export const PARTNER_SERVICE_OBJECT_UNIVERSAL_IDENTIFIER =
'cc953277-db73-45c5-9e6d-3967f9b7756f';
export const PARTNERS_NAV_UNIVERSAL_IDENTIFIER = '3fe15ab5-e38b-4914-af17-2270b210aeb2';
export const POST_INSTALL_FN_UNIVERSAL_IDENTIFIER = 'f92bad2e-5905-4757-96ee-af9869d4ca0c';
export const ON_PARTNER_APPLICATION_CREATED_FN_UNIVERSAL_IDENTIFIER = '43888cce-a2aa-4100-afbc-59a4f978ce53';
export const ON_PARTNER_LINK_CREATED_FN_UNIVERSAL_IDENTIFIER = 'a80a85f8-e32b-429e-82e9-b71cb5da54cd';
export const ON_PARTNER_SERVICE_CREATED_FN_UNIVERSAL_IDENTIFIER = '20129ceb-981e-43da-a25d-0ff6fbe52060';
export const ON_PARTNER_CONTENT_CREATED_FN_UNIVERSAL_IDENTIFIER = '18d0f329-d4a6-4b82-87ea-b78aa1a73e47';
export const ON_WORKSPACE_MEMBER_CREATED_FN_UNIVERSAL_IDENTIFIER = '30c0857b-acd9-4362-b50c-4557d18a13b0';
export const ON_OPPORTUNITY_INTRO_SENT_FN_UNIVERSAL_IDENTIFIER = 'c55a14db-e8ed-4d08-963a-65c44b50bee9';
export const ON_OPPORTUNITY_LISTED_FN_UNIVERSAL_IDENTIFIER = 'ebbb2911-57e5-4edd-bd93-4a28939115c0';
export const INTRO_SENT_AT_FIELD_UNIVERSAL_IDENTIFIER = 'fcf39b0c-0547-415e-806d-b238131ad7cc';
// Roles (Task 2)
export const PARTNER_ROLE_UNIVERSAL_IDENTIFIER = 'c3c1dc2e-1a08-4de5-abb7-2139b3d99343';
// Opportunity pipeline views + nav (simplified to partner presence; UIDs reused
// from the previous matching views so they update in place rather than re-create).
export const OPPORTUNITIES_NO_PARTNER_VIEW_UNIVERSAL_IDENTIFIER = 'fe11e738-6bf3-4714-929c-51c76a3fd050';
// Fresh id (the old matches-overview id '5a8fd51a…' kept its stale KANBAN type +
// view groups on re-sync; a new id forces a clean TABLE recreate).
export const OPPORTUNITIES_WITH_PARTNER_VIEW_UNIVERSAL_IDENTIFIER = '053449b0-3d13-400d-afc2-6182a75b6dff';
export const OPPORTUNITIES_NO_PARTNER_NAV_UNIVERSAL_IDENTIFIER = '00be7449-8927-47c8-a6a1-212d9106587f';
export const OPPORTUNITIES_WITH_PARTNER_NAV_UNIVERSAL_IDENTIFIER = '0cf349c9-fcbf-40f8-8e91-142c02bbde9c';
// Kanban board of with-partner opportunities, grouped by match status.
export const OPPORTUNITIES_WITH_PARTNER_BOARD_VIEW_UNIVERSAL_IDENTIFIER = 'a426c594-513d-4252-808f-3cf26d5532b9';
export const OPPORTUNITIES_WITH_PARTNER_BOARD_NAV_UNIVERSAL_IDENTIFIER = '3226b314-15e5-4182-89fe-b33d341f5684';
// Page layout (Task 6)
export const PARTNER_RECORD_PAGE_UNIVERSAL_IDENTIFIER = 'a888b39e-d64a-48ba-a044-d8cb685fad74';
// All Opportunities view + nav
export const ALL_OPPORTUNITIES_VIEW_UNIVERSAL_IDENTIFIER = '6ce1300b-6e91-4c28-83bb-6f692dbc7a98';
export const ALL_OPPORTUNITIES_NAV_UNIVERSAL_IDENTIFIER = '37944f52-cbe5-4814-a1e6-be5b21425870';
// Partner views + nav (harmonization)
export const PARTNER_APPLICATIONS_VIEW_UNIVERSAL_IDENTIFIER = 'b57a84ed-d7c1-420d-b0eb-348db0dac612';
export const VALIDATED_PARTNERS_VIEW_UNIVERSAL_IDENTIFIER = '13cca6a7-b9f1-4103-b011-ea2e39430899';
export const PARTNER_CONTENT_VIEW_UNIVERSAL_IDENTIFIER = 'd9db705c-795a-4a14-b891-6201149510b3';
export const PARTNER_APPLICATIONS_NAV_UNIVERSAL_IDENTIFIER = '13e2334a-6b1e-4080-8c74-d11109990cc1';
export const VALIDATED_PARTNERS_NAV_UNIVERSAL_IDENTIFIER = '6aed30c6-d80f-4ac6-aab0-db5bc59e5c4b';
export const PARTNER_CONTENT_NAV_UNIVERSAL_IDENTIFIER = '3543723d-80c1-466a-ac35-86f7b284917b';
export const MY_SERVICES_VIEW_UNIVERSAL_IDENTIFIER =
'04d85f9e-342b-4887-bbee-4d52b1dd5cb3';
export const MY_SERVICES_NAV_UNIVERSAL_IDENTIFIER =
'3cd81fb0-e6b9-4669-90d9-caf3ca6e8841';
export const MY_LINKS_VIEW_UNIVERSAL_IDENTIFIER =
'40def5de-e90a-4542-8512-2b63bae248dc';
export const MY_LINKS_NAV_UNIVERSAL_IDENTIFIER =
'bd7b422c-0201-4bef-9170-0a415150d29b';
export const MY_CASE_STUDIES_VIEW_UNIVERSAL_IDENTIFIER =
'e7c4a9b2-1d3f-4e5a-9b6c-8d7e6f5a4b3c';
export const MY_CASE_STUDIES_NAV_UNIVERSAL_IDENTIFIER =
'f8d5b0c3-2e41-5f6b-8c7d-9e8f7a6b5c4d';
// Opportunity record page (standard side panel Fields widget) — view-field UIDs
// reused from the former custom FIELDS_WIDGET view so sync updates in place.
export const OPPORTUNITY_RECORD_PAGE_IS_LISTED_VIEW_FIELD_UNIVERSAL_IDENTIFIER =
'5f8c539e-82df-4db6-af05-d4c4f9b262b1';
export const OPPORTUNITY_RECORD_PAGE_NEED_VIEW_FIELD_UNIVERSAL_IDENTIFIER =
'd44a8293-f9c5-41f6-9e08-85441475a1ce';
export const OPPORTUNITY_RECORD_PAGE_REQUIREMENTS_VIEW_FIELD_UNIVERSAL_IDENTIFIER =
'3a97a3ce-ecfd-417f-8518-120899f1111e';
export const OPPORTUNITY_RECORD_PAGE_PARTNER_VIEW_FIELD_UNIVERSAL_IDENTIFIER =
'd186d83a-2f6b-4693-b7eb-abc47d110ae8';
export const OPPORTUNITY_RECORD_PAGE_APPLICATIONS_VIEW_FIELD_UNIVERSAL_IDENTIFIER =
'5c286d5d-c849-4210-ba55-7a2e7dc28ff1';

View File

@@ -0,0 +1,17 @@
import { defineApplicationRole } from 'twenty-sdk/define';
import {
APP_DISPLAY_NAME,
DEFAULT_ROLE_UNIVERSAL_IDENTIFIER,
} from 'src/constants/universal-identifiers';
export default defineApplicationRole({
universalIdentifier: DEFAULT_ROLE_UNIVERSAL_IDENTIFIER,
label: `${APP_DISPLAY_NAME} default function role`,
description: `${APP_DISPLAY_NAME} default function role`,
canBeAssignedToUsers: false,
canReadAllObjectRecords: true,
canUpdateAllObjectRecords: true,
canSoftDeleteAllObjectRecords: true,
canDestroyAllObjectRecords: false,
});

View File

@@ -0,0 +1,341 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
const { queryMock, mutationMock } = vi.hoisted(() => ({
queryMock: vi.fn(),
mutationMock: vi.fn(),
}));
vi.mock('twenty-client-sdk/core', () => ({
CoreApiClient: vi.fn(function () {
return { query: queryMock, mutation: mutationMock };
}),
}));
import { handler } from '../on-application-created.logic-function';
const MEMBER_ID = 'aaaaaaaa-1111-1111-1111-111111111111';
const PARTNER_ID = 'bbbbbbbb-2222-2222-2222-222222222222';
const APPLICATION_ID = 'cccccccc-3333-3333-3333-333333333333';
const OPPORTUNITY_ID = 'dddddddd-4444-4444-4444-444444444444';
const EXISTING_APPLICATION_ID = 'eeeeeeee-5555-5555-5555-555555555555';
const event = (after: Record<string, unknown>) =>
({ properties: { after } }) as never;
describe('on-application-created', () => {
beforeEach(() => {
queryMock.mockReset();
mutationMock.mockReset();
mutationMock.mockResolvedValue({ updateApplication: { id: APPLICATION_ID } });
});
it('does not stamp when the admin-created row already carries partnerUser and has no opportunity', async () => {
const result = await handler(
event({
id: APPLICATION_ID,
partnerId: PARTNER_ID,
partnerUserId: MEMBER_ID,
}),
);
expect(result).toEqual({});
expect(queryMock).not.toHaveBeenCalled();
expect(mutationMock).not.toHaveBeenCalled();
});
it('grants opportunity visibility on an apply row that already has partnerUser', async () => {
queryMock
.mockResolvedValueOnce({
opportunities: {
edges: [
{ node: { id: OPPORTUNITY_ID, applicantPartnerUserIds: null } },
],
},
})
.mockResolvedValueOnce({
opportunities: {
edges: [
{
node: {
id: OPPORTUNITY_ID,
applicantPartnerUserIds: [MEMBER_ID],
},
},
],
},
});
const result = await handler(
event({
id: APPLICATION_ID,
partnerId: PARTNER_ID,
partnerUserId: MEMBER_ID,
opportunityId: OPPORTUNITY_ID,
}),
);
expect(result).toEqual({});
expect(mutationMock).toHaveBeenCalledTimes(1);
expect(mutationMock).toHaveBeenCalledWith({
updateOpportunity: {
__args: {
id: OPPORTUNITY_ID,
data: { applicantPartnerUserIds: [MEMBER_ID] },
},
id: true,
},
});
});
it('stamps partnerUser from the partner on an admin-created row', async () => {
queryMock.mockResolvedValue({
partners: { edges: [{ node: { id: PARTNER_ID, partnerUserId: MEMBER_ID } }] },
});
const result = await handler(
event({ id: APPLICATION_ID, partnerId: PARTNER_ID }),
);
expect(result).toEqual({ stamped: MEMBER_ID });
expect(mutationMock).toHaveBeenCalledTimes(1);
const args = mutationMock.mock.calls[0][0].updateApplication.__args;
expect(args.id).toBe(APPLICATION_ID);
expect(args.data).toEqual({ partnerUserId: MEMBER_ID });
});
it('stamps partnerUser and grants opportunity visibility on an invite with an opportunity', async () => {
queryMock
.mockResolvedValueOnce({
partners: {
edges: [{ node: { id: PARTNER_ID, partnerUserId: MEMBER_ID } }],
},
})
.mockResolvedValueOnce({
opportunities: {
edges: [
{ node: { id: OPPORTUNITY_ID, applicantPartnerUserIds: null } },
],
},
})
.mockResolvedValueOnce({
opportunities: {
edges: [
{
node: {
id: OPPORTUNITY_ID,
applicantPartnerUserIds: [MEMBER_ID],
},
},
],
},
});
const result = await handler(
event({
id: APPLICATION_ID,
partnerId: PARTNER_ID,
opportunityId: OPPORTUNITY_ID,
}),
);
expect(result).toEqual({ stamped: MEMBER_ID });
const stampCall = mutationMock.mock.calls.find(
(mutationCall) => mutationCall[0].updateApplication,
)?.[0];
expect(stampCall.updateApplication.__args.data).toEqual({
partnerUserId: MEMBER_ID,
});
const grantCall = mutationMock.mock.calls.find(
(mutationCall) => mutationCall[0].updateOpportunity,
)?.[0];
expect(grantCall).toEqual({
updateOpportunity: {
__args: {
id: OPPORTUNITY_ID,
data: { applicantPartnerUserIds: [MEMBER_ID] },
},
id: true,
},
});
});
it('leaves an admin-created row alone when the partner has no member', async () => {
queryMock.mockResolvedValue({
partners: { edges: [{ node: { id: PARTNER_ID, partnerUserId: null } }] },
});
const result = await handler(
event({ id: APPLICATION_ID, partnerId: PARTNER_ID }),
);
expect(result).toEqual({ skipped: true, reason: 'partner_has_no_user' });
expect(mutationMock).not.toHaveBeenCalled();
});
it('leaves an admin-created row alone when the partner row is absent', async () => {
queryMock.mockResolvedValue({ partners: { edges: [] } });
const result = await handler(event({ id: APPLICATION_ID, partnerId: PARTNER_ID }));
expect(result).toEqual({ skipped: true, reason: 'partner_has_no_user' });
expect(mutationMock).not.toHaveBeenCalled();
});
it('resolves the candidacy when the self-apply row already carries partnerUser', async () => {
queryMock.mockResolvedValue({
partners: { edges: [{ node: { id: PARTNER_ID } }] },
});
const result = await handler(
event({
id: APPLICATION_ID,
partnerUserId: MEMBER_ID,
createdBy: { workspaceMemberId: MEMBER_ID },
}),
);
expect(result).toEqual({ applied: true, partnerId: PARTNER_ID });
const args = mutationMock.mock.calls[0][0].updateApplication.__args;
expect(args.data.partnerId).toBe(PARTNER_ID);
expect(args.data.state).toBe('APPLIED');
});
it('does nothing when createdBy.workspaceMemberId is missing', async () => {
const result = await handler(
event({ id: APPLICATION_ID, createdBy: {} }),
);
expect(result).toEqual({});
expect(queryMock).not.toHaveBeenCalled();
expect(mutationMock).not.toHaveBeenCalled();
});
it('does nothing when the member maps to no Partner', async () => {
queryMock.mockResolvedValue({ partners: { edges: [] } });
const result = await handler(
event({
id: APPLICATION_ID,
createdBy: { workspaceMemberId: MEMBER_ID },
}),
);
expect(result).toEqual({});
expect(queryMock).toHaveBeenCalledTimes(1);
expect(mutationMock).not.toHaveBeenCalled();
});
it('updates application with partnerId on the happy path', async () => {
queryMock.mockResolvedValue({
partners: { edges: [{ node: { id: PARTNER_ID } }] },
});
const result = await handler(
event({
id: APPLICATION_ID,
createdBy: { workspaceMemberId: MEMBER_ID },
}),
);
expect(result).toEqual({ applied: true, partnerId: PARTNER_ID });
expect(queryMock).toHaveBeenCalledTimes(1);
expect(mutationMock).toHaveBeenCalledTimes(1);
const call = mutationMock.mock.calls[0][0];
const args = call.updateApplication.__args;
expect(args.id).toBe(APPLICATION_ID);
expect(args.data.partnerId).toBe(PARTNER_ID);
expect(args.data.partnerUserId).toBe(MEMBER_ID);
expect(args.data.state).toBe('APPLIED');
});
it('stamps normally when no duplicate exists for the same opportunity and partner', async () => {
queryMock
.mockResolvedValueOnce({
partners: { edges: [{ node: { id: PARTNER_ID } }] },
})
.mockResolvedValueOnce({ applications: { edges: [] } })
.mockResolvedValueOnce({
opportunities: {
edges: [
{ node: { id: OPPORTUNITY_ID, applicantPartnerUserIds: null } },
],
},
})
.mockResolvedValueOnce({
opportunities: {
edges: [
{
node: {
id: OPPORTUNITY_ID,
applicantPartnerUserIds: [MEMBER_ID],
},
},
],
},
});
const result = await handler(
event({
id: APPLICATION_ID,
opportunityId: OPPORTUNITY_ID,
createdBy: { workspaceMemberId: MEMBER_ID },
}),
);
expect(result).toEqual({ applied: true, partnerId: PARTNER_ID });
const call = mutationMock.mock.calls.find(
(mutationCall) => mutationCall[0].updateApplication,
)?.[0];
expect(call.updateApplication.__args.id).toBe(APPLICATION_ID);
expect(call.updateApplication.__args.data.partnerId).toBe(PARTNER_ID);
expect(call.updateApplication.__args.data.partnerUserId).toBe(MEMBER_ID);
expect(call.updateApplication.__args.data.state).toBe('APPLIED');
const grantCall = mutationMock.mock.calls.find(
(mutationCall) => mutationCall[0].updateOpportunity,
)?.[0];
expect(grantCall).toEqual({
updateOpportunity: {
__args: {
id: OPPORTUNITY_ID,
data: { applicantPartnerUserIds: [MEMBER_ID] },
},
id: true,
},
});
});
it('soft-deletes a duplicate application and keeps the existing one', async () => {
queryMock
.mockResolvedValueOnce({
partners: { edges: [{ node: { id: PARTNER_ID } }] },
})
.mockResolvedValueOnce({
applications: { edges: [{ node: { id: EXISTING_APPLICATION_ID } }] },
});
mutationMock.mockResolvedValue({
deleteApplication: { id: APPLICATION_ID },
});
const result = await handler(
event({
id: APPLICATION_ID,
opportunityId: OPPORTUNITY_ID,
createdBy: { workspaceMemberId: MEMBER_ID },
}),
);
expect(result).toEqual({
duplicate: true,
keptExisting: EXISTING_APPLICATION_ID,
});
expect(queryMock).toHaveBeenCalledTimes(2);
expect(mutationMock).toHaveBeenCalledTimes(1);
expect(mutationMock.mock.calls[0][0].deleteApplication.__args.id).toBe(
APPLICATION_ID,
);
expect(
mutationMock.mock.calls.find((call) => call[0].updateApplication),
).toBeUndefined();
});
});

View File

@@ -0,0 +1,69 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
const { queryMock, mutationMock } = vi.hoisted(() => ({
queryMock: vi.fn(),
mutationMock: vi.fn(),
}));
vi.mock('twenty-client-sdk/core', () => ({
CoreApiClient: vi.fn(function () {
return { query: queryMock, mutation: mutationMock };
}),
}));
import { handler } from '../on-application-set-name.logic-function';
const event = (after: Record<string, unknown>, updatedFields: string[]) =>
({ properties: { after, updatedFields } }) as never;
describe('on-application-set-name', () => {
beforeEach(() => {
queryMock.mockReset();
mutationMock.mockReset();
mutationMock.mockResolvedValue({ updateApplication: { id: 'a1' } });
});
it('labels the application from partner + opportunity names', async () => {
queryMock.mockResolvedValue({
applications: {
edges: [
{
node: {
id: 'a1',
partner: { name: 'Acme Partners' },
opportunity: { name: 'Q3 Renewal' },
},
},
],
},
});
await handler(event({ id: 'a1' }, ['partnerId']));
expect(mutationMock).toHaveBeenCalledWith({
updateApplication: {
__args: { id: 'a1', data: { name: 'Acme Partners · Q3 Renewal' } },
id: true,
},
});
});
it('ignores updates that touch neither relation', async () => {
await handler(event({ id: 'a1' }, ['pitch']));
expect(queryMock).not.toHaveBeenCalled();
expect(mutationMock).not.toHaveBeenCalled();
});
it('falls back to the placeholder label when the application row is absent', async () => {
queryMock.mockResolvedValue({ applications: { edges: [] } });
await handler(event({ id: 'a1' }, ['partnerId']));
expect(mutationMock).toHaveBeenCalledWith({
updateApplication: {
__args: { id: 'a1', data: { name: 'Unassigned · No brief' } },
id: true,
},
});
});
});

View File

@@ -0,0 +1,321 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const { queryMock, mutationMock, metadataQueryMock } = vi.hoisted(() => ({
queryMock: vi.fn(),
mutationMock: vi.fn(),
metadataQueryMock: vi.fn(),
}));
vi.mock('twenty-client-sdk/core', () => ({
CoreApiClient: vi.fn(function () {
return { query: queryMock, mutation: mutationMock };
}),
}));
vi.mock('twenty-client-sdk/metadata', () => ({
MetadataApiClient: vi.fn(function () {
return { query: metadataQueryMock };
}),
}));
import { MIN_PITCH_LENGTH } from 'src/modules/application/apply/constants/apply-to-brief.constants';
import { applyToBrief } from 'src/modules/application/apply/services/apply-to-brief.service';
const USER_ID = 'user-1';
const WORKSPACE_ID = 'workspace-1';
const WORKSPACE_MEMBER_ID = 'member-1';
const PARTNER_ID = 'partner-1';
const PARTNER_NAME = 'Acme Partners';
const OPPORTUNITY_ID = 'opportunity-1';
const AUTHORIZATION = 'Bearer caller-token';
const APP_TOKEN = `header.${Buffer.from(
JSON.stringify({ workspaceId: WORKSPACE_ID }),
).toString('base64url')}.sig`;
type QueryResponses = {
workspaceMembers?: unknown;
partners?: unknown;
opportunities?: unknown;
applications?: unknown;
};
const defaultResponses = (): QueryResponses => ({
workspaceMembers: { edges: [{ node: { id: WORKSPACE_MEMBER_ID } }] },
partners: { edges: [{ node: { id: PARTNER_ID, name: PARTNER_NAME } }] },
opportunities: {
edges: [
{ node: { id: OPPORTUNITY_ID, name: 'Q3 Renewal', isListed: true } },
],
},
applications: { edges: [] },
});
const respondWith = (overrides: QueryResponses = {}) => {
const responses = { ...defaultResponses(), ...overrides };
queryMock.mockImplementation((selection: Record<string, unknown>) => {
const key = Object.keys(selection)[0] as keyof QueryResponses;
return Promise.resolve({ [key as string]: responses[key] });
});
};
const pitchOf = (length: number) => 'a'.repeat(length);
const event = (
body: unknown,
authorization: string | null = AUTHORIZATION,
) => ({
body,
headers: { authorization: authorization ?? undefined },
});
const validBody = {
opportunityId: OPPORTUNITY_ID,
pitch: pitchOf(MIN_PITCH_LENGTH),
};
describe('applyToBrief', () => {
const originalAppToken = process.env.TWENTY_APP_ACCESS_TOKEN;
afterEach(() => {
if (originalAppToken === undefined)
delete process.env.TWENTY_APP_ACCESS_TOKEN;
else process.env.TWENTY_APP_ACCESS_TOKEN = originalAppToken;
});
beforeEach(() => {
queryMock.mockReset();
mutationMock.mockReset();
metadataQueryMock.mockReset();
process.env.TWENTY_APP_ACCESS_TOKEN = APP_TOKEN;
metadataQueryMock.mockResolvedValue({
currentUser: { id: USER_ID, currentWorkspace: { id: WORKSPACE_ID } },
});
mutationMock.mockResolvedValue({
createApplication: { id: 'application-1' },
});
respondWith();
});
it('refuses an unauthenticated caller without creating anything', async () => {
const result = await applyToBrief(event(validBody, null));
expect(result).toEqual({ ok: false, reason: 'UNAUTHENTICATED' });
expect(mutationMock).not.toHaveBeenCalled();
});
it('refuses a caller with no partner without creating anything', async () => {
respondWith({ partners: { edges: [] } });
const result = await applyToBrief(event(validBody));
expect(result).toEqual({ ok: false, reason: 'NO_PARTNER' });
expect(mutationMock).not.toHaveBeenCalled();
});
it('refuses a token minted in another workspace without creating anything', async () => {
metadataQueryMock.mockResolvedValue({
currentUser: { id: USER_ID, currentWorkspace: { id: 'workspace-other' } },
});
const result = await applyToBrief(event(validBody));
expect(result).toEqual({ ok: false, reason: 'UNAUTHENTICATED' });
expect(mutationMock).not.toHaveBeenCalled();
});
it('refuses a brief that is not listed without creating anything', async () => {
respondWith({
opportunities: {
edges: [
{ node: { id: OPPORTUNITY_ID, name: 'Q3 Renewal', isListed: false } },
],
},
});
const result = await applyToBrief(event(validBody));
expect(result).toEqual({ ok: false, reason: 'BRIEF_NOT_OPEN' });
expect(mutationMock).not.toHaveBeenCalled();
});
it('refuses a brief that does not exist without creating anything', async () => {
respondWith({ opportunities: { edges: [] } });
const result = await applyToBrief(event(validBody));
expect(result).toEqual({ ok: false, reason: 'BRIEF_NOT_OPEN' });
expect(mutationMock).not.toHaveBeenCalled();
});
it('refuses a body without an opportunityId without creating anything', async () => {
const result = await applyToBrief(
event({ pitch: pitchOf(MIN_PITCH_LENGTH) }),
);
expect(result).toEqual({ ok: false, reason: 'BAD_REQUEST' });
expect(mutationMock).not.toHaveBeenCalled();
});
it('refuses a pitch one character below the minimum after trim', async () => {
const pitch = ` ${pitchOf(MIN_PITCH_LENGTH - 1)} `;
const result = await applyToBrief(
event({ opportunityId: OPPORTUNITY_ID, pitch }),
);
expect(result).toEqual({ ok: false, reason: 'PITCH_TOO_SHORT' });
expect(mutationMock).not.toHaveBeenCalled();
});
it('refuses an existing application that already carries a pitch', async () => {
respondWith({
applications: {
edges: [
{
node: {
id: 'existing-application',
pitch: 'a real pitch',
state: 'APPLIED',
},
},
],
},
});
const result = await applyToBrief(event(validBody));
expect(result).toEqual({ ok: false, reason: 'ALREADY_APPLIED' });
expect(mutationMock).not.toHaveBeenCalled();
});
it.each([['INTRODUCED'], ['WON'], ['DECLINED']])(
'refuses to fill the pitch of a pitchless row already decided at %s',
async (state) => {
respondWith({
applications: {
edges: [{ node: { id: 'decided-application', pitch: null, state } }],
},
});
const result = await applyToBrief(event(validBody));
expect(result).toEqual({ ok: false, reason: 'BRIEF_NOT_OPEN' });
expect(mutationMock).not.toHaveBeenCalled();
},
);
it.each([['INVITED'], ['APPLIED'], ['BACKUP']])(
'fills the pitch of a pitchless row still in the running at %s',
async (state) => {
respondWith({
applications: {
edges: [{ node: { id: 'live-application', pitch: null, state } }],
},
});
const result = await applyToBrief(event(validBody));
expect(result).toEqual({ ok: true, applicationId: 'live-application' });
},
);
it.each([
['null', null],
['whitespace only', ' \n '],
])(
'fills the pitch of an existing row whose pitch is %s',
async (_label, pitch) => {
respondWith({
applications: {
edges: [
{ node: { id: 'invited-application', pitch, state: 'INVITED' } },
],
},
});
const submittedPitch = ` ${pitchOf(MIN_PITCH_LENGTH)} `;
const result = await applyToBrief(
event({ opportunityId: OPPORTUNITY_ID, pitch: submittedPitch }),
);
expect(result).toEqual({
ok: true,
applicationId: 'invited-application',
});
expect(mutationMock).toHaveBeenCalledTimes(1);
expect(mutationMock).toHaveBeenCalledWith({
updateApplication: {
__args: {
id: 'invited-application',
data: { pitch: submittedPitch.trim() },
},
id: true,
},
});
const data = mutationMock.mock.calls[0][0].updateApplication.__args.data;
expect(Object.keys(data)).toEqual(['pitch']);
expect(data).not.toHaveProperty('state');
},
);
it('creates the application once with the five derived fields', async () => {
const pitch = ` ${pitchOf(MIN_PITCH_LENGTH + 20)} `;
const result = await applyToBrief(
event({ opportunityId: OPPORTUNITY_ID, pitch }),
);
expect(result).toEqual({ ok: true, applicationId: 'application-1' });
expect(mutationMock).toHaveBeenCalledTimes(1);
expect(mutationMock).toHaveBeenCalledWith({
createApplication: {
__args: {
data: {
opportunityId: OPPORTUNITY_ID,
partnerId: PARTNER_ID,
partnerUserId: WORKSPACE_MEMBER_ID,
pitch: pitch.trim(),
name: 'Acme Partners · Q3 Renewal',
},
},
id: true,
},
});
});
it('accepts a pitch of exactly the minimum length', async () => {
const result = await applyToBrief(
event({
opportunityId: OPPORTUNITY_ID,
pitch: pitchOf(MIN_PITCH_LENGTH),
}),
);
expect(result).toEqual({ ok: true, applicationId: 'application-1' });
expect(mutationMock).toHaveBeenCalledTimes(1);
});
it('never reads partnerId or partnerUserId from the request body', async () => {
await applyToBrief(
event({
...validBody,
partnerId: 'spoofed-partner',
partnerUserId: 'spoofed-member',
}),
);
expect(mutationMock).toHaveBeenCalledWith(
expect.objectContaining({
createApplication: expect.objectContaining({
__args: {
data: expect.objectContaining({
partnerId: PARTNER_ID,
partnerUserId: WORKSPACE_MEMBER_ID,
}),
},
}),
}),
);
});
});

View File

@@ -0,0 +1,30 @@
import { defineLogicFunction, type RoutePayload } from 'twenty-sdk/define';
import { APPLY_TO_BRIEF_FUNCTION_ID } from 'src/modules/application/apply/constants/apply-to-brief.constants';
import { applyToBrief } from 'src/modules/application/apply/services/apply-to-brief.service';
import { type ApplyToBriefResult } from 'src/modules/application/apply/types/apply-to-brief.types';
export const handler = (
event: Pick<RoutePayload<unknown>, 'body' | 'headers'>,
): Promise<ApplyToBriefResult> => applyToBrief(event);
export default defineLogicFunction({
universalIdentifier: APPLY_TO_BRIEF_FUNCTION_ID,
name: 'apply-to-brief',
description:
'Creates an Application for the calling partner on a listed brief.',
// Six sequential round trips behind a cold isolate start: a first invocation measured
// 47s wall on a local dev server, so 20s killed it before the first query returned.
timeoutSeconds: 60,
handler,
httpRouteTriggerSettings: {
path: '/apply-to-brief',
httpMethod: 'POST',
// isAuthRequired would make the server mint a token carrying the caller's user claims,
// and the executor intersects the caller's role with the app role — the Partner role
// denies Application writes, so the create would be refused. The function verifies the
// forwarded header itself instead, and keeps an app-only token for the write.
isAuthRequired: false,
forwardedRequestHeaders: ['authorization'],
},
});

View File

@@ -0,0 +1,21 @@
import {
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS,
defineCommandMenuItem,
} from 'twenty-sdk/define';
import {
APPLY_TO_BRIEF_COMMAND_MENU_ITEM_ID,
APPLY_TO_BRIEF_FRONT_COMPONENT_ID,
} from 'src/modules/application/apply/constants/apply-to-brief.constants';
export default defineCommandMenuItem({
universalIdentifier: APPLY_TO_BRIEF_COMMAND_MENU_ITEM_ID,
label: 'Apply to this brief',
shortLabel: 'Apply to this opportunity',
icon: 'IconSend',
isPinned: true,
availabilityType: 'RECORD_SELECTION',
availabilityObjectUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier,
frontComponentUniversalIdentifier: APPLY_TO_BRIEF_FRONT_COMPONENT_ID,
});

View File

@@ -0,0 +1,12 @@
export const APPLY_TO_BRIEF_FRONT_COMPONENT_ID =
'54f96b09-3c98-4d3c-815a-3f95a2e42527';
export const APPLY_TO_BRIEF_FUNCTION_ID =
'38fb146a-abe0-495f-8e20-1ebe99a9926a';
export const APPLY_TO_BRIEF_COMMAND_MENU_ITEM_ID =
'e4668652-9212-4794-ab07-3514c1d0d829';
export const MIN_PITCH_LENGTH = 100;
// The states an existing row may still receive a pitch in. BACKUP belongs here: it is a
// standing shortlist set before the introduction, which is why the outcome cascade also
// leaves it alone. INTRODUCED, WON and DECLINED are decided.
export const PITCHABLE_STATES = ['APPLIED', 'INVITED', 'BACKUP'] as const;

View File

@@ -0,0 +1,179 @@
import { useCallback, useState, type CSSProperties } from 'react';
import { defineFrontComponent } from 'twenty-sdk/define';
import {
enqueueSnackbar,
openSidePanelPage,
AppPath,
useFrontComponentExecutionContext,
type FrontComponentExecutionContext,
} from 'twenty-sdk/front-component';
import {
APPLY_TO_BRIEF_FRONT_COMPONENT_ID,
MIN_PITCH_LENGTH,
} from 'src/modules/application/apply/constants/apply-to-brief.constants';
import {
GENERIC_APPLY_FAILURE_MESSAGE,
getRefusalMessage,
} from 'src/modules/application/apply/front-components/apply-to-brief/refusal-message';
import { type ApplyToBriefResult } from 'src/modules/application/apply/types/apply-to-brief.types';
import { callAppRoute } from 'src/modules/shared/front-components/call-app-route';
import { COLORS, FONT } from 'src/modules/shared/front-components/palette';
const pageStyle: CSSProperties = {
fontFamily: FONT,
color: COLORS.fg,
maxWidth: '100%',
padding: '20px 24px',
display: 'flex',
flexDirection: 'column',
gap: 12,
};
const titleStyle: CSSProperties = { fontSize: 16, fontWeight: 700, margin: 0 };
const labelStyle: CSSProperties = {
fontSize: 12.5,
color: COLORS.muted,
lineHeight: 1.5,
};
const textareaStyle: CSSProperties = {
width: '100%',
boxSizing: 'border-box',
minHeight: 180,
padding: 12,
borderRadius: 8,
border: `1px solid ${COLORS.border}`,
background: COLORS.surface,
color: COLORS.fg,
fontSize: 14,
fontFamily: FONT,
lineHeight: 1.5,
outline: 'none',
resize: 'vertical',
};
const counterStyle: CSSProperties = { fontSize: 12, color: COLORS.muted };
const errorStyle: CSSProperties = { fontSize: 13, color: COLORS.danger };
const footerStyle: CSSProperties = {
display: 'flex',
justifyContent: 'flex-end',
};
const buttonStyle: CSSProperties = {
height: 36,
padding: '0 18px',
borderRadius: 8,
border: 'none',
background: COLORS.accent,
color: '#fff',
fontSize: 13.5,
fontWeight: 650,
fontFamily: FONT,
cursor: 'pointer',
};
const disabledButtonStyle: CSSProperties = {
...buttonStyle,
opacity: 0.5,
cursor: 'not-allowed',
};
// `recordId` is deprecated and only covers a renderer that sends no selection.
const selectBriefId = (
context: FrontComponentExecutionContext,
): string | null => context.selectedRecordIds[0] ?? context.recordId;
const ApplyToBrief = () => {
const briefId = useFrontComponentExecutionContext(selectBriefId);
const [pitch, setPitch] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const pitchLength = pitch.trim().length;
const isPitchLongEnough = pitchLength >= MIN_PITCH_LENGTH;
const apply = useCallback(async () => {
if (!briefId) return;
setIsSubmitting(true);
setErrorMessage(null);
try {
const result = (await callAppRoute('/apply-to-brief', {
opportunityId: briefId,
pitch,
})) as ApplyToBriefResult;
if (result.ok) {
await enqueueSnackbar({
message: 'Application sent',
variant: 'success',
});
await openSidePanelPage({
to: AppPath.RecordShowPage,
params: {
objectNameSingular: 'application',
objectRecordId: result.applicationId,
},
resetNavigationStack: true,
});
} else {
setErrorMessage(getRefusalMessage(result.reason));
}
} catch {
setErrorMessage(GENERIC_APPLY_FAILURE_MESSAGE);
} finally {
setIsSubmitting(false);
}
}, [briefId, pitch]);
if (!briefId) {
return <div style={pageStyle}>Open a brief to apply.</div>;
}
return (
<div style={pageStyle}>
<h1 style={titleStyle}>Apply to this brief</h1>
<label htmlFor="apply-pitch" style={labelStyle}>
Tell the client why your team fits this brief. Twenty introduces up to 2
partners per brief, so an application is not a guarantee of an
introduction.
</label>
<textarea
id="apply-pitch"
style={textareaStyle}
value={pitch}
onChange={(event) => setPitch(event.target.value)}
placeholder="Describe the relevant work you have done…"
disabled={isSubmitting}
/>
<div style={counterStyle}>
{pitchLength} / {MIN_PITCH_LENGTH} characters minimum
</div>
{errorMessage && (
<div role="alert" style={errorStyle}>
{errorMessage}
</div>
)}
<div style={footerStyle}>
<button
style={
isPitchLongEnough && !isSubmitting
? buttonStyle
: disabledButtonStyle
}
onClick={() => void apply()}
disabled={!isPitchLongEnough || isSubmitting}
>
{isSubmitting ? 'Sending…' : 'Send application'}
</button>
</div>
</div>
);
};
export default defineFrontComponent({
universalIdentifier: APPLY_TO_BRIEF_FRONT_COMPONENT_ID,
name: 'Apply to Brief',
description: 'Side panel for a partner to pitch on an open brief.',
component: ApplyToBrief,
});

View File

@@ -0,0 +1,36 @@
import { describe, expect, it } from 'vitest';
import {
GENERIC_APPLY_FAILURE_MESSAGE,
getRefusalMessage,
} from './refusal-message';
describe('getRefusalMessage', () => {
it('maps every known reason to its own plain sentence', () => {
expect(getRefusalMessage('UNAUTHENTICATED')).toBe(
'Sign in again to apply.',
);
expect(getRefusalMessage('NO_PARTNER')).toBe(
'This account is not linked to a partner.',
);
expect(getRefusalMessage('BAD_REQUEST')).toBe(
'Reopen this brief and try again.',
);
expect(getRefusalMessage('BRIEF_NOT_OPEN')).toBe(
'This brief is no longer open for applications.',
);
expect(getRefusalMessage('PITCH_TOO_SHORT')).toBe(
'Add a little more detail before you apply.',
);
expect(getRefusalMessage('ALREADY_APPLIED')).toBe(
'You have already applied to this brief.',
);
});
it('falls back to the generic sentence for an unknown reason', () => {
expect(getRefusalMessage('SOMETHING_NEW')).toBe(
GENERIC_APPLY_FAILURE_MESSAGE,
);
expect(getRefusalMessage('')).toBe(GENERIC_APPLY_FAILURE_MESSAGE);
});
});

View File

@@ -0,0 +1,22 @@
import { type ApplyRefusalReason } from 'src/modules/application/apply/types/apply-to-brief.types';
export const GENERIC_APPLY_FAILURE_MESSAGE =
'Could not send your application. Try again.';
const REFUSAL_MESSAGES: Record<ApplyRefusalReason, string> = {
UNAUTHENTICATED: 'Sign in again to apply.',
NO_PARTNER: 'This account is not linked to a partner.',
BAD_REQUEST: 'Reopen this brief and try again.',
BRIEF_NOT_OPEN: 'This brief is no longer open for applications.',
PITCH_TOO_SHORT: 'Add a little more detail before you apply.',
ALREADY_APPLIED: 'You have already applied to this brief.',
};
const isApplyRefusalReason = (reason: string): reason is ApplyRefusalReason =>
reason in REFUSAL_MESSAGES;
// The route may add a reason the front component does not know yet, so never index blindly.
export const getRefusalMessage = (reason: string): string =>
isApplyRefusalReason(reason)
? REFUSAL_MESSAGES[reason]
: GENERIC_APPLY_FAILURE_MESSAGE;

View File

@@ -0,0 +1,10 @@
import { type CoreApiClient, type CoreSchema } from 'twenty-client-sdk/core';
export function createApplication(
client: CoreApiClient,
data: CoreSchema.ApplicationCreateInput,
) {
return client.mutation({
createApplication: { __args: { data }, id: true },
});
}

View File

@@ -0,0 +1,15 @@
import { type CoreApiClient } from 'twenty-client-sdk/core';
// List form on purpose: the single-record read throws `Record not found` for an unknown id,
// which would hide a missing brief behind the generic failure message.
export function findOpportunityForApply(
client: CoreApiClient,
opportunityId: string,
) {
return client.query({
opportunities: {
__args: { filter: { id: { eq: opportunityId } }, first: 1 },
edges: { node: { id: true, name: true, isListed: true } },
},
});
}

View File

@@ -0,0 +1,94 @@
import { type RoutePayload } from 'twenty-sdk/define';
import { z } from 'zod';
import {
MIN_PITCH_LENGTH,
PITCHABLE_STATES,
} from 'src/modules/application/apply/constants/apply-to-brief.constants';
import { createApplication } from 'src/modules/application/apply/graphql/mutations/create-application';
import { findOpportunityForApply } from 'src/modules/application/apply/graphql/queries/find-opportunity-for-apply';
import { type ApplyToBriefResult } from 'src/modules/application/apply/types/apply-to-brief.types';
import { updateApplication } from 'src/modules/application/graphql/mutations/update-application';
import { findDuplicateApplication } from 'src/modules/application/graphql/queries/find-duplicate-application';
import { buildApplicationName } from 'src/modules/application/utils/build-application-name';
import {
buildAppClient,
errorResponse,
failureResponse,
resolvePartnerFromForwardedToken,
} from 'src/modules/shared/http/resolve-partner-from-request.service';
import { isNonEmptyString } from 'src/modules/shared/utils/is-non-empty-string.util';
const applyToBriefSchema = z.object({
opportunityId: z.string().min(1),
pitch: z.string(),
});
export const applyToBrief = async (
event: Pick<RoutePayload<unknown>, 'body' | 'headers'>,
): Promise<ApplyToBriefResult> => {
const resolved = await resolvePartnerFromForwardedToken(event);
if ('error' in resolved) return errorResponse(resolved.error);
const parsed = applyToBriefSchema.safeParse(event.body);
if (!parsed.success) return errorResponse('BAD_REQUEST');
const { opportunityId, pitch } = parsed.data;
try {
const client = buildAppClient();
const briefs = await findOpportunityForApply(client, opportunityId);
const brief = briefs.opportunities?.edges?.[0]?.node;
if (!brief || brief.isListed !== true)
return errorResponse('BRIEF_NOT_OPEN');
const trimmedPitch = pitch.trim();
if (trimmedPitch.length < MIN_PITCH_LENGTH)
return errorResponse('PITCH_TOO_SHORT');
const duplicates = await findDuplicateApplication(
client,
opportunityId,
resolved.partnerId,
);
const existing = duplicates.applications?.edges?.[0]?.node;
if (existing?.id) {
if (isNonEmptyString(existing.pitch))
return errorResponse('ALREADY_APPLIED');
// Fill the pitch in place and leave the state alone — an INVITED row records that
// Twenty pushed this brief at the partner. Guard on the state rather than on the
// brief being listed: the WON/DECLINED cascade fires on Opportunity.partner, which
// is not coupled to isListed, so a decided row can still sit under a listed brief.
if (!PITCHABLE_STATES.some((state) => state === existing.state)) {
return errorResponse('BRIEF_NOT_OPEN');
}
await updateApplication(client, existing.id, { pitch: trimmedPitch });
return { ok: true, applicationId: existing.id };
}
// on-application-set-name fires on application.updated only, so an insert that already
// carries partnerId never triggers it — the label must be written here.
const created = await createApplication(client, {
opportunityId,
partnerId: resolved.partnerId,
partnerUserId: resolved.workspaceMemberId,
pitch: trimmedPitch,
name: buildApplicationName(resolved.partnerName, brief.name),
});
const applicationId = created.createApplication?.id;
if (!isNonEmptyString(applicationId)) {
return failureResponse(
'apply-to-brief',
new Error('createApplication returned no id'),
);
}
return { ok: true, applicationId };
} catch (err) {
return failureResponse('apply-to-brief', err);
}
};

View File

@@ -0,0 +1,12 @@
export type ApplyRefusalReason =
| 'UNAUTHENTICATED'
| 'NO_PARTNER'
| 'BAD_REQUEST'
| 'BRIEF_NOT_OPEN'
| 'PITCH_TOO_SHORT'
| 'ALREADY_APPLIED';
// `reason` widens to string because failureResponse answers with a sentence, not a code.
export type ApplyToBriefResult =
| { ok: true; applicationId: string }
| { ok: false; reason: ApplyRefusalReason | string };

View File

@@ -0,0 +1,27 @@
import {
FieldType,
RelationType,
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS,
defineField,
} from 'twenty-sdk/define';
import {
APPLICATIONS_AS_PARTNER_USER_FIELD_ID,
APPLICATION_OBJECT_UNIVERSAL_IDENTIFIER,
APPLICATION_PARTNER_USER_FIELD_ID,
} from 'src/modules/application/objects/application.object';
export default defineField({
universalIdentifier: APPLICATIONS_AS_PARTNER_USER_FIELD_ID,
objectUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.workspaceMember.universalIdentifier,
type: FieldType.RELATION,
name: 'partnerApplications',
label: 'Partner Applications',
isNullable: true,
relationTargetObjectMetadataUniversalIdentifier:
APPLICATION_OBJECT_UNIVERSAL_IDENTIFIER,
relationTargetFieldMetadataUniversalIdentifier:
APPLICATION_PARTNER_USER_FIELD_ID,
universalSettings: { relationType: RelationType.ONE_TO_MANY },
});

View File

@@ -0,0 +1,21 @@
import { FieldType, RelationType, defineField } from 'twenty-sdk/define';
import { PARTNER_OBJECT_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
import {
APPLICATIONS_ON_PARTNER_FIELD_ID,
APPLICATION_OBJECT_UNIVERSAL_IDENTIFIER,
APPLICATION_PARTNER_FIELD_ID,
} from 'src/modules/application/objects/application.object';
export default defineField({
universalIdentifier: APPLICATIONS_ON_PARTNER_FIELD_ID,
objectUniversalIdentifier: PARTNER_OBJECT_UNIVERSAL_IDENTIFIER,
type: FieldType.RELATION,
name: 'applications',
label: 'Applications',
isNullable: true,
relationTargetObjectMetadataUniversalIdentifier:
APPLICATION_OBJECT_UNIVERSAL_IDENTIFIER,
relationTargetFieldMetadataUniversalIdentifier: APPLICATION_PARTNER_FIELD_ID,
universalSettings: { relationType: RelationType.ONE_TO_MANY },
});

View File

@@ -0,0 +1,10 @@
import { type CoreApiClient } from 'twenty-client-sdk/core';
export function deleteApplication(client: CoreApiClient, id: string) {
return client.mutation({
deleteApplication: {
__args: { id },
id: true,
},
});
}

View File

@@ -0,0 +1,11 @@
import { type CoreApiClient, type CoreSchema } from 'twenty-client-sdk/core';
export function updateApplication(
client: CoreApiClient,
id: string,
data: CoreSchema.ApplicationUpdateInput,
) {
return client.mutation({
updateApplication: { __args: { id, data }, id: true },
});
}

View File

@@ -0,0 +1,17 @@
import { type CoreApiClient } from 'twenty-client-sdk/core';
// List form on purpose: the single-record read throws `Record not found` for an unknown id.
export function findApplicationWithRelations(client: CoreApiClient, id: string) {
return client.query({
applications: {
__args: { filter: { id: { eq: id } }, first: 1 },
edges: {
node: {
id: true,
partner: { name: true },
opportunity: { name: true },
},
},
},
});
}

View File

@@ -0,0 +1,20 @@
import { type CoreApiClient } from 'twenty-client-sdk/core';
export function findDuplicateApplication(
client: CoreApiClient,
opportunityId: string,
partnerId: string,
) {
return client.query({
applications: {
__args: {
filter: {
opportunityId: { eq: opportunityId },
partnerId: { eq: partnerId },
},
first: 1,
},
edges: { node: { id: true, pitch: true, state: true } },
},
});
}

View File

@@ -0,0 +1,12 @@
import { NavigationMenuItemType, defineNavigationMenuItem } from 'twenty-sdk/define';
import { APPLICATIONS_BY_OPPORTUNITY_VIEW_UNIVERSAL_IDENTIFIER } from 'src/modules/application/views/applications-by-opportunity.view';
export default defineNavigationMenuItem({
universalIdentifier: 'e80057bf-9b46-4502-8ccd-b25870ec293c',
type: NavigationMenuItemType.VIEW,
icon: 'IconTargetArrow',
position: 6,
folderUniversalIdentifier: '0b2e499a-ae74-45e0-af08-243e19fc56aa',
viewUniversalIdentifier: APPLICATIONS_BY_OPPORTUNITY_VIEW_UNIVERSAL_IDENTIFIER,
});

View File

@@ -0,0 +1,12 @@
import { NavigationMenuItemType, defineNavigationMenuItem } from 'twenty-sdk/define';
import { APPLICATIONS_REVIEW_VIEW_UNIVERSAL_IDENTIFIER } from 'src/modules/application/views/applications-review.view';
export default defineNavigationMenuItem({
universalIdentifier: 'fcf7e5e8-9ec2-4f08-898a-39e51a2787d0',
type: NavigationMenuItemType.VIEW,
icon: 'IconUserPlus',
position: 4,
folderUniversalIdentifier: '0b2e499a-ae74-45e0-af08-243e19fc56aa',
viewUniversalIdentifier: APPLICATIONS_REVIEW_VIEW_UNIVERSAL_IDENTIFIER,
});

View File

@@ -0,0 +1,17 @@
import {
NavigationMenuItemType,
defineNavigationMenuItem,
} from 'twenty-sdk/define';
import { MY_APPLICATIONS_VIEW_UNIVERSAL_IDENTIFIER } from 'src/modules/application/views/my-applications.view';
import { PARTNER_WORKSPACE_FOLDER_UNIVERSAL_IDENTIFIER } from 'src/modules/shared/navigation-menu-items/partner-workspace-folder.navigation-menu-item';
export default defineNavigationMenuItem({
universalIdentifier: '71c418f0-5761-4797-830a-2cae6d4bdd49',
type: NavigationMenuItemType.VIEW,
icon: 'IconSend',
position: 2,
folderUniversalIdentifier: PARTNER_WORKSPACE_FOLDER_UNIVERSAL_IDENTIFIER,
viewUniversalIdentifier: MY_APPLICATIONS_VIEW_UNIVERSAL_IDENTIFIER,
});

View File

@@ -0,0 +1,15 @@
import {
NavigationMenuItemType,
defineNavigationMenuItem,
} from 'twenty-sdk/define';
import { PIPELINE_VIEW_UNIVERSAL_IDENTIFIER } from 'src/modules/application/views/pipeline.view';
export default defineNavigationMenuItem({
universalIdentifier: 'ae1632fc-c53c-4a27-8e07-ad1f3a2d08b9',
type: NavigationMenuItemType.VIEW,
icon: 'IconLayoutKanban',
position: 3,
folderUniversalIdentifier: '0b2e499a-ae74-45e0-af08-243e19fc56aa',
viewUniversalIdentifier: PIPELINE_VIEW_UNIVERSAL_IDENTIFIER,
});

View File

@@ -0,0 +1,169 @@
import {
FieldType,
OnDeleteAction,
RelationType,
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS,
defineObject,
} from 'twenty-sdk/define';
import { PARTNER_OBJECT_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
export const APPLICATION_OBJECT_UNIVERSAL_IDENTIFIER =
'f17ec4d0-00c5-4137-9c8b-5507934d80cf';
export const APPLICATION_NAME_FIELD_ID = '51ff5f75-9df2-485f-8852-22484ae0cc3e';
export const APPLICATION_OPPORTUNITY_FIELD_ID =
'8aba48ca-1bfb-4141-b64d-b23d7f5fc41f';
export const APPLICATIONS_ON_OPPORTUNITY_FIELD_ID =
'4a6ce47b-a56b-4b21-81f4-83021fb0e09d';
export const APPLICATION_PARTNER_FIELD_ID =
'2ab5b079-fef3-452c-b4c0-a32ca1709157';
export const APPLICATIONS_ON_PARTNER_FIELD_ID =
'e36002c5-f71b-4048-953a-79f68baba4e2';
export const APPLICATION_PARTNER_USER_FIELD_ID =
'2895bcbb-df5a-4faa-af70-097729c1dd01';
export const APPLICATIONS_AS_PARTNER_USER_FIELD_ID =
'1010f214-ab3f-43aa-a19e-d351bddc2d61';
export const APPLICATION_STATE_FIELD_ID =
'c686db4f-f0e9-41a0-bdb3-3b4024ff59b6';
export const APPLICATION_PITCH_FIELD_ID =
'0a6cd9c9-e1e9-4315-8356-b72077443805';
export default defineObject({
universalIdentifier: APPLICATION_OBJECT_UNIVERSAL_IDENTIFIER,
nameSingular: 'application',
namePlural: 'applications',
labelSingular: 'Application',
labelPlural: 'Applications',
description: 'A partner in play on an opportunity-brief — invited or applied',
icon: 'IconSend',
isSearchable: false,
labelIdentifierFieldMetadataUniversalIdentifier: APPLICATION_NAME_FIELD_ID,
fields: [
{
universalIdentifier: APPLICATION_NAME_FIELD_ID,
type: FieldType.TEXT,
name: 'name',
label: 'Name',
icon: 'IconTag',
defaultValue: "''",
},
{
universalIdentifier: APPLICATION_OPPORTUNITY_FIELD_ID,
type: FieldType.RELATION,
name: 'opportunity',
label: 'Opportunity',
icon: 'IconTargetArrow',
isNullable: true,
relationTargetObjectMetadataUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier,
relationTargetFieldMetadataUniversalIdentifier:
APPLICATIONS_ON_OPPORTUNITY_FIELD_ID,
universalSettings: {
relationType: RelationType.MANY_TO_ONE,
onDelete: OnDeleteAction.SET_NULL,
joinColumnName: 'opportunityId',
},
},
{
universalIdentifier: APPLICATION_PARTNER_FIELD_ID,
type: FieldType.RELATION,
name: 'partner',
label: 'Partner',
icon: 'IconBuildingStore',
isNullable: true,
relationTargetObjectMetadataUniversalIdentifier:
PARTNER_OBJECT_UNIVERSAL_IDENTIFIER,
relationTargetFieldMetadataUniversalIdentifier:
APPLICATIONS_ON_PARTNER_FIELD_ID,
universalSettings: {
relationType: RelationType.MANY_TO_ONE,
onDelete: OnDeleteAction.SET_NULL,
joinColumnName: 'partnerId',
},
},
{
universalIdentifier: APPLICATION_PARTNER_USER_FIELD_ID,
type: FieldType.RELATION,
name: 'partnerUser',
label: 'Partner User',
isNullable: true,
relationTargetObjectMetadataUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.workspaceMember
.universalIdentifier,
relationTargetFieldMetadataUniversalIdentifier:
APPLICATIONS_AS_PARTNER_USER_FIELD_ID,
universalSettings: {
relationType: RelationType.MANY_TO_ONE,
onDelete: OnDeleteAction.SET_NULL,
joinColumnName: 'partnerUserId',
},
},
{
// Candidacy funnel. WON is set ONLY by on-opportunity-partner-won (Task 3) — a locked
// mirror of Opportunity.partner, never a user-set value.
universalIdentifier: APPLICATION_STATE_FIELD_ID,
type: FieldType.SELECT,
name: 'state',
label: 'State',
icon: 'IconProgressCheck',
isNullable: false,
// Self-apply is the primary path → default APPLIED so a partner who applies never sees a
// misleading "Invited" flicker while on-application-created (async) stamps the candidacy.
// Admin invites are a manual action that explicitly sets INVITED.
defaultValue: "'APPLIED'",
options: [
{
id: '7f9d5ae7-64be-4269-8fab-3cfeaf352bcb',
value: 'APPLIED',
label: 'Applied',
position: 0,
color: 'blue',
},
{
id: '2ce94524-4ac3-4a4a-a87c-5c9841eced7c',
value: 'INVITED',
label: 'Invited',
position: 1,
color: 'gray',
},
{
id: 'e4e3d579-17ef-4ef8-b9be-54f7d361ad51',
value: 'INTRODUCED',
label: 'Introduced',
position: 2,
color: 'purple',
},
{
id: '66ff342c-577b-4a84-bbdc-4ffbfa0bdd0e',
value: 'WON',
label: 'Won',
position: 3,
color: 'green',
},
{
id: '83846e19-5008-4f06-b62d-7a1f0dad23e2',
value: 'DECLINED',
label: 'Declined',
position: 4,
color: 'red',
},
{
id: '124f7e81-39b4-47cc-83f7-7dfb81ea751d',
value: 'BACKUP',
label: 'Backup',
position: 5,
color: 'orange',
},
],
},
{
universalIdentifier: APPLICATION_PITCH_FIELD_ID,
type: FieldType.TEXT,
name: 'pitch',
label: 'Pitch',
icon: 'IconMessage',
isNullable: true,
},
],
});

View File

@@ -0,0 +1,23 @@
import { CoreApiClient, type CoreSchema } from 'twenty-client-sdk/core';
import {
type DatabaseEventPayload,
defineLogicFunction,
type ObjectRecordCreateEvent,
} from 'twenty-sdk/define';
import { resolveCandidacy } from 'src/modules/application/services/resolve-candidacy.service';
const ON_APPLICATION_CREATED_FN_ID = '0e055a1c-b8b3-4572-89f3-e76e37bc3f9e';
export const handler = async (
payload: DatabaseEventPayload<ObjectRecordCreateEvent<CoreSchema.Application>>,
): Promise<Record<string, unknown>> =>
resolveCandidacy(new CoreApiClient(), payload.properties.after);
export default defineLogicFunction({
universalIdentifier: ON_APPLICATION_CREATED_FN_ID,
name: 'on-application-created',
timeoutSeconds: 15,
handler,
databaseEventTriggerSettings: { eventName: 'application.created' },
});

View File

@@ -0,0 +1,39 @@
import { CoreApiClient, type CoreSchema } from 'twenty-client-sdk/core';
import {
type DatabaseEventPayload,
defineLogicFunction,
type ObjectRecordUpdateEvent,
} from 'twenty-sdk/define';
import { findApplicationWithRelations } from 'src/modules/application/graphql/queries/find-application-with-relations';
import { updateApplication } from 'src/modules/application/graphql/mutations/update-application';
import { buildApplicationName } from 'src/modules/application/utils/build-application-name';
const ON_APPLICATION_SET_NAME_FN_ID = '8f123b54-a7c5-42d3-8049-a240930257a7';
// Re-label an Application "<partner> · <opportunity>" whenever either relation changes.
export const handler = async (
payload: DatabaseEventPayload<ObjectRecordUpdateEvent<CoreSchema.Application>>,
): Promise<Record<string, unknown>> => {
const { after, updatedFields } = payload.properties;
const touchedRelation = updatedFields?.includes('partnerId') || updatedFields?.includes('opportunityId');
if (!touchedRelation) return {};
const id = after?.id;
if (!id) return {};
const client = new CoreApiClient();
const result = await findApplicationWithRelations(client, id);
const application = result.applications?.edges?.[0]?.node;
const name = buildApplicationName(application?.partner?.name, application?.opportunity?.name);
await updateApplication(client, id, { name });
return { labelled: name };
};
export default defineLogicFunction({
universalIdentifier: ON_APPLICATION_SET_NAME_FN_ID,
name: 'on-application-set-name',
timeoutSeconds: 15,
handler,
databaseEventTriggerSettings: { eventName: 'application.updated' },
});

View File

@@ -0,0 +1,49 @@
import { PageLayoutTabLayoutMode, definePageLayout } from 'twenty-sdk/define';
import { APPLICATION_OBJECT_UNIVERSAL_IDENTIFIER } from 'src/modules/application/objects/application.object';
import { APPLICATION_RECORD_PAGE_FIELDS_VIEW_ID } from 'src/modules/application/views/application-record-page-fields.view';
// Application is a custom (app-owned) object, so we fully control its record page (unlike the
// standard Opportunity). The Fields widget points at the FIELDS_WIDGET view so the opportunity +
// partner relations show in the side panel.
export default definePageLayout({
universalIdentifier: '7280910c-a409-4afc-9dac-ae9a8b3ca244',
name: 'Default Application Layout',
type: 'RECORD_PAGE',
objectUniversalIdentifier: APPLICATION_OBJECT_UNIVERSAL_IDENTIFIER,
tabs: [
{
universalIdentifier: '14270284-dbd2-4d03-b9cd-a8e78c325394',
title: 'Home',
position: 10,
icon: 'IconHome',
layoutMode: PageLayoutTabLayoutMode.VERTICAL_LIST,
widgets: [
{
universalIdentifier: '7f4a57f9-44f8-4fa4-bcdb-b25055c33db0',
title: 'Fields',
type: 'FIELDS',
configuration: {
configurationType: 'FIELDS',
viewUniversalIdentifier: APPLICATION_RECORD_PAGE_FIELDS_VIEW_ID,
},
},
],
},
{
universalIdentifier: '8f09a425-95ce-44a3-92af-7b37a1842851',
title: 'Timeline',
position: 20,
icon: 'IconTimelineEvent',
layoutMode: PageLayoutTabLayoutMode.VERTICAL_LIST,
widgets: [
{
universalIdentifier: '3e9acb85-9445-40d5-9c65-57461df8483c',
title: 'Timeline',
type: 'TIMELINE',
configuration: { configurationType: 'TIMELINE' },
},
],
},
],
});

View File

@@ -0,0 +1,80 @@
import type { CoreApiClient, CoreSchema } from 'twenty-client-sdk/core';
import type {
DatabaseEventPayload,
ObjectRecordCreateEvent,
} from 'twenty-sdk/define';
import { findDuplicateApplication } from 'src/modules/application/graphql/queries/find-duplicate-application';
import { deleteApplication } from 'src/modules/application/graphql/mutations/delete-application';
import { updateApplication } from 'src/modules/application/graphql/mutations/update-application';
import { findPartnerByMember } from 'src/modules/shared/graphql/queries/find-partner-by-member';
import { getPartnerOwner } from 'src/modules/shared/graphql/queries/get-partner-owner';
import { grantOpportunityVisibility } from 'src/modules/shared/services/grant-opportunity-visibility.service';
type ApplicationCreatedProperties = DatabaseEventPayload<
ObjectRecordCreateEvent<CoreSchema.Application>
>['properties'];
// The app route POST /apply-to-brief creates the Application with partnerId and
// partnerUserId already set, so this service skips the candidacy stamp and only grants
// opportunity read access. It still serves the admin path: an invite or an import sets
// partnerId but no partnerUserId, and this service stamps the partner's user so RLS
// doesn't hide the row from its own partner. The createdBy-based self-apply branch below
// is a fallback for rows created by a member without a partner set.
export async function resolveCandidacy(
client: CoreApiClient,
after: ApplicationCreatedProperties['after'],
): Promise<Record<string, unknown>> {
const applicationId = after?.id;
if (!applicationId) return {};
const opportunityId = after.opportunityId;
// Admin path (invite/import): without partnerUser, RLS hides the row from its own partner.
if (after.partnerId) {
if (after.partnerUserId) {
await grantOpportunityVisibility(client, opportunityId, [
after.partnerUserId,
]);
return {};
}
const ownerRes = await getPartnerOwner(client, after.partnerId);
const partnerUserId = ownerRes.partners?.edges?.[0]?.node?.partnerUserId;
if (!partnerUserId) return { skipped: true, reason: 'partner_has_no_user' };
await updateApplication(client, applicationId, { partnerUserId });
await grantOpportunityVisibility(client, opportunityId, [partnerUserId]);
return { stamped: partnerUserId };
}
const memberId = after.createdBy?.workspaceMemberId;
if (!memberId) return {}; // no member actor (system/import) — not a self-apply
const partnerRes = await findPartnerByMember(client, memberId);
const partnerId = partnerRes.partners?.edges?.[0]?.node?.id;
if (!partnerId) return {}; // creator isn't a partner (e.g. admin) — leave it
if (opportunityId) {
const existingRes = await findDuplicateApplication(client, opportunityId, partnerId);
const existingId = existingRes.applications?.edges?.find(
(edge) => edge.node?.id && edge.node.id !== applicationId,
)?.node?.id;
if (existingId) {
// No grant here on purpose: the kept row's own member holds it. A second member of
// the same partner never sees the first member's application either (partnerUser IS me).
await deleteApplication(client, applicationId);
return { duplicate: true, keptExisting: existingId };
}
}
// ponytail: dedupe by (opportunity, partner) above; two near-simultaneous creates could still both pass before either stamps — acceptable.
await updateApplication(client, applicationId, {
partnerId,
partnerUserId: memberId,
state: 'APPLIED',
});
await grantOpportunityVisibility(client, opportunityId, [memberId]);
return { applied: true, partnerId };
}

View File

@@ -0,0 +1,23 @@
import { describe, expect, it } from 'vitest';
import { buildApplicationName } from 'src/modules/application/utils/build-application-name';
describe('buildApplicationName', () => {
it('joins both names with the separator', () => {
expect(buildApplicationName('Acme Partners', 'Q3 Renewal')).toBe('Acme Partners · Q3 Renewal');
});
it('falls back to Unassigned when the partner name is missing', () => {
expect(buildApplicationName(null, 'Q3 Renewal')).toBe('Unassigned · Q3 Renewal');
expect(buildApplicationName(undefined, 'Q3 Renewal')).toBe('Unassigned · Q3 Renewal');
});
it('falls back to No brief when the opportunity name is missing', () => {
expect(buildApplicationName('Acme Partners', null)).toBe('Acme Partners · No brief');
expect(buildApplicationName('Acme Partners', undefined)).toBe('Acme Partners · No brief');
});
it('falls back to both defaults when both names are missing', () => {
expect(buildApplicationName(null, undefined)).toBe('Unassigned · No brief');
});
});

View File

@@ -0,0 +1,8 @@
export const buildApplicationName = (
partnerName: string | null | undefined,
opportunityName: string | null | undefined,
): string => {
const resolvedPartnerName = partnerName ?? 'Unassigned';
const resolvedOpportunityName = opportunityName ?? 'No brief';
return `${resolvedPartnerName} · ${resolvedOpportunityName}`;
};

View File

@@ -0,0 +1,48 @@
import { ViewType, defineView } from 'twenty-sdk/define';
import {
APPLICATION_OBJECT_UNIVERSAL_IDENTIFIER,
APPLICATION_OPPORTUNITY_FIELD_ID,
APPLICATION_PARTNER_FIELD_ID,
APPLICATION_PITCH_FIELD_ID,
APPLICATION_STATE_FIELD_ID,
} from 'src/modules/application/objects/application.object';
export const APPLICATION_RECORD_PAGE_FIELDS_VIEW_ID =
'e004c2ff-462a-45d4-8a77-071dcf093879';
// FIELDS_WIDGET view backing the Application record page side panel. Relation fields
// (opportunity, partner) only render in the fields widget when an explicit view marks them
// visible — this is that view.
export default defineView({
universalIdentifier: APPLICATION_RECORD_PAGE_FIELDS_VIEW_ID,
name: 'Application Record Page Fields',
objectUniversalIdentifier: APPLICATION_OBJECT_UNIVERSAL_IDENTIFIER,
type: ViewType.FIELDS_WIDGET,
fields: [
{
universalIdentifier: '44793f47-5d06-43fa-9d33-23ec7602ecfc',
fieldMetadataUniversalIdentifier: APPLICATION_OPPORTUNITY_FIELD_ID,
position: 0,
isVisible: true,
},
{
universalIdentifier: '7d7ef49a-6d30-44de-b4eb-bb77d93b8982',
fieldMetadataUniversalIdentifier: APPLICATION_PARTNER_FIELD_ID,
position: 1,
isVisible: true,
},
{
universalIdentifier: 'd0278990-928c-4660-ac27-3abca0e52a5e',
fieldMetadataUniversalIdentifier: APPLICATION_STATE_FIELD_ID,
position: 2,
isVisible: true,
},
{
universalIdentifier: '28e0642b-76eb-4d41-83de-b84f07b3aad9',
fieldMetadataUniversalIdentifier: APPLICATION_PITCH_FIELD_ID,
position: 3,
isVisible: true,
},
],
});

View File

@@ -0,0 +1,60 @@
import { ViewSortDirection, ViewType, defineView } from 'twenty-sdk/define';
import {
APPLICATION_OBJECT_UNIVERSAL_IDENTIFIER,
APPLICATION_OPPORTUNITY_FIELD_ID,
APPLICATION_PARTNER_FIELD_ID,
APPLICATION_PITCH_FIELD_ID,
APPLICATION_STATE_FIELD_ID,
} from 'src/modules/application/objects/application.object';
export const APPLICATIONS_BY_OPPORTUNITY_VIEW_UNIVERSAL_IDENTIFIER =
'db489249-284d-41b1-8537-71a655389cb7';
// Relation fields cannot drive TABLE group-by in Twenty (only SELECT + static
// groups[]). Sort by opportunity so applicants cluster per brief instead.
export default defineView({
universalIdentifier: APPLICATIONS_BY_OPPORTUNITY_VIEW_UNIVERSAL_IDENTIFIER,
name: 'Applications by Opportunity',
icon: 'IconTargetArrow',
objectUniversalIdentifier: APPLICATION_OBJECT_UNIVERSAL_IDENTIFIER,
type: ViewType.TABLE,
position: 4,
fields: [
{
universalIdentifier: '1b6984aa-4011-4b77-8121-9f4d987a2e07',
fieldMetadataUniversalIdentifier: APPLICATION_OPPORTUNITY_FIELD_ID,
position: 0,
isVisible: true,
size: 200,
},
{
universalIdentifier: 'f57b1dd2-f415-4c01-8f0a-d55b941b23c7',
fieldMetadataUniversalIdentifier: APPLICATION_PARTNER_FIELD_ID,
position: 1,
isVisible: true,
size: 200,
},
{
universalIdentifier: '612db8df-a809-4557-add4-1802a434efb2',
fieldMetadataUniversalIdentifier: APPLICATION_STATE_FIELD_ID,
position: 2,
isVisible: true,
size: 140,
},
{
universalIdentifier: 'ba53baa3-4db7-466e-a066-0d9156bc0a82',
fieldMetadataUniversalIdentifier: APPLICATION_PITCH_FIELD_ID,
position: 3,
isVisible: true,
size: 280,
},
],
sorts: [
{
universalIdentifier: 'ce31ed78-cd7a-410a-8d95-2e10c846d765',
fieldMetadataUniversalIdentifier: APPLICATION_OPPORTUNITY_FIELD_ID,
direction: ViewSortDirection.ASC,
},
],
});

View File

@@ -0,0 +1,68 @@
import { describe, expect, it } from 'vitest';
import { ViewFilterOperand, ViewType } from 'twenty-sdk/define';
import {
APPLICATION_PARTNER_FIELD_ID,
APPLICATION_STATE_FIELD_ID,
} from 'src/modules/application/objects/application.object';
import { APPLICATIONS_WIDGET_VIEW_UNIVERSAL_IDENTIFIER } from 'src/modules/application/views/applications-widget.view';
import applicationsOnPartnerWidgetView, {
APPLICATIONS_ON_PARTNER_WIDGET_VIEW_UNIVERSAL_IDENTIFIER,
} from './applications-on-partner-widget.view';
const parseFilterValue = (value: unknown) =>
typeof value === 'string' ? JSON.parse(value) : value;
describe('applications-on-partner-widget.view', () => {
it('uses a stable UUID distinct from the opportunity widget view', () => {
expect(APPLICATIONS_ON_PARTNER_WIDGET_VIEW_UNIVERSAL_IDENTIFIER).toBe(
'22d162c7-404a-4f09-92c9-790d3c33a733',
);
expect(applicationsOnPartnerWidgetView.config.universalIdentifier).toBe(
APPLICATIONS_ON_PARTNER_WIDGET_VIEW_UNIVERSAL_IDENTIFIER,
);
expect(APPLICATIONS_ON_PARTNER_WIDGET_VIEW_UNIVERSAL_IDENTIFIER).not.toBe(
APPLICATIONS_WIDGET_VIEW_UNIVERSAL_IDENTIFIER,
);
});
it('is a TABLE_WIDGET named for the partner parent', () => {
expect(applicationsOnPartnerWidgetView.success).toBe(true);
expect(applicationsOnPartnerWidgetView.config.type).toBe(
ViewType.TABLE_WIDGET,
);
expect(applicationsOnPartnerWidgetView.config.name).toBe(
'Applications on Partner Widget',
);
});
it('scopes rows with partner IS current record', () => {
const filters = applicationsOnPartnerWidgetView.config.filters ?? [];
expect(filters).toHaveLength(1);
const partnerFilter = filters[0];
expect(partnerFilter.fieldMetadataUniversalIdentifier).toBe(
APPLICATION_PARTNER_FIELD_ID,
);
expect(partnerFilter.operand).toBe(ViewFilterOperand.IS);
expect(parseFilterValue(partnerFilter.value)).toEqual({
selectedRecordIds: [],
isCurrentRecordSelected: true,
});
});
it('does not filter by application state', () => {
const filters = applicationsOnPartnerWidgetView.config.filters ?? [];
expect(
filters.some(
(filter) =>
filter.fieldMetadataUniversalIdentifier ===
APPLICATION_STATE_FIELD_ID,
),
).toBe(false);
});
});

View File

@@ -0,0 +1,54 @@
import { ViewFilterOperand, ViewType, defineView } from 'twenty-sdk/define';
import {
APPLICATION_OBJECT_UNIVERSAL_IDENTIFIER,
APPLICATION_OPPORTUNITY_FIELD_ID,
APPLICATION_PARTNER_FIELD_ID,
APPLICATION_STATE_FIELD_ID,
} from 'src/modules/application/objects/application.object';
export const APPLICATIONS_ON_PARTNER_WIDGET_VIEW_UNIVERSAL_IDENTIFIER =
'22d162c7-404a-4f09-92c9-790d3c33a733';
// TABLE_WIDGET rather than TABLE so this view stays out of the record index view pickers.
export default defineView({
universalIdentifier: APPLICATIONS_ON_PARTNER_WIDGET_VIEW_UNIVERSAL_IDENTIFIER,
name: 'Applications on Partner Widget',
icon: 'IconSend',
objectUniversalIdentifier: APPLICATION_OBJECT_UNIVERSAL_IDENTIFIER,
type: ViewType.TABLE_WIDGET,
fields: [
{
universalIdentifier: 'd9d4571b-5d73-4a84-ad82-e1e6fb730bc2',
fieldMetadataUniversalIdentifier: APPLICATION_PARTNER_FIELD_ID,
position: 0,
isVisible: true,
size: 220,
},
{
universalIdentifier: '607be829-a697-41f8-8fe6-a2515ba20f91',
fieldMetadataUniversalIdentifier: APPLICATION_OPPORTUNITY_FIELD_ID,
position: 1,
isVisible: true,
size: 220,
},
{
universalIdentifier: 'eb9a5b92-c697-41bf-83a9-bf0780eb4b51',
fieldMetadataUniversalIdentifier: APPLICATION_STATE_FIELD_ID,
position: 2,
isVisible: true,
size: 140,
},
],
filters: [
{
universalIdentifier: '1fc71ed2-319c-4267-b997-033056653d70',
fieldMetadataUniversalIdentifier: APPLICATION_PARTNER_FIELD_ID,
operand: ViewFilterOperand.IS,
value: {
selectedRecordIds: [],
isCurrentRecordSelected: true,
},
},
],
});

View File

@@ -0,0 +1,105 @@
import { ViewSortDirection, ViewType, defineView } from 'twenty-sdk/define';
import {
APPLICATION_NAME_FIELD_ID,
APPLICATION_OBJECT_UNIVERSAL_IDENTIFIER,
APPLICATION_OPPORTUNITY_FIELD_ID,
APPLICATION_PARTNER_FIELD_ID,
APPLICATION_PITCH_FIELD_ID,
APPLICATION_STATE_FIELD_ID,
} from 'src/modules/application/objects/application.object';
export const APPLICATIONS_REVIEW_VIEW_UNIVERSAL_IDENTIFIER =
'cc652ba3-23e8-4e0e-a616-c9fa50b702a0';
export default defineView({
universalIdentifier: APPLICATIONS_REVIEW_VIEW_UNIVERSAL_IDENTIFIER,
name: 'Applications',
icon: 'IconUserPlus',
objectUniversalIdentifier: APPLICATION_OBJECT_UNIVERSAL_IDENTIFIER,
type: ViewType.TABLE,
position: 2,
mainGroupByFieldMetadataUniversalIdentifier: APPLICATION_STATE_FIELD_ID,
groups: [
{
universalIdentifier: '332125fd-b69f-4bb8-b33d-a07ead1ca051',
fieldValue: 'APPLIED',
position: 0,
isVisible: true,
},
{
universalIdentifier: '90af09ea-806b-446a-9816-91dd878eb485',
fieldValue: 'INVITED',
position: 1,
isVisible: true,
},
{
universalIdentifier: 'e16ad28d-27aa-454f-b9f3-ab35697e0856',
fieldValue: 'INTRODUCED',
position: 2,
isVisible: true,
},
{
universalIdentifier: 'f122dd3d-e660-44da-b250-73cbdc6a1135',
fieldValue: 'WON',
position: 3,
isVisible: true,
},
{
universalIdentifier: '22074627-0b0b-4d6d-a377-1cf34cf7bc6c',
fieldValue: 'DECLINED',
position: 4,
isVisible: true,
},
{
universalIdentifier: '274c32e1-d149-45ce-9f48-94fa7a42c77a',
fieldValue: 'BACKUP',
position: 5,
isVisible: true,
},
],
fields: [
{
universalIdentifier: 'e91b83e8-a774-4057-9068-892463de7366',
fieldMetadataUniversalIdentifier: APPLICATION_NAME_FIELD_ID,
position: 0,
isVisible: true,
size: 220,
},
{
universalIdentifier: 'db3ed5b7-468a-468f-a3b6-c482773a7bc1',
fieldMetadataUniversalIdentifier: APPLICATION_OPPORTUNITY_FIELD_ID,
position: 1,
isVisible: true,
size: 200,
},
{
universalIdentifier: '0142b9ed-3f9c-4697-ab3f-9240c7812b4d',
fieldMetadataUniversalIdentifier: APPLICATION_PARTNER_FIELD_ID,
position: 2,
isVisible: true,
size: 200,
},
{
universalIdentifier: 'a2317ac0-1086-44c5-8f3a-c8a7d9f8edcf',
fieldMetadataUniversalIdentifier: APPLICATION_STATE_FIELD_ID,
position: 3,
isVisible: true,
size: 140,
},
{
universalIdentifier: '36a138d6-09dc-4ed9-8517-6b20778f5836',
fieldMetadataUniversalIdentifier: APPLICATION_PITCH_FIELD_ID,
position: 4,
isVisible: true,
size: 280,
},
],
sorts: [
{
universalIdentifier: '2ea53941-c791-4684-b328-c3787292dbf4',
fieldMetadataUniversalIdentifier: APPLICATION_STATE_FIELD_ID,
direction: ViewSortDirection.ASC,
},
],
});

View File

@@ -0,0 +1,62 @@
import { describe, expect, it } from 'vitest';
import { ViewFilterOperand, ViewType } from 'twenty-sdk/define';
import {
APPLICATION_OPPORTUNITY_FIELD_ID,
APPLICATION_STATE_FIELD_ID,
} from 'src/modules/application/objects/application.object';
import applicationsWidgetView, {
APPLICATIONS_WIDGET_VIEW_UNIVERSAL_IDENTIFIER,
} from './applications-widget.view';
const parseFilterValue = (value: unknown) =>
typeof value === 'string' ? JSON.parse(value) : value;
describe('applications-widget.view', () => {
it('keeps the existing universal identifier so sync updates in place', () => {
expect(APPLICATIONS_WIDGET_VIEW_UNIVERSAL_IDENTIFIER).toBe(
'cbaf92ec-c1a2-41c2-b471-cc131b060e4e',
);
expect(applicationsWidgetView.config.universalIdentifier).toBe(
APPLICATIONS_WIDGET_VIEW_UNIVERSAL_IDENTIFIER,
);
});
it('is a TABLE_WIDGET named for the opportunity parent', () => {
expect(applicationsWidgetView.success).toBe(true);
expect(applicationsWidgetView.config.type).toBe(ViewType.TABLE_WIDGET);
expect(applicationsWidgetView.config.name).toBe(
'Applications on Opportunity Widget',
);
});
it('scopes rows with opportunity IS current record', () => {
const filters = applicationsWidgetView.config.filters ?? [];
expect(filters).toHaveLength(1);
const opportunityFilter = filters[0];
expect(opportunityFilter.fieldMetadataUniversalIdentifier).toBe(
APPLICATION_OPPORTUNITY_FIELD_ID,
);
expect(opportunityFilter.operand).toBe(ViewFilterOperand.IS);
expect(parseFilterValue(opportunityFilter.value)).toEqual({
selectedRecordIds: [],
isCurrentRecordSelected: true,
});
});
it('does not filter by application state', () => {
const filters = applicationsWidgetView.config.filters ?? [];
expect(
filters.some(
(filter) =>
filter.fieldMetadataUniversalIdentifier ===
APPLICATION_STATE_FIELD_ID,
),
).toBe(false);
});
});

View File

@@ -0,0 +1,54 @@
import { ViewFilterOperand, ViewType, defineView } from 'twenty-sdk/define';
import {
APPLICATION_OBJECT_UNIVERSAL_IDENTIFIER,
APPLICATION_OPPORTUNITY_FIELD_ID,
APPLICATION_PARTNER_FIELD_ID,
APPLICATION_STATE_FIELD_ID,
} from 'src/modules/application/objects/application.object';
export const APPLICATIONS_WIDGET_VIEW_UNIVERSAL_IDENTIFIER =
'cbaf92ec-c1a2-41c2-b471-cc131b060e4e';
// TABLE_WIDGET rather than TABLE so this view stays out of the record index view pickers.
export default defineView({
universalIdentifier: APPLICATIONS_WIDGET_VIEW_UNIVERSAL_IDENTIFIER,
name: 'Applications on Opportunity Widget',
icon: 'IconSend',
objectUniversalIdentifier: APPLICATION_OBJECT_UNIVERSAL_IDENTIFIER,
type: ViewType.TABLE_WIDGET,
fields: [
{
universalIdentifier: '644e184f-82ec-4762-bc04-8c9998c2784f',
fieldMetadataUniversalIdentifier: APPLICATION_PARTNER_FIELD_ID,
position: 0,
isVisible: true,
size: 220,
},
{
universalIdentifier: '0b009608-bbf4-4b2f-b2c1-533be28abc76',
fieldMetadataUniversalIdentifier: APPLICATION_OPPORTUNITY_FIELD_ID,
position: 1,
isVisible: true,
size: 220,
},
{
universalIdentifier: '7fd677f5-01b9-4bef-9e9f-02ecb2e69eb3',
fieldMetadataUniversalIdentifier: APPLICATION_STATE_FIELD_ID,
position: 2,
isVisible: true,
size: 140,
},
],
filters: [
{
universalIdentifier: 'df46abe6-a328-4a60-b658-e0866af03319',
fieldMetadataUniversalIdentifier: APPLICATION_OPPORTUNITY_FIELD_ID,
operand: ViewFilterOperand.IS,
value: {
selectedRecordIds: [],
isCurrentRecordSelected: true,
},
},
],
});

View File

@@ -0,0 +1,45 @@
import { ViewType, defineView } from 'twenty-sdk/define';
import {
APPLICATION_OBJECT_UNIVERSAL_IDENTIFIER,
APPLICATION_OPPORTUNITY_FIELD_ID,
APPLICATION_PITCH_FIELD_ID,
APPLICATION_STATE_FIELD_ID,
} from 'src/modules/application/objects/application.object';
export const MY_APPLICATIONS_VIEW_UNIVERSAL_IDENTIFIER =
'cba45e02-b3a7-420f-ace5-5e2773076080';
// Partner-facing list of the partner's own candidacies on opportunity briefs.
// partner sees only their own rows once B7 RLS lands; unfiltered pre-B7.
export default defineView({
universalIdentifier: MY_APPLICATIONS_VIEW_UNIVERSAL_IDENTIFIER,
name: 'My Applications',
icon: 'IconSend',
objectUniversalIdentifier: APPLICATION_OBJECT_UNIVERSAL_IDENTIFIER,
type: ViewType.TABLE,
position: 0,
fields: [
{
universalIdentifier: 'fe004e19-5aee-4716-aa6f-3d27cdc52ae2',
fieldMetadataUniversalIdentifier: APPLICATION_OPPORTUNITY_FIELD_ID,
position: 0,
isVisible: true,
size: 200,
},
{
universalIdentifier: '9cf77b15-fdd6-4123-bdbf-a50e422417c2',
fieldMetadataUniversalIdentifier: APPLICATION_STATE_FIELD_ID,
position: 1,
isVisible: true,
size: 140,
},
{
universalIdentifier: '321a7f6c-0207-47c2-860f-706eaa5c2fa9',
fieldMetadataUniversalIdentifier: APPLICATION_PITCH_FIELD_ID,
position: 2,
isVisible: true,
size: 280,
},
],
});

View File

@@ -0,0 +1,87 @@
import { ViewType, defineView } from 'twenty-sdk/define';
import {
APPLICATION_NAME_FIELD_ID,
APPLICATION_OBJECT_UNIVERSAL_IDENTIFIER,
APPLICATION_OPPORTUNITY_FIELD_ID,
APPLICATION_PARTNER_FIELD_ID,
APPLICATION_PITCH_FIELD_ID,
APPLICATION_STATE_FIELD_ID,
} from 'src/modules/application/objects/application.object';
export const PIPELINE_VIEW_UNIVERSAL_IDENTIFIER =
'aff718b3-9f2b-42c4-9f03-8710624a8f53';
export default defineView({
universalIdentifier: PIPELINE_VIEW_UNIVERSAL_IDENTIFIER,
name: 'Pipeline',
icon: 'IconLayoutKanban',
objectUniversalIdentifier: APPLICATION_OBJECT_UNIVERSAL_IDENTIFIER,
position: 3,
type: ViewType.KANBAN,
mainGroupByFieldMetadataUniversalIdentifier: APPLICATION_STATE_FIELD_ID,
groups: [
{
universalIdentifier: 'f83ea54b-9123-4bac-9f70-f6ccae30a32e',
fieldValue: 'APPLIED',
position: 0,
isVisible: true,
},
{
universalIdentifier: '28d99cbc-27d0-41d6-9389-8cc4f900ffaf',
fieldValue: 'INVITED',
position: 1,
isVisible: true,
},
{
universalIdentifier: 'e37b029a-87c2-48dd-b0af-31692c61a8d0',
fieldValue: 'INTRODUCED',
position: 2,
isVisible: true,
},
{
universalIdentifier: '1454e5fe-742d-4ee4-9ac9-6e900db47c42',
fieldValue: 'BACKUP',
position: 3,
isVisible: true,
},
{
universalIdentifier: '811f5481-17d4-4f02-a72d-1ba68ba1b7b4',
fieldValue: 'WON',
position: 4,
isVisible: true,
},
{
universalIdentifier: '7b5eb05e-c07d-4c56-a20a-baaf262f7359',
fieldValue: 'DECLINED',
position: 5,
isVisible: true,
},
],
fields: [
{
universalIdentifier: '9bd43629-07ff-4e4b-b9d5-d9fec80a6f12',
fieldMetadataUniversalIdentifier: APPLICATION_NAME_FIELD_ID,
position: 0,
isVisible: true,
},
{
universalIdentifier: '8b8f3084-bf14-428e-add5-9d2501b13c7d',
fieldMetadataUniversalIdentifier: APPLICATION_OPPORTUNITY_FIELD_ID,
position: 1,
isVisible: true,
},
{
universalIdentifier: '42ea9d40-c568-459e-9a39-367c0f07e384',
fieldMetadataUniversalIdentifier: APPLICATION_PARTNER_FIELD_ID,
position: 2,
isVisible: true,
},
{
universalIdentifier: 'a6052de9-961f-4323-9f73-0406c6c26283',
fieldMetadataUniversalIdentifier: APPLICATION_PITCH_FIELD_ID,
position: 3,
isVisible: true,
},
],
});

View File

@@ -0,0 +1,58 @@
export const OPPORTUNITY_STAGE_FIELD_UNIVERSAL_IDENTIFIER =
'20202020-6f76-477d-8551-28cd65b2b4b9';
/** Stock Opportunity.stage options plus partners archival stages. */
export const OPPORTUNITY_STAGE_OPTIONS = [
{
id: '20202020-8e01-4afd-9c39-d2063097587a',
value: 'NEW',
label: 'New',
position: 0,
color: 'red',
},
{
id: '20202020-e685-4671-ac32-26d304dacb6e',
value: 'SCREENING',
label: 'Screening',
position: 1,
color: 'purple',
},
{
id: '20202020-dde9-4acc-b5ca-f6531a8ecb4a',
value: 'MEETING',
label: 'Meeting',
position: 2,
color: 'sky',
},
{
id: '20202020-696e-4f6b-91bc-f413e9b2f654',
value: 'PROPOSAL',
label: 'Proposal',
position: 3,
color: 'turquoise',
},
{
id: '20202020-0bb5-4a6f-a8b2-774bbad21104',
value: 'CUSTOMER',
label: 'Customer',
position: 4,
color: 'yellow',
},
{
id: 'f3a8c2d1-9e4b-4f7a-8c3d-2b1e0f9a8c7d',
value: 'DONE',
label: 'Done',
position: 5,
color: 'green',
},
{
id: 'e7d6c5b4-a3f2-4e1d-9c0b-8a7f6e5d4c3b',
value: 'DEAD',
label: 'Dead',
position: 6,
color: 'gray',
},
] as const;
export type OpportunityStageValue =
(typeof OPPORTUNITY_STAGE_OPTIONS)[number]['value'];

View File

@@ -0,0 +1,27 @@
import {
FieldType,
RelationType,
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS,
defineField,
} from 'twenty-sdk/define';
import {
APPLICATIONS_ON_OPPORTUNITY_FIELD_ID,
APPLICATION_OBJECT_UNIVERSAL_IDENTIFIER,
APPLICATION_OPPORTUNITY_FIELD_ID,
} from 'src/modules/application/objects/application.object';
export default defineField({
universalIdentifier: APPLICATIONS_ON_OPPORTUNITY_FIELD_ID,
objectUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier,
type: FieldType.RELATION,
name: 'applications',
label: 'Applications',
isNullable: true,
relationTargetObjectMetadataUniversalIdentifier:
APPLICATION_OBJECT_UNIVERSAL_IDENTIFIER,
relationTargetFieldMetadataUniversalIdentifier:
APPLICATION_OPPORTUNITY_FIELD_ID,
universalSettings: { relationType: RelationType.ONE_TO_MANY },
});

View File

@@ -0,0 +1,17 @@
import { FieldType, RelationType, STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS, defineField } from 'twenty-sdk/define';
import { OPPORTUNITIES_AS_PARTNER_USER_FIELD_ID, PARTNER_USER_ON_OPPORTUNITY_FIELD_ID } from './partner-user-on-opportunity.field';
export default defineField({
universalIdentifier: OPPORTUNITIES_AS_PARTNER_USER_FIELD_ID,
objectUniversalIdentifier: STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.workspaceMember.universalIdentifier,
type: FieldType.RELATION,
name: 'opportunitiesAsPartnerUser',
label: 'Opportunities (as partner user)',
isNullable: true,
relationTargetObjectMetadataUniversalIdentifier: STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier,
relationTargetFieldMetadataUniversalIdentifier: PARTNER_USER_ON_OPPORTUNITY_FIELD_ID,
universalSettings: {
relationType: RelationType.ONE_TO_MANY,
},
});

View File

@@ -0,0 +1,18 @@
import { FieldType, RelationType, STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS, defineField } from 'twenty-sdk/define';
import { PARTNER_OBJECT_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
import { OPPORTUNITIES_ON_PARTNER_FIELD_ID, PARTNER_ON_OPPORTUNITY_FIELD_ID } from './partner-on-opportunity.field';
export default defineField({
universalIdentifier: OPPORTUNITIES_ON_PARTNER_FIELD_ID,
objectUniversalIdentifier: PARTNER_OBJECT_UNIVERSAL_IDENTIFIER,
type: FieldType.RELATION,
name: 'opportunities',
label: 'Opportunities',
isNullable: true,
relationTargetObjectMetadataUniversalIdentifier: STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier,
relationTargetFieldMetadataUniversalIdentifier: PARTNER_ON_OPPORTUNITY_FIELD_ID,
universalSettings: {
relationType: RelationType.ONE_TO_MANY,
},
});

View File

@@ -0,0 +1,27 @@
import {
FieldType,
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS,
defineField,
} from 'twenty-sdk/define';
export const OPPORTUNITY_APPLICANT_PARTNER_USER_IDS_FIELD_ID =
'223ec774-6cd6-4c19-8f32-308faa6b80b1';
// RLS cannot join Opportunity → Application, so applicant member ids live on the
// opportunity. After unlist, (partnerUser IS me) OR (isListed) is not enough.
// The list is append-only and stamped from the Application create event: deleting an
// application does not revoke, and an opportunity linked after creation is not granted.
export default defineField({
universalIdentifier: OPPORTUNITY_APPLICANT_PARTNER_USER_IDS_FIELD_ID,
objectUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier,
type: FieldType.ARRAY,
name: 'applicantPartnerUserIds',
// Named as ids because that is what it renders. Applications is the readable list.
label: 'Applicant Member IDs',
description:
'Internal RLS allowlist of workspace member ids. Read Applications for the applicants themselves. The app maintains this list — editing it by hand grants or removes partner access to the brief.',
icon: 'IconLock',
isNullable: true,
isUIEditable: false,
});

View File

@@ -0,0 +1,17 @@
import { FieldType, STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS, defineField } from 'twenty-sdk/define';
export const OPPORTUNITY_DESIGN_DOC_STATUS_FIELD_ID = 'cc6b8a59-f860-493f-8b9a-f138c078fbf1';
export default defineField({
universalIdentifier: OPPORTUNITY_DESIGN_DOC_STATUS_FIELD_ID,
objectUniversalIdentifier: STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier,
type: FieldType.SELECT,
name: 'designDocStatus',
label: 'Design Doc Status',
defaultValue: "'DRAFT'",
options: [
{ id: '1901c790-22af-4149-a792-09374d67acfd', value: 'DRAFT', label: 'Draft', position: 0, color: 'gray' },
{ id: '02cbe191-cc96-4b42-9d8e-f85cd47bed24', value: 'DONE', label: 'Done', position: 1, color: 'green' },
{ id: '943e1389-12cd-4605-8066-db27ba68a50a', value: 'SHARED_WITH_PARTNER', label: 'Shared with Partner', position: 2, color: 'blue' },
],
});

View File

@@ -0,0 +1,12 @@
import { FieldType, STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS, defineField } from 'twenty-sdk/define';
export const OPPORTUNITY_DESIGN_DOC_URL_FIELD_ID = '37e5428c-6c8c-4616-b626-f0ea1caa443d';
export default defineField({
universalIdentifier: OPPORTUNITY_DESIGN_DOC_URL_FIELD_ID,
objectUniversalIdentifier: STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier,
type: FieldType.LINKS,
name: 'designDocUrl',
label: 'Design Doc URL',
isNullable: true,
});

View File

@@ -0,0 +1,16 @@
import { FieldType, STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS, defineField } from 'twenty-sdk/define';
export const OPPORTUNITY_HOSTING_TYPE_FIELD_ID = '7ac7517f-bbca-4b4c-8996-6f864f71219b';
export default defineField({
universalIdentifier: OPPORTUNITY_HOSTING_TYPE_FIELD_ID,
objectUniversalIdentifier: STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier,
type: FieldType.SELECT,
name: 'hostingType',
label: 'Hosting Type',
isNullable: true,
options: [
{ id: '42c108d7-a874-4d1f-be4c-e87edd08f3c7', value: 'CLOUD', label: 'Cloud', position: 0, color: 'sky' },
{ id: '0fe995f4-42de-4160-96af-b3e7d542dfdd', value: 'SELF_HOSTING', label: 'Self-hosting', position: 1, color: 'purple' },
],
});

View File

@@ -0,0 +1,11 @@
import { INTRO_SENT_AT_FIELD_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
import { FieldType, STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS, defineField } from 'twenty-sdk/define';
export default defineField({
universalIdentifier: INTRO_SENT_AT_FIELD_UNIVERSAL_IDENTIFIER,
objectUniversalIdentifier: STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier,
type: FieldType.DATE_TIME,
name: 'introSentAt',
label: 'Intro Sent At',
isNullable: true,
});

View File

@@ -0,0 +1,21 @@
import {
FieldType,
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS,
defineField,
} from 'twenty-sdk/define';
export const OPPORTUNITY_IS_LISTED_FIELD_ID =
'0384ac3e-7aaa-4f32-bc6c-a6de8ebdd6c3';
// Marketplace flag: a listed brief is visible to all partners via RLS. Applicants stay
// visible after unlist through applicantPartnerUserIds (see partner.role.ts).
export default defineField({
universalIdentifier: OPPORTUNITY_IS_LISTED_FIELD_ID,
objectUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier,
type: FieldType.BOOLEAN,
name: 'isListed',
label: 'Listed',
icon: 'IconBuildingStore',
isNullable: true,
});

View File

@@ -0,0 +1,12 @@
import { FieldType, STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS, defineField } from 'twenty-sdk/define';
export const OPPORTUNITY_LOST_REASON_FIELD_ID = '834e233d-b171-409e-825f-77ac49b0f19d';
export default defineField({
universalIdentifier: OPPORTUNITY_LOST_REASON_FIELD_ID,
objectUniversalIdentifier: STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier,
type: FieldType.TEXT,
name: 'lostReason',
label: 'Lost Reason',
isNullable: true,
});

View File

@@ -0,0 +1,19 @@
import {
FieldType,
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS,
defineField,
} from 'twenty-sdk/define';
export const OPPORTUNITY_NEED_FIELD_ID =
'fce63509-40c6-43e4-9def-d532c29cc0b8';
export default defineField({
universalIdentifier: OPPORTUNITY_NEED_FIELD_ID,
objectUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier,
type: FieldType.TEXT,
name: 'need',
label: 'Need',
icon: 'IconTargetArrow',
isNullable: true,
});

View File

@@ -0,0 +1,12 @@
import { FieldType, STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS, defineField } from 'twenty-sdk/define';
export const OPPORTUNITY_NUMBER_OF_SEATS_FIELD_ID = '90c683ec-2365-4533-a187-7b9ae162b753';
export default defineField({
universalIdentifier: OPPORTUNITY_NUMBER_OF_SEATS_FIELD_ID,
objectUniversalIdentifier: STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier,
type: FieldType.NUMBER,
name: 'numberOfSeats',
label: 'Number of Seats',
isNullable: true,
});

View File

@@ -0,0 +1,19 @@
import {
FieldType,
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS,
defineField,
} from 'twenty-sdk/define';
export const OPPORTUNITY_REQUIREMENTS_FIELD_ID =
'68a8e0f1-6b68-4f99-9bad-9a78c21fc56d';
export default defineField({
universalIdentifier: OPPORTUNITY_REQUIREMENTS_FIELD_ID,
objectUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier,
type: FieldType.TEXT,
name: 'requirements',
label: 'Requirements',
icon: 'IconListCheck',
isNullable: true,
});

View File

@@ -0,0 +1,16 @@
import { FieldType, STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS, defineField } from 'twenty-sdk/define';
export const OPPORTUNITY_SUBSCRIPTION_FREQUENCY_FIELD_ID = '59d5de53-202f-4913-a417-8a08970d87cc';
export default defineField({
universalIdentifier: OPPORTUNITY_SUBSCRIPTION_FREQUENCY_FIELD_ID,
objectUniversalIdentifier: STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier,
type: FieldType.SELECT,
name: 'subscriptionFrequency',
label: 'Subscription Frequency',
isNullable: true,
options: [
{ id: 'e53a9ebb-11d6-40de-93f2-6bcb6ab7141c', value: 'MONTHLY', label: 'Monthly', position: 0, color: 'blue' },
{ id: '6eca9f01-f891-4c9d-bef6-9238c8e67392', value: 'ANNUAL', label: 'Annual', position: 1, color: 'green' },
],
});

View File

@@ -0,0 +1,17 @@
import { FieldType, STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS, defineField } from 'twenty-sdk/define';
export const OPPORTUNITY_SUBSCRIPTION_TYPE_FIELD_ID = 'a58214e9-38f9-4faf-8927-09b3980fd8c3';
export default defineField({
universalIdentifier: OPPORTUNITY_SUBSCRIPTION_TYPE_FIELD_ID,
objectUniversalIdentifier: STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier,
type: FieldType.SELECT,
name: 'subscriptionType',
label: 'Subscription Type',
isNullable: true,
options: [
{ id: '6d91b477-5bf1-4f5c-8aef-577b6c21fe45', value: 'PRO', label: 'Pro', position: 0, color: 'blue' },
{ id: '7b64f281-3445-4429-a5f0-af9484dff8b4', value: 'ORG', label: 'Org', position: 1, color: 'green' },
{ id: 'bf202f8f-caf9-45bf-a80c-65b344b2798d', value: 'ENT', label: 'Enterprise', position: 2, color: 'purple' },
],
});

View File

@@ -0,0 +1,12 @@
import { FieldType, STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS, defineField } from 'twenty-sdk/define';
export const OPPORTUNITY_TFT_ID_FIELD_ID = '2e3e1d04-2719-4e0d-9a6b-ec73acf896c5';
export default defineField({
universalIdentifier: OPPORTUNITY_TFT_ID_FIELD_ID,
objectUniversalIdentifier: STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier,
type: FieldType.TEXT,
name: 'tftOpportunityId',
label: 'TfT Opportunity ID',
isNullable: true,
});

View File

@@ -0,0 +1,12 @@
import { FieldType, STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS, defineField } from 'twenty-sdk/define';
export const OPPORTUNITY_USE_CASE_FIELD_ID = '1bc57f52-a621-4243-ae3e-05c3f504b90c';
export default defineField({
universalIdentifier: OPPORTUNITY_USE_CASE_FIELD_ID,
objectUniversalIdentifier: STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier,
type: FieldType.TEXT,
name: 'useCase',
label: 'Use Case',
isNullable: true,
});

View File

@@ -0,0 +1,23 @@
import { FieldType, OnDeleteAction, RelationType, STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS, defineField } from 'twenty-sdk/define';
import { PARTNER_OBJECT_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
export const PARTNER_ON_OPPORTUNITY_FIELD_ID = 'd9eeacaa-2f9e-44cc-b5f6-5e1526256d49';
export const OPPORTUNITIES_ON_PARTNER_FIELD_ID = '8c04a5d4-c423-487e-bd78-7142a75b2896';
export default defineField({
universalIdentifier: PARTNER_ON_OPPORTUNITY_FIELD_ID,
objectUniversalIdentifier: STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier,
type: FieldType.RELATION,
name: 'partner',
label: 'Partner',
icon: 'IconBuildingStore',
isNullable: true,
relationTargetObjectMetadataUniversalIdentifier: PARTNER_OBJECT_UNIVERSAL_IDENTIFIER,
relationTargetFieldMetadataUniversalIdentifier: OPPORTUNITIES_ON_PARTNER_FIELD_ID,
universalSettings: {
relationType: RelationType.MANY_TO_ONE,
onDelete: OnDeleteAction.SET_NULL,
joinColumnName: 'partnerId',
},
});

View File

@@ -0,0 +1,20 @@
import { FieldType, OnDeleteAction, RelationType, STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS, defineField } from 'twenty-sdk/define';
export const PARTNER_USER_ON_OPPORTUNITY_FIELD_ID = '7022a40a-a954-4e6b-96b8-1faff0919ec0';
export const OPPORTUNITIES_AS_PARTNER_USER_FIELD_ID = 'b03a26e8-6d9d-4d70-930b-2006929c9869';
export default defineField({
universalIdentifier: PARTNER_USER_ON_OPPORTUNITY_FIELD_ID,
objectUniversalIdentifier: STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier,
type: FieldType.RELATION,
name: 'partnerUser',
label: 'Partner User',
isNullable: true,
relationTargetObjectMetadataUniversalIdentifier: STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.workspaceMember.universalIdentifier,
relationTargetFieldMetadataUniversalIdentifier: OPPORTUNITIES_AS_PARTNER_USER_FIELD_ID,
universalSettings: {
relationType: RelationType.MANY_TO_ONE,
onDelete: OnDeleteAction.SET_NULL,
joinColumnName: 'partnerUserId',
},
});

View File

@@ -0,0 +1,25 @@
import { FieldType, OnDeleteAction, RelationType, STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS, defineField } from 'twenty-sdk/define';
import { PARTNER_OBJECT_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
export const REFERRED_BY_PARTNER_ON_OPPORTUNITY_FIELD_ID = '70fb45ca-8be9-418c-b8f8-ce91da3676d0';
export const REFERRED_OPPORTUNITIES_ON_PARTNER_FIELD_ID = '85bd7d91-c38c-468f-bdb0-60883761ee24';
// Whose public profile page the brief was submitted from — not who works the deal
// (that is `partner`).
export default defineField({
universalIdentifier: REFERRED_BY_PARTNER_ON_OPPORTUNITY_FIELD_ID,
objectUniversalIdentifier: STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier,
type: FieldType.RELATION,
name: 'referredByPartner',
label: 'Referred by',
icon: 'IconShare',
isNullable: true,
relationTargetObjectMetadataUniversalIdentifier: PARTNER_OBJECT_UNIVERSAL_IDENTIFIER,
relationTargetFieldMetadataUniversalIdentifier: REFERRED_OPPORTUNITIES_ON_PARTNER_FIELD_ID,
universalSettings: {
relationType: RelationType.MANY_TO_ONE,
onDelete: OnDeleteAction.SET_NULL,
joinColumnName: 'referredByPartnerId',
},
});

View File

@@ -0,0 +1,18 @@
import { FieldType, RelationType, STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS, defineField } from 'twenty-sdk/define';
import { PARTNER_OBJECT_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
import { REFERRED_BY_PARTNER_ON_OPPORTUNITY_FIELD_ID, REFERRED_OPPORTUNITIES_ON_PARTNER_FIELD_ID } from './referred-by-partner-on-opportunity.field';
export default defineField({
universalIdentifier: REFERRED_OPPORTUNITIES_ON_PARTNER_FIELD_ID,
objectUniversalIdentifier: PARTNER_OBJECT_UNIVERSAL_IDENTIFIER,
type: FieldType.RELATION,
name: 'referredOpportunities',
label: 'Referred opportunities',
isNullable: true,
relationTargetObjectMetadataUniversalIdentifier: STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier,
relationTargetFieldMetadataUniversalIdentifier: REFERRED_BY_PARTNER_ON_OPPORTUNITY_FIELD_ID,
universalSettings: {
relationType: RelationType.ONE_TO_MANY,
},
});

View File

@@ -0,0 +1,103 @@
import { readFileSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { describe, expect, it } from 'vitest';
import {
DEAL_BOARD_STAGE_LABELS,
DISCORD_CHAT_URL,
HOW_TO_PROCESS_BODY_LINKS,
HOW_TO_PROCESS_STEPS,
} from './admin-copy';
const APPLICATION_OBJECT_SOURCE = readFileSync(
resolve(
dirname(fileURLToPath(import.meta.url)),
'../../../application/objects/application.object.ts',
),
'utf8',
);
describe('HOW_TO_PROCESS_STEPS', () => {
it('uses Deals-board stage labels from OPPORTUNITY_STAGE_OPTIONS', () => {
const expected = ['New', 'Screening', 'Meeting', 'Proposal', 'Customer'];
expect(DEAL_BOARD_STAGE_LABELS).toEqual(expected);
const winnerStep = HOW_TO_PROCESS_STEPS.find((step) => step.num === '06');
expect(winnerStep?.body).toContain(expected.join(' → '));
});
it('states the six automations the operator must not double-do', () => {
const text = HOW_TO_PROCESS_STEPS.map((step) =>
[step.body, step.note, ...(step.bullets ?? [])].join('\n'),
).join('\n');
expect(text).toMatch(/already Listed/i);
expect(text).toMatch(/Discord/i);
expect(text).toMatch(/Twenty Internal/);
expect(text).not.toMatch(/\bTFT\b/);
expect(text).toMatch(/daily digest/i);
expect(text).toMatch(/Intro Sent At/i);
expect(text).toMatch(/Listed.*off/i);
expect(text).toMatch(/keep it in My Applications/i);
expect(text).toMatch(/Won/i);
expect(text).toMatch(/Declined/i);
expect(text).toMatch(/Backup stays/i);
});
it('uses Application state labels from application.object.ts', () => {
for (const label of ['Applied', 'Invited', 'Won', 'Declined', 'Backup']) {
expect(APPLICATION_OBJECT_SOURCE).toContain(`label: '${label}'`);
}
const matchStep = HOW_TO_PROCESS_STEPS.find((step) => step.num === '04');
expect(matchStep?.pills?.join(' ')).toMatch(/Applied/);
expect(matchStep?.pills?.join(' ')).toMatch(/Invited/);
});
it('names each skill, says run locally, and points at GitHub', () => {
const prepareStep = HOW_TO_PROCESS_STEPS.find((step) => step.num === '02');
const matchStep = HOW_TO_PROCESS_STEPS.find((step) => step.num === '04');
const introStep = HOW_TO_PROCESS_STEPS.find((step) => step.num === '05');
const leadBrief = prepareStep?.skills?.[0];
const shortlist = matchStep?.skills?.[0];
const intro = introStep?.skills?.[0];
expect(prepareStep?.body).toMatch(/Design Doc URL/);
expect(prepareStep?.body).not.toMatch(/run locally/i);
expect(leadBrief?.name).toBe('twenty-lead-brief');
expect(leadBrief?.trigger).toBe('/twenty-lead-brief');
expect(leadBrief?.githubUrl).toContain(
'src/skills/twenty-lead-brief/SKILL.md',
);
expect(leadBrief?.outputs.join('\n')).toMatch(/Design Doc URL/);
expect(leadBrief?.outputs.join('\n')).toMatch(/partner-match-criteria/);
expect(matchStep?.body).toContain('/twenty-partner-shortlist');
expect(matchStep?.body).toContain('/twenty-lead-brief');
expect(matchStep?.body).toContain('/twenty-partner-intro');
expect(matchStep?.body).toMatch(/MCP/);
expect(matchStep?.body).toMatch(/changes in the CRM/i);
expect(shortlist?.name).toBe('twenty-partner-shortlist');
expect(shortlist?.githubUrl).toContain(
'src/skills/twenty-partner-shortlist/SKILL.md',
);
expect(introStep?.body).toContain('/twenty-partner-intro');
expect(intro?.name).toBe('twenty-partner-intro');
expect(intro?.githubUrl).toContain(
'src/skills/twenty-partner-intro/SKILL.md',
);
});
it('links Discord', () => {
expect(HOW_TO_PROCESS_BODY_LINKS).toEqual([
{ label: 'Discord', href: DISCORD_CHAT_URL },
]);
expect(DISCORD_CHAT_URL).toBe(
'https://discord.com/channels/1130383047699738754/1513506376595538032',
);
});
});

View File

@@ -0,0 +1,70 @@
import { OPPORTUNITY_STAGE_OPTIONS } from 'src/modules/opportunity/constants/opportunity-stage-options';
import {
PLAYBOOK_SKILL_INTRO,
PLAYBOOK_SKILL_LEAD_BRIEF,
PLAYBOOK_SKILL_SHORTLIST,
} from 'src/modules/opportunity/how-to-process/constants/playbook-skills';
import { type PlaybookStep } from 'src/modules/opportunity/how-to-process/types/playbook-step.type';
import { type PlaybookLink } from 'src/modules/opportunity/how-to-process/utils/playbook-nav';
export const DISCORD_CHAT_URL =
'https://discord.com/channels/1130383047699738754/1513506376595538032';
export const HOW_TO_PROCESS_KICKER = 'Playbook';
export const HOW_TO_PROCESS_TITLE = 'How to process';
export const HOW_TO_PROCESS_LEDE =
'This is the path from a new deal to a winner. Each step says what you do, then what the app does.';
export const HOW_TO_PROCESS_BODY_LINKS: readonly PlaybookLink[] = [
{ label: 'Discord', href: DISCORD_CHAT_URL },
];
export const DEAL_BOARD_STAGE_LABELS = OPPORTUNITY_STAGE_OPTIONS.filter(
(option) => option.value !== 'DONE' && option.value !== 'DEAD',
).map((option) => option.label);
const DEAL_STAGE_PATH = DEAL_BOARD_STAGE_LABELS.join(' → ');
export const HOW_TO_PROCESS_STEPS: PlaybookStep[] = [
{
num: '01',
heading: 'Where the deal comes from',
body: 'Three intakes. Treat each one differently.',
bullets: [
'Marketplace brief — already Listed. Discord gets a ping on submit. Do not list it again.',
'Twenty Internal — not listed. You list it later.',
'Sales call — /twenty-lead-brief writes the Opportunity and the Design Doc URL.',
],
},
{
num: '02',
heading: 'Prepare the brief',
body: 'Fill Need, Requirements, and Design Doc URL from the skill output. Do not intro without the doc. Partner mail carries that link.',
skills: [PLAYBOOK_SKILL_LEAD_BRIEF],
},
{
num: '03',
heading: 'List, or do not',
body: 'Turn Listed on when partners may see the brief. Open Briefs then shows the row. Discord pings on that flip. A daily digest mails validated partners. Do not also mail the whole directory.',
note: 'A marketplace brief is already Listed. Do not list twice.',
},
{
num: '04',
heading: 'Match',
body: 'Briefs to Match is the queue with no Partner yet. You can do all of this through MCP. Ask the agent to make the changes in the CRM. Skills are also available: /twenty-partner-shortlist, or wait for applications. /twenty-lead-brief is available if the brief is still thin. /twenty-partner-intro is available once you name the partners.',
pills: ['Applied — the partner came in', 'Invited — you pushed them'],
skills: [PLAYBOOK_SKILL_SHORTLIST],
},
{
num: '05',
heading: 'Intro',
body: 'After you name the partners, /twenty-partner-intro creates Invited rows and sets Intro Sent At. The app then turns Listed off. Turning Listed off removes the brief from Open Briefs, but applied and invited partners keep it in My Applications.',
note: 'Do not send a second wave unless you mean to. Unlisting is not a way to hide the brief from the partners who already applied.',
skills: [PLAYBOOK_SKILL_INTRO],
},
{
num: '06',
heading: 'Pick the winner',
body: `Set Partner on the Opportunity. The winner Application goes to Won. The others go to Declined. Backup stays. Clear Partner and only Won returns to Applied. Move the deal on Deals: ${DEAL_STAGE_PATH}.`,
},
];

View File

@@ -0,0 +1,26 @@
import { readFileSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { describe, expect, it } from 'vitest';
const AGENTS_MD = readFileSync(
resolve(dirname(fileURLToPath(import.meta.url)), '../../../../../AGENTS.md'),
'utf8',
);
describe('AGENTS.md opportunity playbooks', () => {
it('requires lockstep updates for How to process and How to apply', () => {
expect(AGENTS_MD).toContain('How to process');
expect(AGENTS_MD).toContain('How to apply');
expect(AGENTS_MD).toContain('lockstep');
expect(AGENTS_MD).toContain('Open Briefs');
expect(AGENTS_MD).toContain('My Applications');
expect(AGENTS_MD).toMatch(/operator language/i);
expect(AGENTS_MD).toContain('twenty-lead-brief');
expect(AGENTS_MD).toContain('GitHub');
expect(AGENTS_MD).toContain('Twenty Internal');
expect(AGENTS_MD).toContain('MCP');
expect(AGENTS_MD).toMatch(/Do not open Apply/);
});
});

View File

@@ -0,0 +1,19 @@
export const HOW_TO_PROCESS_FRONT_COMPONENT_ID =
'1fb20c84-f25e-4e6e-ac26-ac973fe45c03';
export const HOW_TO_PROCESS_PAGE_LAYOUT_ID =
'0fcc49da-fd43-4f96-9b61-9bf32bd7e18b';
export const HOW_TO_PROCESS_PAGE_TAB_ID =
'56b8edd7-5d3b-4928-be03-96969bb3670e';
export const HOW_TO_PROCESS_PAGE_WIDGET_ID =
'756fc44b-9a05-41fa-a171-2148ab1536c4';
export const HOW_TO_PROCESS_NAV_ITEM_ID =
'23a068ec-8f9f-496e-9b58-22aae5348682';
export const HOW_TO_APPLY_FRONT_COMPONENT_ID =
'3770f0a4-408c-42cf-ac80-fe1752b40625';
export const HOW_TO_APPLY_PAGE_LAYOUT_ID =
'a35eb9dd-c828-4bad-8bfb-986e1c9e9cde';
export const HOW_TO_APPLY_PAGE_TAB_ID = '8ec88001-040c-474a-af00-372d87a51501';
export const HOW_TO_APPLY_PAGE_WIDGET_ID =
'3ceda86c-e3ab-447c-9be6-b4a692c3d71a';
export const HOW_TO_APPLY_NAV_ITEM_ID = 'b8d751cf-978c-4291-89cb-478d639a8539';

View File

@@ -0,0 +1,57 @@
import { describe, expect, it } from 'vitest';
import {
MIN_PITCH_LENGTH,
PITCHABLE_STATES,
} from 'src/modules/application/apply/constants/apply-to-brief.constants';
import {
HOW_TO_APPLY_BODY_LINKS,
HOW_TO_APPLY_HEADER_LINKS,
HOW_TO_APPLY_STEPS,
} from './partner-copy';
describe('HOW_TO_APPLY_STEPS', () => {
it('uses MIN_PITCH_LENGTH for the pitch rule', () => {
const applyStep = HOW_TO_APPLY_STEPS.find((step) => step.num === '02');
expect(applyStep?.body).toContain(String(MIN_PITCH_LENGTH));
});
it('states one application, pitch not editable, and Won/Declined are not partner-set', () => {
const text = HOW_TO_APPLY_STEPS.map((step) =>
[step.body, step.note, ...(step.bullets ?? [])].join('\n'),
).join('\n');
expect(text).toMatch(/one application per brief/i);
expect(text).toMatch(/cannot edit/i);
expect(text).toMatch(/Won/);
expect(text).toMatch(/Declined/);
expect(text).toMatch(/do not set those/i);
expect(text).toMatch(/My Applications still shows the brief/i);
});
it('names unpitchable states as the complement of PITCHABLE_STATES', () => {
const applyStep = HOW_TO_APPLY_STEPS.find((step) => step.num === '02');
expect([...PITCHABLE_STATES].sort()).toEqual([
'APPLIED',
'BACKUP',
'INVITED',
]);
expect(applyStep?.note).toContain('Introduced');
expect(applyStep?.note).toContain('Won');
expect(applyStep?.note).toContain('Declined');
expect(applyStep?.note).toMatch(/cannot take a pitch/i);
});
it('links Open Briefs and My Applications at the top, not Apply', () => {
expect(HOW_TO_APPLY_HEADER_LINKS).toEqual([
{ label: 'Open Briefs', action: 'openBriefs' },
{ label: 'My Applications', action: 'myApplications' },
]);
expect(HOW_TO_APPLY_BODY_LINKS).toBe(HOW_TO_APPLY_HEADER_LINKS);
const applyStep = HOW_TO_APPLY_STEPS.find((step) => step.num === '02');
expect(applyStep?.body).toMatch(/Apply at the top/i);
});
});

View File

@@ -0,0 +1,35 @@
import { MIN_PITCH_LENGTH } from 'src/modules/application/apply/constants/apply-to-brief.constants';
import { type PlaybookStep } from 'src/modules/opportunity/how-to-process/types/playbook-step.type';
import { type PlaybookLink } from 'src/modules/opportunity/how-to-process/utils/playbook-nav';
export const HOW_TO_APPLY_KICKER = 'Playbook';
export const HOW_TO_APPLY_TITLE = 'How to apply';
export const HOW_TO_APPLY_LEDE =
'Open Briefs is the live marketplace. Apply is pinned at the top of each brief. Twenty picks the winner.';
export const HOW_TO_APPLY_HEADER_LINKS: readonly PlaybookLink[] = [
{ label: 'Open Briefs', action: 'openBriefs' },
{ label: 'My Applications', action: 'myApplications' },
];
export const HOW_TO_APPLY_BODY_LINKS = HOW_TO_APPLY_HEADER_LINKS;
export const HOW_TO_APPLY_STEPS: PlaybookStep[] = [
{
num: '01',
heading: 'Open Briefs',
body: 'Open Briefs is the live marketplace. A row is here only when Twenty has listed the brief. If it is gone, the brief is closed or already in intro.',
},
{
num: '02',
heading: 'Apply',
body: `Open the brief. Use Apply at the top of the record. Write a pitch of at least ${MIN_PITCH_LENGTH} characters. The pitch is set once. You cannot edit it later. One application per brief. A second apply is rejected.`,
note: 'If Twenty already invited you, Apply fills the pitch on that row. The state stays Invited or Backup. Introduced, Won, and Declined cannot take a pitch.',
},
{
num: '03',
heading: 'What happens next',
body: 'Your row lands in My Applications as Applied. Twenty may also invite you. Then the row is Invited. After an intro, wait for Twenty. Do not chase the client unless Twenty asks. Won or Declined is set when Twenty picks the Partner. You do not set those yourself.',
note: 'A listed brief can still have a decided row. Listing and the winner field are not the same thing. My Applications still shows the brief after it leaves Open Briefs.',
},
];

View File

@@ -0,0 +1,25 @@
import { existsSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { describe, expect, it } from 'vitest';
import { PLAYBOOK_SKILLS } from './playbook-skills';
const SKILLS_ROOT = resolve(
dirname(fileURLToPath(import.meta.url)),
'../../../../skills',
);
describe('PLAYBOOK_SKILLS', () => {
it('points each GitHub URL at a SKILL.md that exists in this app', () => {
for (const skill of PLAYBOOK_SKILLS) {
expect(existsSync(resolve(SKILLS_ROOT, skill.name, 'SKILL.md'))).toBe(
true,
);
expect(skill.githubUrl).toBe(
`https://github.com/twentyhq/twenty/blob/main/packages/twenty-apps/internal/twenty-partners/src/skills/${skill.name}/SKILL.md`,
);
}
});
});

View File

@@ -0,0 +1,49 @@
const SKILL_GITHUB_BASE =
'https://github.com/twentyhq/twenty/blob/main/packages/twenty-apps/internal/twenty-partners/src/skills';
export type PlaybookSkill = {
name: string;
trigger: string;
githubUrl: string;
outputs: readonly string[];
};
export const PLAYBOOK_SKILL_LEAD_BRIEF: PlaybookSkill = {
name: 'twenty-lead-brief',
trigger: '/twenty-lead-brief',
githubUrl: `${SKILL_GITHUB_BASE}/twenty-lead-brief/SKILL.md`,
outputs: [
'A qualification summary in the conversation',
'A partner brief — paste it into a Google Doc',
'partner-match-criteria.md for the match step',
'The Opportunity, with Design Doc URL set to that Doc',
],
};
export const PLAYBOOK_SKILL_SHORTLIST: PlaybookSkill = {
name: 'twenty-partner-shortlist',
trigger: '/twenty-partner-shortlist',
githubUrl: `${SKILL_GITHUB_BASE}/twenty-partner-shortlist/SKILL.md`,
outputs: [
'A shortlist with a reason for each name',
'partner-shortlist.md in the lead folder',
'No CRM write — you review, then intro',
],
};
export const PLAYBOOK_SKILL_INTRO: PlaybookSkill = {
name: 'twenty-partner-intro',
trigger: '/twenty-partner-intro',
githubUrl: `${SKILL_GITHUB_BASE}/twenty-partner-intro/SKILL.md`,
outputs: [
'Invited application rows for the names you picked',
'Intro Sent At on the Opportunity',
'Gmail drafts that carry the Design Doc URL',
],
};
export const PLAYBOOK_SKILLS: readonly PlaybookSkill[] = [
PLAYBOOK_SKILL_LEAD_BRIEF,
PLAYBOOK_SKILL_SHORTLIST,
PLAYBOOK_SKILL_INTRO,
];

View File

@@ -0,0 +1,31 @@
import { defineFrontComponent } from 'twenty-sdk/define';
import { HOW_TO_APPLY_FRONT_COMPONENT_ID } from 'src/modules/opportunity/how-to-process/constants/how-to-process.constants';
import {
HOW_TO_APPLY_BODY_LINKS,
HOW_TO_APPLY_HEADER_LINKS,
HOW_TO_APPLY_KICKER,
HOW_TO_APPLY_LEDE,
HOW_TO_APPLY_STEPS,
HOW_TO_APPLY_TITLE,
} from 'src/modules/opportunity/how-to-process/constants/partner-copy';
import { PlaybookArticle } from 'src/modules/opportunity/how-to-process/front-components/playbook-article';
const HowToApply = () => (
<PlaybookArticle
kicker={HOW_TO_APPLY_KICKER}
title={HOW_TO_APPLY_TITLE}
lede={HOW_TO_APPLY_LEDE}
steps={HOW_TO_APPLY_STEPS}
headerLinks={HOW_TO_APPLY_HEADER_LINKS}
bodyLinks={HOW_TO_APPLY_BODY_LINKS}
/>
);
export default defineFrontComponent({
universalIdentifier: HOW_TO_APPLY_FRONT_COMPONENT_ID,
name: 'How to apply',
description:
'Partner playbook for Open Briefs and apply in Partner Workspace.',
component: HowToApply,
});

View File

@@ -0,0 +1,31 @@
import { defineFrontComponent } from 'twenty-sdk/define';
import {
HOW_TO_PROCESS_BODY_LINKS,
HOW_TO_PROCESS_KICKER,
HOW_TO_PROCESS_LEDE,
HOW_TO_PROCESS_STEPS,
HOW_TO_PROCESS_TITLE,
} from 'src/modules/opportunity/how-to-process/constants/admin-copy';
import { HOW_TO_PROCESS_FRONT_COMPONENT_ID } from 'src/modules/opportunity/how-to-process/constants/how-to-process.constants';
import { PLAYBOOK_SKILLS } from 'src/modules/opportunity/how-to-process/constants/playbook-skills';
import { PlaybookArticle } from 'src/modules/opportunity/how-to-process/front-components/playbook-article';
const HowToProcess = () => (
<PlaybookArticle
kicker={HOW_TO_PROCESS_KICKER}
title={HOW_TO_PROCESS_TITLE}
lede={HOW_TO_PROCESS_LEDE}
steps={HOW_TO_PROCESS_STEPS}
bodyLinks={HOW_TO_PROCESS_BODY_LINKS}
skills={PLAYBOOK_SKILLS}
/>
);
export default defineFrontComponent({
universalIdentifier: HOW_TO_PROCESS_FRONT_COMPONENT_ID,
name: 'How to process',
description:
'Admin playbook for the opportunity path in Matching Admin Workspace.',
component: HowToProcess,
});

View File

@@ -0,0 +1,100 @@
import { type PlaybookSkill } from 'src/modules/opportunity/how-to-process/constants/playbook-skills';
import { PlaybookMarkedText } from 'src/modules/opportunity/how-to-process/front-components/playbook-marked-text';
import { PlaybookNavLink } from 'src/modules/opportunity/how-to-process/front-components/playbook-nav-link';
import { PlaybookSkillBlock } from 'src/modules/opportunity/how-to-process/front-components/playbook-skill-block';
import { type PlaybookStep } from 'src/modules/opportunity/how-to-process/types/playbook-step.type';
import { type PlaybookLink } from 'src/modules/opportunity/how-to-process/utils/playbook-nav';
import { PLAYBOOK_STYLES as styles } from 'src/modules/shared/front-components/playbook-styles';
type PlaybookArticleProps = {
kicker: string;
title: string;
lede: string;
steps: PlaybookStep[];
headerLinks?: ReadonlyArray<PlaybookLink>;
bodyLinks?: ReadonlyArray<PlaybookLink>;
skills?: ReadonlyArray<PlaybookSkill>;
};
export const PlaybookArticle = ({
kicker,
title,
lede,
steps,
headerLinks,
bodyLinks = [],
skills = [],
}: PlaybookArticleProps) => (
<div style={styles.root}>
<article style={styles.article}>
<p style={styles.kicker}>{kicker}</p>
<h1 style={styles.title}>{title}</h1>
<p style={headerLinks !== undefined ? styles.ledeWithLinks : styles.lede}>
<PlaybookMarkedText text={lede} links={bodyLinks} skills={skills} />
</p>
{headerLinks !== undefined ? (
<nav style={styles.headerLinkRow}>
{headerLinks.map((link) => (
<PlaybookNavLink key={link.label} link={link}>
{link.label}
</PlaybookNavLink>
))}
</nav>
) : null}
{steps.map((step, index) => (
<section
key={step.num}
style={index === steps.length - 1 ? styles.lastStep : styles.step}
>
<div style={styles.num}>{step.num}</div>
<div>
<h2 style={styles.heading}>{step.heading}</h2>
<p style={styles.body}>
<PlaybookMarkedText
text={step.body}
links={bodyLinks}
skills={skills}
/>
</p>
{step.pills !== undefined ? (
<div style={styles.pillRow}>
{step.pills.map((pill) => (
<span key={pill} style={styles.pill}>
{pill}
</span>
))}
</div>
) : null}
{step.bullets !== undefined ? (
<ul style={styles.list}>
{step.bullets.map((bullet) => (
<li key={bullet}>
<PlaybookMarkedText
text={bullet}
links={bodyLinks}
skills={skills}
/>
</li>
))}
</ul>
) : null}
{step.skills !== undefined
? step.skills.map((skill) => (
<PlaybookSkillBlock key={skill.name} skill={skill} />
))
: null}
{step.note !== undefined ? (
<p style={styles.note}>
<PlaybookMarkedText
text={step.note}
links={bodyLinks}
skills={skills}
/>
</p>
) : null}
</div>
</section>
))}
</article>
</div>
);

View File

@@ -0,0 +1,45 @@
import { type PlaybookSkill } from 'src/modules/opportunity/how-to-process/constants/playbook-skills';
import { PlaybookNavLink } from 'src/modules/opportunity/how-to-process/front-components/playbook-nav-link';
import { type PlaybookLink } from 'src/modules/opportunity/how-to-process/utils/playbook-nav';
import { splitPlaybookMarks } from 'src/modules/opportunity/how-to-process/utils/split-playbook-marks';
import { PLAYBOOK_STYLES as styles } from 'src/modules/shared/front-components/playbook-styles';
type PlaybookMarkedTextProps = {
text: string;
links?: ReadonlyArray<PlaybookLink>;
skills?: ReadonlyArray<PlaybookSkill>;
};
export const PlaybookMarkedText = ({
text,
links = [],
skills = [],
}: PlaybookMarkedTextProps) => (
<>
{splitPlaybookMarks(text, { links, skills }).map((mark, index) => {
if (mark.kind === 'skill') {
return (
<a
key={`${mark.value}-${index}`}
href={mark.githubUrl}
target="_blank"
rel="noreferrer"
style={styles.link}
>
<code style={styles.code}>{mark.value}</code>
</a>
);
}
if (mark.kind === 'link') {
return (
<PlaybookNavLink key={`${mark.value}-${index}`} link={mark.link}>
{mark.value}
</PlaybookNavLink>
);
}
return <span key={`text-${index}`}>{mark.value}</span>;
})}
</>
);

View File

@@ -0,0 +1,32 @@
import {
type PlaybookLink,
runPlaybookNav,
} from 'src/modules/opportunity/how-to-process/utils/playbook-nav';
import { PLAYBOOK_STYLES as styles } from 'src/modules/shared/front-components/playbook-styles';
type PlaybookNavLinkProps = {
link: PlaybookLink;
children: string;
};
export const PlaybookNavLink = ({ link, children }: PlaybookNavLinkProps) => {
if ('action' in link) {
return (
<button
type="button"
style={styles.linkButton}
onClick={() => {
void runPlaybookNav(link.action);
}}
>
{children}
</button>
);
}
return (
<a href={link.href} target="_blank" rel="noreferrer" style={styles.link}>
{children}
</a>
);
};

View File

@@ -0,0 +1,29 @@
import { type PlaybookSkill } from 'src/modules/opportunity/how-to-process/constants/playbook-skills';
import { PLAYBOOK_STYLES as styles } from 'src/modules/shared/front-components/playbook-styles';
type PlaybookSkillBlockProps = {
skill: PlaybookSkill;
};
export const PlaybookSkillBlock = ({ skill }: PlaybookSkillBlockProps) => (
<div style={styles.skill}>
<p style={styles.skillKicker}>Run locally</p>
<p style={styles.body}>
<code style={styles.code}>{skill.trigger}</code>
{' · '}
<a
href={skill.githubUrl}
target="_blank"
rel="noreferrer"
style={styles.link}
>
View on GitHub
</a>
</p>
<ul style={styles.skillOutputs}>
{skill.outputs.map((output) => (
<li key={output}>{output}</li>
))}
</ul>
</div>
);

View File

@@ -0,0 +1,20 @@
import {
NavigationMenuItemType,
defineNavigationMenuItem,
} from 'twenty-sdk/define';
import {
HOW_TO_APPLY_NAV_ITEM_ID,
HOW_TO_APPLY_PAGE_LAYOUT_ID,
} from 'src/modules/opportunity/how-to-process/constants/how-to-process.constants';
import { PARTNER_WORKSPACE_FOLDER_UNIVERSAL_IDENTIFIER } from 'src/modules/shared/navigation-menu-items/partner-workspace-folder.navigation-menu-item';
export default defineNavigationMenuItem({
universalIdentifier: HOW_TO_APPLY_NAV_ITEM_ID,
name: 'How to apply',
type: NavigationMenuItemType.PAGE_LAYOUT,
icon: 'IconListCheck',
position: 0,
folderUniversalIdentifier: PARTNER_WORKSPACE_FOLDER_UNIVERSAL_IDENTIFIER,
pageLayoutUniversalIdentifier: HOW_TO_APPLY_PAGE_LAYOUT_ID,
});

View File

@@ -0,0 +1,19 @@
import {
NavigationMenuItemType,
defineNavigationMenuItem,
} from 'twenty-sdk/define';
import {
HOW_TO_PROCESS_NAV_ITEM_ID,
HOW_TO_PROCESS_PAGE_LAYOUT_ID,
} from 'src/modules/opportunity/how-to-process/constants/how-to-process.constants';
export default defineNavigationMenuItem({
universalIdentifier: HOW_TO_PROCESS_NAV_ITEM_ID,
name: 'How to process',
type: NavigationMenuItemType.PAGE_LAYOUT,
icon: 'IconListCheck',
position: 10,
folderUniversalIdentifier: '0b2e499a-ae74-45e0-af08-243e19fc56aa',
pageLayoutUniversalIdentifier: HOW_TO_PROCESS_PAGE_LAYOUT_ID,
});

View File

@@ -0,0 +1,34 @@
import { PageLayoutTabLayoutMode, definePageLayout } from 'twenty-sdk/define';
import {
HOW_TO_APPLY_FRONT_COMPONENT_ID,
HOW_TO_APPLY_PAGE_LAYOUT_ID,
HOW_TO_APPLY_PAGE_TAB_ID,
HOW_TO_APPLY_PAGE_WIDGET_ID,
} from 'src/modules/opportunity/how-to-process/constants/how-to-process.constants';
export default definePageLayout({
universalIdentifier: HOW_TO_APPLY_PAGE_LAYOUT_ID,
name: 'How to apply',
type: 'STANDALONE_PAGE',
tabs: [
{
universalIdentifier: HOW_TO_APPLY_PAGE_TAB_ID,
title: 'How to apply',
position: 0,
icon: 'IconListCheck',
layoutMode: PageLayoutTabLayoutMode.CANVAS,
widgets: [
{
universalIdentifier: HOW_TO_APPLY_PAGE_WIDGET_ID,
title: 'How to apply',
type: 'FRONT_COMPONENT',
configuration: {
configurationType: 'FRONT_COMPONENT',
frontComponentUniversalIdentifier: HOW_TO_APPLY_FRONT_COMPONENT_ID,
},
},
],
},
],
});

View File

@@ -0,0 +1,35 @@
import { PageLayoutTabLayoutMode, definePageLayout } from 'twenty-sdk/define';
import {
HOW_TO_PROCESS_FRONT_COMPONENT_ID,
HOW_TO_PROCESS_PAGE_LAYOUT_ID,
HOW_TO_PROCESS_PAGE_TAB_ID,
HOW_TO_PROCESS_PAGE_WIDGET_ID,
} from 'src/modules/opportunity/how-to-process/constants/how-to-process.constants';
export default definePageLayout({
universalIdentifier: HOW_TO_PROCESS_PAGE_LAYOUT_ID,
name: 'How to process',
type: 'STANDALONE_PAGE',
tabs: [
{
universalIdentifier: HOW_TO_PROCESS_PAGE_TAB_ID,
title: 'How to process',
position: 0,
icon: 'IconListCheck',
layoutMode: PageLayoutTabLayoutMode.CANVAS,
widgets: [
{
universalIdentifier: HOW_TO_PROCESS_PAGE_WIDGET_ID,
title: 'How to process',
type: 'FRONT_COMPONENT',
configuration: {
configurationType: 'FRONT_COMPONENT',
frontComponentUniversalIdentifier:
HOW_TO_PROCESS_FRONT_COMPONENT_ID,
},
},
],
},
],
});

View File

@@ -0,0 +1,11 @@
import { type PlaybookSkill } from 'src/modules/opportunity/how-to-process/constants/playbook-skills';
export type PlaybookStep = {
num: string;
heading: string;
body: string;
bullets?: string[];
pills?: string[];
note?: string;
skills?: readonly PlaybookSkill[];
};

View File

@@ -0,0 +1,18 @@
import { readFileSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { describe, expect, it } from 'vitest';
const PLAYBOOK_NAV_SOURCE = readFileSync(
resolve(dirname(fileURLToPath(import.meta.url)), './playbook-nav.ts'),
'utf8',
);
describe('runPlaybookNav', () => {
it('does not open Apply from a standalone playbook page', () => {
expect(PLAYBOOK_NAV_SOURCE).not.toContain('APPLY_TO_BRIEF');
expect(PLAYBOOK_NAV_SOURCE).not.toContain('openSidePanelPage');
expect(PLAYBOOK_NAV_SOURCE).not.toMatch(/'apply'/);
});
});

View File

@@ -0,0 +1,31 @@
import { AppPath, navigate } from 'twenty-sdk/front-component';
import { MY_APPLICATIONS_VIEW_UNIVERSAL_IDENTIFIER } from 'src/modules/application/views/my-applications.view';
import { OPEN_BRIEFS_VIEW_UNIVERSAL_IDENTIFIER } from 'src/modules/opportunity/views/open-briefs.view';
export type PlaybookNavAction = 'openBriefs' | 'myApplications';
export type PlaybookLink =
| { label: string; href: string }
| { label: string; action: PlaybookNavAction };
export const runPlaybookNav = async (action: PlaybookNavAction) => {
switch (action) {
case 'openBriefs': {
await navigate(
AppPath.RecordIndexPage,
{ objectNamePlural: 'opportunities' },
{ viewId: OPEN_BRIEFS_VIEW_UNIVERSAL_IDENTIFIER },
);
return;
}
case 'myApplications': {
await navigate(
AppPath.RecordIndexPage,
{ objectNamePlural: 'applications' },
{ viewId: MY_APPLICATIONS_VIEW_UNIVERSAL_IDENTIFIER },
);
return;
}
}
};

View File

@@ -0,0 +1,101 @@
import { describe, expect, it } from 'vitest';
import { PLAYBOOK_SKILL_LEAD_BRIEF } from 'src/modules/opportunity/how-to-process/constants/playbook-skills';
import { splitPlaybookMarks } from 'src/modules/opportunity/how-to-process/utils/split-playbook-marks';
describe('splitPlaybookMarks', () => {
it('wraps skill triggers as GitHub-linked code tokens with leftover text', () => {
const marks = splitPlaybookMarks('Run /twenty-lead-brief locally.', {
skills: [PLAYBOOK_SKILL_LEAD_BRIEF],
});
expect(marks).toEqual([
{ kind: 'text', value: 'Run ' },
{
kind: 'skill',
value: '/twenty-lead-brief',
githubUrl: PLAYBOOK_SKILL_LEAD_BRIEF.githubUrl,
},
{ kind: 'text', value: ' locally.' },
]);
});
it('prefers the longer skill trigger over the skill name', () => {
const marks = splitPlaybookMarks(
'twenty-lead-brief vs /twenty-lead-brief',
{ skills: [PLAYBOOK_SKILL_LEAD_BRIEF] },
);
expect(marks).toEqual([
{
kind: 'skill',
value: 'twenty-lead-brief',
githubUrl: PLAYBOOK_SKILL_LEAD_BRIEF.githubUrl,
},
{ kind: 'text', value: ' vs ' },
{
kind: 'skill',
value: '/twenty-lead-brief',
githubUrl: PLAYBOOK_SKILL_LEAD_BRIEF.githubUrl,
},
]);
});
it('does not treat Applied as Apply', () => {
const marks = splitPlaybookMarks('lands as Applied.', {
links: [{ label: 'Apply', action: 'openBriefs' }],
});
expect(marks).toEqual([{ kind: 'text', value: 'lands as Applied.' }]);
});
it('links Open Briefs and My Applications labels', () => {
const openBriefs = {
label: 'Open Briefs',
action: 'openBriefs' as const,
};
const myApplications = {
label: 'My Applications',
action: 'myApplications' as const,
};
const marks = splitPlaybookMarks('See Open Briefs and My Applications.', {
links: [openBriefs, myApplications],
});
expect(marks).toEqual([
{ kind: 'text', value: 'See ' },
{ kind: 'link', value: 'Open Briefs', link: openBriefs },
{ kind: 'text', value: ' and ' },
{ kind: 'link', value: 'My Applications', link: myApplications },
{ kind: 'text', value: '.' },
]);
});
it('links href terms', () => {
const discord = {
label: 'Discord',
href: 'https://discord.com/channels/1130383047699738754/1513506376595538032',
};
expect(
splitPlaybookMarks('Ping Discord after list.', { links: [discord] }),
).toEqual([
{ kind: 'text', value: 'Ping ' },
{ kind: 'link', value: 'Discord', link: discord },
{ kind: 'text', value: ' after list.' },
]);
});
it('returns a single text mark when there are no terms or no matches', () => {
expect(splitPlaybookMarks('')).toEqual([{ kind: 'text', value: '' }]);
expect(splitPlaybookMarks('nothing to mark')).toEqual([
{ kind: 'text', value: 'nothing to mark' },
]);
expect(
splitPlaybookMarks('nothing to mark', {
skills: [PLAYBOOK_SKILL_LEAD_BRIEF],
}),
).toEqual([{ kind: 'text', value: 'nothing to mark' }]);
});
});

View File

@@ -0,0 +1,90 @@
import { type PlaybookSkill } from 'src/modules/opportunity/how-to-process/constants/playbook-skills';
import { type PlaybookLink } from 'src/modules/opportunity/how-to-process/utils/playbook-nav';
export type PlaybookMark =
| { kind: 'text'; value: string }
| { kind: 'skill'; value: string; githubUrl: string }
| { kind: 'link'; value: string; link: PlaybookLink };
type SplitPlaybookMarksOptions = {
links?: ReadonlyArray<PlaybookLink>;
skills?: ReadonlyArray<PlaybookSkill>;
};
const escapeRegExp = (value: string): string =>
value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
export const splitPlaybookMarks = (
text: string,
options: SplitPlaybookMarksOptions = {},
): PlaybookMark[] => {
const links = options.links ?? [];
const skills = options.skills ?? [];
const terms = [
...skills.flatMap((skill) => [
{
kind: 'skill' as const,
value: skill.trigger,
githubUrl: skill.githubUrl,
},
{
kind: 'skill' as const,
value: skill.name,
githubUrl: skill.githubUrl,
},
]),
...links.map((link) => ({
kind: 'link' as const,
value: link.label,
link,
})),
].sort((left, right) => right.value.length - left.value.length);
if (terms.length === 0) {
return [{ kind: 'text', value: text }];
}
const pattern = new RegExp(
`(${terms
.map((term) => {
const escaped = escapeRegExp(term.value);
return term.kind === 'link' ? `\\b${escaped}\\b` : escaped;
})
.join('|')})`,
'g',
);
const marks: PlaybookMark[] = [];
let lastIndex = 0;
for (const match of text.matchAll(pattern)) {
const matched = match[1];
const index = match.index ?? 0;
if (index > lastIndex) {
marks.push({ kind: 'text', value: text.slice(lastIndex, index) });
}
const term = terms.find((candidate) => candidate.value === matched);
if (term?.kind === 'skill') {
marks.push({
kind: 'skill',
value: matched,
githubUrl: term.githubUrl,
});
} else if (term?.kind === 'link') {
marks.push({ kind: 'link', value: matched, link: term.link });
} else {
marks.push({ kind: 'text', value: matched });
}
lastIndex = index + matched.length;
}
if (lastIndex < text.length) {
marks.push({ kind: 'text', value: text.slice(lastIndex) });
}
return marks;
};

Some files were not shown because too many files have changed in this diff Show More