feat(twenty-partners): import an opportunity from TFT via manual workflow (#21979)

## 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.
This commit is contained in:
Rashad Karanouh
2026-06-23 10:47:16 +04:00
committed by GitHub
parent 6e7d8ef96c
commit 50c4659cae
3 changed files with 316 additions and 1 deletions
@@ -1,6 +1,6 @@
{
"name": "twenty-partners",
"version": "1.0.0",
"version": "1.1.0",
"license": "MIT",
"engines": {
"node": "^24.5.0",
@@ -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> = {},
): 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',
});
});
});
@@ -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<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 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<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 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<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 (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],
},
});