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:
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "twenty-partners",
|
||||
"version": "1.0.0",
|
||||
"version": "1.1.0",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^24.5.0",
|
||||
|
||||
+98
@@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
+217
@@ -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],
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user