From a9bd65bf864511dd98eba804555daaa87ded5fab Mon Sep 17 00:00:00 2001 From: Tony Xiao Date: Thu, 21 Mar 2024 21:52:58 -0700 Subject: [PATCH] feat: dead simple destinationSync for twenty --- connectors/connector-airtable/server.ts | 6 +- .../connector-twenty/crm-unifiedModels.ts | 333 ++++++++++++++++++ connectors/connector-twenty/package.json | 1 + .../{index.spec.ts => sdk.spec.ts} | 0 connectors/connector-twenty/server.spec.ts | 39 ++ connectors/connector-twenty/server.ts | 33 +- pnpm-lock.yaml | 19 + 7 files changed, 426 insertions(+), 5 deletions(-) create mode 100644 connectors/connector-twenty/crm-unifiedModels.ts rename connectors/connector-twenty/{index.spec.ts => sdk.spec.ts} (100%) create mode 100644 connectors/connector-twenty/server.spec.ts diff --git a/connectors/connector-airtable/server.ts b/connectors/connector-airtable/server.ts index 674fd62f..662253bf 100644 --- a/connectors/connector-airtable/server.ts +++ b/connectors/connector-airtable/server.ts @@ -1,8 +1,6 @@ -import type {ConnectorServer} from '@usevenice/cdk' +import type {ConnectorServer, Pta} from '@usevenice/cdk' import {handlersLink} from '@usevenice/cdk' -import type {Pta} from '@usevenice/cdk' import {fromCompletion} from '@usevenice/util' - import {makeAirtableClient} from './AirtableClient' import type {airtableSchemas} from './def' @@ -43,7 +41,7 @@ export const airtableServer = { ...partialTxn, }, } - + // TODO: This is probably not right. fromCompletion(airtable.insertData({data: record, entityName})) return op }, diff --git a/connectors/connector-twenty/crm-unifiedModels.ts b/connectors/connector-twenty/crm-unifiedModels.ts new file mode 100644 index 00000000..a921cc08 --- /dev/null +++ b/connectors/connector-twenty/crm-unifiedModels.ts @@ -0,0 +1,333 @@ +import {z} from '@opensdks/util-zod' + +// TODO: Move me into my own package + +export const zBaseRecord = z.object({ + id: z.string(), + /** z.string().datetime() does not work for simple things like `2023-07-19T23:46:48.000+0000` */ + updated_at: z.string().describe('ISO8601 date string'), + raw_data: z.record(z.unknown()).optional(), +}) + +export const address_type = z + .enum(['primary', 'mailing', 'other', 'billing', 'shipping']) + .openapi({ref: 'crm.address_type'}) + +export const address = z + .object({ + /** @enum {string} */ + address_type, + /** @example San Francisco */ + city: z.string().nullable(), + /** @example USA */ + country: z.string().nullable(), + /** @example 94107 */ + postal_code: z.string().nullable(), + /** @example CA */ + state: z.string().nullable(), + /** @example 525 Brannan */ + street_1: z.string().nullable(), + /** @example null */ + street_2: z.string().nullable(), + }) + .openapi({ref: 'crm.address'}) + +export const email_address_type = z + .enum(['primary', 'work', 'other']) + .openapi({ref: 'crm.email_address_type'}) + +export const email_address = z + .object({ + /** @example hello@supaglue.com */ + email_address: z.string(), + + email_address_type, + }) + .openapi({ref: 'crm.email_address'}) + +export const phone_number_type = z + .enum(['primary', 'mobile', 'fax', 'other']) + .openapi({ref: 'crm.phone_number_type'}) + +export const phone_number = z.object({ + /** @example +14151234567 */ + phone_number: z.string().nullable(), + + phone_number_type, +}) + +export const lifecycle_stage = z + .enum([ + 'subscriber', + 'lead', + 'marketingqualifiedlead', + 'salesqualifiedlead', + 'opportunity', + 'customer', + 'evangelist', + 'other', + ]) + .openapi({ref: 'crm.lifecycle_stage'}) + +export const account = zBaseRecord + .extend({ + name: z.string().nullish(), + is_deleted: z.boolean().nullish(), + website: z.string().nullish(), + industry: z.string().nullish(), + number_of_employees: z.number().nullish(), + owner_id: z.string().nullish(), + created_at: z.string().nullish(), + // not implemented yet + description: z.string().nullish().openapi({example: 'Integration API'}), + last_activity_at: z.string().nullish().describe('date-time'), + addresses: z.array(address).nullish(), + phone_numbers: z.array(phone_number).nullish(), + lifecycle_stage: lifecycle_stage.nullish(), + last_modified_at: z.string().nullish(), + }) + .openapi({ref: 'crm.account'}) + +export const contact = zBaseRecord + .extend({ + first_name: z.string().nullish(), + last_name: z.string().nullish(), + email: z.string().nullish().describe('Primary email address'), + phone: z.string().nullish().describe('Primary phone number'), + }) + .openapi({ref: 'crm.contact'}) + +export const lead = zBaseRecord + .extend({ + name: z.string().nullish(), + first_name: z.string().nullish(), + last_name: z.string().nullish(), + owner_id: z.string().nullish(), + title: z.string().nullish(), + company: z.string().nullish(), + converted_date: z.string().nullish(), + lead_source: z.string().nullish(), + converted_account_id: z.string().nullish(), + converted_contact_id: z.string().nullish(), + addresses: z.array(address).nullish(), + email_addresses: z.array(email_address).nullish(), + phone_numbers: z.array(phone_number).nullish(), + created_at: z.string().nullish(), + is_deleted: z.boolean().nullish(), + last_modified_at: z.string().nullish(), + }) + .openapi({ref: 'crm.lead'}) + +export const opportunity_status = z + .enum(['OPEN', 'WON', 'LOST']) + .openapi({ref: 'crm.opportunity_status'}) + +export const opportunity = zBaseRecord + .extend({ + name: z.string().nullish(), + description: z.string().nullish(), + owner_id: z.string().nullish(), + status: opportunity_status.nullish(), + stage: z.string().nullish(), + close_date: z.string().nullish(), + account_id: z.string().nullish(), + pipeline: z.string().nullish(), + amount: z.number().nullish(), + last_activity_at: z.string().nullish(), + created_at: z.string().nullish(), + is_deleted: z.boolean().nullish(), + last_modified_at: z.string().nullish(), + }) + .openapi({ref: 'crm.opportunity'}) + +export const user = zBaseRecord + .extend({ + name: z.string().nullish(), + email: z.string().nullish(), + is_active: z.boolean().nullish(), + created_at: z.string().nullish(), + is_deleted: z.boolean().nullish(), + last_modified_at: z.string().nullish(), + }) + .openapi({ref: 'crm.user'}) + +// MARK: - Meta + +export const meta_object = z + .object({ + // Custom object does not always have an id + id: z.string(), + name: z.string(), + }) + .openapi({ref: 'crm.meta.object'}) + +export const meta_property = z + .object({ + id: z.string().openapi({ + description: + 'The machine name of the property as it appears in the third-party Provider', + example: 'FirstName', + }), + label: z.string().openapi({ + description: + 'The human-readable name of the property as provided by the third-party Provider.', + example: 'First Name', + }), + type: z.string().optional().openapi({ + description: + 'The type of the property as provided by the third-party Provider. These types are not unified by Supaglue. For Intercom, this is string, integer, boolean, or object. For Outreach, this is integer, boolean, number, array, or string.', + example: 'string', + }), + raw_details: z.record(z.unknown()).optional().openapi({ + description: + 'The raw details of the property as provided by the third-party Provider, if available.', + example: {}, + }), + }) + .openapi({ref: 'crm.meta.property'}) + +export const meta_property_type = z + .enum([ + 'text', + 'textarea', + 'number', + 'picklist', + 'multipicklist', + 'date', + 'datetime', + 'boolean', + 'url', + 'other', + ]) + .openapi({ + ref: 'crm.meta.property_type', + description: ` +:::note +\`picklist\` and \`multipicklist\` property types are currently only supported in Salesforce and Hubspot +::: + +:::note +\`url\` property type currently is only natively supported in Salesforce. +:::`.trim(), + }) + +export const meta_association_schema = z + .object({ + id: z.string(), + source_object: z.string().openapi({example: 'contact'}), + target_object: z.string().openapi({example: 'my_custom_object'}), + display_name: z.string(), + }) + .openapi({ref: 'crm.meta.association_schema'}) + +export const meta_pick_list_option = z + .object({ + label: z.string().openapi({example: 'Option 1'}), + value: z.string().openapi({example: 'option_1'}), + description: z.string().optional(), + hidden: z.boolean().optional().describe('Defaults to false.'), + }) + .openapi({ref: 'crm.meta.pick_list_option'}) + +export const meta_custom_object_field = z + .object({ + id: z + .string() + .describe( + 'The machine name of the property as it appears in the third-party Provider. In Salesforce, this must end with `__c`.', + ), + label: z + .string() + .describe( + 'The human-readable name of the property as provided by the third-party Provider.', + ), + description: z.string().describe('A description of the field.').optional(), + is_required: z + .boolean() + .describe( + 'Whether or not this field is required. Must be false for Salesforce boolean fields.', + ) + .optional(), + default_value: z + .union([z.string(), z.number(), z.boolean()]) + .optional() + .describe( + 'The default value for the property. Only supported for Salesforce.', + ), + group_name: z.string().optional().openapi({ + description: + "Only applicable for Hubspot. If specified, Supaglue will attempt to attach the field to this group if it exists, or create it if it doesn't.", + example: 'my group', + }), + type: meta_property_type, + precision: z + .number() + .describe( + 'Only applicable in Salesforce. If not given, will default to 18.', + ) + .optional(), + scale: z + .number() + .describe( + 'Only applicable in Salesforce. If not given, will default to 0.', + ) + .optional(), + options: z + .array(meta_pick_list_option) + .describe('The list of options for a picklist/multipicklist field.') + .optional(), + raw_details: z + .record(z.unknown()) + .describe( + 'The raw details of the property as provided by the third-party Provider, if available.', + ) + .optional(), + }) + .openapi({ref: 'crm.meta.custom_object_field'}) + +export const account_input = account + .pick({ + /** @example Integration API */ + description: true, + /** @example API's */ + industry: true, + /** @example Sample Customer */ + name: true, + /** @example 276000 */ + number_of_employees: true, + /** @example https:supaglue.com/ */ + website: true, + addresses: true, + phone_numbers: true, + /** @example 9f3e97fd-4d5d-4efc-959d-bbebfac079f5 */ + owner_id: true, + lifecycle_stage: true, + }) + .partial() + .extend({ + passthrough_fields: z.record(z.unknown()).nullish(), + }) + .openapi({ref: 'crm.account_input'}) + +export const contact_input = contact + .pick({ + /** @example George */ + first_name: true, + /** @example Xing */ + last_name: true, + email: true, + phone: true, + // /** @example 64571bff-48ea-4469-9fa0-ee1a0bab38bd */ + // account_id: true, + // addresses: true, + // email_addresses: true, + // phone_numbers: true, + // /** @example 9f3e97fd-4d5d-4efc-959d-bbebfac079f5 */ + // owner_id: true, + // lifecycle_stage: true, + }) + .partial() + .extend({ + passthrough_fields: z.record(z.unknown()).nullish(), + }) + .openapi({ref: 'crm.contact_input'}) diff --git a/connectors/connector-twenty/package.json b/connectors/connector-twenty/package.json index 997c391b..bcb7ddfe 100644 --- a/connectors/connector-twenty/package.json +++ b/connectors/connector-twenty/package.json @@ -6,6 +6,7 @@ "module": "./index.ts", "dependencies": { "@opensdks/sdk-twenty": "^0.0.3", + "@opensdks/util-zod": "^0.0.15", "@usevenice/cdk": "workspace:*", "@usevenice/util": "workspace:*" }, diff --git a/connectors/connector-twenty/index.spec.ts b/connectors/connector-twenty/sdk.spec.ts similarity index 100% rename from connectors/connector-twenty/index.spec.ts rename to connectors/connector-twenty/sdk.spec.ts diff --git a/connectors/connector-twenty/server.spec.ts b/connectors/connector-twenty/server.spec.ts new file mode 100644 index 00000000..10fe06b2 --- /dev/null +++ b/connectors/connector-twenty/server.spec.ts @@ -0,0 +1,39 @@ +// /* eslint-disable jest/no-standalone-expect */ +import type { + EndUserId, + EntityPayloadWithRaw, + SyncOperation, +} from '@usevenice/cdk' +import {rxjs, toCompletion} from '@usevenice/util' +import twentyServer from './server' + +// eslint-disable-next-line @typescript-eslint/no-non-null-assertion +const accessToken = process.env['TWENTY_ACCESS_TOKEN']! +const maybeTest = accessToken ? test : test.skip + +maybeTest('destinationSync', async () => { + const destLink = twentyServer.destinationSync({ + config: {}, + endUser: {id: 'esur_12' as EndUserId}, + settings: {access_token: accessToken}, + source: {id: 'reso_123'}, + state: {}, + }) + const src = rxjs.from([ + { + data: { + id: '123', + connectorName: 'salesforce', + entity: { + name: 'sfdc', + website: 'sfdc.com', + }, + entityName: 'account', + sourceId: 'reso_123', + }, + type: 'data', + } satisfies SyncOperation, + ]) + + await toCompletion(destLink(src)) +}) diff --git a/connectors/connector-twenty/server.ts b/connectors/connector-twenty/server.ts index 6aa0352f..48b43ce7 100644 --- a/connectors/connector-twenty/server.ts +++ b/connectors/connector-twenty/server.ts @@ -1,6 +1,37 @@ +import {initTwentySDK} from '@opensdks/sdk-twenty' import type {ConnectorServer} from '@usevenice/cdk' +import {handlersLink} from '@usevenice/cdk' +import * as unified from './crm-unifiedModels' import type {twentySchemas} from './def' -export const twentyServer = {} satisfies ConnectorServer +export const twentyServer = { + destinationSync: ({settings}) => { + const twenty = initTwentySDK({ + headers: {authorization: `Bearer ${settings.access_token}`}, + }) + + return handlersLink({ + data: async (op) => { + // crm account + if (op.data.entityName === 'account') { + const account = unified.account + .partial() + .nullish() + .parse(op.data.entity) + if (account) { + await twenty.core.POST('/companies', { + body: { + name: account.name ?? '', + // TODO: What's the standard format for website + domainName: account.website ?? '', + }, + }) + } + } + return op + }, + }) + }, +} satisfies ConnectorServer export default twentyServer diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 559e4bc7..f1b20749 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1133,6 +1133,9 @@ importers: '@opensdks/sdk-twenty': specifier: ^0.0.3 version: 0.0.3 + '@opensdks/util-zod': + specifier: ^0.0.15 + version: 0.0.15 '@usevenice/cdk': specifier: workspace:* version: link:../../kits/cdk @@ -4759,6 +4762,13 @@ packages: '@opensdks/runtime': 0.0.16 dev: true + /@opensdks/util-zod@0.0.15: + resolution: {integrity: sha512-CHyk/ay5yKNFBZKDfGcJQGZ4ZhScfNhGe6xon6XjqbhP+AYv4tYJl1GTbTqJdN2N15KnG3vuXjbi6SX0SGwbhw==} + dependencies: + zod: 3.22.4 + zod-openapi: 2.14.0(zod@3.22.4) + dev: false + /@panva/asn1.js@1.0.0: resolution: {integrity: sha512-UdkG3mLEqXgnlKsWanWcgb6dOjUzJ+XC5f+aWw30qrtjxeNUSfKX1cd5FBzOaXQumoe9nIqeZUvrRJS03HCCtw==} engines: {node: '>=10.13.0'} @@ -21608,6 +21618,15 @@ packages: dependencies: zod: 3.22.4 + /zod-openapi@2.14.0(zod@3.22.4): + resolution: {integrity: sha512-TywDpZKfbNSMspWtT152DaO+/cK0xqK/bv7Kt4ZMHhzEZS1rCFYxxFwDZdQd4Nrq5g4kX/R1SKI+FkYg4QkBlw==} + engines: {node: '>=16.11'} + peerDependencies: + zod: ^3.21.4 + dependencies: + zod: 3.22.4 + dev: false + /zod-to-json-schema@3.21.1(zod@3.22.4): resolution: {integrity: sha512-y5g0MPxDq+YG/T+cHGPYH4PcBpyCqwK6wxeJ76MR563y0gk/14HKfebq8xHiItY7lkc9GDFygCnkvNDTvAhYAg==} peerDependencies: