From 50c4659cae632b0ae590865a5daea49f356c5baf Mon Sep 17 00:00:00 2001 From: Rashad Karanouh <11599358+rashad@users.noreply.github.com> Date: Tue, 23 Jun 2026 10:47:16 +0400 Subject: [PATCH] feat(twenty-partners): import an opportunity from TFT via manual workflow (#21979) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What Adds a one-way, manual copy of a single Opportunity from the **twentyfortwenty (TFT)** workspace into **partners**. No automatic/echo sync — one record per button press. A TFT-side **manual Workflow** (a "Run workflow" button on the Opportunity record) → **HTTP Request** action → `POST /s/opportunities` on the partners app. The new `import-opportunity-from-tft` logic function: - **Shared-secret guard** on the `x-application-secret` header vs the existing `PARTNER_APPLICATION_SECRET` app variable (the SDK's `isAuthRequired` only accepts user JWTs, not API keys — same pattern as `submit-partner-application`). - **Idempotent on `tftOpportunityId`** (a field the partners Opportunity object already has); falls back to `name` for manual calls. Re-press → no duplicate. - **Find-or-create** the Company (by name) and the point-of-contact Person (by primary email), then `createOpportunity` with `name / amount / closeDate / stage / companyId / pointOfContactId`. ## Out of scope - The **TFT-side workflow is a UI step** (built once in the TFT workspace) — it can't live in this repo. The exact HTTP action config + body mapping lives in the workflow itself. - Owner/workspace-member copy and stage-enum remapping are intentionally skipped (YAGNI). ## Files - `src/logic-functions/import-opportunity-from-tft.logic-function.ts` — the handler + manifest. - `src/logic-functions/__tests__/import-opportunity-from-tft.test.ts` — unit tests (auth reject / idempotent / mapped create). - `package.json` — version bump **0.5.5 → 0.6.0** (minor; new feature). ## Verification (local bundle) - `yarn test:unit` 3/3 · `yarn lint` 0/0 · `yarn twenty dev --once` → `created logicFunction import-opportunity-from-tft`, no manifest warnings. - Live `POST /s/opportunities` with the secret → `201 {ok:true,created:true,id}` (confirms the app role can create Opportunity + Company + Person). Re-POST same `tftOpportunityId` → `created:false`. Wrong secret → `unauthorized`. ## Deploy note Additive (new logic function + HTTP route) — upgrades cleanly with `deploy` + `install`. `PARTNER_APPLICATION_SECRET` is already set on prod, so no new application variable to configure. --- .../internal/twenty-partners/package.json | 2 +- .../import-opportunity-from-tft.test.ts | 98 ++++++++ ...ort-opportunity-from-tft.logic-function.ts | 217 ++++++++++++++++++ 3 files changed, 316 insertions(+), 1 deletion(-) create mode 100644 packages/twenty-apps/internal/twenty-partners/src/logic-functions/__tests__/import-opportunity-from-tft.test.ts create mode 100644 packages/twenty-apps/internal/twenty-partners/src/logic-functions/import-opportunity-from-tft.logic-function.ts diff --git a/packages/twenty-apps/internal/twenty-partners/package.json b/packages/twenty-apps/internal/twenty-partners/package.json index 2e9785fb85..12bed78387 100644 --- a/packages/twenty-apps/internal/twenty-partners/package.json +++ b/packages/twenty-apps/internal/twenty-partners/package.json @@ -1,6 +1,6 @@ { "name": "twenty-partners", - "version": "1.0.0", + "version": "1.1.0", "license": "MIT", "engines": { "node": "^24.5.0", diff --git a/packages/twenty-apps/internal/twenty-partners/src/logic-functions/__tests__/import-opportunity-from-tft.test.ts b/packages/twenty-apps/internal/twenty-partners/src/logic-functions/__tests__/import-opportunity-from-tft.test.ts new file mode 100644 index 0000000000..defa896f31 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-partners/src/logic-functions/__tests__/import-opportunity-from-tft.test.ts @@ -0,0 +1,98 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +// Mock the codegen client BEFORE importing the handler. vi.hoisted lets the +// factory reference the mocks safely despite hoisting. +const { queryMock, mutationMock } = vi.hoisted(() => ({ + queryMock: vi.fn(), + mutationMock: vi.fn(), +})); +vi.mock('twenty-client-sdk/core', () => ({ + CoreApiClient: class { + query = queryMock; + mutation = mutationMock; + }, +})); + +import { + handler, + type ImportOpportunityFromTftInput, +} from '../import-opportunity-from-tft.logic-function'; + +const SECRET = 'test-secret-abc123'; + +const baseInput = ( + overrides: Partial = {}, +): ImportOpportunityFromTftInput => ({ + tftOpportunityId: 'tft-opp-1', + name: 'Acme rollout', + amountMicros: 5000000, + currencyCode: 'EUR', + closeDate: '2026-07-01T00:00:00.000Z', + stage: 'MEETING', + company: { name: 'Acme', domain: 'acme.com' }, + pointOfContact: { email: 'ada@acme.com', firstName: 'Ada', lastName: 'Lovelace' }, + ...overrides, +}); + +const authedEvent = (input: ImportOpportunityFromTftInput) => ({ + body: input, + headers: { 'x-application-secret': SECRET }, +}); + +beforeEach(() => { + queryMock.mockReset(); + mutationMock.mockReset(); + process.env.PARTNER_APPLICATION_SECRET = SECRET; +}); + +afterEach(() => { + delete process.env.PARTNER_APPLICATION_SECRET; +}); + +describe('import-opportunity-from-tft handler', () => { + it('rejects a wrong/missing secret without touching the client', async () => { + const result = await handler({ body: baseInput(), headers: {} }); + expect(result).toEqual({ ok: false, reason: 'unauthorized' }); + expect(queryMock).not.toHaveBeenCalled(); + expect(mutationMock).not.toHaveBeenCalled(); + }); + + it('is idempotent: an existing tftOpportunityId returns created:false and creates nothing', async () => { + queryMock.mockResolvedValueOnce({ + opportunities: { edges: [{ node: { id: 'existing-opp' } }] }, + }); + + const result = await handler(authedEvent(baseInput())); + + expect(result).toEqual({ ok: true, created: false, id: 'existing-opp' }); + expect(mutationMock).not.toHaveBeenCalled(); + }); + + it('creates the opportunity with mapped fields + resolved company/contact', async () => { + queryMock + .mockResolvedValueOnce({ opportunities: { edges: [] } }) // dedup miss + .mockResolvedValueOnce({ companies: { edges: [] } }) // company miss + .mockResolvedValueOnce({ people: { edges: [] } }); // contact miss + mutationMock + .mockResolvedValueOnce({ createCompany: { id: 'company-1' } }) + .mockResolvedValueOnce({ createPerson: { id: 'person-1' } }) + .mockResolvedValueOnce({ createOpportunity: { id: 'opp-1' } }); + + const result = await handler(authedEvent(baseInput())); + + expect(result).toEqual({ ok: true, created: true, id: 'opp-1' }); + + const createOppCall = mutationMock.mock.calls.find( + ([arg]) => 'createOpportunity' in arg, + ); + expect(createOppCall?.[0].createOpportunity.__args.data).toEqual({ + name: 'Acme rollout', + tftOpportunityId: 'tft-opp-1', + amount: { amountMicros: 5000000, currencyCode: 'EUR' }, + closeDate: '2026-07-01T00:00:00.000Z', + stage: 'MEETING', + companyId: 'company-1', + pointOfContactId: 'person-1', + }); + }); +}); diff --git a/packages/twenty-apps/internal/twenty-partners/src/logic-functions/import-opportunity-from-tft.logic-function.ts b/packages/twenty-apps/internal/twenty-partners/src/logic-functions/import-opportunity-from-tft.logic-function.ts new file mode 100644 index 0000000000..8d864bda95 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-partners/src/logic-functions/import-opportunity-from-tft.logic-function.ts @@ -0,0 +1,217 @@ +import { CoreApiClient, type CoreSchema } from 'twenty-client-sdk/core'; +import { defineLogicFunction } from 'twenty-sdk/define'; +import { z } from 'zod'; + +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'; + +// Request contract — the JSON the TFT workflow POSTs. +export const importOpportunityFromTftSchema = 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(), + 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; + 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 { + 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 lookup = await client.query({ + companies: { + __args: { filter: { name: { eq: name } }, first: 1 }, + edges: { node: { id: true } }, + }, + }); + const existing = lookup.companies?.edges?.[0]?.node; + if (existing) return existing.id; + } + + 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 { + 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 lookup = await client.query({ + people: { + __args: { filter: { emails: { primaryEmail: { eq: email } } }, first: 1 }, + edges: { node: { id: true } }, + }, + }); + const existing = lookup.people?.edges?.[0]?.node; + if (existing) return existing.id; + } + + 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 => { + // 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 (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], + }, +});