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

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

63
packages/twenty-zapier/.gitignore vendored Normal file
View File

@@ -0,0 +1,63 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/
lib/
# Dependency directories
node_modules/
jspm_packages/
# Typescript v1 declaration files
typings/
# Optional npm cache directory
.npm
# Optional oxlint cache
.oxlintcache
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# environment variables file
.env
.environment
# next.js build output
.next

View File

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

View File

@@ -0,0 +1,4 @@
{
"id": 193409,
"key": "App193409"
}

View File

@@ -0,0 +1,21 @@
## 2.2.0
- Fix authentication issue
## 2.1.1
- Add changelog
## 2.1.0
- Fix some authentication issues
- Remove position from input fields
- Fix required boolean fields that should not be
## 2.0.0
- First release
## 1.0.0
Initial release to public.

View File

@@ -0,0 +1 @@
module.exports = require('./lib/index.cjs').default;

View File

@@ -0,0 +1,12 @@
import { pathsToModuleNameMapper } from 'ts-jest';
export default {
preset: 'ts-jest',
testEnvironment: 'node',
rootDir: './src',
testMatch: ['**/__tests__/**/*.test.ts'],
moduleNameMapper: pathsToModuleNameMapper(
{ 'src/*': ['./src/*'] },
{ prefix: '<rootDir>/../' },
),
};

View File

@@ -0,0 +1,33 @@
{
"name": "twenty-zapier",
"version": "2.2.0",
"description": "Effortlessly sync Twenty with 3000+ apps. Automate tasks, boost productivity, and supercharge your customer relationships!",
"main": "lib/index.cjs",
"engines": {
"node": "^24.5.0",
"npm": "please-use-yarn",
"yarn": "^4.0.2"
},
"private": true,
"zapier": {
"convertedByCLIVersion": "15.4.1"
},
"dependencies": {
"@sniptt/guards": "^0.2.0",
"dotenv": "^16.4.5",
"libphonenumber-js": "^1.10.26",
"zapier-platform-core": "19.0.0",
"zod": "^4.1.11"
},
"devDependencies": {
"@types/jest": "^30.0.0",
"jest": "29.7.0",
"jest-environment-node": "^29.4.1",
"rimraf": "^3.0.2",
"ts-jest": "^29.1.1",
"twenty-shared": "workspace:*",
"vite": "^8.0.0",
"vite-plugin-dts": "^4.5.4",
"zapier-platform-cli": "^19.0.0"
}
}

View File

@@ -0,0 +1,84 @@
{
"name": "twenty-zapier",
"projectType": "application",
"tags": ["scope:zapier"],
"targets": {
"build": {
"outputs": ["{projectRoot}/lib"],
"executor": "nx:run-commands",
"options": {
"cwd": "{projectRoot}",
"commands": ["npx vite build"]
},
"dependsOn": ["clean", "^build"]
},
"format": {
"executor": "nx:run-commands",
"options": {
"cwd": "{projectRoot}",
"commands": ["npx oxfmt ."]
},
"configurations": {
"check": {
"commands": ["npx oxfmt --check ."]
}
}
},
"test": {
"executor": "nx:run-commands",
"options": {
"cwd": "{projectRoot}",
"commands": ["NODE_ENV=test jest --testTimeout 10000"]
}
},
"lint": {},
"typecheck": {},
"validate": {
"executor": "nx:run-commands",
"options": {
"cwd": "{projectRoot}",
"commands": ["zapier-platform validate"]
},
"dependsOn": ["build"]
},
"versions": {
"executor": "nx:run-commands",
"options": {
"cwd": "{projectRoot}",
"commands": ["zapier-platform versions"]
},
"dependsOn": ["build"]
},
"watch": {
"executor": "nx:run-commands",
"options": {
"cwd": "{projectRoot}",
"commands": ["nx run twenty-zapier:clean && npx tsc --watch"]
},
"dependsOn": ["build"]
},
"clean": {
"executor": "nx:run-commands",
"options": {
"cwd": "{projectRoot}",
"commands": ["rimraf ./lib"]
}
},
"deploy": {
"executor": "nx:run-commands",
"options": {
"cwd": "{projectRoot}",
"commands": ["zapier-platform push --disable-dependency-detection"]
},
"dependsOn": ["build"]
},
"promote": {
"executor": "nx:run-commands",
"options": {
"cwd": "{projectRoot}",
"commands": ["zapier-platform promote"]
},
"dependsOn": ["build"]
}
}
}

View File

@@ -0,0 +1,57 @@
import { createAppTester, tools } from 'zapier-platform-core';
import App from 'src/index';
import { getBundleForTest } from 'src/utils/getBundleForTest';
const appTester = createAppTester(App);
tools.env.inject();
describe('custom auth', () => {
it('passes authentication and returns json', async () => {
const bundle = getBundleForTest();
const response = await appTester(App.authentication.test, bundle);
expect(response.data).toHaveProperty('currentWorkspace');
expect(response.data.currentWorkspace).toHaveProperty('displayName');
});
it('passes authentication with api url and returns json', async () => {
const bundle = getBundleForTest();
const bundleWithApiUrl = {
...bundle,
authData: { ...bundle.authData, apiUrl: 'http://localhost:3000' },
};
const response = await appTester(App.authentication.test, bundleWithApiUrl);
expect(response.data).toHaveProperty('currentWorkspace');
expect(response.data.currentWorkspace).toHaveProperty('displayName');
});
it('fail authentication with bad api url', async () => {
const bundle = getBundleForTest();
const bundleWithApiUrl = {
...bundle,
authData: { ...bundle.authData, apiUrl: 'http://invalid' },
};
try {
const response = await appTester(
App.authentication.test,
bundleWithApiUrl,
);
expect(response.data).toHaveProperty('currentWorkspace');
expect(response.data.currentWorkspace).toHaveProperty('displayName');
} catch (error: any) {
expect(error.message).toBeDefined();
}
});
it('fails on bad auth token format', async () => {
const bundle = getBundleForTest();
bundle.authData.apiKey = 'bad';
try {
await appTester(App.authentication.test, bundle);
} catch (error: any) {
expect(error.message).toContain('UNAUTHENTICATED');
return;
}
throw new Error('appTester should have thrown');
});
});

View File

@@ -0,0 +1,39 @@
import type { Bundle, ZObject } from 'zapier-platform-core';
import requestDb from 'src/utils/requestDb';
const testAuthentication = async (z: ZObject, bundle: Bundle) => {
return await requestDb({
z,
bundle,
query: 'query currentWorkspace {currentWorkspace {id displayName}}',
endpoint: 'metadata',
});
};
export default {
type: 'custom',
test: testAuthentication,
fields: [
{
computed: false,
key: 'apiKey',
required: true,
label: 'Api Key',
type: 'string',
helpText:
'Create an API key in [your twenty workspace](https://app.twenty.com/settings/apis)',
},
{
computed: false,
key: 'apiUrl',
required: false,
label: 'Self hosted instance url',
type: 'string',
placeholder: 'https://crm.custom-url.com',
helpText: 'Set this only if you self-host Twenty',
},
],
connectionLabel: '{{data.currentWorkspace.displayName}}',
customConfig: {},
};

View File

@@ -0,0 +1,164 @@
import {
type Bundle,
createAppTester,
tools,
type ZObject,
} from 'zapier-platform-core';
import { crudRecordKey } from 'src/creates/crud_record';
import App from 'src/index';
import { getBundleForTest } from 'src/utils/getBundleForTest';
import requestDb from 'src/utils/requestDb';
import { DatabaseEventAction } from 'src/utils/triggers/triggers.utils';
const appTester = createAppTester(App);
tools.env.inject();
describe('creates.create_company', () => {
test('should run to create a Company Record', async () => {
const bundle = getBundleForTest({
nameSingular: 'Company',
crudZapierOperation: DatabaseEventAction.CREATED,
name: 'Company Name',
address: { addressCity: 'Paris' },
linkedinLink: {
primaryLinkUrl: 'http://test.com/linkedin_url',
primaryLinkLabel: 'Test linkedinUrl',
secondaryLinks: [
'{ url: "http://test.com/linkedin_url2", label: "Test linkedinUrl2" }',
],
},
});
const result = await appTester(
App.creates[crudRecordKey].operation.perform,
bundle,
);
expect(result).toBeDefined();
expect(result.data?.createCompany?.id).toBeDefined();
const checkDbResult = await appTester(
(z: ZObject, bundle: Bundle) =>
requestDb({
z,
bundle,
query: `query findCompany {company(filter: {id: {eq: "${result.data.createCompany.id}"}}){id name address{addressCity}}}`,
}),
bundle,
);
expect(checkDbResult.data.company.address.addressCity).toEqual('Paris');
});
test('should run to create a Person Record', async () => {
const bundle = getBundleForTest({
nameSingular: 'Person',
crudZapierOperation: DatabaseEventAction.CREATED,
name: { firstName: 'John', lastName: 'Doe' },
phones: {
primaryPhoneNumber: '610203040',
primaryPhoneCountryCode: 'FR',
primaryPhoneCallingCode: '+33',
additionalPhones: [
'{number: "610203041", countryCode: "FR", callingCode: "+33"}',
],
},
});
const result = await appTester(
App.creates[crudRecordKey].operation.perform,
bundle,
);
expect(result).toBeDefined();
expect(result.data?.createPerson?.id).toBeDefined();
const checkDbResult = await appTester(
(z: ZObject, bundle: Bundle) =>
requestDb({
z,
bundle,
query: `query findPerson {person(filter: {id: {eq: "${result.data.createPerson.id}"}}){phones{primaryPhoneNumber}}}`,
}),
bundle,
);
expect(checkDbResult.data.person.phones.primaryPhoneNumber).toEqual(
'610203040',
);
});
});
describe('creates.update_company', () => {
test('should run to update a Company record', async () => {
const createBundle = getBundleForTest({
nameSingular: 'Company',
crudZapierOperation: DatabaseEventAction.CREATED,
name: 'Company Name',
});
const createResult = await appTester(
App.creates[crudRecordKey].operation.perform,
createBundle,
);
const companyId = createResult.data?.createCompany?.id;
const updateBundle = getBundleForTest({
nameSingular: 'Company',
crudZapierOperation: DatabaseEventAction.UPDATED,
id: companyId,
name: 'Updated Company Name',
});
const updateResult = await appTester(
App.creates[crudRecordKey].operation.perform,
updateBundle,
);
expect(updateResult).toBeDefined();
expect(updateResult.data?.updateCompany?.id).toBeDefined();
const checkDbResult = await appTester(
(z: ZObject, bundle: Bundle) =>
requestDb({
z,
bundle,
query: `query findCompany {company(filter: {id: {eq: "${companyId}"}}){id name}}`,
}),
updateBundle,
);
expect(checkDbResult.data.company.name).toEqual('Updated Company Name');
});
});
describe('creates.delete_company', () => {
test('should run to delete a Company record', async () => {
const createBundle = getBundleForTest({
nameSingular: 'Company',
crudZapierOperation: DatabaseEventAction.CREATED,
name: 'Delete Company Name',
});
const createResult = await appTester(
App.creates[crudRecordKey].operation.perform,
createBundle,
);
const companyId = createResult.data?.createCompany?.id;
const deleteBundle = getBundleForTest({
nameSingular: 'Company',
crudZapierOperation: DatabaseEventAction.DELETED,
id: companyId,
});
const deleteResult = await appTester(
App.creates[crudRecordKey].operation.perform,
deleteBundle,
);
expect(deleteResult).toBeDefined();
expect(deleteResult.data?.deleteCompany?.id).toBeDefined();
const checkDbResult = await appTester(
(z: ZObject, bundle: Bundle) =>
requestDb({
z,
bundle,
query: `query findCompanies {companies(filter: {id: {eq: "${companyId}"}}){edges{node{id}}}}`,
}),
deleteBundle,
);
expect(checkDbResult.data.companies.edges.length).toEqual(0);
});
});

View File

@@ -0,0 +1,159 @@
import type { Bundle, ZObject } from 'zapier-platform-core';
import { findObjectNamesSingularKey } from 'src/triggers/find_object_names_singular';
import { listRecordIdsKey } from 'src/triggers/list_record_ids';
import { computeInputFields } from 'src/utils/computeInputFields';
import { type InputData, type Node, type Schema } from 'src/utils/data.types';
import handleQueryParams from 'src/utils/handleQueryParams';
import requestDb, { requestSchema } from 'src/utils/requestDb';
import { DatabaseEventAction } from 'src/utils/triggers/triggers.utils';
import { isNonEmptyString } from '@sniptt/guards';
const getObjectNode = (
schema: Schema,
nameSingular: string,
): Node | undefined =>
schema.data.objects.edges.find(
(edge) => edge.node.nameSingular === nameSingular,
)?.node;
const capitalize = (stringToCapitalize: string) => {
if (!isNonEmptyString(stringToCapitalize)) return '';
return stringToCapitalize[0].toUpperCase() + stringToCapitalize.slice(1);
};
export const recordInputFields = async (
z: ZObject,
bundle: Bundle,
idRequired = false,
) => {
const schema = await requestSchema(z, bundle);
const node = schema.data.objects.edges.filter(
(edge) => edge.node.nameSingular === bundle.inputData.nameSingular,
)[0].node;
return computeInputFields(node, idRequired);
};
const computeFields = async (z: ZObject, bundle: Bundle) => {
const operation = bundle.inputData.crudZapierOperation;
switch (operation) {
case DatabaseEventAction.DELETED:
return [
{
key: 'id',
label: 'Id',
type: 'string',
dynamic: `${listRecordIdsKey}.id`,
required: true,
},
];
case DatabaseEventAction.UPDATED:
return recordInputFields(z, bundle, true);
case DatabaseEventAction.CREATED:
return recordInputFields(z, bundle, false);
default:
return [];
}
};
const computeQueryParameters = (
operation: DatabaseEventAction,
data: InputData,
node?: Node,
): string => {
switch (operation) {
case DatabaseEventAction.CREATED:
return `data:{${handleQueryParams(data, node)}}`;
case DatabaseEventAction.UPDATED:
return `
data:{${handleQueryParams(data, node)}},
id: "${data.id}"
`;
case DatabaseEventAction.DELETED:
return `
id: "${data.id}"
`;
default:
return '';
}
};
const getOperationFromDatabaseEventAction = (
z: ZObject,
databaseEventAction: DatabaseEventAction,
): 'create' | 'update' | 'delete' => {
switch (databaseEventAction) {
case DatabaseEventAction.CREATED:
return 'create';
case DatabaseEventAction.UPDATED:
return 'update';
case DatabaseEventAction.DELETED:
return 'delete';
default:
throw new z.errors.Error(
`Unknown databaseEventAction: ${databaseEventAction}`,
'Error',
404,
);
}
};
const perform = async (z: ZObject, bundle: Bundle<InputData>) => {
const data = bundle.inputData;
const operation = data.crudZapierOperation;
const queryOperation = getOperationFromDatabaseEventAction(z, operation);
const nameSingular = data.nameSingular;
const node =
operation === DatabaseEventAction.DELETED
? undefined
: getObjectNode(await requestSchema(z, bundle), nameSingular);
delete data.nameSingular;
delete data.crudZapierOperation;
const query = `
mutation ${queryOperation}${capitalize(nameSingular)} {
${queryOperation}${capitalize(nameSingular)}(
${computeQueryParameters(operation, data, node)}
)
{id}
}`;
return await requestDb({ z, bundle, query });
};
export const crudRecordKey = 'crud_record';
export default {
display: {
description: 'Create, Update or Delete a Record in Twenty.',
hidden: false,
label: 'Create, Update or Delete Record',
},
key: crudRecordKey,
noun: 'Record',
operation: {
inputFields: [
{
key: 'nameSingular',
required: true,
label: 'Record Name',
dynamic: `${findObjectNamesSingularKey}.nameSingular.labelSingular`,
altersDynamicFields: true,
},
{
key: 'crudZapierOperation',
required: true,
label: 'Operation',
choices: {
[DatabaseEventAction.CREATED]: DatabaseEventAction.CREATED,
[DatabaseEventAction.UPDATED]: DatabaseEventAction.UPDATED,
[DatabaseEventAction.DELETED]: DatabaseEventAction.DELETED,
},
altersDynamicFields: true,
},
computeFields,
],
sample: {
id: '179ed459-79cf-41d9-ab85-96397fa8e936',
},
perform,
},
};

View File

@@ -0,0 +1,27 @@
import { version as platformVersion } from 'zapier-platform-core';
import 'dotenv/config';
import { version } from '../package.json';
import crudRecord, { crudRecordKey } from 'src/creates/crud_record';
import findObjectNamesSingular, {
findObjectNamesSingularKey,
} from 'src/triggers/find_object_names_singular';
import listRecordIds, { listRecordIdsKey } from 'src/triggers/list_record_ids';
import triggerRecord, { triggerRecordKey } from 'src/triggers/trigger_record';
import authentication from 'src/authentication';
export default {
version,
platformVersion,
authentication: authentication,
triggers: {
[findObjectNamesSingularKey]: findObjectNamesSingular,
[listRecordIdsKey]: listRecordIds,
[triggerRecordKey]: triggerRecord,
},
creates: {
[crudRecordKey]: crudRecord,
},
};

View File

@@ -0,0 +1,19 @@
import { createAppTester, tools } from 'zapier-platform-core';
import { getBundleForTest } from 'src/utils/getBundleForTest';
import App from 'src/index';
import { findObjectNamesSingularKey } from 'src/triggers/find_object_names_singular';
tools.env.inject();
const appTester = createAppTester(App);
describe('triggers.find_object_names_singular', () => {
test('should run', async () => {
const bundle = getBundleForTest({});
const result = await appTester(
App.triggers[findObjectNamesSingularKey].operation.perform,
bundle,
);
expect(result).toBeDefined();
expect(result.length).toBeGreaterThan(1);
expect(result[0].nameSingular).toBeDefined();
});
});

View File

@@ -0,0 +1,20 @@
import { createAppTester, tools } from 'zapier-platform-core';
import App from 'src/index';
import { listRecordIdsKey } from 'src/triggers/list_record_ids';
import { getBundleForTest } from 'src/utils/getBundleForTest';
tools.env.inject();
const appTester = createAppTester(App);
describe('triggers.list_record_ids', () => {
test('should run', async () => {
const bundle = getBundleForTest({ nameSingular: 'company' });
const result = await appTester(
App.triggers[listRecordIdsKey].operation.perform,
bundle,
);
expect(result).toBeDefined();
expect(result.length).toBeGreaterThan(1);
expect(result[0].record.id).toBeDefined();
});
});

View File

@@ -0,0 +1,309 @@
import {
type Bundle,
createAppTester,
tools,
type ZObject,
} from 'zapier-platform-core';
import App from 'src/index';
import { triggerRecordKey } from 'src/triggers/trigger_record';
import { getBundleForTest } from 'src/utils/getBundleForTest';
import requestDb from 'src/utils/requestDb';
import { DatabaseEventAction } from 'src/utils/triggers/triggers.utils';
tools.env.inject();
const appTester = createAppTester(App);
describe('triggers.trigger_record.created', () => {
test('should succeed to subscribe', async () => {
const bundle = getBundleForTest({});
bundle.inputData.nameSingular = 'company';
bundle.inputData.operation = DatabaseEventAction.CREATED;
bundle.targetUrl = 'https://test.com';
const result = await appTester(
App.triggers[triggerRecordKey].operation.performSubscribe,
bundle,
);
expect(result).toBeDefined();
expect(result.id).toBeDefined();
const checkDbResult = await appTester(
(z: ZObject, bundle: Bundle) =>
requestDb({
z,
bundle,
query: `query webhook {webhook(id: "${result.id}"){id operations}}`,
endpoint: 'metadata',
}),
bundle,
);
expect(checkDbResult.data.webhook.operations[0]).toEqual('company.created');
});
test('should succeed to unsubscribe', async () => {
const bundle = getBundleForTest({});
bundle.inputData.nameSingular = 'company';
bundle.inputData.operation = DatabaseEventAction.CREATED;
bundle.targetUrl = 'https://test.com';
const result = await appTester(
App.triggers[triggerRecordKey].operation.performSubscribe,
bundle,
);
const unsubscribeBundle = getBundleForTest({});
unsubscribeBundle.subscribeData = { id: result.id };
const unsubscribeResult = await appTester(
App.triggers[triggerRecordKey].operation.performUnsubscribe,
unsubscribeBundle,
);
expect(unsubscribeResult).toBeTruthy();
const checkDbResult = await appTester(
(z: ZObject, bundle: Bundle) =>
requestDb({
z,
bundle,
query: `query webhook {webhooks {id}}`,
endpoint: 'metadata',
}),
bundle,
);
expect(
// @ts-expect-error legacy noImplicitAny
checkDbResult.data.webhooks.filter((webhook) => webhook.id === result.id)
.length,
).toEqual(0);
});
test('should load company from webhook', async () => {
const bundle = {
cleanedRequest: {
record: {
id: 'd6ccb1d1-a90b-4822-a992-a0dd946592c9',
name: '',
domainName: '',
createdAt: '2023-10-19 10:10:12.490',
address: { addressCity: null },
employees: null,
linkedinUrl: null,
xUrl: null,
annualRecurringRevenue: null,
idealCustomerProfile: false,
},
},
};
const results = await appTester(
App.triggers[triggerRecordKey].operation.perform,
bundle,
);
expect(results.length).toEqual(1);
const company = results[0];
expect(company.record.id).toEqual('d6ccb1d1-a90b-4822-a992-a0dd946592c9');
});
it('should load companies from list', async () => {
const bundle = getBundleForTest({});
bundle.inputData.nameSingular = 'company';
bundle.inputData.operation = DatabaseEventAction.CREATED;
const results = await appTester(
App.triggers[triggerRecordKey].operation.performList,
bundle,
);
expect(results.length).toBeGreaterThan(1);
const firstCompany = results[0];
expect(firstCompany.record).toBeDefined();
});
});
describe('triggers.trigger_record.update', () => {
test('should succeed to subscribe', async () => {
const bundle = getBundleForTest({});
bundle.inputData.nameSingular = 'company';
bundle.inputData.operation = DatabaseEventAction.UPDATED;
bundle.targetUrl = 'https://test.com';
const result = await appTester(
App.triggers[triggerRecordKey].operation.performSubscribe,
bundle,
);
expect(result).toBeDefined();
expect(result.id).toBeDefined();
const checkDbResult = await appTester(
(z: ZObject, bundle: Bundle) =>
requestDb({
z,
bundle,
query: `query webhook {webhook(id: "${result.id}"){id operations }}`,
endpoint: 'metadata',
}),
bundle,
);
expect(checkDbResult.data.webhook.operations[0]).toEqual('company.updated');
});
test('should succeed to unsubscribe', async () => {
const bundle = getBundleForTest({});
bundle.inputData.nameSingular = 'company';
bundle.inputData.operation = DatabaseEventAction.UPDATED;
bundle.targetUrl = 'https://test.com';
const result = await appTester(
App.triggers[triggerRecordKey].operation.performSubscribe,
bundle,
);
const unsubscribeBundle = getBundleForTest({});
unsubscribeBundle.subscribeData = { id: result.id };
const unsubscribeResult = await appTester(
App.triggers[triggerRecordKey].operation.performUnsubscribe,
unsubscribeBundle,
);
expect(unsubscribeResult).toBeTruthy();
const checkDbResult = await appTester(
(z: ZObject, bundle: Bundle) =>
requestDb({
z,
bundle,
query: `query webhook {webhooks {id}}`,
endpoint: 'metadata',
}),
bundle,
);
expect(
// @ts-expect-error legacy noImplicitAny
checkDbResult.data.webhooks.filter((webhook) => webhook.id === result.id)
.length,
).toEqual(0);
});
it('should load companies from list', async () => {
const bundle = getBundleForTest({});
bundle.inputData.nameSingular = 'company';
bundle.inputData.operation = DatabaseEventAction.UPDATED;
const results = await appTester(
App.triggers[triggerRecordKey].operation.performList,
bundle,
);
expect(results.length).toBeGreaterThan(1);
const firstCompany = results[0];
expect(firstCompany.record).toBeDefined();
expect(firstCompany.updatedFields).toBeDefined();
});
});
describe('triggers.trigger_record.delete', () => {
test('should succeed to subscribe', async () => {
const bundle = getBundleForTest({});
bundle.inputData.nameSingular = 'company';
bundle.inputData.operation = DatabaseEventAction.DELETED;
bundle.targetUrl = 'https://test.com';
const result = await appTester(
App.triggers[triggerRecordKey].operation.performSubscribe,
bundle,
);
expect(result).toBeDefined();
expect(result.id).toBeDefined();
const checkDbResult = await appTester(
(z: ZObject, bundle: Bundle) =>
requestDb({
z,
bundle,
query: `query webhook {webhook(id: "${result.id}"){id operations }}`,
endpoint: 'metadata',
}),
bundle,
);
expect(checkDbResult.data.webhook.operations[0]).toEqual('company.deleted');
});
test('should succeed to unsubscribe', async () => {
const bundle = getBundleForTest({});
bundle.inputData.nameSingular = 'company';
bundle.inputData.operation = DatabaseEventAction.DELETED;
bundle.targetUrl = 'https://test.com';
const result = await appTester(
App.triggers[triggerRecordKey].operation.performSubscribe,
bundle,
);
const unsubscribeBundle = getBundleForTest({});
unsubscribeBundle.subscribeData = { id: result.id };
const unsubscribeResult = await appTester(
App.triggers[triggerRecordKey].operation.performUnsubscribe,
unsubscribeBundle,
);
expect(unsubscribeResult).toBeTruthy();
const checkDbResult = await appTester(
(z: ZObject, bundle: Bundle) =>
requestDb({
z,
bundle,
query: `query webhook {webhooks {id}}`,
endpoint: 'metadata',
}),
bundle,
);
expect(
// @ts-expect-error legacy noImplicitAny
checkDbResult.data.webhooks.filter((webhook) => webhook.id === result.id)
.length,
).toEqual(0);
});
it('should load companies from list', async () => {
const bundle = getBundleForTest({});
bundle.inputData.nameSingular = 'company';
bundle.inputData.operation = DatabaseEventAction.DELETED;
const results = await appTester(
App.triggers[triggerRecordKey].operation.performList,
bundle,
);
expect(results.length).toBeGreaterThan(1);
const firstCompany = results[0];
expect(firstCompany).toBeDefined();
expect(firstCompany.record.id).toBeDefined();
expect(Object.keys(firstCompany).length).toEqual(1);
});
});

View File

@@ -0,0 +1,30 @@
import type { Bundle, ZObject } from 'zapier-platform-core';
import { requestSchema } from 'src/utils/requestDb';
const objectListRequest = async (z: ZObject, bundle: Bundle) => {
const schema = await requestSchema(z, bundle);
return schema.data.objects.edges.map((edge: any) => {
const object = edge.node;
return {
id: object.nameSingular,
nameSingular: object.nameSingular,
labelSingular: object.labelSingular,
};
});
};
export const findObjectNamesSingularKey = 'find_object_names_singular';
export default {
display: {
description: 'Find objects',
label: 'Find objects',
hidden: true,
},
key: findObjectNamesSingularKey,
noun: 'Object',
operation: {
perform: objectListRequest,
},
};

View File

@@ -0,0 +1,16 @@
import { performList } from 'src/utils/triggers/triggers.utils';
export const listRecordIdsKey = 'list_record_ids';
export default {
display: {
description: 'List Record Ids of an object.',
label: 'List Record Ids.',
hidden: true,
},
key: listRecordIdsKey,
noun: 'Object',
operation: {
perform: performList,
},
};

View File

@@ -0,0 +1,59 @@
import { findObjectNamesSingularKey } from 'src/triggers/find_object_names_singular';
import {
performSubscribe,
performUnsubscribe,
perform,
performList,
DatabaseEventAction,
} from '../utils/triggers/triggers.utils';
export const triggerRecordKey = 'trigger_record';
const choices = Object.values(DatabaseEventAction).reduce(
(acc, action) => {
acc[action] = action;
return acc;
},
{} as Record<DatabaseEventAction, DatabaseEventAction>,
);
export default {
key: triggerRecordKey,
noun: 'Record',
display: {
label: 'Record Trigger',
description:
'Triggers when a Record is created, updated, deleted or destroyed.',
},
operation: {
inputFields: [
{
key: 'nameSingular',
required: true,
label: 'Record Name',
dynamic: `${findObjectNamesSingularKey}.nameSingular.labelSingular`,
altersDynamicFields: true,
},
{
key: 'operation',
required: true,
label: 'Operation',
choices,
altersDynamicFields: true,
},
],
type: 'hook',
performSubscribe,
performUnsubscribe,
perform,
performList,
sample: {
id: 'f75f6b2e-9442-4c72-aa95-47d8e5ec8cb3',
createdAt: '2023-10-19T07:37:25.306Z',
},
outputFields: [
{ key: 'id', label: 'ID' },
{ key: 'createdAt', label: 'Created At' },
],
},
};

View File

@@ -0,0 +1,318 @@
import { FieldMetadataType } from 'twenty-shared/types';
import { computeInputFields } from 'src/utils/computeInputFields';
import { type InputField } from 'src/utils/data.types';
describe('computeInputFields', () => {
test('should create Person input fields properly', () => {
const personNode = {
nameSingular: 'person',
namePlural: 'people',
labelSingular: 'Person',
fields: {
edges: [
{
node: {
type: FieldMetadataType.RELATION,
name: 'favorites',
label: 'Favorites',
description: 'Favorites linked to the contact',
isNullable: true,
defaultValue: null,
},
},
{
node: {
type: FieldMetadataType.CURRENCY,
name: 'annualSalary',
label: 'Annual Salary',
description: 'Annual Salary of the Person',
isNullable: true,
defaultValue: null,
},
},
{
node: {
type: FieldMetadataType.TEXT,
name: 'jobTitle',
label: 'Job Title',
description: 'Contact’s job title',
isNullable: false,
defaultValue: {
value: '',
},
},
},
{
node: {
type: FieldMetadataType.DATE_TIME,
name: 'updatedAt',
label: 'Update date',
description: null,
isNullable: false,
defaultValue: {
type: 'now',
},
},
},
{
node: {
type: FieldMetadataType.FULL_NAME,
name: 'name',
label: 'Name',
description: 'Contact’s name',
isNullable: true,
defaultValue: {
lastName: '',
firstName: '',
},
},
},
{
node: {
type: FieldMetadataType.UUID,
name: 'id',
label: 'Id',
description: null,
icon: null,
isNullable: false,
defaultValue: {
type: 'uuid',
},
},
},
{
node: {
type: FieldMetadataType.NUMBER,
name: 'recordPosition',
label: 'RecordPosition',
description: 'Record Position',
isNullable: true,
defaultValue: null,
},
},
{
node: {
type: FieldMetadataType.LINKS,
name: 'whatsapp',
label: 'Whatsapp',
description: 'Contact’s Whatsapp account',
isNullable: true,
defaultValue: null,
},
},
{
node: {
type: FieldMetadataType.UUID,
name: 'companyId',
label: 'Company id (foreign key)',
description: 'Contact’s company id foreign key',
isNullable: true,
defaultValue: null,
},
},
{
node: {
type: FieldMetadataType.BOOLEAN,
name: 'ICP',
label: 'ICP',
description: 'ICP',
isNullable: false,
defaultValue: false,
},
},
],
},
};
const baseExpectedResult: InputField[] = [
{
key: 'annualSalary__amountMicros',
label: 'Annual Salary: Amount Micros',
type: 'integer',
helpText:
'Annual Salary of the Person: Amount Micros. eg: set 3210000 for 3.21$',
required: false,
list: false,
placeholder: undefined,
},
{
key: 'annualSalary__currencyCode',
label: 'Annual Salary: Currency Code',
type: 'string',
helpText:
'Annual Salary of the Person: Currency Code. eg: USD, EUR, etc...',
required: false,
list: false,
placeholder: undefined,
},
{
key: 'jobTitle',
label: 'Job Title',
type: 'string',
helpText: 'Contact’s job title',
required: false,
list: false,
placeholder: undefined,
},
{
key: 'updatedAt',
label: 'Update date',
type: 'datetime',
helpText: null,
required: false,
list: false,
placeholder: undefined,
},
{
key: 'name__firstName',
label: 'Name: First Name',
type: 'string',
helpText: 'Contact’s name: First Name',
required: false,
list: false,
placeholder: undefined,
},
{
key: 'name__lastName',
label: 'Name: Last Name',
type: 'string',
helpText: 'Contact’s name: Last Name',
required: false,
list: false,
placeholder: undefined,
},
{
key: 'recordPosition',
label: 'RecordPosition',
type: 'integer',
helpText: 'Record Position',
required: false,
list: false,
placeholder: undefined,
},
{
key: 'whatsapp__primaryLinkLabel',
label: 'Whatsapp: Primary Link Label',
type: 'string',
helpText: 'Contact’s Whatsapp account: Primary Link Label',
required: false,
list: false,
placeholder: undefined,
},
{
key: 'whatsapp__primaryLinkUrl',
label: 'Whatsapp: Primary Link Url',
type: 'string',
helpText: 'Contact’s Whatsapp account: Primary Link Url',
required: false,
list: false,
placeholder: undefined,
},
{
key: 'whatsapp__secondaryLinks',
label: 'Whatsapp: Secondary Links',
type: 'string',
helpText: 'Contact’s Whatsapp account: Secondary Links',
required: false,
list: true,
placeholder: '{ url: "", label: "" }',
},
{
key: 'companyId',
label: 'Company id (foreign key)',
type: 'string',
helpText: 'Contact’s company id foreign key',
required: false,
list: false,
placeholder: undefined,
},
{
key: 'ICP',
label: 'ICP',
type: 'boolean',
helpText: 'ICP',
required: false,
list: false,
placeholder: undefined,
},
];
const idInputField: InputField = {
key: 'id',
label: 'Id',
type: 'string',
helpText: null,
list: false,
placeholder: undefined,
required: false,
};
const expectedResult = [idInputField].concat(baseExpectedResult);
expect(computeInputFields(personNode)).toEqual(expectedResult);
idInputField.required = true;
const idRequiredExpectedResult = [idInputField].concat(baseExpectedResult);
expect(computeInputFields(personNode, true)).toEqual(
idRequiredExpectedResult,
);
});
test('should create Select and Multi Select input fields with choices', () => {
const node = {
nameSingular: 'opportunity',
namePlural: 'opportunities',
labelSingular: 'Opportunity',
fields: {
edges: [
{
node: {
type: FieldMetadataType.SELECT,
name: 'stage',
label: 'Stage',
description: 'Opportunity stage',
isNullable: true,
defaultValue: null,
options: [
{ value: 'NEW', label: 'New' },
{ value: 'SCREENING', label: 'Screening' },
],
},
},
{
node: {
type: FieldMetadataType.MULTI_SELECT,
name: 'tags',
label: 'Tags',
description: 'Opportunity tags',
isNullable: true,
defaultValue: null,
options: [
{ value: 'URGENT', label: 'Urgent' },
{ value: 'VIP', label: 'VIP' },
],
},
},
],
},
};
const expectedResult: InputField[] = [
{
key: 'stage',
label: 'Stage',
type: 'string',
helpText: 'Opportunity stage',
required: false,
list: false,
placeholder: undefined,
choices: { NEW: 'New', SCREENING: 'Screening' },
},
{
key: 'tags',
label: 'Tags',
type: 'string',
helpText: 'Opportunity tags',
required: false,
list: true,
placeholder: undefined,
choices: { URGENT: 'Urgent', VIP: 'VIP' },
},
];
expect(computeInputFields(node)).toEqual(expectedResult);
});
});

View File

@@ -0,0 +1,118 @@
import { FieldMetadataType } from 'twenty-shared/types';
import { type Node } from 'src/utils/data.types';
import handleQueryParams from 'src/utils/handleQueryParams';
describe('utils.handleQueryParams', () => {
test('should handle empty values', () => {
const inputData = {};
const result = handleQueryParams(inputData);
const expectedResult = '';
expect(result).toEqual(expectedResult);
});
test('should format', () => {
const inputData = {
name: 'Company Name',
address: { addressCity: 'Paris' },
domainName: 'Company Domain Name',
linkedinUrl__url: '/linkedin_url',
linkedinUrl__label: 'Test linkedinUrl',
whatsapp__primaryLinkUrl: '/whatsapp_url',
whatsapp__primaryLinkLabel: 'Whatsapp Link',
whatsapp__secondaryLinks: [
"{url: '/secondary_whatsapp_url',label: 'Secondary Whatsapp Link'}",
],
emails: {
primaryEmail: 'primary@email.com',
additionalEmails: ['secondary@email.com'],
},
phones: {
primaryPhoneNumber: '322110011',
primaryPhoneCountryCode: 'FR',
primaryPhoneCallingCode: '+33',
additionalPhones: [
"{ phoneNumber: '322110012', countryCode: 'FR', callingCode: '+33' }",
],
},
xUrl__url: '/x_url',
xUrl__label: 'Test xUrl',
annualRecurringRevenue: 100000,
idealCustomerProfile: true,
employees: 25,
};
const result = handleQueryParams(inputData);
const expectedResult =
'name: "Company Name", ' +
'address: {addressCity: "Paris"}, ' +
'domainName: "Company Domain Name", ' +
'linkedinUrl: {url: "/linkedin_url", label: "Test linkedinUrl"}, ' +
'whatsapp: {primaryLinkUrl: "/whatsapp_url", primaryLinkLabel: "Whatsapp Link", secondaryLinks: [{url: \'/secondary_whatsapp_url\',label: \'Secondary Whatsapp Link\'}]}, ' +
'emails: {primaryEmail: "primary@email.com", additionalEmails: ["secondary@email.com"]}, ' +
'phones: {primaryPhoneNumber: "322110011", primaryPhoneCountryCode: "FR", primaryPhoneCallingCode: "+33", additionalPhones: [{ phoneNumber: \'322110012\', countryCode: \'FR\', callingCode: \'+33\' }]}, ' +
'xUrl: {url: "/x_url", label: "Test xUrl"}, ' +
'annualRecurringRevenue: 100000, ' +
'idealCustomerProfile: true, ' +
'employees: 25';
expect(result).toEqual(expectedResult);
});
test('should not quote Select, Multi Select and Rating enum values', () => {
const inputData = {
name: 'Opportunity Name',
stage: 'SCREENING',
tags: ['URGENT', 'VIP'],
rating: 'RATING_3',
};
const node: Node = {
nameSingular: 'opportunity',
namePlural: 'opportunities',
labelSingular: 'Opportunity',
fields: {
edges: [
{
node: {
type: FieldMetadataType.TEXT,
name: 'name',
label: 'Name',
description: null,
isNullable: true,
defaultValue: null,
},
},
{
node: {
type: FieldMetadataType.SELECT,
name: 'stage',
label: 'Stage',
description: null,
isNullable: true,
defaultValue: null,
},
},
{
node: {
type: FieldMetadataType.MULTI_SELECT,
name: 'tags',
label: 'Tags',
description: null,
isNullable: true,
defaultValue: null,
},
},
{
node: {
type: FieldMetadataType.RATING,
name: 'rating',
label: 'Rating',
description: null,
isNullable: true,
defaultValue: null,
},
},
],
},
};
const result = handleQueryParams(inputData, node);
const expectedResult =
'name: "Opportunity Name", stage: SCREENING, tags: [URGENT, VIP], rating: RATING_3';
expect(result).toEqual(expectedResult);
});
});

View File

@@ -0,0 +1,296 @@
import { FieldMetadataType } from 'twenty-shared/types';
import {
type InputField,
type Node,
type NodeField,
} from 'src/utils/data.types';
import { isNonEmptyArray } from '@sniptt/guards';
const getListFromFieldMetadataType = (fieldMetadataType: FieldMetadataType) => {
return (
fieldMetadataType === FieldMetadataType.ARRAY ||
fieldMetadataType === FieldMetadataType.MULTI_SELECT
);
};
const getChoicesFromNodeField = (
nodeField: NodeField,
): { [value: string]: string } | undefined => {
if (!isNonEmptyArray(nodeField.options)) {
return undefined;
}
return Object.fromEntries(
nodeField.options.map((option) => [option.value, option.label]),
);
};
const getTypeFromFieldMetadataType = (
fieldMetadataType: FieldMetadataType,
): string | undefined => {
switch (fieldMetadataType) {
case FieldMetadataType.UUID:
case FieldMetadataType.TEXT:
case FieldMetadataType.ARRAY:
case FieldMetadataType.RATING:
case FieldMetadataType.SELECT:
case FieldMetadataType.MULTI_SELECT:
return 'string';
case FieldMetadataType.DATE_TIME:
return 'datetime';
case FieldMetadataType.DATE:
return 'date';
case FieldMetadataType.BOOLEAN:
return 'boolean';
case FieldMetadataType.NUMBER:
case FieldMetadataType.POSITION:
return 'integer';
case FieldMetadataType.NUMERIC:
return 'number';
default:
return undefined;
}
};
const get_subfieldsFromField = (nodeField: NodeField): NodeField[] => {
switch (nodeField.type) {
case FieldMetadataType.FULL_NAME: {
const firstName: NodeField = {
type: FieldMetadataType.TEXT,
name: 'firstName',
label: 'First Name',
description: 'First Name',
isNullable: true,
defaultValue: null,
};
const lastName: NodeField = {
type: FieldMetadataType.TEXT,
name: 'lastName',
label: 'Last Name',
description: 'Last Name',
isNullable: true,
defaultValue: null,
};
return [firstName, lastName];
}
case FieldMetadataType.CURRENCY: {
const amountMicros: NodeField = {
type: FieldMetadataType.NUMBER,
name: 'amountMicros',
label: 'Amount Micros',
description: 'Amount Micros. eg: set 3210000 for 3.21$',
isNullable: true,
defaultValue: null,
};
const currencyCode: NodeField = {
type: FieldMetadataType.TEXT,
name: 'currencyCode',
label: 'Currency Code',
description: 'Currency Code. eg: USD, EUR, etc...',
isNullable: true,
defaultValue: null,
};
return [amountMicros, currencyCode];
}
case FieldMetadataType.ADDRESS: {
const address1: NodeField = {
type: FieldMetadataType.TEXT,
name: 'addressStreet1',
label: 'Address',
description: 'Address',
isNullable: true,
defaultValue: null,
};
const address2: NodeField = {
type: FieldMetadataType.TEXT,
name: 'addressStreet2',
label: 'Address 2',
description: 'Address 2',
isNullable: true,
defaultValue: null,
};
const city: NodeField = {
type: FieldMetadataType.TEXT,
name: 'addressCity',
label: 'City',
description: 'City',
isNullable: true,
defaultValue: null,
};
const state: NodeField = {
type: FieldMetadataType.TEXT,
name: 'addressState',
label: 'State',
description: 'State',
isNullable: true,
defaultValue: null,
};
const postalCode: NodeField = {
type: FieldMetadataType.TEXT,
name: 'addressPostalCode',
label: 'Postal Code',
description: 'Postal Code',
isNullable: true,
defaultValue: null,
};
const country: NodeField = {
type: FieldMetadataType.TEXT,
name: 'addressCountry',
label: 'Country',
description: 'Country',
isNullable: true,
defaultValue: null,
};
return [address1, address2, city, state, postalCode, country];
}
case FieldMetadataType.PHONES: {
const primaryPhoneNumber: NodeField = {
type: FieldMetadataType.TEXT,
name: 'primaryPhoneNumber',
label: 'Primary Phone Number',
description: 'Primary Phone Number. 600112233',
isNullable: true,
defaultValue: null,
};
const primaryPhoneCountryCode: NodeField = {
type: FieldMetadataType.TEXT,
name: 'primaryPhoneCountryCode',
label: 'Primary Phone Country Code',
description: 'Primary Phone Country Code. eg: +33',
isNullable: true,
defaultValue: null,
};
const additionalPhones: NodeField = {
type: FieldMetadataType.TEXT,
name: 'additionalPhones',
label: 'Additional Phones',
description: 'Additional Phones',
isNullable: true,
defaultValue: null,
placeholder: '{ number: "", callingCode: "" }',
list: true,
};
return [primaryPhoneNumber, primaryPhoneCountryCode, additionalPhones];
}
case FieldMetadataType.EMAILS: {
const primaryEmail: NodeField = {
type: FieldMetadataType.TEXT,
name: 'primaryEmail',
label: 'Primary Email',
description: 'Primary Email',
isNullable: true,
defaultValue: null,
};
const additionalEmails: NodeField = {
type: FieldMetadataType.TEXT,
name: 'additionalEmails',
label: 'Additional Emails',
description: 'Additional Emails',
list: true,
isNullable: true,
defaultValue: null,
};
return [primaryEmail, additionalEmails];
}
case FieldMetadataType.LINKS: {
const primaryLinkLabel: NodeField = {
type: FieldMetadataType.TEXT,
name: 'primaryLinkLabel',
label: 'Primary Link Label',
description: 'Primary Link Label',
isNullable: true,
defaultValue: null,
};
const primaryLinkUrl: NodeField = {
type: FieldMetadataType.TEXT,
name: 'primaryLinkUrl',
label: 'Primary Link Url',
description: 'Primary Link Url',
isNullable: true,
defaultValue: null,
};
const secondaryLinks: NodeField = {
type: FieldMetadataType.TEXT,
name: 'secondaryLinks',
label: 'Secondary Links',
description: 'Secondary Links',
isNullable: true,
defaultValue: null,
placeholder: '{ url: "", label: "" }',
list: true,
};
return [primaryLinkLabel, primaryLinkUrl, secondaryLinks];
}
default:
throw new Error(`Unknown nodeField type: ${nodeField.type}`);
}
};
const isFieldRequired = (nodeField: NodeField): boolean => {
return !nodeField.isNullable && nodeField.defaultValue === null;
};
export const computeInputFields = (
node: Node,
isRequired = false,
): InputField[] => {
const result = [];
for (const field of node.fields.edges) {
const nodeField = field.node;
switch (nodeField.type) {
case FieldMetadataType.FULL_NAME:
case FieldMetadataType.CURRENCY:
case FieldMetadataType.PHONES:
case FieldMetadataType.EMAILS:
case FieldMetadataType.LINKS:
case FieldMetadataType.ADDRESS:
for (const subNodeField of get_subfieldsFromField(nodeField)) {
const field = {
key: `${nodeField.name}__${subNodeField.name}`,
label: `${nodeField.label}: ${subNodeField.label}`,
type: getTypeFromFieldMetadataType(subNodeField.type),
helpText: `${nodeField.description}: ${subNodeField.description}`,
required: isFieldRequired(subNodeField),
list: !!subNodeField.list,
placeholder: subNodeField.placeholder,
} as InputField;
result.push(field);
}
break;
case FieldMetadataType.UUID:
case FieldMetadataType.TEXT:
case FieldMetadataType.DATE_TIME:
case FieldMetadataType.DATE:
case FieldMetadataType.BOOLEAN:
case FieldMetadataType.NUMBER:
case FieldMetadataType.NUMERIC:
case FieldMetadataType.ARRAY:
case FieldMetadataType.RATING:
case FieldMetadataType.SELECT:
case FieldMetadataType.MULTI_SELECT: {
const nodeFieldType = getTypeFromFieldMetadataType(nodeField.type);
if (!nodeFieldType) {
break;
}
const required =
(isRequired && nodeField.name === 'id') ||
(!isRequired && isFieldRequired(nodeField));
const field = {
key: nodeField.name,
label: nodeField.label,
type: nodeFieldType,
helpText: nodeField.description,
required,
list: getListFromFieldMetadataType(nodeField.type),
placeholder: undefined,
choices: getChoicesFromNodeField(nodeField),
};
result.push(field);
break;
}
default:
break;
}
}
return result.sort((a, _) => (a.key === 'id' ? -1 : 1));
};

View File

@@ -0,0 +1,50 @@
import { type FieldMetadataType } from 'twenty-shared/types';
export type InputData = { [x: string]: any };
export type NodeFieldOption = {
value: string;
label: string;
};
export type NodeField = {
type: FieldMetadataType;
name: string;
label: string;
description: string | null;
isNullable: boolean;
defaultValue: boolean | object | null;
list?: boolean;
placeholder?: string;
options?: NodeFieldOption[] | null;
};
export type Node = {
nameSingular: string;
namePlural: string;
labelSingular: string;
fields: {
edges: {
node: NodeField;
}[];
};
};
export type InputField = {
key: string;
label: string;
type: string;
helpText: string | null;
required: boolean;
list?: boolean;
placeholder?: string;
choices?: { [value: string]: string };
};
export type Schema = {
data: {
objects: {
edges: { node: Node }[];
};
};
};

View File

@@ -0,0 +1,30 @@
import { type Bundle } from 'zapier-platform-core';
import { type InputData } from 'src/utils/data.types';
const ADMIN_TEST_TOKEN =
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIyMDIwMjAyMC1lNmI1LTQ2ODAtOGEzMi1iODIwOTczNzE1NmIiLCJ1c2VySWQiOiIyMDIwMjAyMC1lNmI1LTQ2ODAtOGEzMi1iODIwOTczNzE1NmIiLCJ3b3Jrc3BhY2VJZCI6IjIwMjAyMDIwLTFjMjUtNGQwMi1iZjI1LTZhZWNjZjdlYTQxOSIsIndvcmtzcGFjZU1lbWJlcklkIjoiMjAyMDIwMjAtNDYzZi00MzViLTgyOGMtMTA3ZTAwN2EyNzExIiwidXNlcldvcmtzcGFjZUlkIjoiMjAyMDIwMjAtMWU3Yy00M2Q5LWE1ZGItNjg1YjUwNjlkODE2IiwidHlwZSI6IkFDQ0VTUyIsImF1dGhQcm92aWRlciI6InBhc3N3b3JkIiwiaWF0IjoxNzUxMjgxNzA0LCJleHAiOjIwNjY4NTc3MDR9.HMGqCsVlOAPVUBhKSGlD1X86VoHKt4LIUtET3CGIdik';
const TEST_URL = 'http://localhost:3000';
export const getBundleForTest = (inputData?: InputData): Bundle => {
return {
authData: {
apiKey: ADMIN_TEST_TOKEN,
apiUrl: TEST_URL,
},
inputData: inputData || {},
cleanedRequest: {},
inputDataRaw: {},
meta: {
isBulkRead: false,
isFillingDynamicDropdown: false,
isLoadingSample: false,
isPopulatingDedupe: false,
isTestingAuth: false,
limit: 1,
page: 1,
timezone: null,
inputFields: {},
},
};
};

View File

@@ -0,0 +1,79 @@
import { type FieldMetadataType } from 'twenty-shared/types';
import { isFieldMetadataEnumKind } from 'twenty-shared/utils';
import { type InputData, type Node } from 'src/utils/data.types';
const RAW_JSON_SUBFIELD_NAMES = ['secondaryLinks', 'additionalPhones'];
const isPlainObject = (value: unknown): value is InputData =>
typeof value === 'object' && value !== null && !Array.isArray(value);
const getFieldTypeByName = (node?: Node): Map<string, FieldMetadataType> =>
new Map(
(node?.fields.edges ?? []).map((edge) => [edge.node.name, edge.node.type]),
);
const nestCompositeFields = (inputData: InputData): InputData => {
const nestedInputData: InputData = {};
for (const [key, value] of Object.entries(inputData)) {
if (key.includes('__')) {
const [fieldName, subFieldName] = key.split('__');
nestedInputData[fieldName] = {
...nestedInputData[fieldName],
[subFieldName]: value,
};
} else {
nestedInputData[key] = value;
}
}
return nestedInputData;
};
const isUnquotedField = (
fieldName: string,
fieldTypeByName: Map<string, FieldMetadataType>,
): boolean => {
const fieldType = fieldTypeByName.get(fieldName);
return (
(fieldType !== undefined && isFieldMetadataEnumKind(fieldType)) ||
RAW_JSON_SUBFIELD_NAMES.includes(fieldName)
);
};
const serializeValue = (
fieldName: string,
value: unknown,
fieldTypeByName: Map<string, FieldMetadataType>,
): string => {
if (Array.isArray(value)) {
const items = value.map((item) =>
serializeValue(fieldName, item, fieldTypeByName),
);
return `[${items.join(', ')}]`;
}
if (isPlainObject(value)) {
return `{${serializeFields(value, fieldTypeByName)}}`;
}
if (
typeof value === 'string' &&
!isUnquotedField(fieldName, fieldTypeByName)
) {
return `"${value}"`;
}
return `${value}`;
};
const serializeFields = (
inputData: InputData,
fieldTypeByName: Map<string, FieldMetadataType>,
): string =>
Object.entries(inputData)
.map(
([key, value]) =>
`${key}: ${serializeValue(key, value, fieldTypeByName)}`,
)
.join(', ');
const handleQueryParams = (inputData: InputData, node?: Node): string =>
serializeFields(nestCompositeFields(inputData), getFieldTypeByName(node));
export default handleQueryParams;

View File

@@ -0,0 +1,108 @@
import type { Bundle, HttpRequestOptions, ZObject } from 'zapier-platform-core';
import { type Schema } from 'src/utils/data.types';
export const requestSchema = async (
z: ZObject,
bundle: Bundle,
): Promise<Schema> => {
const query = `query GetObjects {
objects(paging: {first: 1000}, filter: {isActive: {is:true}}) {
edges {
node {
nameSingular
namePlural
labelSingular
fields(paging: {first: 1000}, filter: {isActive: {is:true}}) {
edges {
node {
type
name
label
description
isNullable
defaultValue
options
}
}
}
}
}
}
}`;
return await requestDb({ z, bundle, query, endpoint: 'metadata' });
};
const requestDb = async ({
z,
bundle,
query,
endpoint = 'graphql',
}: {
z: ZObject;
bundle: Bundle;
query: string;
endpoint?: string;
}) => {
const options = {
url: `${bundle.authData.apiUrl || process.env.SERVER_BASE_URL}/${endpoint}`,
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
Authorization: `Bearer ${bundle.authData.apiKey}`,
},
body: {
query,
},
} satisfies HttpRequestOptions;
return z
.request(options)
.then((response) => {
const results = response.json;
if (results.errors) {
throw new z.errors.Error(
`query: ${query}, error: ${JSON.stringify(results.errors)}`,
'ApiError',
response.status,
);
}
response.throwForStatus();
return results;
})
.catch((err) => {
throw new z.errors.Error(
`query: ${query}, error: ${err.message}`,
'Error',
err.status,
);
});
};
export const requestDbViaRestApi = (
z: ZObject,
bundle: Bundle,
objectNamePlural: string,
): Promise<Record<string, any>[]> => {
const options = {
url: `${
bundle.authData.apiUrl || process.env.SERVER_BASE_URL
}/rest/${objectNamePlural}?limit:3`,
method: 'GET',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
Authorization: `Bearer ${bundle.authData.apiKey}`,
},
} satisfies HttpRequestOptions;
return z
.request(options)
.then((response) => response.json.data[objectNamePlural])
.catch((err) => {
throw new z.errors.Error(`Error: ${err.message}`, 'Error', err.status);
});
};
export default requestDb;

View File

@@ -0,0 +1,103 @@
import { type Bundle, type ZObject } from 'zapier-platform-core';
import requestDb, {
requestDbViaRestApi,
requestSchema,
} from 'src/utils/requestDb';
import handleQueryParams from 'src/utils/handleQueryParams';
import { type InputData } from 'src/utils/data.types';
export enum DatabaseEventAction {
CREATED = 'created',
UPDATED = 'updated',
DELETED = 'deleted',
}
export const performSubscribe = async (z: ZObject, bundle: Bundle) => {
const data = {
targetUrl: bundle.targetUrl,
operations: [
`${bundle.inputData.nameSingular}.${bundle.inputData.operation}`,
],
secret: '',
};
const result = await requestDb({
z,
bundle,
query: `mutation createWebhook {createWebhook(input:{${handleQueryParams(
data,
)}}) {id}}`,
endpoint: 'metadata',
});
return result.data.createWebhook;
};
export const performUnsubscribe = async (
z: ZObject,
bundle: Bundle,
): Promise<boolean> => {
const data = { id: bundle.subscribeData?.id };
const result = await requestDb({
z,
bundle,
query: `mutation deleteWebhook {deleteWebhook(${handleQueryParams(data)}) {id}}`,
endpoint: 'metadata',
});
return result.data.deleteWebhook;
};
export const perform = (z: ZObject, bundle: Bundle) => {
const data = {
record: bundle.cleanedRequest.record,
...(bundle.cleanedRequest.updatedFields && {
updatedFields: bundle.cleanedRequest.updatedFields,
}),
};
if (data.record.createdAt) {
data.record.createdAt = data.record.createdAt + 'Z';
}
if (data.record.updatedAt) {
data.record.updatedAt = data.record.updatedAt + 'Z';
}
if (data.record.revokedAt) {
data.record.revokedAt = data.record.revokedAt + 'Z';
}
if (data.record.expiresAt) {
data.record.expiresAt = data.record.expiresAt + 'Z';
}
return [data];
};
const getNamePluralFromNameSingular = async (
z: ZObject,
bundle: Bundle,
nameSingular: string,
): Promise<string> => {
const result = await requestSchema(z, bundle);
for (const object of result.data.objects.edges) {
if (object.node.nameSingular === nameSingular) {
return object.node.namePlural;
}
}
throw new Error(`Unknown Object Name Singular ${nameSingular}`);
};
export const performList = async (
z: ZObject,
bundle: Bundle<InputData>,
): Promise<{ record: Record<string, any>; updatedFields?: string[] }[]> => {
const nameSingular = bundle.inputData.nameSingular;
const namePlural = await getNamePluralFromNameSingular(
z,
bundle,
nameSingular,
);
const results = await requestDbViaRestApi(z, bundle, namePlural);
return results.map((result) => ({
record: result,
...(bundle.inputData.operation === DatabaseEventAction.UPDATED && {
updatedFields: [Object.keys(result).filter((key) => key !== 'id')?.[0]],
}),
}));
};

View File

@@ -0,0 +1,18 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"target": "es2019",
"module": "commonjs",
"moduleResolution": "node",
"lib": ["esnext"],
"outDir": "lib",
"rootDir": ".",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"paths": {
"src/*": ["./src/*"]
}
},
"exclude": ["node_modules", "lib"]
}

View File

@@ -0,0 +1,68 @@
import { defineConfig } from 'vite';
import packageJson from './package.json';
import type { PackageJson } from 'type-fest';
const entryFileNames = (chunk: any, extension: 'cjs') => {
if (!chunk.isEntry) {
throw new Error(
`Should never occurs, encountered a non entry chunk ${chunk.facadeModuleId}`,
);
}
const splitFaceModuleId = chunk.facadeModuleId?.split('/');
if (splitFaceModuleId === undefined) {
throw new Error(
`Should never occurs splitFaceModuleId is undefined ${chunk.facadeModuleId}`,
);
}
const moduleDirectory = splitFaceModuleId[splitFaceModuleId?.length - 2];
if (moduleDirectory === 'src') {
return `${chunk.name}.${extension}`;
}
return `${moduleDirectory}.${extension}`;
};
export default defineConfig(() => {
return {
root: __dirname,
cacheDir: '../../node_modules/.vite/packages/twenty-zapier',
resolve: {
tsconfigPaths: true,
},
build: {
emptyOutDir: false,
outDir: 'lib',
lib: { entry: 'src/index.ts', name: 'twenty-zapier' },
rollupOptions: {
external: (id: string) => {
if (/^node:/.test(id)) {
return true;
}
const builtins = ['path', 'fs', 'child_process', 'util'];
if (builtins.includes(id)) {
return true;
}
const deps = Object.keys(
(packageJson as PackageJson).dependencies || {},
);
return deps.some((dep) => id === dep || id.startsWith(dep + '/'));
},
output: [
{
format: 'cjs',
esModule: true,
exports: 'named',
entryFileNames: (chunk) => entryFileNames(chunk, 'cjs'),
},
],
},
},
logLevel: 'warn',
};
});