v1.3.2 — Modularize partners app into vertical-slice modules/ (#23168)

**Version:** `twenty-partners@1.3.2` (patch — internal refactor, no
visible behavior change)

## What & why

Reorganizes the `twenty-partners` SDK app from a flat, type-first layout
(`src/{objects,fields,views,logic-functions,front-components,…}`) into a
**vertical-slice** layout under `src/modules/<domain>/<feature>/`. Files
that
change together now live together; each SDK entrypoint is a thin
discoverable
shim over a service + graphql-ops + mapper/connector layer.

This is a pure structural refactor — **no object, field, view, enum,
logic
function, trigger, role, or application variable changed.**

## Final layout

```
src/modules/
  shared/        http · services · graphql · utils · front-components · navigation-menu-items (cross-domain nav folders)
  opportunity/   fields·view-fields·views·navigation-menu-items·page-layouts·constants + intake/ + matching/
  partner/       objects·fields·constants·utils + directory/ · self-service/ · marketplace/ · application-intake/ (Discord connector/)
  application/   objects·fields·views·navigation-menu-items·page-layouts + services · graphql
```

Every `defineLogicFunction` entrypoint is now a thin
`*.logic-function.ts`
(all < 40 lines) at its domain/feature root, delegating to a
`*.service.ts`;
graphql operations live in `graphql/{queries,mutations}/`, pure
transforms in
`mappers/`, outbound APIs (the Discord webhook) in `connector/`, pure
helpers
in `utils/`.

## Safety — the load-bearing invariant

The server diffs app primitives by `universalIdentifier`, so a
changed/dropped
UUID would drop-and-recreate the object on prod (data loss). This branch
holds
that line:

- **887 `universalIdentifier`s byte-identical** to the branch base
(every
relocation is a `git mv`; every extracted entrypoint keeps its original
UUID/name/trigger verbatim). Re-verified byte-identical across the
rebase.
- `yarn twenty dev --once` against a live workspace = **"No changes.
Twenty
metadata matches your manifest."**, confirmed idempotent on a second run
—
the whole refactor is a metadata no-op (zero create/delete/identity
change).
- Every extracted graphql op was verified **byte-identical** to its
original
(args, `first:` caps, pagination, selection sets), and the
partner-application
Discord embed's deliberate PII omission (no email / hourly rate) is
preserved.

## Rebased onto latest `main`

This branch is rebased onto `main` (`d20e5378fd`) and now carries main's
`twenty-sdk` / `twenty-client-sdk` **2.23.0-alpha.2** bump.

Note for reviewers: main had independently bumped this package to
`1.3.1`, so
the original `1.3.0 → 1.3.1` commit here was redundant and git dropped
it during
the rebase (`patch contents already upstream`) — with **no textual
conflict**,
since both sides wrote the same version string. The bump is therefore
now
**`1.3.2`**. The rebase touched only `package.json` and `yarn.lock`;
**every line
of refactored source is byte-identical** to the pre-rebase tree.

## Verification

All run on the rebased tree, against SDK `2.23.0-alpha.2` and a live
Twenty
server `v2.23.2`:

| Check | Result |
|---|---|
| `universalIdentifier` set | 887, byte-identical |
| `yarn twenty dev --once` | "No changes" (idempotent on re-run) |
| Typecheck | pass |
| `yarn lint` | 0 warnings, 0 errors (287 files) |
| `yarn test:unit` | 158/158 (23 files) |
| `yarn test:integration` | 45/45 (13 files) |

## Also in this PR

- **Architecture convention doc** — `AGENTS.md` (+ a one-line
`CLAUDE.md` pointer)
at the package root documents the vertical-slice conventions this
refactor
establishes: the layout, the dependency rule (`logic-function → service
→
graphql/connector`), file naming, connector = outbound-only (inbound
webhooks
are logic-functions), and the UUID invariant. It ships here so the doc
and the
  structure it describes land together.
- **`modules/shared/`** dedup: the secret-guarded intake envelope, the
find-or-create-company/person helpers + their graphql ops, `collectAll`
  pagination, `http-url`/`strip-markdown`/`is-non-empty-string` utils.
- **Vitest configs collapsed** into one `vitest.config.ts` with `unit` +
  `integration` projects (`yarn test:unit` / `yarn test:integration`).
- Cross-domain nav folders (`pipeline-folder`,
`partner-workspace-folder`)
  hoisted to `modules/shared/navigation-menu-items/`.

## Deferred (non-blocking, tracked follow-ups)

- Add direct unit tests for the shared `collectAll` / `isNonEmptyString`
utils
  (currently covered indirectly).
- Move `submit-client-brief`'s zod schema out of its mapper file into
its own
  schema file (mirroring the partner side).
- Route `stamp-partner-user-on-child` through the shared self-service
mutation ops.
- `find-partner-by-member.ts` is duplicated identically in the
`application` and
  `self-service` domains; a candidate to hoist into `modules/shared/`.
This commit is contained in:
Rashad Karanouh
2026-07-23 13:59:00 +02:00
committed by GitHub
parent d20e5378fd
commit 9d8ce7c325
295 changed files with 3333 additions and 2690 deletions
@@ -0,0 +1,104 @@
# 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).
## 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/`.
@@ -0,0 +1 @@
@AGENTS.md
@@ -1,6 +1,6 @@
{
"name": "twenty-partners",
"version": "1.3.1",
"version": "1.3.2",
"license": "MIT",
"engines": {
"node": "^24.5.0",
@@ -15,7 +15,8 @@
"lint": "oxlint -c .oxlintrc.json .",
"lint:fix": "oxlint --fix -c .oxlintrc.json .",
"test": "vitest run",
"test:unit": "vitest run --config vitest.unit.config.ts",
"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",
@@ -1,10 +0,0 @@
export type {
MyPartnerProfileResult,
MyProfilePayload,
} from 'src/logic-functions/get-my-partner-profile.logic-function';
export type { SaveResult } from 'src/logic-functions/save-my-partner-profile.logic-function';
export type { SaveLinksResult } from 'src/logic-functions/save-my-partner-links.logic-function';
export type { SaveServicesResult } from 'src/logic-functions/save-my-partner-services.logic-function';
export type { SaveContentResult } from 'src/logic-functions/save-my-partner-content.logic-function';
export type { SubmitContentForReviewResult } from 'src/logic-functions/submit-partner-content-for-review.logic-function';
export type { ProfileOptions, SelectOption } from 'src/constants/my-profile.constants';
@@ -1,211 +0,0 @@
import { type CoreApiClient } from 'twenty-client-sdk/core';
import { defineLogicFunction, type RoutePayload } from 'twenty-sdk/define';
import { PROFILE_OPTIONS, type ProfileOptions } from 'src/constants/my-profile.constants';
import { isCaseStudy } from './content-type';
import { firstFileUrl } from './profile-picture';
import { buildAppClient, errorResponse, failureResponse, resolvePartnerFromRequest } from './resolve-partner-from-request';
export const GET_MY_PARTNER_PROFILE_ID = 'eacfd95b-de02-4f03-aa38-3cae31bb30a9';
export type MyProfilePayload = {
id: string;
name: string | null;
introduction: string | null;
city: string | null;
country: string | null;
languagesSpoken: string[] | null;
partnerScope: string[] | null;
skills: string[] | null;
typeOfTeam: string | null;
availability: string | null;
hourlyRate: { amountMicros: number | null; currencyCode: string | null } | null;
projectBudgetMin: { amountMicros: number | null; currencyCode: string | null } | null;
website: string | null;
linkedin: string | null;
calendarLink: string | null;
profilePicture: string | null;
profilePictureUrl: string | null;
region: string[] | null;
deploymentExpertise: string[] | null;
links: { id: string; name: string | null; url: string | null; sortOrder: number | null }[];
services: {
id: string;
title: string | null;
description: string | null;
sortOrder: number | null;
}[];
caseStudies: {
id: string;
name: string | null;
clientName: string | null;
headline: string | null;
bodyMarkdown: string | null;
coverImageUrl: string | null;
caseStudyLink: string | null;
status: string | null;
}[];
};
export type MyPartnerProfileResult =
| { ok: true; profile: MyProfilePayload; options: ProfileOptions }
| { ok: false; reason: string };
// CoreApiClient is codegenerated from the synced workspace schema, so the
// selection is strictly typed and the response shape derives from it.
const queryMyPartnerProfile = (client: CoreApiClient, partnerId: string) =>
client.query({
partners: {
__args: {
filter: { id: { eq: partnerId } },
first: 1,
},
edges: {
node: {
id: true,
name: true,
introduction: true,
city: true,
country: true,
languagesSpoken: true,
partnerScope: true,
skills: true,
typeOfTeam: true,
availability: true,
hourlyRate: { amountMicros: true, currencyCode: true },
projectBudgetMin: { amountMicros: true, currencyCode: true },
website: { primaryLinkUrl: true },
linkedin: { primaryLinkUrl: true },
calendarLink: { primaryLinkUrl: true },
profilePicture: { primaryLinkUrl: true },
profilePictureFile: { url: true },
region: true,
deploymentExpertise: true,
partnerLinks: {
edges: {
node: {
id: true,
name: true,
url: { primaryLinkUrl: true },
sortOrder: true,
},
},
},
partnerServices: {
edges: {
node: {
id: true,
title: true,
description: true,
sortOrder: true,
},
},
},
partnerContents: {
edges: {
node: {
id: true,
name: true,
clientName: true,
headline: true,
body: { markdown: true },
coverImageUrl: true,
caseStudyLink: { primaryLinkUrl: true },
status: true,
contentType: true,
},
},
},
},
},
},
});
export type PartnerNode = NonNullable<
Awaited<ReturnType<typeof queryMyPartnerProfile>>['partners']
>['edges'][number]['node'];
export const mapMyProfilePayload = (node: PartnerNode): MyProfilePayload => ({
id: node.id,
name: node.name ?? null,
introduction: node.introduction ?? null,
city: node.city ?? null,
country: node.country ?? null,
languagesSpoken: node.languagesSpoken ?? null,
partnerScope: node.partnerScope ?? null,
skills: node.skills ?? null,
typeOfTeam: node.typeOfTeam ?? null,
availability: node.availability ?? null,
hourlyRate: node.hourlyRate ?? null,
projectBudgetMin: node.projectBudgetMin ?? null,
website: node.website?.primaryLinkUrl ?? null,
linkedin: node.linkedin?.primaryLinkUrl ?? null,
calendarLink: node.calendarLink?.primaryLinkUrl ?? null,
profilePicture: node.profilePicture?.primaryLinkUrl ?? null,
profilePictureUrl:
firstFileUrl(node.profilePictureFile) ?? node.profilePicture?.primaryLinkUrl ?? null,
region: node.region ?? null,
deploymentExpertise: node.deploymentExpertise ?? null,
links: (node.partnerLinks?.edges ?? []).map((e) => ({
id: e.node.id,
name: e.node.name ?? null,
url: e.node.url?.primaryLinkUrl ?? null,
sortOrder: e.node.sortOrder ?? null,
})),
services: (node.partnerServices?.edges ?? []).map((e) => ({
id: e.node.id,
title: e.node.title ?? null,
description: e.node.description ?? null,
sortOrder: e.node.sortOrder ?? null,
})),
caseStudies: (node.partnerContents?.edges ?? [])
.filter((e) => isCaseStudy(e.node.contentType))
.map((e) => ({
id: e.node.id,
name: e.node.name ?? null,
clientName: e.node.clientName ?? null,
headline: e.node.headline ?? null,
bodyMarkdown: e.node.body?.markdown ?? null,
// Edit form binds the text coverImageUrl field only; the file cover's signed URL
// must not round-trip through save (it would persist an expiring URL).
coverImageUrl: e.node.coverImageUrl || null,
caseStudyLink: e.node.caseStudyLink?.primaryLinkUrl ?? null,
status: e.node.status ?? null,
})),
});
export const handler = async (
event: RoutePayload<unknown>,
): Promise<MyPartnerProfileResult> => {
const resolved = await resolvePartnerFromRequest(event);
if ('error' in resolved) return errorResponse(resolved.error);
try {
const client = buildAppClient();
const result = await queryMyPartnerProfile(client, resolved.partnerId);
const node = result.partners?.edges?.[0]?.node;
if (!node) {
return errorResponse('NO_PARTNER');
}
return { ok: true, profile: mapMyProfilePayload(node), options: PROFILE_OPTIONS };
} catch (err) {
return failureResponse('get-my-partner-profile', err);
}
};
export default defineLogicFunction({
universalIdentifier: GET_MY_PARTNER_PROFILE_ID,
name: 'get-my-partner-profile',
description:
"Returns the calling partner's own profile + links + services + case studies + enum options.",
timeoutSeconds: 20,
handler,
httpRouteTriggerSettings: {
path: '/my-partner-profile',
httpMethod: 'POST',
isAuthRequired: true,
},
});
@@ -1,226 +0,0 @@
import { CoreApiClient, type CoreSchema } from 'twenty-client-sdk/core';
import { defineLogicFunction } from 'twenty-sdk/define';
import { z } from 'zod';
import {
findCompanyIdByExactName,
findPersonIdByPrimaryEmail,
} from './find-or-create-company-and-person';
function isNonEmptyString(value: unknown): value is string {
return typeof value === 'string' && value.trim().length > 0;
}
// Manual one-way copy of one Opportunity from the TFT workspace into partners.
export const IMPORT_OPPORTUNITY_FROM_TFT_LOGIC_FUNCTION_ID =
'4c220eaf-a23f-4af2-8d69-38a6c460019f';
const APPLICATION_SECRET_HEADER = 'x-application-secret';
// TFT POSTs `null` for empty fields; treat null as "field absent".
const dropNulls = (value: unknown): unknown =>
value === null
? undefined
: Array.isArray(value)
? value.map(dropNulls)
: typeof value === 'object'
? Object.fromEntries(
Object.entries(value).map(([key, val]) => [key, dropNulls(val)]),
)
: value;
// Request contract — the JSON the TFT workflow POSTs.
export const importOpportunityFromTftSchema = z.preprocess(dropNulls, z.object({
tftOpportunityId: z.string().optional(),
name: z.string().trim().min(1),
amountMicros: z.number().optional(),
currencyCode: z.string().optional(),
closeDate: z.string().optional(),
stage: z.string().optional(),
useCase: z.string().optional(),
company: z
.object({
name: z.string().optional(),
domain: z.string().optional(),
})
.optional(),
pointOfContact: z
.object({
email: z.string().optional(),
firstName: z.string().optional(),
lastName: z.string().optional(),
})
.optional(),
}));
export type ImportOpportunityFromTftInput = z.infer<
typeof importOpportunityFromTftSchema
>;
type ImportOpportunityFromTftEvent = {
headers?: Record<string, string | undefined>;
body?: unknown;
};
export type ImportOpportunityFromTftResult =
| { ok: true; created: boolean; id: string }
| { ok: false; reason: string };
// Find by exact name, else create.
async function findOrCreateCompanyId(
client: CoreApiClient,
company: ImportOpportunityFromTftInput['company'],
): Promise<string | undefined> {
const name = isNonEmptyString(company?.name) ? company.name.trim() : undefined;
const domain = isNonEmptyString(company?.domain) ? company.domain.trim() : undefined;
if (name === undefined && domain === undefined) return undefined;
if (name !== undefined) {
const existing = await findCompanyIdByExactName(client, name);
if (existing !== undefined) return existing;
}
const companyData: CoreSchema.CompanyCreateInput = { name: name ?? domain! };
if (domain !== undefined) companyData.domainName = { primaryLinkUrl: domain };
const result = await client.mutation({
createCompany: { __args: { data: companyData }, id: true },
});
const id = result.createCompany?.id;
if (id === undefined) throw new Error('createCompany did not return an id');
return id;
}
// Find by primary email, else create — name-only contacts can't be deduped.
async function findOrCreatePersonId(
client: CoreApiClient,
pointOfContact: ImportOpportunityFromTftInput['pointOfContact'],
companyId: string | undefined,
): Promise<string | undefined> {
const email = isNonEmptyString(pointOfContact?.email)
? pointOfContact.email.trim()
: undefined;
const firstName = isNonEmptyString(pointOfContact?.firstName)
? pointOfContact.firstName.trim()
: '';
const lastName = isNonEmptyString(pointOfContact?.lastName)
? pointOfContact.lastName.trim()
: '';
if (email === undefined && firstName === '' && lastName === '') return undefined;
if (email !== undefined) {
const existing = await findPersonIdByPrimaryEmail(client, email);
if (existing !== undefined) return existing;
}
const personData: CoreSchema.PersonCreateInput = { name: { firstName, lastName } };
if (email !== undefined) personData.emails = { primaryEmail: email };
if (companyId !== undefined) personData.companyId = companyId;
const result = await client.mutation({
createPerson: { __args: { data: personData }, id: true },
});
const id = result.createPerson?.id;
if (id === undefined) throw new Error('createPerson did not return an id');
return id;
}
export const handler = async (
event: ImportOpportunityFromTftEvent | ImportOpportunityFromTftInput,
): Promise<ImportOpportunityFromTftResult> => {
// HTTP event ({ body, headers }) or a flat input for direct calls.
const looksLikeEvent =
typeof event === 'object' &&
event !== null &&
('body' in event || 'headers' in event);
const headers = looksLikeEvent
? (event as ImportOpportunityFromTftEvent).headers ?? {}
: {};
const rawInput = looksLikeEvent
? (event as ImportOpportunityFromTftEvent).body
: event;
// isAuthRequired only accepts user JWTs, not API keys; guard with the shared secret.
const expectedSecret = process.env.PARTNER_APPLICATION_SECRET;
if (!isNonEmptyString(expectedSecret)) return { ok: false, reason: 'unauthorized' };
if (headers[APPLICATION_SECRET_HEADER] !== expectedSecret) {
return { ok: false, reason: 'unauthorized' };
}
const parsed = importOpportunityFromTftSchema.safeParse(rawInput);
if (!parsed.success) return { ok: false, reason: 'invalid_input' };
const input = parsed.data;
try {
const client = new CoreApiClient();
const name = input.name.trim();
const tftOpportunityId = isNonEmptyString(input.tftOpportunityId)
? input.tftOpportunityId.trim()
: undefined;
// Idempotency: stable source id first, name as a fallback for manual calls.
const dedupeFilter: CoreSchema.OpportunityFilterInput =
tftOpportunityId !== undefined
? { tftOpportunityId: { eq: tftOpportunityId } }
: { name: { eq: name } };
const existing = await client.query({
opportunities: {
__args: { filter: dedupeFilter, first: 1 },
edges: { node: { id: true } },
},
});
const existingId = existing.opportunities?.edges?.[0]?.node?.id;
if (existingId !== undefined) return { ok: true, created: false, id: existingId };
const companyId = await findOrCreateCompanyId(client, input.company);
const pointOfContactId = await findOrCreatePersonId(
client,
input.pointOfContact,
companyId,
);
const opportunityData: CoreSchema.OpportunityCreateInput = { name };
if (tftOpportunityId !== undefined) opportunityData.tftOpportunityId = tftOpportunityId;
if (input.amountMicros !== undefined) {
opportunityData.amount = {
amountMicros: input.amountMicros,
currencyCode: input.currencyCode ?? 'USD',
};
}
if (isNonEmptyString(input.closeDate)) opportunityData.closeDate = input.closeDate;
if (isNonEmptyString(input.stage)) {
opportunityData.stage = input.stage as CoreSchema.OpportunityStageEnum;
}
if (isNonEmptyString(input.useCase)) {
opportunityData.need = input.useCase.trim();
}
if (companyId !== undefined) opportunityData.companyId = companyId;
if (pointOfContactId !== undefined) opportunityData.pointOfContactId = pointOfContactId;
const result = await client.mutation({
createOpportunity: { __args: { data: opportunityData }, id: true },
});
const id = result.createOpportunity?.id;
if (id === undefined) throw new Error('createOpportunity did not return an id');
return { ok: true, created: true, id };
} catch (err) {
return { ok: false, reason: err instanceof Error ? err.message : String(err) };
}
};
export default defineLogicFunction({
universalIdentifier: IMPORT_OPPORTUNITY_FROM_TFT_LOGIC_FUNCTION_ID,
name: 'import-opportunity-from-tft',
description:
'Receive one opportunity pushed from the TFT workspace and create it in partners (find-or-create company + contact, idempotent on tftOpportunityId).',
timeoutSeconds: 15,
handler,
httpRouteTriggerSettings: {
path: '/opportunities',
httpMethod: 'POST',
isAuthRequired: false,
forwardedRequestHeaders: [APPLICATION_SECRET_HEADER],
},
});
@@ -1,103 +0,0 @@
import { CoreApiClient } from 'twenty-client-sdk/core';
import { defineLogicFunction } from 'twenty-sdk/define';
import {
mapPartnerForMarketplace,
type MarketplaceListPartner,
} from './map-partner-for-marketplace';
export const LIST_AVAILABLE_PARTNERS_LOGIC_FUNCTION_ID =
'0f91164f-f492-41e8-9bb0-481be5a3d5b9';
// CoreApiClient is codegenerated from the synced workspace schema, so the query
// selection is strictly typed. Keep the fetch in one place and derive the
// response shape from it, so the HTTP contract can never drift from what we
// actually ask the API for.
const queryAvailablePartners = (client: CoreApiClient) =>
client.query({
partners: {
__args: {
filter: {
validationStage: { eq: 'VALIDATED' },
availability: { eq: 'AVAILABLE' },
slug: { neq: '' },
},
orderBy: [{ name: 'AscNullsLast' }],
first: 100,
},
edges: {
node: {
id: true,
name: true,
slug: true,
introduction: true,
languagesSpoken: true,
deploymentExpertise: true,
partnerScope: true,
region: true,
calendarLink: { primaryLinkUrl: true },
hourlyRate: { amountMicros: true, currencyCode: true },
projectBudgetMin: { amountMicros: true, currencyCode: true },
linkedin: { primaryLinkUrl: true },
website: { primaryLinkUrl: true },
// profilePicture is the legacy LINKS url; profilePictureFile is the
// new FILES upload (its items expose `url`). Display prefers the file.
profilePicture: { primaryLinkUrl: true },
profilePictureFile: { url: true },
skills: true,
city: true,
country: true,
},
},
},
});
type AvailablePartnerRaw = NonNullable<
Awaited<ReturnType<typeof queryAvailablePartners>>['partners']
>['edges'][number]['node'];
type ListAvailablePartnersResult =
| { ok: true; count: number; partners: MarketplaceListPartner[] }
| { ok: false; reason: string };
const mapListPartner = (node: AvailablePartnerRaw): MarketplaceListPartner => {
const mapped = mapPartnerForMarketplace(node, 'list');
if ('projectBudgetTypical' in mapped) {
throw new Error(
'list-available-partners received profile payload from list mapper',
);
}
return mapped;
};
export const handler = async (): Promise<ListAvailablePartnersResult> => {
try {
const client = new CoreApiClient();
const result = await queryAvailablePartners(client);
const partners = (result.partners?.edges ?? []).map(({ node }) =>
mapListPartner(node),
);
return { ok: true, count: partners.length, partners };
} catch (err) {
return {
ok: false,
reason: err instanceof Error ? err.message : String(err),
};
}
};
export default defineLogicFunction({
universalIdentifier: LIST_AVAILABLE_PARTNERS_LOGIC_FUNCTION_ID,
name: 'list-available-partners',
description: 'Returns all partners with validationStage=VALIDATED and availability=AVAILABLE.',
timeoutSeconds: 10,
handler,
httpRouteTriggerSettings: {
path: '/partners',
httpMethod: 'GET',
isAuthRequired: false,
},
});
@@ -1,89 +0,0 @@
import { CoreApiClient, type CoreSchema } from 'twenty-client-sdk/core';
import {
type DatabaseEventPayload,
defineLogicFunction,
type ObjectRecordCreateEvent,
} from 'twenty-sdk/define';
const ON_APPLICATION_CREATED_FN_ID = '0e055a1c-b8b3-4572-89f3-e76e37bc3f9e';
// A partner self-applies via the "Apply to brief as partner" workflow: a Create Record action
// makes an Application with the opportunity set and createdBy = the clicking member, but no
// partner. Resolve the partner from createdBy and complete the candidacy. Admin-created
// applications (partner already set, or the creator is not a partner) are left untouched. The
// name is set by on-application-set-name, which fires on the partnerId update below.
export const handler = async (
payload: DatabaseEventPayload<ObjectRecordCreateEvent<CoreSchema.Application>>,
): Promise<Record<string, unknown>> => {
const after = payload.properties.after;
const applicationId = after?.id;
if (!applicationId) return {};
if (after.partnerId) return {}; // already linked (admin path) — leave it
const memberId = after.createdBy?.workspaceMemberId;
if (!memberId) return {}; // no member actor (system/import) — not a self-apply
const client = new CoreApiClient();
const partnerRes = await client.query({
partners: {
__args: { filter: { partnerUserId: { eq: memberId } }, first: 1 },
edges: { node: { id: true } },
},
});
const partnerId = partnerRes.partners?.edges?.[0]?.node?.id;
if (!partnerId) return {}; // creator isn't a partner (e.g. admin) — leave it
const opportunityId = after.opportunityId;
if (opportunityId) {
const existingRes = await client.query({
applications: {
__args: {
filter: {
opportunityId: { eq: opportunityId },
partnerId: { eq: partnerId },
},
first: 1,
},
edges: { node: { id: true } },
},
});
const existingId = existingRes.applications?.edges?.find(
(edge) => edge.node?.id && edge.node.id !== applicationId,
)?.node?.id;
if (existingId) {
await client.mutation({
deleteApplication: {
__args: { id: applicationId },
id: true,
},
});
return { duplicate: true, keptExisting: existingId };
}
}
const now = new Date().toISOString();
await client.mutation({
updateApplication: {
__args: {
id: applicationId,
data: {
partnerId,
partnerUserId: memberId,
state: 'APPLIED',
lastActivityAt: now,
},
},
id: true,
},
});
// ponytail: dedupe by (opportunity, partner) above; two near-simultaneous creates could still both pass before either stamps — acceptable.
return { applied: true, partnerId };
};
export default defineLogicFunction({
universalIdentifier: ON_APPLICATION_CREATED_FN_ID,
name: 'on-application-created',
timeoutSeconds: 15,
handler,
databaseEventTriggerSettings: { eventName: 'application.created' },
});
@@ -1,94 +0,0 @@
import { CoreApiClient, type CoreSchema } from 'twenty-client-sdk/core';
import {
type DatabaseEventPayload,
defineLogicFunction,
type ObjectRecordUpdateEvent,
} from 'twenty-sdk/define';
const ON_OPP_PARTNER_WON_FN_ID = '683f407e-e7a0-435d-a380-e51e536770f8';
const APPLICATIONS_PAGE_SIZE = 200;
// Every application on the brief, paginated fully — a single capped page would strand overflow
// applications in a stale WON/BACKUP/APPLIED state out of sync with Opportunity.partner.
async function collectApplications(client: CoreApiClient, opportunityId: string) {
const query = (after?: string) =>
client.query({
applications: {
__args: {
filter: { opportunityId: { eq: opportunityId } },
first: APPLICATIONS_PAGE_SIZE,
...(after ? { after } : {}),
},
edges: { node: { id: true, partnerId: true, state: true } },
pageInfo: { hasNextPage: true, endCursor: true },
},
});
type ApplicationNode = NonNullable<
NonNullable<
Awaited<ReturnType<typeof query>>['applications']
>['edges'][number]['node']
>;
const applications: ApplicationNode[] = [];
let after: string | undefined;
for (;;) {
const page = await query(after);
for (const edge of page.applications?.edges ?? []) {
if (edge?.node) applications.push(edge.node);
}
if (!page.applications?.pageInfo?.hasNextPage) break;
after = page.applications.pageInfo.endCursor ?? undefined;
}
return applications;
}
// WON/BACKUP mirror of Opportunity.partner: on assign, winner -> WON and other active apps ->
// BACKUP; on unassign, WON/BACKUP -> APPLIED. DECLINED is never touched. Runs under the app
// identity, bypassing partner locks.
export const handler = async (
payload: DatabaseEventPayload<ObjectRecordUpdateEvent<CoreSchema.Opportunity>>,
): Promise<Record<string, unknown>> => {
const { after, updatedFields } = payload.properties;
if (!updatedFields?.includes('partnerId')) return {};
const opportunityId = after?.id;
if (!opportunityId) return {};
const newPartnerId = after?.partnerId ?? null;
const client = new CoreApiClient();
const applications = await collectApplications(client, opportunityId);
const setState = async (id: string, state: string) => {
await client.mutation({
updateApplication: { __args: { id, data: { state } }, id: true },
});
};
if (newPartnerId) {
// Winner -> WON; every other active (non-DECLINED) application -> BACKUP.
for (const node of applications) {
if (node.state === 'DECLINED') continue;
const target = node.partnerId === newPartnerId ? 'WON' : 'BACKUP';
if (node.state !== target) await setState(node.id, target);
}
const winner = applications.find(
(node) => node.partnerId === newPartnerId,
);
return { won: winner?.id ?? null };
}
// Unassigned: WON and BACKUP applications re-open to APPLIED. DECLINED untouched.
for (const node of applications) {
if (node.state === 'WON' || node.state === 'BACKUP') {
await setState(node.id, 'APPLIED');
}
}
return { won: null, cleared: true };
};
export default defineLogicFunction({
universalIdentifier: ON_OPP_PARTNER_WON_FN_ID,
name: 'on-opportunity-partner-won',
timeoutSeconds: 15,
handler,
databaseEventTriggerSettings: { eventName: 'opportunity.updated' },
});
@@ -1,190 +0,0 @@
import { CoreApiClient, type CoreSchema } from 'twenty-client-sdk/core';
import {
type DatabaseEventPayload,
defineLogicFunction,
type ObjectRecordCreateEvent,
} from 'twenty-sdk/define';
import { ON_PARTNER_APPLICATION_CREATED_FN_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
// Generous bound on the Discord call. The trigger runs out-of-band on the
// worker (no user waiting), so this only needs to stay under the function's
// 10s budget — not be tight. Guards against a hung/slow webhook.
const DISCORD_TIMEOUT_MS = 8000;
// Twenty brand blue (#4a38f5) as a Discord embed integer color.
const TWENTY_BLUE = 0x4a38f5;
function isNonEmptyString(value: unknown): value is string {
return typeof value === 'string' && value.trim().length > 0;
}
type DiscordField = { name: string; value: string; inline?: boolean };
type PartnerForEmbed = {
id: string;
name?: string | null;
country?: string | null;
partnerScope?: string[] | null;
skills?: string[] | null;
languagesSpoken?: string[] | null;
applicant?: {
firstName?: string | null;
lastName?: string | null;
} | null;
};
// Pure embed builder, exported for unit testing. Empty fields are omitted.
export function buildApplicationEmbed(
partner: PartnerForEmbed,
frontendUrl: string | undefined,
): Record<string, unknown> {
// Discord packs up to 3 inline fields per row. We want two paired rows —
// Applicant | Company, then Country | Languages — followed by two full-width
// rows (Partner scope, Skills). A trailing zero-width inline "spacer" fills
// each paired row to 3 columns so the next group starts on its own line.
const SPACER: DiscordField = { name: '', value: '', inline: true };
const fields: DiscordField[] = [];
const applicantName = [partner.applicant?.firstName, partner.applicant?.lastName]
.filter(isNonEmptyString)
.join(' ')
.trim();
const pairedRow1: DiscordField[] = [];
if (isNonEmptyString(applicantName)) {
pairedRow1.push({ name: 'Applicant', value: applicantName, inline: true });
}
if (isNonEmptyString(partner.name)) {
pairedRow1.push({ name: 'Company', value: partner.name.trim(), inline: true });
}
const pairedRow2: DiscordField[] = [];
if (isNonEmptyString(partner.country)) {
pairedRow2.push({ name: 'Country', value: partner.country, inline: true });
}
if (partner.languagesSpoken && partner.languagesSpoken.length > 0) {
pairedRow2.push({ name: 'Languages', value: partner.languagesSpoken.join(', '), inline: true });
}
for (const field of pairedRow1) fields.push(field);
if (pairedRow1.length > 0) fields.push(SPACER);
for (const field of pairedRow2) fields.push(field);
if (pairedRow2.length > 0) fields.push(SPACER);
if (partner.partnerScope && partner.partnerScope.length > 0) {
fields.push({ name: 'Partner scope', value: partner.partnerScope.join(', ') });
}
if (partner.skills && partner.skills.length > 0) {
fields.push({ name: 'Skills', value: partner.skills.join(', ') });
}
const embed: Record<string, unknown> = {
title: 'New partner application',
color: TWENTY_BLUE,
timestamp: new Date().toISOString(),
fields,
};
if (isNonEmptyString(frontendUrl)) {
embed.url = `${frontendUrl.replace(/\/+$/, '')}/object/partner/${partner.id}`;
}
return embed;
}
export const handler = async (
payload: DatabaseEventPayload<ObjectRecordCreateEvent<CoreSchema.Partner>>,
): Promise<Record<string, unknown>> => {
// The event 'after' hydrates the actor composite as a NESTED object — the
// generated Partner type matches the live partner.created payload shape.
const after = payload.properties.after;
if (!after?.id) return {};
// Form-only: the installed app stamps createdBy.source = 'APPLICATION'.
// Seed/import authenticate via API key (API); the UI is MANUAL — both skipped.
if (after.createdBy?.source !== 'APPLICATION') return {};
const webhookUrl = process.env.DISCORD_WEBHOOK_URL;
if (!isNonEmptyString(webhookUrl)) return {};
const frontendUrl = process.env.PARTNER_APP_FRONTEND_URL;
try {
const client = new CoreApiClient();
const res = await client.query({
partner: {
__args: { filter: { id: { eq: after.id } } },
id: true,
name: true,
country: true,
partnerScope: true,
skills: true,
languagesSpoken: true,
persons: {
edges: {
node: {
name: { firstName: true, lastName: true },
},
},
},
},
});
const node = res.partner;
if (!node) return {};
const applicantNode = node.persons?.edges?.[0]?.node;
const partnerForEmbed: PartnerForEmbed = {
id: after.id,
name: node.name,
country: node.country,
partnerScope: node.partnerScope,
skills: node.skills,
languagesSpoken: node.languagesSpoken,
applicant: applicantNode
? {
firstName: applicantNode.name?.firstName,
lastName: applicantNode.name?.lastName,
}
: null,
};
const embed = buildApplicationEmbed(partnerForEmbed, frontendUrl);
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), DISCORD_TIMEOUT_MS);
try {
const response = await fetch(webhookUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ embeds: [embed] }),
signal: controller.signal,
});
// fetch only rejects on network errors, not on HTTP error statuses, so a
// non-2xx (dead webhook, rejected payload, rate limit) must be detected
// explicitly — otherwise a failed post is reported as delivered.
if (!response.ok) {
console.warn(
`on-partner-application-created: Discord webhook responded ${response.status}`,
);
return { notified: false };
}
} finally {
clearTimeout(timeout);
}
return { notified: true };
} catch {
// Best-effort: a Discord failure must never fail the trigger.
return { notified: false };
}
};
export default defineLogicFunction({
universalIdentifier: ON_PARTNER_APPLICATION_CREATED_FN_UNIVERSAL_IDENTIFIER,
name: 'on-partner-application-created',
description:
'Posts a Discord notification when the partner application form creates a new Partner.',
timeoutSeconds: 10,
handler,
databaseEventTriggerSettings: {
eventName: 'partner.created',
},
});
@@ -1,95 +0,0 @@
import { CoreApiClient, type CoreSchema } from 'twenty-client-sdk/core';
import {
type DatabaseEventPayload,
defineLogicFunction,
type ObjectRecordCreateEvent,
} from 'twenty-sdk/define';
import { ON_PARTNER_CONTENT_CREATED_FN_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
import { stampPartnerUserFromPartner } from './stamp-partner-user-on-child';
const CASE_STUDY_CONTENT_TYPE: CoreSchema.PartnerContent['contentType'] = [
'CASE_STUDY',
];
const resolvePartnerIdForMember = async (
client: CoreApiClient,
memberId: string,
): Promise<string | undefined> => {
const partnerRes = await client.query({
partners: {
__args: { filter: { partnerUserId: { eq: memberId } }, first: 1 },
edges: { node: { id: true } },
},
});
return partnerRes.partners?.edges?.[0]?.node?.id;
};
// Default to CASE_STUDY only when the caller left contentType empty (the self-service path);
// an explicit CUSTOMER_QUOTE / PARTNER_QUOTE / LOGO must be preserved, not overwritten.
const hasNoContentType = (
contentType: CoreSchema.PartnerContent['contentType'] | null | undefined,
): boolean => !contentType || contentType.length === 0;
export const handler = async (
payload: DatabaseEventPayload<ObjectRecordCreateEvent<CoreSchema.PartnerContent>>,
): Promise<Record<string, unknown>> => {
const after = payload.properties.after;
const childId = after?.id;
if (!childId) return {};
const client = new CoreApiClient();
let partnerId = after.partnerId;
if (!partnerId) {
const memberId = after.createdBy?.workspaceMemberId;
if (!memberId) return {};
partnerId = await resolvePartnerIdForMember(client, memberId);
if (!partnerId) return {};
await client.mutation({
updatePartnerContent: {
__args: {
id: childId,
data: {
partnerId,
partnerUserId: memberId,
...(hasNoContentType(after.contentType)
? { contentType: CASE_STUDY_CONTENT_TYPE }
: {}),
},
},
id: true,
},
});
return { linked: true, partnerId };
}
await stampPartnerUserFromPartner(client, partnerId, 'partnerContent', childId);
if (hasNoContentType(after.contentType)) {
await client.mutation({
updatePartnerContent: {
__args: {
id: childId,
data: { contentType: CASE_STUDY_CONTENT_TYPE },
},
id: true,
},
});
}
return { stamped: true };
};
export default defineLogicFunction({
universalIdentifier: ON_PARTNER_CONTENT_CREATED_FN_UNIVERSAL_IDENTIFIER,
name: 'on-partner-content-created',
timeoutSeconds: 10,
handler,
databaseEventTriggerSettings: { eventName: 'partnerContent.created' },
});
@@ -1,69 +0,0 @@
import { CoreApiClient, type CoreSchema } from 'twenty-client-sdk/core';
import {
type DatabaseEventPayload,
defineLogicFunction,
type ObjectRecordCreateEvent,
} from 'twenty-sdk/define';
import { ON_PARTNER_LINK_CREATED_FN_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
import { stampPartnerUserFromPartner } from './stamp-partner-user-on-child';
const resolvePartnerIdForMember = async (
client: CoreApiClient,
memberId: string,
): Promise<string | undefined> => {
const partnerRes = await client.query({
partners: {
__args: { filter: { partnerUserId: { eq: memberId } }, first: 1 },
edges: { node: { id: true } },
},
});
return partnerRes.partners?.edges?.[0]?.node?.id;
};
export const handler = async (
payload: DatabaseEventPayload<ObjectRecordCreateEvent<CoreSchema.PartnerLink>>,
): Promise<Record<string, unknown>> => {
const after = payload.properties.after;
const childId = after?.id;
if (!childId) return {};
const client = new CoreApiClient();
let partnerId = after.partnerId;
if (!partnerId) {
const memberId = after.createdBy?.workspaceMemberId;
if (!memberId) return {};
partnerId = await resolvePartnerIdForMember(client, memberId);
if (!partnerId) return {};
await client.mutation({
updatePartnerLink: {
__args: {
id: childId,
data: {
partnerId,
partnerUserId: memberId,
},
},
id: true,
},
});
return { linked: true, partnerId };
}
await stampPartnerUserFromPartner(client, partnerId, 'partnerLink', childId);
return { stamped: true };
};
export default defineLogicFunction({
universalIdentifier: ON_PARTNER_LINK_CREATED_FN_UNIVERSAL_IDENTIFIER,
name: 'on-partner-link-created',
timeoutSeconds: 10,
handler,
databaseEventTriggerSettings: { eventName: 'partnerLink.created' },
});
@@ -1,69 +0,0 @@
import { CoreApiClient, type CoreSchema } from 'twenty-client-sdk/core';
import {
type DatabaseEventPayload,
defineLogicFunction,
type ObjectRecordCreateEvent,
} from 'twenty-sdk/define';
import { ON_PARTNER_SERVICE_CREATED_FN_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
import { stampPartnerUserFromPartner } from './stamp-partner-user-on-child';
const resolvePartnerIdForMember = async (
client: CoreApiClient,
memberId: string,
): Promise<string | undefined> => {
const partnerRes = await client.query({
partners: {
__args: { filter: { partnerUserId: { eq: memberId } }, first: 1 },
edges: { node: { id: true } },
},
});
return partnerRes.partners?.edges?.[0]?.node?.id;
};
export const handler = async (
payload: DatabaseEventPayload<ObjectRecordCreateEvent<CoreSchema.PartnerService>>,
): Promise<Record<string, unknown>> => {
const after = payload.properties.after;
const childId = after?.id;
if (!childId) return {};
const client = new CoreApiClient();
let partnerId = after.partnerId;
if (!partnerId) {
const memberId = after.createdBy?.workspaceMemberId;
if (!memberId) return {};
partnerId = await resolvePartnerIdForMember(client, memberId);
if (!partnerId) return {};
await client.mutation({
updatePartnerService: {
__args: {
id: childId,
data: {
partnerId,
partnerUserId: memberId,
},
},
id: true,
},
});
return { linked: true, partnerId };
}
await stampPartnerUserFromPartner(client, partnerId, 'partnerService', childId);
return { stamped: true };
};
export default defineLogicFunction({
universalIdentifier: ON_PARTNER_SERVICE_CREATED_FN_UNIVERSAL_IDENTIFIER,
name: 'on-partner-service-created',
timeoutSeconds: 10,
handler,
databaseEventTriggerSettings: { eventName: 'partnerService.created' },
});
@@ -1,214 +0,0 @@
import { type CoreApiClient, type CoreSchema } from 'twenty-client-sdk/core';
import { defineLogicFunction, type RoutePayload } from 'twenty-sdk/define';
import { z } from 'zod';
import { isCaseStudy } from './content-type';
import { isHttpUrl } from './http-url';
import { buildReconcilePlan } from './reconcile-children';
import { buildAppClient, errorResponse, failureResponse, resolvePartnerFromRequest } from './resolve-partner-from-request';
export const SAVE_MY_PARTNER_CONTENT_ID = 'e574fc61-6d9e-48db-9e98-a9b8160188cc';
export const saveContentSchema = z.object({
caseStudies: z.array(
z.object({
id: z.string().optional(),
name: z.string(),
clientName: z.string().optional(),
headline: z.string().optional(),
bodyMarkdown: z.string().optional(),
caseStudyLink: z
.string()
.refine((value) => value === '' || isHttpUrl(value), {
message: 'URL must use http or https',
})
.optional(),
coverImageUrl: z.string().optional(),
published: z.boolean().optional(),
}),
).max(50, 'Too many case studies in a single request (max 50)'),
});
export type SaveContentInput = z.infer<typeof saveContentSchema>;
type CaseStudyItem = SaveContentInput['caseStudies'][number];
export type CaseStudyRow = {
id: string;
name: string | null;
clientName: string | null;
headline: string | null;
bodyMarkdown: string | null;
coverImageUrl: string | null;
caseStudyLink: string | null;
status: string | null;
};
export type SaveContentResult =
| { ok: true; caseStudies: CaseStudyRow[] }
| { ok: false; reason: string };
// Partner self-controls visibility: published → APPROVED (public), draft → WIP (hidden).
// Ownership (partnerId/partnerUser) and contentType are stamped server-side by the
// on-partner-content-created trigger, never written by the caller — so a partner cannot
// repoint a case study onto another partner's public marketplace profile.
// Create and update share every field mapping; they diverge only on how status is
// derived, so keep the common fields here and let each add its own status handling.
const buildContentBaseData = (item: CaseStudyItem) => ({
name: item.name,
clientName: item.clientName,
headline: item.headline,
body: { markdown: item.bodyMarkdown ?? '' },
caseStudyLink: item.caseStudyLink ? { primaryLinkUrl: item.caseStudyLink } : undefined,
coverImageUrl: item.coverImageUrl,
});
export function buildContentCreateData(
item: CaseStudyItem,
): CoreSchema.PartnerContentCreateInput {
return { ...buildContentBaseData(item), status: item.published ? 'APPROVED' : 'WIP' };
}
export function buildContentUpdateData(
item: CaseStudyItem,
): CoreSchema.PartnerContentUpdateInput {
return {
...buildContentBaseData(item),
// Only touch status when the caller specified published; a partial edit must not unpublish an APPROVED case study.
...(item.published !== undefined ? { status: item.published ? 'APPROVED' : 'WIP' } : {}),
};
}
const queryExistingContentIds = async (
client: CoreApiClient,
partnerId: string,
): Promise<string[]> => {
const result = await client.query({
partnerContents: {
__args: { filter: { partnerId: { eq: partnerId } } },
edges: { node: { id: true, contentType: true } },
},
});
return (result.partnerContents?.edges ?? [])
.filter((edge) => isCaseStudy(edge.node.contentType))
.map((edge) => edge.node.id);
};
const queryContentRows = async (
client: CoreApiClient,
partnerId: string,
): Promise<CaseStudyRow[]> => {
const result = await client.query({
partnerContents: {
__args: { filter: { partnerId: { eq: partnerId } } },
edges: {
node: {
id: true,
name: true,
clientName: true,
headline: true,
body: { markdown: true },
coverImageUrl: true,
caseStudyLink: { primaryLinkUrl: true },
status: true,
contentType: true,
},
},
},
});
return (result.partnerContents?.edges ?? [])
.filter((edge) => isCaseStudy(edge.node.contentType))
.map((edge) => ({
id: edge.node.id,
name: edge.node.name ?? null,
clientName: edge.node.clientName ?? null,
headline: edge.node.headline ?? null,
bodyMarkdown: edge.node.body?.markdown ?? null,
coverImageUrl: edge.node.coverImageUrl || null,
caseStudyLink: edge.node.caseStudyLink?.primaryLinkUrl ?? null,
status: edge.node.status ?? null,
}));
};
export const handler = async (event: RoutePayload<unknown>): Promise<SaveContentResult> => {
const resolved = await resolvePartnerFromRequest(event);
if ('error' in resolved) return errorResponse(resolved.error);
const parsed = saveContentSchema.safeParse(event.body);
if (!parsed.success) {
return errorResponse(parsed.error.issues[0]?.message ?? 'invalid_input');
}
try {
const client = buildAppClient();
const existingIds = await queryExistingContentIds(client, resolved.partnerId);
const plan = buildReconcilePlan(existingIds, parsed.data.caseStudies);
if (!plan) return errorResponse('FORBIDDEN');
// A just-created row isn't owner-stamped by the trigger yet, so the caller's own re-read
// (RLS-scoped) can't see it. Return it optimistically from the input + new id.
const createdRows: CaseStudyRow[] = [];
for (const item of plan.toCreate) {
const created = await client.mutation({
createPartnerContent: {
__args: { data: buildContentCreateData(item) },
id: true,
},
});
const newId = created.createPartnerContent?.id;
if (newId !== undefined) {
createdRows.push({
id: newId,
name: item.name,
clientName: item.clientName ?? null,
headline: item.headline ?? null,
bodyMarkdown: item.bodyMarkdown ?? null,
coverImageUrl: item.coverImageUrl ?? null,
caseStudyLink: item.caseStudyLink ?? null,
status: item.published ? 'APPROVED' : 'WIP',
});
}
}
for (const item of plan.toUpdate) {
// buildReconcilePlan only puts items with a defined id into toUpdate.
if (item.id === undefined) continue;
await client.mutation({
updatePartnerContent: {
__args: { id: item.id, data: buildContentUpdateData(item) },
id: true,
},
});
}
for (const id of plan.toDelete) {
await client.mutation({
deletePartnerContent: { __args: { id }, id: true },
});
}
// A just-created row can surface in the re-read too once the trigger stamps its
// partnerId, so drop those ids before appending the optimistic createdRows.
const existingRows = await queryContentRows(client, resolved.partnerId);
const createdIds = new Set(createdRows.map((row) => row.id));
const deduped = existingRows.filter((row) => !createdIds.has(row.id));
return { ok: true, caseStudies: [...deduped, ...createdRows] };
} catch (err) {
return failureResponse('save-my-partner-content', err);
}
};
export default defineLogicFunction({
universalIdentifier: SAVE_MY_PARTNER_CONTENT_ID,
name: 'save-my-partner-content',
description:
"Reconciles the calling partner's own case studies (create/update/delete in one call); each row is published (APPROVED) or kept as a draft (WIP) per its published flag.",
timeoutSeconds: 20,
handler,
httpRouteTriggerSettings: {
path: '/save-my-partner-content',
httpMethod: 'POST',
isAuthRequired: true,
},
});
@@ -1,144 +0,0 @@
import { type CoreApiClient } from 'twenty-client-sdk/core';
import { defineLogicFunction, type RoutePayload } from 'twenty-sdk/define';
import { z } from 'zod';
import { isHttpUrl } from './http-url';
import { buildReconcilePlan } from './reconcile-children';
import { buildAppClient, errorResponse, failureResponse, resolvePartnerFromRequest } from './resolve-partner-from-request';
export const SAVE_MY_PARTNER_LINKS_ID = 'b56d1158-4e79-4fdb-a7c4-e0f8871b2d42';
export const saveLinksSchema = z.object({
links: z.array(
z.object({
id: z.string().optional(),
name: z.string(),
url: z.string().refine((value) => value === '' || isHttpUrl(value), {
message: 'URL must use http or https',
}),
sortOrder: z.number().optional(),
}),
),
});
export type SaveLinksInput = z.infer<typeof saveLinksSchema>;
export type LinkRow = {
id: string;
name: string | null;
url: string | null;
sortOrder: number | null;
};
export type SaveLinksResult = { ok: true; links: LinkRow[] } | { ok: false; reason: string };
const queryExistingLinkIds = async (
client: CoreApiClient,
partnerId: string,
): Promise<string[]> => {
const result = await client.query({
partnerLinks: {
__args: { filter: { partnerId: { eq: partnerId } } },
edges: { node: { id: true } },
},
});
return (result.partnerLinks?.edges ?? []).map((edge) => edge.node.id);
};
const queryLinkRows = async (client: CoreApiClient, partnerId: string): Promise<LinkRow[]> => {
const result = await client.query({
partnerLinks: {
__args: { filter: { partnerId: { eq: partnerId } } },
edges: {
node: {
id: true,
name: true,
url: { primaryLinkUrl: true },
sortOrder: true,
},
},
},
});
return (result.partnerLinks?.edges ?? []).map((edge) => ({
id: edge.node.id,
name: edge.node.name ?? null,
url: edge.node.url?.primaryLinkUrl ?? null,
sortOrder: edge.node.sortOrder ?? null,
}));
};
export const handler = async (event: RoutePayload<unknown>): Promise<SaveLinksResult> => {
const resolved = await resolvePartnerFromRequest(event);
if ('error' in resolved) return errorResponse(resolved.error);
const parsed = saveLinksSchema.safeParse(event.body);
if (!parsed.success) {
return errorResponse(parsed.error.issues[0]?.message ?? 'invalid_input');
}
try {
const client = buildAppClient();
const existingIds = await queryExistingLinkIds(client, resolved.partnerId);
const plan = buildReconcilePlan(existingIds, parsed.data.links);
if (!plan) return errorResponse('FORBIDDEN');
for (const link of plan.toCreate) {
await client.mutation({
createPartnerLink: {
__args: {
data: {
partnerId: resolved.partnerId,
name: link.name,
url: { primaryLinkUrl: link.url },
sortOrder: link.sortOrder,
},
},
id: true,
},
});
}
for (const link of plan.toUpdate) {
// buildReconcilePlan only puts items with a defined id into toUpdate.
if (link.id === undefined) continue;
await client.mutation({
updatePartnerLink: {
__args: {
id: link.id,
data: {
name: link.name,
url: { primaryLinkUrl: link.url },
sortOrder: link.sortOrder,
},
},
id: true,
},
});
}
for (const id of plan.toDelete) {
await client.mutation({
deletePartnerLink: { __args: { id }, id: true },
});
}
const links = await queryLinkRows(client, resolved.partnerId);
return { ok: true, links };
} catch (err) {
return failureResponse('save-my-partner-links', err);
}
};
export default defineLogicFunction({
universalIdentifier: SAVE_MY_PARTNER_LINKS_ID,
name: 'save-my-partner-links',
description: "Reconciles the calling partner's own links (create/update/delete in one call).",
timeoutSeconds: 20,
handler,
httpRouteTriggerSettings: {
path: '/save-my-partner-links',
httpMethod: 'POST',
isAuthRequired: true,
},
});
@@ -1,147 +0,0 @@
import { type CoreApiClient } from 'twenty-client-sdk/core';
import { defineLogicFunction, type RoutePayload } from 'twenty-sdk/define';
import { z } from 'zod';
import { buildReconcilePlan } from './reconcile-children';
import { buildAppClient, errorResponse, failureResponse, resolvePartnerFromRequest } from './resolve-partner-from-request';
export const SAVE_MY_PARTNER_SERVICES_ID = '878a6e36-62f4-4590-807d-ef6204d2d168';
export const saveServicesSchema = z.object({
services: z.array(
z.object({
id: z.string().optional(),
title: z.string(),
description: z.string(),
sortOrder: z.number().optional(),
}),
),
});
export type SaveServicesInput = z.infer<typeof saveServicesSchema>;
export type ServiceRow = {
id: string;
title: string | null;
description: string | null;
sortOrder: number | null;
};
export type SaveServicesResult =
| { ok: true; services: ServiceRow[] }
| { ok: false; reason: string };
const queryExistingServiceIds = async (
client: CoreApiClient,
partnerId: string,
): Promise<string[]> => {
const result = await client.query({
partnerServices: {
__args: { filter: { partnerId: { eq: partnerId } } },
edges: { node: { id: true } },
},
});
return (result.partnerServices?.edges ?? []).map((edge) => edge.node.id);
};
const queryServiceRows = async (
client: CoreApiClient,
partnerId: string,
): Promise<ServiceRow[]> => {
const result = await client.query({
partnerServices: {
__args: { filter: { partnerId: { eq: partnerId } } },
edges: {
node: {
id: true,
title: true,
description: true,
sortOrder: true,
},
},
},
});
return (result.partnerServices?.edges ?? []).map((edge) => ({
id: edge.node.id,
title: edge.node.title ?? null,
description: edge.node.description ?? null,
sortOrder: edge.node.sortOrder ?? null,
}));
};
export const handler = async (event: RoutePayload<unknown>): Promise<SaveServicesResult> => {
const resolved = await resolvePartnerFromRequest(event);
if ('error' in resolved) return errorResponse(resolved.error);
const parsed = saveServicesSchema.safeParse(event.body);
if (!parsed.success) {
return errorResponse(parsed.error.issues[0]?.message ?? 'invalid_input');
}
try {
const client = buildAppClient();
const existingIds = await queryExistingServiceIds(client, resolved.partnerId);
const plan = buildReconcilePlan(existingIds, parsed.data.services);
if (!plan) return errorResponse('FORBIDDEN');
for (const service of plan.toCreate) {
await client.mutation({
createPartnerService: {
__args: {
data: {
partnerId: resolved.partnerId,
title: service.title,
description: service.description,
sortOrder: service.sortOrder,
},
},
id: true,
},
});
}
for (const service of plan.toUpdate) {
// buildReconcilePlan only puts items with a defined id into toUpdate.
if (service.id === undefined) continue;
await client.mutation({
updatePartnerService: {
__args: {
id: service.id,
data: {
title: service.title,
description: service.description,
sortOrder: service.sortOrder,
},
},
id: true,
},
});
}
for (const id of plan.toDelete) {
await client.mutation({
deletePartnerService: { __args: { id }, id: true },
});
}
const services = await queryServiceRows(client, resolved.partnerId);
return { ok: true, services };
} catch (err) {
return failureResponse('save-my-partner-services', err);
}
};
export default defineLogicFunction({
universalIdentifier: SAVE_MY_PARTNER_SERVICES_ID,
name: 'save-my-partner-services',
description:
"Reconciles the calling partner's own services (create/update/delete in one call).",
timeoutSeconds: 20,
handler,
httpRouteTriggerSettings: {
path: '/save-my-partner-services',
httpMethod: 'POST',
isAuthRequired: true,
},
});
@@ -1,149 +0,0 @@
import { CoreApiClient, type CoreSchema } from 'twenty-client-sdk/core';
import { defineLogicFunction } from 'twenty-sdk/define';
import { z } from 'zod';
import {
findOrCreateCompanyByName,
findOrCreatePersonByEmail,
} from './find-or-create-company-and-person';
export const SUBMIT_CLIENT_BRIEF_LOGIC_FUNCTION_ID =
'a8f3c2e1-9b4d-4a7f-8c6e-1d2f3a4b5c6d';
const HOSTING_LABEL: Record<'CLOUD' | 'SELF_HOSTING', string> = {
CLOUD: 'Cloud',
SELF_HOSTING: 'Self-hosting',
};
export const submitClientBriefSchema = z.object({
firstName: z.string().trim().min(1),
lastName: z.string(),
email: z.string().trim().email(),
companyName: z.string().trim().min(1),
need: z.string().trim().min(1),
requirements: z.string().optional(),
hostingType: z.enum(['CLOUD', 'SELF_HOSTING']).optional(),
country: z.string().optional(),
languages: z.array(z.string()).optional(),
seatCount: z.string().optional(),
timeline: z.string().optional(),
budgetRange: z.string().optional(),
});
export type SubmitClientBriefInput = z.infer<typeof submitClientBriefSchema>;
export type SubmitClientBriefResult =
| { ok: true; opportunityId: string }
| { ok: false; reason: string };
function isNonEmptyString(value: unknown): value is string {
return typeof value === 'string' && value.trim().length > 0;
}
export function buildRequirementsText(input: SubmitClientBriefInput): string | null {
const base = isNonEmptyString(input.requirements) ? input.requirements.trim() : '';
const bullets: string[] = [];
if (input.hostingType !== undefined) {
bullets.push(`• Hosting: ${HOSTING_LABEL[input.hostingType]}`);
}
if (isNonEmptyString(input.seatCount)) bullets.push(`• Seats: ${input.seatCount.trim()}`);
if (isNonEmptyString(input.country)) bullets.push(`• Country: ${input.country.trim()}`);
if (input.languages !== undefined && input.languages.length > 0) {
bullets.push(`• Languages: ${input.languages.join(', ')}`);
}
if (isNonEmptyString(input.timeline)) bullets.push(`• Timeline: ${input.timeline.trim()}`);
if (isNonEmptyString(input.budgetRange)) bullets.push(`• Budget: ${input.budgetRange.trim()}`);
if (bullets.length === 0) return base.length > 0 ? base : null;
const block = `---\nAdditional context:\n${bullets.join('\n')}`;
return base ? `${base}\n\n${block}` : block;
}
type SubmitClientBriefEvent = {
headers?: Record<string, string | undefined>;
body?: unknown;
};
const APPLICATION_SECRET_HEADER = 'x-application-secret';
export const handler = async (
event: SubmitClientBriefEvent | SubmitClientBriefInput,
): Promise<SubmitClientBriefResult> => {
const looksLikeEvent =
typeof event === 'object' &&
event !== null &&
('body' in event || 'headers' in event);
const headers = looksLikeEvent
? (event as SubmitClientBriefEvent).headers ?? {}
: {};
const rawInput = looksLikeEvent
? (event as SubmitClientBriefEvent).body
: event;
const expectedSecret = process.env.PARTNER_APPLICATION_SECRET;
if (!isNonEmptyString(expectedSecret)) {
return { ok: false, reason: 'unauthorized' };
}
const providedSecret = headers[APPLICATION_SECRET_HEADER];
if (providedSecret !== expectedSecret) {
return { ok: false, reason: 'unauthorized' };
}
const parsed = submitClientBriefSchema.safeParse(rawInput);
if (!parsed.success) {
return { ok: false, reason: 'invalid_input' };
}
const input = parsed.data;
try {
const client = new CoreApiClient();
const name = `${input.companyName.trim()} — marketplace brief`;
const requirements = buildRequirementsText(input);
const companyId = await findOrCreateCompanyByName(client, input.companyName);
const pointOfContactId = await findOrCreatePersonByEmail(client, {
email: input.email,
firstName: input.firstName,
lastName: input.lastName,
companyId,
});
const opportunityData: CoreSchema.OpportunityCreateInput = {
name,
need: input.need,
isListed: false,
stage: 'NEW',
companyId,
pointOfContactId,
};
if (requirements !== null) {
opportunityData.requirements = requirements;
}
const result = await client.mutation({
createOpportunity: { __args: { data: opportunityData }, id: true },
});
const opportunityId = result.createOpportunity?.id;
if (opportunityId === undefined) {
throw new Error('createOpportunity did not return an id');
}
return { ok: true, opportunityId };
} catch (err) {
return { ok: false, reason: err instanceof Error ? err.message : String(err) };
}
};
export default defineLogicFunction({
universalIdentifier: SUBMIT_CLIENT_BRIEF_LOGIC_FUNCTION_ID,
name: 'submit-client-brief',
description: 'Create an unlisted Opportunity from the public marketplace brief form.',
timeoutSeconds: 15,
handler,
httpRouteTriggerSettings: {
path: '/client-briefs',
httpMethod: 'POST',
isAuthRequired: false,
forwardedRequestHeaders: [APPLICATION_SECRET_HEADER],
},
});
@@ -1,337 +0,0 @@
import { CoreApiClient, type CoreSchema } from 'twenty-client-sdk/core';
import { defineLogicFunction } from 'twenty-sdk/define';
import { z } from 'zod';
import { slugify } from '../scripts/slugify';
import { deriveDeploymentExpertise } from './derive-deployment-expertise';
import { deriveRegion } from './derive-region';
export const SUBMIT_PARTNER_APPLICATION_LOGIC_FUNCTION_ID =
'7b1e2c5f-3a14-4f7d-8e91-0b5e2a3c4d76';
export const PARTNER_COUNTRY_VALUES = [
'AFGHANISTAN','ALBANIA','ALGERIA','ANDORRA','ANGOLA','ANTIGUA_AND_BARBUDA','ARGENTINA','ARMENIA','AUSTRALIA','AUSTRIA','AZERBAIJAN','BAHAMAS','BAHRAIN','BANGLADESH','BARBADOS','BELARUS','BELGIUM','BELIZE','BENIN','BHUTAN','BOLIVIA','BOSNIA_AND_HERZEGOVINA','BOTSWANA','BRAZIL','BRUNEI','BULGARIA','BURKINA_FASO','BURUNDI','CAMBODIA','CAMEROON','CANADA','CAPE_VERDE','CENTRAL_AFRICAN_REPUBLIC','CHAD','CHILE','CHINA','COLOMBIA','COMOROS','CONGO','DR_CONGO','COSTA_RICA','CROATIA','CUBA','CYPRUS','CZECH_REPUBLIC','DENMARK','DJIBOUTI','DOMINICA','DOMINICAN_REPUBLIC','ECUADOR','EGYPT','EL_SALVADOR','EQUATORIAL_GUINEA','ERITREA','ESTONIA','ESWATINI','ETHIOPIA','FIJI','FINLAND','FRANCE','GABON','GAMBIA','GEORGIA','GERMANY','GHANA','GREECE','GRENADA','GUATEMALA','GUINEA','GUINEA_BISSAU','GUYANA','HAITI','HONDURAS','HUNGARY','ICELAND','INDIA','INDONESIA','IRAN','IRAQ','IRELAND','ISRAEL','ITALY','IVORY_COAST','JAMAICA','JAPAN','JORDAN','KAZAKHSTAN','KENYA','KIRIBATI','KOSOVO','KUWAIT','KYRGYZSTAN','LAOS','LATVIA','LEBANON','LESOTHO','LIBERIA','LIBYA','LIECHTENSTEIN','LITHUANIA','LUXEMBOURG','MADAGASCAR','MALAWI','MALAYSIA','MALDIVES','MALI','MALTA','MARSHALL_ISLANDS','MAURITANIA','MAURITIUS','MEXICO','MICRONESIA','MOLDOVA','MONACO','MONGOLIA','MONTENEGRO','MOROCCO','MOZAMBIQUE','MYANMAR','NAMIBIA','NAURU','NEPAL','NETHERLANDS','NEW_ZEALAND','NICARAGUA','NIGER','NIGERIA','NORTH_KOREA','NORTH_MACEDONIA','NORWAY','OMAN','PAKISTAN','PALAU','PALESTINE','PANAMA','PAPUA_NEW_GUINEA','PARAGUAY','PERU','PHILIPPINES','POLAND','PORTUGAL','QATAR','ROMANIA','RUSSIA','RWANDA','SAINT_KITTS_AND_NEVIS','SAINT_LUCIA','SAINT_VINCENT','SAMOA','SAN_MARINO','SAO_TOME_AND_PRINCIPE','SAUDI_ARABIA','SENEGAL','SERBIA','SEYCHELLES','SIERRA_LEONE','SINGAPORE','SLOVAKIA','SLOVENIA','SOLOMON_ISLANDS','SOMALIA','SOUTH_AFRICA','SOUTH_KOREA','SOUTH_SUDAN','SPAIN','SRI_LANKA','SUDAN','SURINAME','SWEDEN','SWITZERLAND','SYRIA','TAIWAN','TAJIKISTAN','TANZANIA','THAILAND','TIMOR_LESTE','TOGO','TONGA','TRINIDAD_AND_TOBAGO','TUNISIA','TURKEY','TURKMENISTAN','TUVALU','UGANDA','UKRAINE','UNITED_ARAB_EMIRATES','UNITED_KINGDOM','UNITED_STATES','URUGUAY','UZBEKISTAN','VANUATU','VATICAN','VENEZUELA','VIETNAM','YEMEN','ZAMBIA','ZIMBABWE',
] as const;
const PARTNER_LANGUAGE_VALUES = [
'ENGLISH','FRENCH','GERMAN','SPANISH','PORTUGUESE','ITALIAN','DUTCH','ARABIC','CHINESE','JAPANESE','RUSSIAN','HINDI',
] as const;
const PARTNER_SCOPE_VALUES = [
'ADVISORY','SOLUTIONING','DEVELOPMENT','HOSTING','SUPPORT',
] as const;
const PARTNER_TYPE_OF_TEAM_VALUES = ['SOLO','AGENCY'] as const;
// The request contract. zod is the single source of truth: it validates the
// incoming body at runtime and the input type is inferred from it, so the two
// can never drift. Enum-valued fields are constrained to the same option sets
// the Partner object accepts.
export const submitPartnerApplicationSchema = z.object({
firstName: z.string().trim().min(1),
lastName: z.string(),
email: z.string().trim().min(1),
companyName: z.string().trim().min(1),
domainName: z.string().optional(),
linkedin: z.string().optional(),
city: z.string().optional(),
country: z.enum(PARTNER_COUNTRY_VALUES).optional(),
languages: z.array(z.enum(PARTNER_LANGUAGE_VALUES)).optional(),
typeOfTeam: z.enum(PARTNER_TYPE_OF_TEAM_VALUES).optional(),
partnerScope: z.array(z.enum(PARTNER_SCOPE_VALUES)).optional(),
skills: z.array(z.string()).optional(),
applicationNotes: z.string().optional(),
hourlyRate: z.number().optional(),
projectBudgetMin: z.number().optional(),
calendarLink: z.string().optional(),
});
export type SubmitPartnerApplicationInput = z.infer<
typeof submitPartnerApplicationSchema
>;
export type SubmitPartnerApplicationResult =
| { ok: true; created: boolean; partnerId: string }
| { ok: false; reason: string };
function isNonEmptyString(value: unknown): value is string {
return typeof value === 'string' && value.trim().length > 0;
}
function toMicros(usd: number | undefined): { amountMicros: number; currencyCode: 'USD' } | undefined {
if (typeof usd !== 'number' || !Number.isFinite(usd) || usd < 0) return undefined;
return { amountMicros: Math.round(usd * 1_000_000), currencyCode: 'USD' };
}
function buildApplicationNotes(input: SubmitPartnerApplicationInput): string | null {
return isNonEmptyString(input.applicationNotes) ? input.applicationNotes.trim() : null;
}
// Mirrors the subset of Partner{Create,Update}Input this handler writes. The
// enum-typed columns are narrowed from the validated string inputs below.
type PartnerFieldsForUpsert = {
name: string;
linkedin?: { primaryLinkUrl: string };
website?: { primaryLinkUrl: string };
city?: string;
country?: CoreSchema.PartnerCountryEnum;
languagesSpoken?: CoreSchema.PartnerLanguagesSpokenEnum[];
typeOfTeam?: CoreSchema.PartnerTypeOfTeamEnum;
partnerScope?: CoreSchema.PartnerPartnerScopeEnum[];
skills?: string[];
hourlyRate?: { amountMicros: number; currencyCode: 'USD' };
projectBudgetMin?: { amountMicros: number; currencyCode: 'USD' };
calendarLink?: { primaryLinkUrl: string };
applicationNotes?: string | null;
};
function buildPartnerFields(input: SubmitPartnerApplicationInput): PartnerFieldsForUpsert {
const fields: PartnerFieldsForUpsert = {
name: input.companyName.trim(),
};
if (isNonEmptyString(input.linkedin)) fields.linkedin = { primaryLinkUrl: input.linkedin.trim() };
if (isNonEmptyString(input.domainName)) fields.website = { primaryLinkUrl: input.domainName.trim() };
if (isNonEmptyString(input.city)) fields.city = input.city.trim();
// validate() has already checked these against the allowed value sets, so
// narrowing the validated strings to their enum types here is sound.
if (input.country !== undefined) fields.country = input.country as CoreSchema.PartnerCountryEnum;
if (input.languages !== undefined && input.languages.length > 0)
fields.languagesSpoken = input.languages as CoreSchema.PartnerLanguagesSpokenEnum[];
if (input.typeOfTeam !== undefined) fields.typeOfTeam = input.typeOfTeam as CoreSchema.PartnerTypeOfTeamEnum;
if (input.partnerScope !== undefined && input.partnerScope.length > 0)
fields.partnerScope = input.partnerScope as CoreSchema.PartnerPartnerScopeEnum[];
if (input.skills !== undefined && input.skills.length > 0) fields.skills = input.skills.filter(isNonEmptyString);
const hourly = toMicros(input.hourlyRate);
if (hourly) fields.hourlyRate = hourly;
const min = toMicros(input.projectBudgetMin);
if (min) fields.projectBudgetMin = min;
if (isNonEmptyString(input.calendarLink)) fields.calendarLink = { primaryLinkUrl: input.calendarLink.trim() };
const notes = buildApplicationNotes(input);
if (notes !== null) fields.applicationNotes = notes;
return fields;
}
function normalizeDomainHost(
value: string | null | undefined,
): string | undefined {
if (!isNonEmptyString(value)) return undefined;
const host = value
.trim()
.toLowerCase()
.replace(/^https?:\/\//, '')
.replace(/^www\./, '')
.replace(/[/:?#].*$/, '');
return host.length > 0 ? host : undefined;
}
// ponytail: matches active rows only — soft-deleted companies still hold the unique index; clear those with `yarn purge:prod`.
async function findOrCreateCompanyId(
client: CoreApiClient,
input: SubmitPartnerApplicationInput,
): Promise<string> {
const domain = isNonEmptyString(input.domainName)
? input.domainName.trim()
: undefined;
const host = normalizeDomainHost(domain);
if (host !== undefined) {
// Broad ilike catches every stored URL form (bare, any protocol, paths, www).
// Paginate to exhaustion so the real match is never paged out; client-side
// normalization rejects false positives on each page.
let cursor: string | null = null;
do {
const existing = await client.query({
companies: {
__args: {
filter: { domainName: { primaryLinkUrl: { ilike: `%${host}%` } } },
first: 20,
...(cursor !== null ? { after: cursor } : {}),
},
pageInfo: { hasNextPage: true, endCursor: true },
edges: { node: { id: true, domainName: { primaryLinkUrl: true } } },
},
});
const match = existing.companies?.edges?.find(
(edge) => normalizeDomainHost(edge.node.domainName?.primaryLinkUrl) === host,
);
if (match !== undefined) {
return match.node.id;
}
const pageInfo = existing.companies?.pageInfo;
cursor = pageInfo?.hasNextPage ? (pageInfo.endCursor ?? null) : null;
} while (cursor !== null);
}
const companyData: CoreSchema.CompanyCreateInput = {
name: input.companyName.trim(),
};
if (domain !== undefined) {
companyData.domainName = { primaryLinkUrl: domain };
}
const companyResult = await client.mutation({
createCompany: { __args: { data: companyData }, id: true },
});
const companyId = companyResult.createCompany?.id;
if (companyId === undefined) {
throw new Error('createCompany did not return an id');
}
return companyId;
}
type SubmitPartnerApplicationEvent = {
headers?: Record<string, string | undefined>;
body?: unknown;
};
const APPLICATION_SECRET_HEADER = 'x-application-secret';
export const handler = async (
event: SubmitPartnerApplicationEvent | SubmitPartnerApplicationInput,
): Promise<SubmitPartnerApplicationResult> => {
// Accept either { body, headers } (HTTP) or a flat input object (direct call from tests).
const looksLikeEvent =
typeof event === 'object' &&
event !== null &&
('body' in event || 'headers' in event);
const headers = looksLikeEvent
? (event as SubmitPartnerApplicationEvent).headers ?? {}
: {};
const rawInput = looksLikeEvent
? (event as SubmitPartnerApplicationEvent).body
: event;
// Shared-secret guard. The Twenty SDK's isAuthRequired flag only accepts
// user-session JWTs, not workspace API keys, so we authenticate at the
// handler level via a custom header allowlisted in forwardedRequestHeaders.
const expectedSecret = process.env.PARTNER_APPLICATION_SECRET;
if (!isNonEmptyString(expectedSecret)) {
return { ok: false, reason: 'unauthorized' };
}
const providedSecret = headers[APPLICATION_SECRET_HEADER];
if (providedSecret !== expectedSecret) {
return { ok: false, reason: 'unauthorized' };
}
const parsed = submitPartnerApplicationSchema.safeParse(rawInput);
if (!parsed.success) {
return { ok: false, reason: 'invalid_input' };
}
const input = parsed.data;
try {
const client = new CoreApiClient();
const email = input.email.trim();
const partnerFields = buildPartnerFields(input);
const personLookup = await client.query({
people: {
__args: {
filter: { emails: { primaryEmail: { eq: email } } },
first: 1,
},
edges: {
node: {
id: true,
partner: { id: true, company: { id: true } },
},
},
},
});
const existingEdge = personLookup.people?.edges?.[0]?.node;
if (existingEdge && existingEdge.partner) {
const partnerId = existingEdge.partner.id;
await client.mutation({
updatePartner: {
__args: { id: partnerId, data: partnerFields },
id: true,
},
});
await client.mutation({
updatePerson: {
__args: {
id: existingEdge.id,
data: {
name: { firstName: input.firstName.trim(), lastName: input.lastName.trim() },
},
},
id: true,
},
});
return { ok: true, created: false, partnerId };
}
const companyId = await findOrCreateCompanyId(client, input);
const region = deriveRegion(input.country);
const partnerResult = await client.mutation({
createPartner: {
__args: {
data: {
...partnerFields,
slug: slugify(input.companyName),
validationStage: 'APPLICATION',
reviewed: false,
partnerTier: 'NEW',
companyId,
deploymentExpertise: deriveDeploymentExpertise(input.partnerScope) as CoreSchema.PartnerDeploymentExpertiseEnum[],
...(region ? { region: [region] as CoreSchema.PartnerRegionEnum[] } : {}),
},
},
id: true,
},
});
const partnerId = partnerResult.createPartner?.id;
if (partnerId === undefined) {
throw new Error('createPartner did not return an id');
}
if (existingEdge) {
await client.mutation({
updatePerson: {
__args: {
id: existingEdge.id,
data: {
partnerId,
name: { firstName: input.firstName.trim(), lastName: input.lastName.trim() },
},
},
id: true,
},
});
} else {
await client.mutation({
createPerson: {
__args: {
data: {
name: { firstName: input.firstName.trim(), lastName: input.lastName.trim() },
emails: { primaryEmail: email },
partnerId,
companyId,
},
},
id: true,
},
});
}
return { ok: true, created: true, partnerId };
} catch (err) {
return { ok: false, reason: err instanceof Error ? err.message : String(err) };
}
};
export default defineLogicFunction({
universalIdentifier: SUBMIT_PARTNER_APPLICATION_LOGIC_FUNCTION_ID,
name: 'submit-partner-application',
description: 'Receive a partner application from the website and idempotently upsert Partner / Person / Company.',
timeoutSeconds: 15,
handler,
httpRouteTriggerSettings: {
path: '/partner-applications',
httpMethod: 'POST',
isAuthRequired: false,
forwardedRequestHeaders: [APPLICATION_SECRET_HEADER],
},
});
@@ -1,90 +0,0 @@
import { type CoreApiClient } from 'twenty-client-sdk/core';
import { defineLogicFunction, type RoutePayload } from 'twenty-sdk/define';
import { z } from 'zod';
import { buildAppClient, errorResponse, failureResponse, resolvePartnerFromRequest } from './resolve-partner-from-request';
export const SUBMIT_PARTNER_CONTENT_FOR_REVIEW_ID = '6d722484-bbe9-4ffc-b017-5164e3a5a03c';
export const submitContentForReviewSchema = z.object({
recordId: z.string(),
});
export type SubmitContentForReviewInput = z.infer<typeof submitContentForReviewSchema>;
export type SubmitContentForReviewResult =
| { ok: true; status: 'UNDER_CUSTOMER_PARTNER_REVIEW' }
| { ok: false; reason: string };
// Only a WIP row can be submitted — this is the one status transition a partner
// can trigger themselves; every other transition stays staff-controlled.
export function canSubmitForReview(status: string | null): boolean {
return status === 'WIP';
}
const queryContentOwnerAndStatus = async (
client: CoreApiClient,
recordId: string,
): Promise<{ partnerId: string | null; status: string | null } | null> => {
const result = await client.query({
partnerContents: {
__args: { filter: { id: { eq: recordId } }, first: 1 },
edges: { node: { partnerId: true, status: true } },
},
});
const node = result.partnerContents?.edges?.[0]?.node;
if (!node) return null;
return { partnerId: node.partnerId ?? null, status: node.status ?? null };
};
export const handler = async (
event: RoutePayload<unknown>,
): Promise<SubmitContentForReviewResult> => {
const resolved = await resolvePartnerFromRequest(event);
if ('error' in resolved) return errorResponse(resolved.error);
const parsed = submitContentForReviewSchema.safeParse(event.body);
if (!parsed.success) {
return errorResponse(parsed.error.issues[0]?.message ?? 'invalid_input');
}
try {
const client = buildAppClient();
const content = await queryContentOwnerAndStatus(client, parsed.data.recordId);
if (!content || content.partnerId !== resolved.partnerId) {
return errorResponse('FORBIDDEN');
}
if (!canSubmitForReview(content.status)) {
return errorResponse('NOT_SUBMITTABLE');
}
await client.mutation({
updatePartnerContent: {
__args: {
id: parsed.data.recordId,
data: { status: 'UNDER_CUSTOMER_PARTNER_REVIEW' },
},
id: true,
},
});
return { ok: true, status: 'UNDER_CUSTOMER_PARTNER_REVIEW' };
} catch (err) {
return failureResponse('submit-partner-content-for-review', err);
}
};
export default defineLogicFunction({
universalIdentifier: SUBMIT_PARTNER_CONTENT_FOR_REVIEW_ID,
name: 'submit-partner-content-for-review',
description:
"Flips the calling partner's own WIP case study/content to under-review.",
timeoutSeconds: 15,
handler,
httpRouteTriggerSettings: {
path: '/submit-partner-content-for-review',
httpMethod: 'POST',
isAuthRequired: true,
},
});
@@ -11,7 +11,7 @@ vi.mock('twenty-client-sdk/core', () => ({
}),
}));
import { handler } from '../on-application-created';
import { handler } from '../on-application-created.logic-function';
const MEMBER_ID = 'aaaaaaaa-1111-1111-1111-111111111111';
const PARTNER_ID = 'bbbbbbbb-2222-2222-2222-222222222222';
@@ -11,7 +11,7 @@ vi.mock('twenty-client-sdk/core', () => ({
}),
}));
import { handler } from '../on-application-set-name';
import { handler } from '../on-application-set-name.logic-function';
const event = (after: Record<string, unknown>, updatedFields: string[]) =>
({ properties: { after, updatedFields } }) as never;
@@ -9,7 +9,7 @@ import {
APPLICATIONS_AS_PARTNER_USER_FIELD_ID,
APPLICATION_OBJECT_UNIVERSAL_IDENTIFIER,
APPLICATION_PARTNER_USER_FIELD_ID,
} from 'src/objects/application.object';
} from 'src/modules/application/objects/application.object';
export default defineField({
universalIdentifier: APPLICATIONS_AS_PARTNER_USER_FIELD_ID,
@@ -5,7 +5,7 @@ import {
APPLICATIONS_ON_PARTNER_FIELD_ID,
APPLICATION_OBJECT_UNIVERSAL_IDENTIFIER,
APPLICATION_PARTNER_FIELD_ID,
} from 'src/objects/application.object';
} from 'src/modules/application/objects/application.object';
export default defineField({
universalIdentifier: APPLICATIONS_ON_PARTNER_FIELD_ID,
@@ -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,
},
});
}
@@ -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 },
});
}
@@ -0,0 +1,12 @@
import { type CoreApiClient } from 'twenty-client-sdk/core';
export function findApplicationWithRelations(client: CoreApiClient, id: string) {
return client.query({
application: {
__args: { filter: { id: { eq: id } } },
id: true,
partner: { name: true },
opportunity: { name: true },
},
});
}
@@ -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 } },
},
});
}
@@ -0,0 +1,10 @@
import { type CoreApiClient } from 'twenty-client-sdk/core';
export function findPartnerByMember(client: CoreApiClient, memberId: string) {
return client.query({
partners: {
__args: { filter: { partnerUserId: { eq: memberId } }, first: 1 },
edges: { node: { id: true } },
},
});
}
@@ -1,6 +1,6 @@
import { NavigationMenuItemType, defineNavigationMenuItem } from 'twenty-sdk/define';
import { APPLICATIONS_BY_OPPORTUNITY_VIEW_UNIVERSAL_IDENTIFIER } from 'src/views/applications-by-opportunity.view';
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',
@@ -1,6 +1,6 @@
import { NavigationMenuItemType, defineNavigationMenuItem } from 'twenty-sdk/define';
import { APPLICATIONS_REVIEW_VIEW_UNIVERSAL_IDENTIFIER } from 'src/views/applications-review.view';
import { APPLICATIONS_REVIEW_VIEW_UNIVERSAL_IDENTIFIER } from 'src/modules/application/views/applications-review.view';
export default defineNavigationMenuItem({
universalIdentifier: 'fcf7e5e8-9ec2-4f08-898a-39e51a2787d0',
@@ -1,6 +1,6 @@
import { NavigationMenuItemType, defineNavigationMenuItem } from 'twenty-sdk/define';
import { FOLLOWUP_APPLICATIONS_VIEW_UNIVERSAL_IDENTIFIER } from 'src/views/followup-applications.view';
import { FOLLOWUP_APPLICATIONS_VIEW_UNIVERSAL_IDENTIFIER } from 'src/modules/application/views/followup-applications.view';
export default defineNavigationMenuItem({
universalIdentifier: '636cddf9-b104-432d-a5d3-3bad36b7a54a',
@@ -1,8 +1,8 @@
import { NavigationMenuItemType, defineNavigationMenuItem } from 'twenty-sdk/define';
import { MY_APPLICATIONS_VIEW_UNIVERSAL_IDENTIFIER } from 'src/views/my-applications.view';
import { MY_APPLICATIONS_VIEW_UNIVERSAL_IDENTIFIER } from 'src/modules/application/views/my-applications.view';
import { PARTNER_WORKSPACE_FOLDER_UNIVERSAL_IDENTIFIER } from './partner-workspace-folder.navigation-menu-item';
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',
@@ -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' },
});
@@ -5,6 +5,9 @@ import {
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';
const ON_APPLICATION_SET_NAME_FN_ID = '8f123b54-a7c5-42d3-8049-a240930257a7';
// Re-label an Application "<partner> · <opportunity>" whenever either relation changes.
@@ -12,30 +15,18 @@ 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');
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 client.query({
application: {
__args: { filter: { id: { eq: id } } },
id: true,
partner: { name: true },
opportunity: { name: true },
},
});
const result = await findApplicationWithRelations(client, id);
const partnerName = result.application?.partner?.name ?? 'Unassigned';
const opportunityName = result.application?.opportunity?.name ?? 'No brief';
const name = `${partnerName} · ${opportunityName}`;
await client.mutation({
updateApplication: { __args: { id, data: { name } }, id: true },
});
await updateApplication(client, id, { name });
return { labelled: name };
};
@@ -1,7 +1,7 @@
import { PageLayoutTabLayoutMode, definePageLayout } from 'twenty-sdk/define';
import { APPLICATION_OBJECT_UNIVERSAL_IDENTIFIER } from 'src/objects/application.object';
import { APPLICATION_RECORD_PAGE_FIELDS_VIEW_ID } from 'src/views/application-record-page-fields.view';
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 +
@@ -0,0 +1,57 @@
import type { CoreApiClient, CoreSchema } from 'twenty-client-sdk/core';
import type {
DatabaseEventPayload,
ObjectRecordCreateEvent,
} from 'twenty-sdk/define';
import { findPartnerByMember } from 'src/modules/application/graphql/queries/find-partner-by-member';
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';
type ApplicationCreatedProperties = DatabaseEventPayload<
ObjectRecordCreateEvent<CoreSchema.Application>
>['properties'];
// A partner self-applies via the "Apply to brief as partner" workflow: a Create Record action
// makes an Application with the opportunity set and createdBy = the clicking member, but no
// partner. Resolve the partner from createdBy and complete the candidacy. Admin-created
// applications (partner already set, or the creator is not a partner) are left untouched. The
// name is set by on-application-set-name, which fires on the partnerId update below.
export async function resolveCandidacy(
client: CoreApiClient,
after: ApplicationCreatedProperties['after'],
): Promise<Record<string, unknown>> {
const applicationId = after?.id;
if (!applicationId) return {};
if (after.partnerId) return {}; // already linked (admin path) — leave it
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
const opportunityId = after.opportunityId;
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) {
await deleteApplication(client, applicationId);
return { duplicate: true, keptExisting: existingId };
}
}
const now = new Date().toISOString();
await updateApplication(client, applicationId, {
partnerId,
partnerUserId: memberId,
state: 'APPLIED',
lastActivityAt: now,
});
// ponytail: dedupe by (opportunity, partner) above; two near-simultaneous creates could still both pass before either stamps — acceptable.
return { applied: true, partnerId };
}
@@ -7,7 +7,7 @@ import {
APPLICATION_PARTNER_FIELD_ID,
APPLICATION_PITCH_FIELD_ID,
APPLICATION_STATE_FIELD_ID,
} from 'src/objects/application.object';
} from 'src/modules/application/objects/application.object';
export const APPLICATION_RECORD_PAGE_FIELDS_VIEW_ID =
'e004c2ff-462a-45d4-8a77-071dcf093879';
@@ -7,7 +7,7 @@ import {
APPLICATION_PARTNER_FIELD_ID,
APPLICATION_PITCH_FIELD_ID,
APPLICATION_STATE_FIELD_ID,
} from 'src/objects/application.object';
} from 'src/modules/application/objects/application.object';
export const APPLICATIONS_BY_OPPORTUNITY_VIEW_UNIVERSAL_IDENTIFIER =
'db489249-284d-41b1-8537-71a655389cb7';
@@ -8,7 +8,7 @@ import {
APPLICATION_PARTNER_FIELD_ID,
APPLICATION_PITCH_FIELD_ID,
APPLICATION_STATE_FIELD_ID,
} from 'src/objects/application.object';
} from 'src/modules/application/objects/application.object';
export const APPLICATIONS_REVIEW_VIEW_UNIVERSAL_IDENTIFIER =
'cc652ba3-23e8-4e0e-a616-c9fa50b702a0';
@@ -8,7 +8,7 @@ import {
APPLICATION_PARTNER_FIELD_ID,
APPLICATION_PITCH_FIELD_ID,
APPLICATION_STATE_FIELD_ID,
} from 'src/objects/application.object';
} from 'src/modules/application/objects/application.object';
export const FOLLOWUP_APPLICATIONS_VIEW_UNIVERSAL_IDENTIFIER =
'c815e9f5-511b-467f-85b0-08ef341ff856';
@@ -6,7 +6,7 @@ import {
APPLICATION_OPPORTUNITY_FIELD_ID,
APPLICATION_PITCH_FIELD_ID,
APPLICATION_STATE_FIELD_ID,
} from 'src/objects/application.object';
} from 'src/modules/application/objects/application.object';
export const MY_APPLICATIONS_VIEW_UNIVERSAL_IDENTIFIER =
'cba45e02-b3a7-420f-ace5-5e2773076080';
@@ -9,7 +9,7 @@ import {
APPLICATIONS_ON_OPPORTUNITY_FIELD_ID,
APPLICATION_OBJECT_UNIVERSAL_IDENTIFIER,
APPLICATION_OPPORTUNITY_FIELD_ID,
} from 'src/objects/application.object';
} from 'src/modules/application/objects/application.object';
export default defineField({
universalIdentifier: APPLICATIONS_ON_OPPORTUNITY_FIELD_ID,
@@ -0,0 +1,10 @@
import type { CoreApiClient, CoreSchema } from 'twenty-client-sdk/core';
export function createOpportunity(
client: CoreApiClient,
data: CoreSchema.OpportunityCreateInput,
) {
return client.mutation({
createOpportunity: { __args: { data }, id: true },
});
}
@@ -0,0 +1,13 @@
import type { CoreApiClient, CoreSchema } from 'twenty-client-sdk/core';
export function findOpportunityByDedupeKey(
client: CoreApiClient,
filter: CoreSchema.OpportunityFilterInput,
) {
return client.query({
opportunities: {
__args: { filter, first: 1 },
edges: { node: { id: true } },
},
});
}
@@ -0,0 +1,36 @@
import { defineLogicFunction } from 'twenty-sdk/define';
import {
APPLICATION_SECRET_HEADER,
readSecretGuardedEvent,
} from 'src/modules/shared/http/read-secret-guarded-event';
import { importOpportunityFromTftSchema } from 'src/modules/opportunity/intake/mappers/import-opportunity-from-tft.mapper';
import {
importOpportunityFromTft,
type ImportOpportunityFromTftResult,
} from 'src/modules/opportunity/intake/services/import-opportunity-from-tft.service';
export const IMPORT_OPPORTUNITY_FROM_TFT_LOGIC_FUNCTION_ID =
'4c220eaf-a23f-4af2-8d69-38a6c460019f';
// isAuthRequired only accepts user JWTs, not API keys; guard with the shared secret.
export const handler = async (event: unknown): Promise<ImportOpportunityFromTftResult> => {
const guard = readSecretGuardedEvent(event, importOpportunityFromTftSchema);
if (!guard.ok) return { ok: false, reason: guard.reason };
return importOpportunityFromTft(guard.input);
};
export default defineLogicFunction({
universalIdentifier: IMPORT_OPPORTUNITY_FROM_TFT_LOGIC_FUNCTION_ID,
name: 'import-opportunity-from-tft',
description:
'Receive one opportunity pushed from the TFT workspace and create it in partners (find-or-create company + contact, idempotent on tftOpportunityId).',
timeoutSeconds: 15,
handler,
httpRouteTriggerSettings: {
path: '/opportunities',
httpMethod: 'POST',
isAuthRequired: false,
forwardedRequestHeaders: [APPLICATION_SECRET_HEADER],
},
});
@@ -13,10 +13,8 @@ vi.mock('twenty-client-sdk/core', () => ({
},
}));
import {
handler,
type ImportOpportunityFromTftInput,
} from '../import-opportunity-from-tft.logic-function';
import { handler } from './import-opportunity-from-tft.logic-function';
import { type ImportOpportunityFromTftInput } from './mappers/import-opportunity-from-tft.mapper';
const SECRET = 'test-secret-abc123';
@@ -0,0 +1,43 @@
import { z } from 'zod';
import { isNonEmptyString } from 'src/modules/shared/utils/is-non-empty-string.util';
export const submitClientBriefSchema = z.object({
firstName: z.string().trim().min(1),
lastName: z.string(),
email: z.string().trim().email(),
companyName: z.string().trim().min(1),
need: z.string().trim().min(1),
requirements: z.string().optional(),
hostingType: z.enum(['CLOUD', 'SELF_HOSTING']).optional(),
country: z.string().optional(),
languages: z.array(z.string()).optional(),
seatCount: z.string().optional(),
timeline: z.string().optional(),
budgetRange: z.string().optional(),
});
export type SubmitClientBriefInput = z.infer<typeof submitClientBriefSchema>;
const HOSTING_LABEL: Record<'CLOUD' | 'SELF_HOSTING', string> = {
CLOUD: 'Cloud',
SELF_HOSTING: 'Self-hosting',
};
export function buildRequirementsText(input: SubmitClientBriefInput): string | null {
const base = isNonEmptyString(input.requirements) ? input.requirements.trim() : '';
const bullets: string[] = [];
if (input.hostingType !== undefined) {
bullets.push(`• Hosting: ${HOSTING_LABEL[input.hostingType]}`);
}
if (isNonEmptyString(input.seatCount)) bullets.push(`• Seats: ${input.seatCount.trim()}`);
if (isNonEmptyString(input.country)) bullets.push(`• Country: ${input.country.trim()}`);
if (input.languages !== undefined && input.languages.length > 0) {
bullets.push(`• Languages: ${input.languages.join(', ')}`);
}
if (isNonEmptyString(input.timeline)) bullets.push(`• Timeline: ${input.timeline.trim()}`);
if (isNonEmptyString(input.budgetRange)) bullets.push(`• Budget: ${input.budgetRange.trim()}`);
if (bullets.length === 0) return base.length > 0 ? base : null;
const block = `---\nAdditional context:\n${bullets.join('\n')}`;
return base ? `${base}\n\n${block}` : block;
}
@@ -0,0 +1,75 @@
import { type CoreSchema } from 'twenty-client-sdk/core';
import { z } from 'zod';
import { isNonEmptyString } from 'src/modules/shared/utils/is-non-empty-string.util';
// TFT POSTs `null` for empty fields; treat null as "field absent".
const dropNulls = (value: unknown): unknown =>
value === null
? undefined
: Array.isArray(value)
? value.map(dropNulls)
: typeof value === 'object'
? Object.fromEntries(
Object.entries(value).map(([key, val]) => [key, dropNulls(val)]),
)
: value;
// Request contract — the JSON the TFT workflow POSTs.
export const importOpportunityFromTftSchema = z.preprocess(dropNulls, z.object({
tftOpportunityId: z.string().optional(),
name: z.string().trim().min(1),
amountMicros: z.number().optional(),
currencyCode: z.string().optional(),
closeDate: z.string().optional(),
stage: z.string().optional(),
useCase: z.string().optional(),
company: z
.object({
name: z.string().optional(),
domain: z.string().optional(),
})
.optional(),
pointOfContact: z
.object({
email: z.string().optional(),
firstName: z.string().optional(),
lastName: z.string().optional(),
})
.optional(),
}));
export type ImportOpportunityFromTftInput = z.infer<
typeof importOpportunityFromTftSchema
>;
export function mapToOpportunityCreateInput(
input: ImportOpportunityFromTftInput,
refs: { companyId: string | undefined; pointOfContactId: string | undefined },
): CoreSchema.OpportunityCreateInput {
const name = input.name.trim();
const tftOpportunityId = isNonEmptyString(input.tftOpportunityId)
? input.tftOpportunityId.trim()
: undefined;
const opportunityData: CoreSchema.OpportunityCreateInput = { name };
if (tftOpportunityId !== undefined) opportunityData.tftOpportunityId = tftOpportunityId;
if (input.amountMicros !== undefined) {
opportunityData.amount = {
amountMicros: input.amountMicros,
currencyCode: input.currencyCode ?? 'USD',
};
}
if (isNonEmptyString(input.closeDate)) opportunityData.closeDate = input.closeDate;
if (isNonEmptyString(input.stage)) {
opportunityData.stage = input.stage as CoreSchema.OpportunityStageEnum;
}
if (isNonEmptyString(input.useCase)) {
opportunityData.need = input.useCase.trim();
}
if (refs.companyId !== undefined) opportunityData.companyId = refs.companyId;
if (refs.pointOfContactId !== undefined) {
opportunityData.pointOfContactId = refs.pointOfContactId;
}
return opportunityData;
}
@@ -0,0 +1,119 @@
import { CoreApiClient, type CoreSchema } from 'twenty-client-sdk/core';
import {
findCompanyIdByExactName,
findPersonIdByPrimaryEmail,
} from 'src/modules/shared/services/find-or-create-company-and-person.service';
import { isNonEmptyString } from 'src/modules/shared/utils/is-non-empty-string.util';
import { createOpportunity } from 'src/modules/opportunity/intake/graphql/mutations/create-opportunity';
import { findOpportunityByDedupeKey } from 'src/modules/opportunity/intake/graphql/queries/find-opportunity-by-dedupe-key';
import {
type ImportOpportunityFromTftInput,
mapToOpportunityCreateInput,
} from 'src/modules/opportunity/intake/mappers/import-opportunity-from-tft.mapper';
export type ImportOpportunityFromTftResult =
| { ok: true; created: boolean; id: string }
| { ok: false; reason: string };
// Find by exact name, else create. Keeps its own create path (TFT sends a domain and may omit
// the name), so the shared find-or-create helper — name-only — cannot express it.
async function findOrCreateCompanyId(
client: CoreApiClient,
company: ImportOpportunityFromTftInput['company'],
): Promise<string | undefined> {
const name = isNonEmptyString(company?.name) ? company.name.trim() : undefined;
const domain = isNonEmptyString(company?.domain) ? company.domain.trim() : undefined;
if (name === undefined && domain === undefined) return undefined;
if (name !== undefined) {
const existing = await findCompanyIdByExactName(client, name);
if (existing !== undefined) return existing;
}
const companyData: CoreSchema.CompanyCreateInput = { name: name ?? domain! };
if (domain !== undefined) companyData.domainName = { primaryLinkUrl: domain };
const result = await client.mutation({
createCompany: { __args: { data: companyData }, id: true },
});
const id = result.createCompany?.id;
if (id === undefined) throw new Error('createCompany did not return an id');
return id;
}
// Find by primary email, else create — name-only contacts can't be deduped.
async function findOrCreatePersonId(
client: CoreApiClient,
pointOfContact: ImportOpportunityFromTftInput['pointOfContact'],
companyId: string | undefined,
): Promise<string | undefined> {
const email = isNonEmptyString(pointOfContact?.email)
? pointOfContact.email.trim()
: undefined;
const firstName = isNonEmptyString(pointOfContact?.firstName)
? pointOfContact.firstName.trim()
: '';
const lastName = isNonEmptyString(pointOfContact?.lastName)
? pointOfContact.lastName.trim()
: '';
if (email === undefined && firstName === '' && lastName === '') return undefined;
if (email !== undefined) {
const existing = await findPersonIdByPrimaryEmail(client, email);
if (existing !== undefined) return existing;
}
const personData: CoreSchema.PersonCreateInput = { name: { firstName, lastName } };
if (email !== undefined) personData.emails = { primaryEmail: email };
if (companyId !== undefined) personData.companyId = companyId;
const result = await client.mutation({
createPerson: { __args: { data: personData }, id: true },
});
const id = result.createPerson?.id;
if (id === undefined) throw new Error('createPerson did not return an id');
return id;
}
// Manual one-way copy of one Opportunity from the TFT workspace into partners; idempotent on
// tftOpportunityId (name as a fallback for manual calls).
export async function importOpportunityFromTft(
input: ImportOpportunityFromTftInput,
): Promise<ImportOpportunityFromTftResult> {
try {
const client = new CoreApiClient();
const name = input.name.trim();
const tftOpportunityId = isNonEmptyString(input.tftOpportunityId)
? input.tftOpportunityId.trim()
: undefined;
const dedupeFilter: CoreSchema.OpportunityFilterInput =
tftOpportunityId !== undefined
? { tftOpportunityId: { eq: tftOpportunityId } }
: { name: { eq: name } };
const existing = await findOpportunityByDedupeKey(client, dedupeFilter);
const existingId = existing.opportunities?.edges?.[0]?.node?.id;
if (existingId !== undefined) return { ok: true, created: false, id: existingId };
const companyId = await findOrCreateCompanyId(client, input.company);
const pointOfContactId = await findOrCreatePersonId(
client,
input.pointOfContact,
companyId,
);
const opportunityData = mapToOpportunityCreateInput(input, {
companyId,
pointOfContactId,
});
const result = await createOpportunity(client, opportunityData);
const id = result.createOpportunity?.id;
if (id === undefined) throw new Error('createOpportunity did not return an id');
return { ok: true, created: true, id };
} catch (err) {
return { ok: false, reason: err instanceof Error ? err.message : String(err) };
}
}
@@ -0,0 +1,55 @@
import { CoreApiClient, type CoreSchema } from 'twenty-client-sdk/core';
import {
findOrCreateCompanyByName,
findOrCreatePersonByEmail,
} from 'src/modules/shared/services/find-or-create-company-and-person.service';
import { createOpportunity } from 'src/modules/opportunity/intake/graphql/mutations/create-opportunity';
import {
buildRequirementsText,
type SubmitClientBriefInput,
} from 'src/modules/opportunity/intake/mappers/build-requirements-text.mapper';
export type SubmitClientBriefResult =
| { ok: true; opportunityId: string }
| { ok: false; reason: string };
export async function submitClientBrief(
input: SubmitClientBriefInput,
): Promise<SubmitClientBriefResult> {
try {
const client = new CoreApiClient();
const name = `${input.companyName.trim()} — marketplace brief`;
const requirements = buildRequirementsText(input);
const companyId = await findOrCreateCompanyByName(client, input.companyName);
const pointOfContactId = await findOrCreatePersonByEmail(client, {
email: input.email,
firstName: input.firstName,
lastName: input.lastName,
companyId,
});
const opportunityData: CoreSchema.OpportunityCreateInput = {
name,
need: input.need,
isListed: false,
stage: 'NEW',
companyId,
pointOfContactId,
};
if (requirements !== null) {
opportunityData.requirements = requirements;
}
const result = await createOpportunity(client, opportunityData);
const opportunityId = result.createOpportunity?.id;
if (opportunityId === undefined) {
throw new Error('createOpportunity did not return an id');
}
return { ok: true, opportunityId };
} catch (err) {
return { ok: false, reason: err instanceof Error ? err.message : String(err) };
}
}
@@ -1,7 +1,8 @@
import { CoreApiClient } from 'twenty-client-sdk/core';
import { afterEach, beforeAll, describe, expect, it } from 'vitest';
import { handler, type SubmitClientBriefInput } from '../submit-client-brief.logic-function';
import { handler } from './submit-client-brief.logic-function';
import { type SubmitClientBriefInput } from './mappers/build-requirements-text.mapper';
const TEST_SECRET = 'test-secret-abc123';
process.env.PARTNER_APPLICATION_SECRET = TEST_SECRET;
@@ -0,0 +1,34 @@
import { defineLogicFunction } from 'twenty-sdk/define';
import {
APPLICATION_SECRET_HEADER,
readSecretGuardedEvent,
} from 'src/modules/shared/http/read-secret-guarded-event';
import { submitClientBriefSchema } from 'src/modules/opportunity/intake/mappers/build-requirements-text.mapper';
import {
submitClientBrief,
type SubmitClientBriefResult,
} from 'src/modules/opportunity/intake/services/submit-client-brief.service';
export const SUBMIT_CLIENT_BRIEF_LOGIC_FUNCTION_ID =
'a8f3c2e1-9b4d-4a7f-8c6e-1d2f3a4b5c6d';
export const handler = async (event: unknown): Promise<SubmitClientBriefResult> => {
const guard = readSecretGuardedEvent(event, submitClientBriefSchema);
if (!guard.ok) return { ok: false, reason: guard.reason };
return submitClientBrief(guard.input);
};
export default defineLogicFunction({
universalIdentifier: SUBMIT_CLIENT_BRIEF_LOGIC_FUNCTION_ID,
name: 'submit-client-brief',
description: 'Create an unlisted Opportunity from the public marketplace brief form.',
timeoutSeconds: 15,
handler,
httpRouteTriggerSettings: {
path: '/client-briefs',
httpMethod: 'POST',
isAuthRequired: false,
forwardedRequestHeaders: [APPLICATION_SECRET_HEADER],
},
});
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest';
import { buildRequirementsText } from '../submit-client-brief.logic-function';
import { buildRequirementsText } from './mappers/build-requirements-text.mapper';
const base = {
firstName: 'Jane',
@@ -0,0 +1,7 @@
import type { CoreApiClient } from 'twenty-client-sdk/core';
export function updateApplicationState(client: CoreApiClient, id: string, state: string) {
return client.mutation({
updateApplication: { __args: { id, data: { state } }, id: true },
});
}
@@ -0,0 +1,11 @@
import type { CoreApiClient } from 'twenty-client-sdk/core';
export function updateCompanyPartnerUser(
client: CoreApiClient,
companyId: string,
partnerUserId: string | null,
) {
return client.mutation({
updateCompany: { __args: { id: companyId, data: { partnerUserId } }, id: true },
});
}
@@ -0,0 +1,11 @@
import type { CoreApiClient } from 'twenty-client-sdk/core';
export function updateOpportunityPartnerUser(
client: CoreApiClient,
opportunityId: string,
partnerUserId: string | null,
) {
return client.mutation({
updateOpportunity: { __args: { id: opportunityId, data: { partnerUserId } }, id: true },
});
}
@@ -0,0 +1,11 @@
import type { CoreApiClient } from 'twenty-client-sdk/core';
export function updatePersonPartnerUser(
client: CoreApiClient,
id: string,
partnerUserId: string | null,
) {
return client.mutation({
updatePerson: { __args: { id, data: { partnerUserId } }, id: true },
});
}
@@ -0,0 +1,26 @@
import type { CoreApiClient } from 'twenty-client-sdk/core';
export function findOpportunityStillUsingCompany(
client: CoreApiClient,
params: {
companyId: string;
removedPartnerId: string | null | undefined;
removedMemberId: string;
},
) {
const { companyId, removedPartnerId, removedMemberId } = params;
return client.query({
opportunities: {
__args: {
filter: {
companyId: { eq: companyId },
...(removedPartnerId
? { partnerId: { eq: removedPartnerId } }
: { partnerUserId: { eq: removedMemberId } }),
},
first: 1,
},
edges: { node: { id: true } },
},
});
}
@@ -0,0 +1,11 @@
import type { CoreApiClient } from 'twenty-client-sdk/core';
export function getCompanyPartnerUser(client: CoreApiClient, companyId: string) {
return client.query({
company: {
__args: { filter: { id: { eq: companyId } } },
id: true,
partnerUserId: true,
},
});
}
@@ -0,0 +1,12 @@
import type { CoreApiClient } from 'twenty-client-sdk/core';
export function getOpportunityCascadeFields(client: CoreApiClient, opportunityId: string) {
return client.query({
opportunity: {
__args: { filter: { id: { eq: opportunityId } } },
id: true,
partnerUserId: true,
companyId: true,
},
});
}
@@ -0,0 +1,7 @@
import type { CoreApiClient } from 'twenty-client-sdk/core';
export function getPartnerPartnerUser(client: CoreApiClient, partnerId: string) {
return client.query({
partner: { __args: { filter: { id: { eq: partnerId } } }, id: true, partnerUserId: true },
});
}
@@ -0,0 +1,21 @@
import type { CoreApiClient } from 'twenty-client-sdk/core';
const APPLICATIONS_PAGE_SIZE = 200;
export function listApplicationsByOpportunity(
client: CoreApiClient,
opportunityId: string,
after?: string,
) {
return client.query({
applications: {
__args: {
filter: { opportunityId: { eq: opportunityId } },
first: APPLICATIONS_PAGE_SIZE,
...(after ? { after } : {}),
},
edges: { node: { id: true, partnerId: true, state: true } },
pageInfo: { hasNextPage: true, endCursor: true },
},
});
}
@@ -0,0 +1,17 @@
import type { CoreApiClient, CoreSchema } from 'twenty-client-sdk/core';
const PEOPLE_PAGE_SIZE = 200;
export function listPeopleByFilter(
client: CoreApiClient,
filter: CoreSchema.PersonFilterInput,
after?: string,
) {
return client.query({
people: {
__args: { filter, first: PEOPLE_PAGE_SIZE, ...(after ? { after } : {}) },
edges: { node: { id: true } },
pageInfo: { hasNextPage: true, endCursor: true },
},
});
}
@@ -1,9 +1,9 @@
import { CoreApiClient } from 'twenty-client-sdk/core';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import onOpportunityPartnerAssigned from '../on-opportunity-partner-assigned';
import onOpportunityPartnerAssigned from './on-opportunity-partner-assigned.logic-function';
// defineLogicFunction wraps the handler in a ValidationResult; the fn is on config.handler.
// The SDK wraps the handler in a ValidationResult; the fn is on config.handler.
const handler = onOpportunityPartnerAssigned.config.handler;
function requireId(id: string | null | undefined, what: string): string {
@@ -0,0 +1,25 @@
import { CoreApiClient, type CoreSchema } from 'twenty-client-sdk/core';
import {
type DatabaseEventPayload,
defineLogicFunction,
type ObjectRecordUpdateEvent,
} from 'twenty-sdk/define';
import { propagatePartnerUser } from 'src/modules/opportunity/matching/services/propagate-partner-user.service';
const handler = async (
payload: DatabaseEventPayload<ObjectRecordUpdateEvent<CoreSchema.Opportunity>>,
): Promise<Record<string, unknown>> => {
const { after, before, updatedFields } = payload.properties;
if (!updatedFields?.includes('partnerId') || !after?.id) return {};
return propagatePartnerUser(new CoreApiClient(), { opportunityId: after.id, before, after });
};
export default defineLogicFunction({
// UNCHANGED — kept inline (not in universal-identifiers.ts, which is bundle-local).
universalIdentifier: 'd7e4a4e6-9142-4597-adcf-6fb83c0f042d',
name: 'on-opportunity-partner-assigned',
timeoutSeconds: 15,
handler,
databaseEventTriggerSettings: { eventName: 'opportunity.updated' },
});
@@ -0,0 +1,27 @@
import { CoreApiClient, type CoreSchema } from 'twenty-client-sdk/core';
import {
type DatabaseEventPayload,
defineLogicFunction,
type ObjectRecordUpdateEvent,
} from 'twenty-sdk/define';
import { syncApplicationOutcomes } from 'src/modules/opportunity/matching/services/sync-application-outcomes.service';
export const handler = async (
payload: DatabaseEventPayload<ObjectRecordUpdateEvent<CoreSchema.Opportunity>>,
): Promise<Record<string, unknown>> => {
const { after, updatedFields } = payload.properties;
if (!updatedFields?.includes('partnerId') || !after?.id) return {};
return syncApplicationOutcomes(new CoreApiClient(), {
opportunityId: after.id,
newPartnerId: after.partnerId ?? null,
});
};
export default defineLogicFunction({
universalIdentifier: '683f407e-e7a0-435d-a380-e51e536770f8',
name: 'on-opportunity-partner-won',
timeoutSeconds: 15,
handler,
databaseEventTriggerSettings: { eventName: 'opportunity.updated' },
});
@@ -10,7 +10,7 @@ vi.mock('twenty-client-sdk/core', () => ({
}),
}));
import { handler } from '../on-opportunity-partner-won';
import { handler } from './on-opportunity-partner-won.logic-function';
const OPP = 'aaaaaaaa-0000-0000-0000-000000000001';
const P_WIN = 'bbbbbbbb-0000-0000-0000-000000000001';
@@ -1,40 +1,32 @@
import { CoreApiClient, type CoreSchema } from 'twenty-client-sdk/core';
import {
type DatabaseEventPayload,
defineLogicFunction,
type ObjectRecordUpdateEvent,
import type { CoreApiClient, CoreSchema } from 'twenty-client-sdk/core';
import type {
DatabaseEventPayload,
ObjectRecordUpdateEvent,
} from 'twenty-sdk/define';
// Defined here (not in universal-identifiers.ts) to avoid touching that file's local-only id.
const ON_OPP_PARTNER_ASSIGNED_FN_UNIVERSAL_IDENTIFIER = 'd7e4a4e6-9142-4597-adcf-6fb83c0f042d';
import { collectAll } from 'src/modules/shared/utils/paginate.util';
import { getCompanyPartnerUser } from 'src/modules/opportunity/matching/graphql/queries/get-company-partner-user';
import { getOpportunityCascadeFields } from 'src/modules/opportunity/matching/graphql/queries/get-opportunity-cascade-fields';
import { getPartnerPartnerUser } from 'src/modules/opportunity/matching/graphql/queries/get-partner-partner-user';
import { findOpportunityStillUsingCompany } from 'src/modules/opportunity/matching/graphql/queries/find-opportunity-still-using-company';
import { listPeopleByFilter } from 'src/modules/opportunity/matching/graphql/queries/list-people-by-filter';
import { updateCompanyPartnerUser } from 'src/modules/opportunity/matching/graphql/mutations/update-company-partner-user';
import { updateOpportunityPartnerUser } from 'src/modules/opportunity/matching/graphql/mutations/update-opportunity-partner-user';
import { updatePersonPartnerUser } from 'src/modules/opportunity/matching/graphql/mutations/update-person-partner-user';
const PEOPLE_PAGE_SIZE = 200;
type OpportunityUpdateProperties = DatabaseEventPayload<
ObjectRecordUpdateEvent<CoreSchema.Opportunity>
>['properties'];
// Every Person id matching the filter, paginated fully — a company can have more than one
// page of contacts and a single capped page would leave RLS stamps stale on the rest.
async function collectPeopleIds(
client: CoreApiClient,
filter: CoreSchema.PersonFilterInput,
): Promise<string[]> {
const ids: string[] = [];
let after: string | undefined;
for (;;) {
const page = await client.query({
people: {
__args: { filter, first: PEOPLE_PAGE_SIZE, ...(after ? { after } : {}) },
edges: { node: { id: true } },
pageInfo: { hasNextPage: true, endCursor: true },
},
});
for (const edge of page.people?.edges ?? []) {
if (edge?.node?.id) ids.push(edge.node.id);
}
if (!page.people?.pageInfo?.hasNextPage) break;
after = page.people.pageInfo.endCursor ?? undefined;
}
return ids;
const nodes = await collectAll(async (after) => {
const page = await listPeopleByFilter(client, filter, after);
return page.people;
});
return nodes.map((node) => node.id).filter((id): id is string => Boolean(id));
}
// Set (or clear, with null) partnerUser on each Person, attempting all of them even if some
@@ -46,11 +38,7 @@ async function setPeoplePartnerUser(
partnerUserId: string | null,
): Promise<number> {
const results = await Promise.allSettled(
personIds.map((id) =>
client.mutation({
updatePerson: { __args: { id, data: { partnerUserId } }, id: true },
}),
),
personIds.map((id) => updatePersonPartnerUser(client, id, partnerUserId)),
);
return results.filter((result) => result.status === 'rejected').length;
}
@@ -60,16 +48,15 @@ async function setPeoplePartnerUser(
// assign stamps it onto the Opportunity + linked Company + People; unassign clears it,
// keeping the Company/People if the same Partner still has another opportunity on that
// Company. Runs under the app identity, so its writes bypass partner RLS / field locks.
const handler = async (
payload: DatabaseEventPayload<ObjectRecordUpdateEvent<CoreSchema.Opportunity>>,
): Promise<Record<string, unknown>> => {
const { after, before, updatedFields } = payload.properties;
if (!updatedFields?.includes('partnerId')) return {};
const opportunityId = after?.id;
if (!opportunityId) return {};
const client = new CoreApiClient();
export async function propagatePartnerUser(
client: CoreApiClient,
params: {
opportunityId: string;
before: OpportunityUpdateProperties['before'];
after: OpportunityUpdateProperties['after'];
},
): Promise<Record<string, unknown>> {
const { opportunityId, before, after } = params;
const partnerId = after?.partnerId;
// ── Unassign: the partner was removed from the opportunity ───────────────────
@@ -83,24 +70,12 @@ const handler = async (
const removedPartnerId = before?.partnerId;
if (!removedMemberId || !companyId) {
const oppResult = await client.query({
opportunity: {
__args: { filter: { id: { eq: opportunityId } } },
id: true,
partnerUserId: true,
companyId: true,
},
});
const oppResult = await getOpportunityCascadeFields(client, opportunityId);
removedMemberId = removedMemberId ?? oppResult.opportunity?.partnerUserId ?? null;
companyId = companyId ?? oppResult.opportunity?.companyId ?? null;
}
await client.mutation({
updateOpportunity: {
__args: { id: opportunityId, data: { partnerUserId: null } },
id: true,
},
});
await updateOpportunityPartnerUser(client, opportunityId, null);
if (!removedMemberId || !companyId) {
return { cascaded: true, cleared: true };
@@ -110,39 +85,19 @@ const handler = async (
// it. Decided on partnerId, not the derived partnerUser stamp (which a prior partial
// cascade may have left unset); fall back to the stamp only if the old partnerId is
// unavailable. The just-cleared opportunity no longer matches either filter.
const stillInUse = await client.query({
opportunities: {
__args: {
filter: {
companyId: { eq: companyId },
...(removedPartnerId
? { partnerId: { eq: removedPartnerId } }
: { partnerUserId: { eq: removedMemberId } }),
},
first: 1,
},
edges: { node: { id: true } },
},
const stillInUse = await findOpportunityStillUsingCompany(client, {
companyId,
removedPartnerId,
removedMemberId,
});
if ((stillInUse.opportunities?.edges?.length ?? 0) > 0) {
return { cascaded: true, cleared: true, companyKept: true };
}
// Clear the company (only if it belongs to this member) and every person stamped for them.
const companyResult = await client.query({
company: {
__args: { filter: { id: { eq: companyId } } },
id: true,
partnerUserId: true,
},
});
const companyResult = await getCompanyPartnerUser(client, companyId);
if (companyResult.company?.partnerUserId === removedMemberId) {
await client.mutation({
updateCompany: {
__args: { id: companyId, data: { partnerUserId: null } },
id: true,
},
});
await updateCompanyPartnerUser(client, companyId, null);
}
const peopleIds = await collectPeopleIds(client, {
@@ -159,15 +114,11 @@ const handler = async (
}
// ── Assign / reassign ────────────────────────────────────────────────────────
const partnerResult = await client.query({
partner: { __args: { filter: { id: { eq: partnerId } } }, id: true, partnerUserId: true },
});
const partnerResult = await getPartnerPartnerUser(client, partnerId);
const partnerUserId = partnerResult.partner?.partnerUserId;
if (!partnerUserId) return { cascaded: false, reason: 'partner_has_no_user' };
await client.mutation({
updateOpportunity: { __args: { id: opportunityId, data: { partnerUserId } }, id: true },
});
await updateOpportunityPartnerUser(client, opportunityId, partnerUserId);
const companyId = after?.companyId;
if (!companyId) return { cascaded: true, partnerUserId };
@@ -176,21 +127,13 @@ const handler = async (
// partnerUser column on Company/Person models one owner per company, so reassigning it
// here would steal the company (and its contacts) from the other partner and expose their
// data. Stamp only the opportunity in that case and leave the company/people alone.
const companyResult = await client.query({
company: {
__args: { filter: { id: { eq: companyId } } },
id: true,
partnerUserId: true,
},
});
const companyResult = await getCompanyPartnerUser(client, companyId);
const companyOwner = companyResult.company?.partnerUserId;
if (companyOwner && companyOwner !== partnerUserId) {
return { cascaded: true, partnerUserId, companyShared: true };
}
await client.mutation({
updateCompany: { __args: { id: companyId, data: { partnerUserId } }, id: true },
});
await updateCompanyPartnerUser(client, companyId, partnerUserId);
const peopleIds = await collectPeopleIds(client, { companyId: { eq: companyId } });
const failed = await setPeoplePartnerUser(client, peopleIds, partnerUserId);
@@ -201,12 +144,4 @@ const handler = async (
}
return { cascaded: true, partnerUserId };
};
export default defineLogicFunction({
universalIdentifier: ON_OPP_PARTNER_ASSIGNED_FN_UNIVERSAL_IDENTIFIER,
name: 'on-opportunity-partner-assigned',
timeoutSeconds: 15,
handler,
databaseEventTriggerSettings: { eventName: 'opportunity.updated' },
});
}
@@ -0,0 +1,41 @@
import type { CoreApiClient } from 'twenty-client-sdk/core';
import { collectAll } from 'src/modules/shared/utils/paginate.util';
import { listApplicationsByOpportunity } from 'src/modules/opportunity/matching/graphql/queries/list-applications-by-opportunity';
import { updateApplicationState } from 'src/modules/opportunity/matching/graphql/mutations/update-application-state';
// WON/BACKUP mirror of Opportunity.partner: on assign, winner -> WON and other active apps ->
// BACKUP; on unassign, WON/BACKUP -> APPLIED. DECLINED is never touched. Runs under the app
// identity, bypassing partner locks.
export async function syncApplicationOutcomes(
client: CoreApiClient,
params: { opportunityId: string; newPartnerId: string | null },
): Promise<Record<string, unknown>> {
const { opportunityId, newPartnerId } = params;
const applications = await collectAll(async (after) => {
const page = await listApplicationsByOpportunity(client, opportunityId, after);
return page.applications;
});
const setState = (id: string, state: string) => updateApplicationState(client, id, state);
if (newPartnerId) {
// Winner -> WON; every other active (non-DECLINED) application -> BACKUP.
for (const node of applications) {
if (node.state === 'DECLINED') continue;
const target = node.partnerId === newPartnerId ? 'WON' : 'BACKUP';
if (node.state !== target) await setState(node.id, target);
}
const winner = applications.find((node) => node.partnerId === newPartnerId);
return { won: winner?.id ?? null };
}
// Unassigned: WON and BACKUP applications re-open to APPLIED. DECLINED untouched.
for (const node of applications) {
if (node.state === 'WON' || node.state === 'BACKUP') {
await setState(node.id, 'APPLIED');
}
}
return { won: null, cleared: true };
}
@@ -1,6 +1,6 @@
import { NavigationMenuItemType, defineNavigationMenuItem } from 'twenty-sdk/define';
import { BRIEFS_TO_MATCH_VIEW_UNIVERSAL_IDENTIFIER } from 'src/views/briefs-to-match.view';
import { BRIEFS_TO_MATCH_VIEW_UNIVERSAL_IDENTIFIER } from 'src/modules/opportunity/views/briefs-to-match.view';
export default defineNavigationMenuItem({
universalIdentifier: 'c46f6b03-ee43-4799-9615-270ad28d2848',
@@ -1,6 +1,6 @@
import { NavigationMenuItemType, defineNavigationMenuItem } from 'twenty-sdk/define';
import { DEALS_BOARD_VIEW_UNIVERSAL_IDENTIFIER } from 'src/views/deals-board.view';
import { DEALS_BOARD_VIEW_UNIVERSAL_IDENTIFIER } from 'src/modules/opportunity/views/deals-board.view';
export default defineNavigationMenuItem({
universalIdentifier: '9e4dde3a-46f1-431c-b682-2e1fa8a5622c',
@@ -1,6 +1,6 @@
import { NavigationMenuItemType, defineNavigationMenuItem } from 'twenty-sdk/define';
import { FOLLOWUP_BRIEFS_VIEW_UNIVERSAL_IDENTIFIER } from 'src/views/followup-briefs.view';
import { FOLLOWUP_BRIEFS_VIEW_UNIVERSAL_IDENTIFIER } from 'src/modules/opportunity/views/followup-briefs.view';
export default defineNavigationMenuItem({
universalIdentifier: 'd4017fb3-0bfd-4099-94e8-e1ceffa8faca',
@@ -1,8 +1,8 @@
import { NavigationMenuItemType, defineNavigationMenuItem } from 'twenty-sdk/define';
import { MY_DEALS_VIEW_UNIVERSAL_IDENTIFIER } from 'src/views/my-deals.view';
import { MY_DEALS_VIEW_UNIVERSAL_IDENTIFIER } from 'src/modules/opportunity/views/my-deals.view';
import { PARTNER_WORKSPACE_FOLDER_UNIVERSAL_IDENTIFIER } from './partner-workspace-folder.navigation-menu-item';
import { PARTNER_WORKSPACE_FOLDER_UNIVERSAL_IDENTIFIER } from 'src/modules/shared/navigation-menu-items/partner-workspace-folder.navigation-menu-item';
export default defineNavigationMenuItem({
universalIdentifier: '783d4920-6bf6-4dbf-b705-228ffcc9a7d7',
@@ -1,8 +1,8 @@
import { NavigationMenuItemType, defineNavigationMenuItem } from 'twenty-sdk/define';
import { OPEN_BRIEFS_VIEW_UNIVERSAL_IDENTIFIER } from 'src/views/open-briefs.view';
import { OPEN_BRIEFS_VIEW_UNIVERSAL_IDENTIFIER } from 'src/modules/opportunity/views/open-briefs.view';
import { PARTNER_WORKSPACE_FOLDER_UNIVERSAL_IDENTIFIER } from './partner-workspace-folder.navigation-menu-item';
import { PARTNER_WORKSPACE_FOLDER_UNIVERSAL_IDENTIFIER } from 'src/modules/shared/navigation-menu-items/partner-workspace-folder.navigation-menu-item';
export default defineNavigationMenuItem({
universalIdentifier: '1392ad4d-3792-4187-a1c5-ee05815dcfde',
@@ -4,7 +4,7 @@ import {
} from 'twenty-sdk/define';
import { OPPORTUNITY_RECORD_PAGE_APPLICATIONS_VIEW_FIELD_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
import { APPLICATIONS_ON_OPPORTUNITY_FIELD_ID } from 'src/objects/application.object';
import { APPLICATIONS_ON_OPPORTUNITY_FIELD_ID } from 'src/modules/application/objects/application.object';
const OPPORTUNITY_RECORD_PAGE_FIELDS =
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.views.opportunityRecordPageFields;
@@ -4,7 +4,7 @@ import {
} from 'twenty-sdk/define';
import { OPPORTUNITY_RECORD_PAGE_IS_LISTED_VIEW_FIELD_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
import { OPPORTUNITY_IS_LISTED_FIELD_ID } from 'src/fields/opportunity-is-listed.field';
import { OPPORTUNITY_IS_LISTED_FIELD_ID } from 'src/modules/opportunity/fields/opportunity-is-listed.field';
const OPPORTUNITY_RECORD_PAGE_FIELDS =
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.views.opportunityRecordPageFields;
@@ -4,7 +4,7 @@ import {
} from 'twenty-sdk/define';
import { OPPORTUNITY_RECORD_PAGE_NEED_VIEW_FIELD_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
import { OPPORTUNITY_NEED_FIELD_ID } from 'src/fields/opportunity-need.field';
import { OPPORTUNITY_NEED_FIELD_ID } from 'src/modules/opportunity/fields/opportunity-need.field';
const OPPORTUNITY_RECORD_PAGE_FIELDS =
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.views.opportunityRecordPageFields;
@@ -4,7 +4,7 @@ import {
} from 'twenty-sdk/define';
import { OPPORTUNITY_RECORD_PAGE_PARTNER_VIEW_FIELD_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
import { PARTNER_ON_OPPORTUNITY_FIELD_ID } from 'src/fields/partner-on-opportunity.field';
import { PARTNER_ON_OPPORTUNITY_FIELD_ID } from 'src/modules/opportunity/fields/partner-on-opportunity.field';
const OPPORTUNITY_RECORD_PAGE_FIELDS =
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.views.opportunityRecordPageFields;
@@ -4,7 +4,7 @@ import {
} from 'twenty-sdk/define';
import { OPPORTUNITY_RECORD_PAGE_REQUIREMENTS_VIEW_FIELD_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
import { OPPORTUNITY_REQUIREMENTS_FIELD_ID } from 'src/fields/opportunity-requirements.field';
import { OPPORTUNITY_REQUIREMENTS_FIELD_ID } from 'src/modules/opportunity/fields/opportunity-requirements.field';
const OPPORTUNITY_RECORD_PAGE_FIELDS =
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.views.opportunityRecordPageFields;

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