diff --git a/verticals/vertical-crm/commonModels.ts b/verticals/vertical-crm/commonModels.ts index 78e702a..c1b3314 100644 --- a/verticals/vertical-crm/commonModels.ts +++ b/verticals/vertical-crm/commonModels.ts @@ -5,13 +5,12 @@ export const account = zBaseRecord id: z.string().nullish(), name: z.string().nullish(), updated_at: z.date().nullish(), - isDeleted: z.boolean().nullish(), + is_deleted: z.boolean().nullish(), website: z.string().nullish(), industry: z.string().nullish(), - numberOfEmployees: z.number().nullish(), - ownerId: z.string().nullish(), - createdAt: z.date().nullish(), - updatedAt: z.date().nullish(), + number_of_employees: z.number().nullish(), + owner_id: z.string().nullish(), + created_at: z.date().nullish(), }) .openapi({ref: 'crm.account'}) @@ -26,15 +25,15 @@ export const lead = zBaseRecord .extend({ id: z.string().nullish(), name: z.string().nullish(), - firstName: z.string().nullish(), - lastName: z.string().nullish(), - ownerId: 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(), - convertedDate: z.date().nullish(), - leadSource: z.string().nullish(), - convertedAccountId: z.string().nullish(), - convertedContactId: z.string().nullish(), + converted_date: z.date().nullish(), + lead_source: z.string().nullish(), + converted_account_id: z.string().nullish(), + converted_contact_id: z.string().nullish(), addresses: z .array( z.object({ @@ -43,32 +42,32 @@ export const lead = zBaseRecord city: z.string().nullish(), state: z.string().nullish(), country: z.string().nullish(), - postalCode: z.string().nullish(), - addressType: z.string().nullish(), + postal_code: z.string().nullish(), + address_type: z.string().nullish(), }), ) .nullish(), - emailAddresses: z + email_addresses: z .array( z.object({ - emailAddress: z.string().nullish(), - emailAddressType: z.string().nullish(), + email_address: z.string().nullish(), + email_address_type: z.string().nullish(), }), ) .nullish(), - phoneNumbers: z + phone_numbers: z .array( z.object({ - phoneNumber: z.string().nullish(), - phoneNumberType: z.string().nullish(), + phone_number: z.string().nullish(), + phone_number_type: z.string().nullish(), }), ) .nullish(), - createdAt: z.date().nullish(), - updatedAt: z.date().nullish(), - isDeleted: z.boolean().nullish(), - lastModifiedAt: z.date().nullish(), - rawData: z.record(z.unknown()).nullish(), + created_at: z.date().nullish(), + updated_at: z.date().nullish(), + is_deleted: z.boolean().nullish(), + last_modified_at: z.date().nullish(), + raw_data: z.record(z.unknown()).nullish(), }) .openapi({ref: 'crm.lead'}) @@ -78,21 +77,18 @@ export const opportunity = zBaseRecord name: z.string().nullish(), updated_at: z.date().nullish(), description: z.string().nullish(), - ownerId: z.string().nullish(), + owner_id: z.string().nullish(), status: z.string().nullish(), stage: z.string().nullish(), - closeDate: z.date().nullish(), - accountId: z.string().nullish(), - // pipeline is not supported in salesforce + close_date: z.date().nullish(), + account_id: z.string().nullish(), pipeline: z.string().nullish(), - // TODO: This should be parseFloat, but we need to migrate our customers - amount: z.string().nullish(), - lastActivityAt: z.date().nullish(), - createdAt: z.date().nullish(), - updatedAt: z.date().nullish(), - isDeleted: z.boolean().nullish(), - lastModifiedAt: z.date().nullish(), - rawData: z.record(z.unknown()).nullish(), + amount: z.number().nullish(), + last_activity_at: z.date().nullish(), + created_at: z.date().nullish(), + is_deleted: z.boolean().nullish(), + last_modified_at: z.date().nullish(), + raw_data: z.record(z.unknown()).nullish(), }) .openapi({ref: 'crm.opportunity'}) @@ -101,15 +97,53 @@ export const user = zBaseRecord id: z.string().nullish(), name: z.string().nullish(), email: z.string().nullish(), - isActive: z.boolean().nullish(), - createdAt: z.date().nullish(), - updatedAt: z.date().nullish(), - isDeleted: z.boolean().nullish(), - lastModifiedAt: z.date().nullish(), - rawData: z.record(z.unknown()).nullish(), + is_active: z.boolean().nullish(), + created_at: z.date().nullish(), + updated_at: z.date().nullish(), + is_deleted: z.boolean().nullish(), + last_modified_at: z.date().nullish(), + raw_data: z.record(z.unknown()).nullish(), }) .openapi({ref: 'crm.user'}) +export const meta_standard_object = z + .object({ + name: z.string(), + }) + .openapi({ref: 'crm.meta_standard_object'}) + +export const meta_custom_object = z + .object({ + id: z.string(), + name: z.string(), + }) + .openapi({ref: 'crm.meta_custom_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 metaStandardObject = z .object({ name: z.string(), diff --git a/verticals/vertical-crm/providers/hubspot-provider.ts b/verticals/vertical-crm/providers/hubspot-provider.ts index 0e6fd23..ba3591e 100644 --- a/verticals/vertical-crm/providers/hubspot-provider.ts +++ b/verticals/vertical-crm/providers/hubspot-provider.ts @@ -9,6 +9,26 @@ export type SimplePublicObject = Oas_crm_contacts['components']['schemas']['SimplePublicObject'] export type Owner = Oas_crm_owners['components']['schemas']['PublicOwner'] +// // In certain cases, Hubspot cannot determine the object type based on just the name for custom objects, +// // so we need to get the ID. +// const getObjectTypeIdFromNameOrId = async(nameOrId: string): Promise => { +// // Standard objects can be referred by name no problem +// if (isStandardObjectType(nameOrId)) { +// return nameOrId; +// } +// if (this.#isAlreadyObjectTypeId(nameOrId)) { +// return nameOrId; +// } +// await this.maybeRefreshAccessToken(); +// const schemas = await this.#client.crm.schemas.coreApi.getAll(); +// const schemaId = schemas.results.find((schema) => schema.name === nameOrId || schema.objectTypeId === nameOrId) +// ?.objectTypeId; +// if (!schemaId) { +// throw new NotFoundError(`Could not find custom object schema with name or id ${nameOrId}`); +// } +// return schemaId; +// } + export const HUBSPOT_STANDARD_OBJECTS = [ 'company', 'contact', @@ -427,4 +447,57 @@ export const hubspotProvider = { const res = await instance.crm_schemas.GET('/crm/v3/schemas') return res.data.results.map((obj) => ({id: obj.id, name: obj.name})) }, + metadataListProperties: async ({instance, input}) => { + const res = await instance.crm_properties.GET( + '/crm/v3/properties/{objectType}', + { + params: {path: {objectType: input.name}}, + }, + ) + return res.data.results.map((obj) => ({id: obj.name, label: obj.label})) + }, + // metadataCreateObjectsSchema: async ({instance, input}) => { + // const res = await instance.crm_schemas.POST('/crm/v3/schemas', { + // body: { + // name: input.name, + // labels: input.label.singular, + // description: input.description || '', + // properties: input.fields.map((p) => ({ + // type: p.type || 'string', + // label: p.label, + // name: p.label, + // fieldType: p.type || 'string', + // })), + // primaryFieldId: input.primaryFieldId, + // }, + // }) + // console.log('input:', input) + // // console.log('res:', res) + // return [{id: '123', name: input.name}] + // }, + metadataCreateAssociation: async ({instance, input}) => { + const res = await instance.crm_associations.POST( + '/crm/v3/associations/{fromObjectType}/{toObjectType}/batch/create', + { + params: { + path: { + fromObjectType: input.sourceObject, + toObjectType: input.targetObject, + }, + }, + body: { + inputs: [ + { + from: {id: input.sourceObject}, + to: {id: input.targetObject}, + type: `${input.sourceObject}_${input.targetObject}`, + }, + ], + }, + }, + ) + console.log('res:', res.data.errors[0]) + console.log('res:', res.data.errors[1]) + return res.data + }, } satisfies CRMProvider diff --git a/verticals/vertical-crm/providers/salesforce-provider.ts b/verticals/vertical-crm/providers/salesforce-provider.ts index aeeaea2..5aeac64 100644 --- a/verticals/vertical-crm/providers/salesforce-provider.ts +++ b/verticals/vertical-crm/providers/salesforce-provider.ts @@ -17,13 +17,14 @@ import { initSalesforceSDK, type SalesforceSDK as _SalesforceSDK, } from '@opensdks/sdk-salesforce' -import {CustomObjectSchemaCreateParams} from '../../types/custom_object' -import {PropertyUnified} from '../../types/property' +import type {CustomObjectSchemaCreateParams} from '../../types/custom_object' +import type {PropertyUnified} from '../../types/property' import {BadRequestError} from '../errors' import type {CRMProvider} from '../router' import {commonModels} from '../router' import {SALESFORCE_STANDARD_OBJECTS} from './salesforce/constants' -import {updateFieldPermissions} from './salesforce/updatePermissions' + +// import {updateFieldPermissions} from './salesforce/updatePermissions' export type SFDC = SalesforceSDKTypes['oas']['components']['schemas'] @@ -39,12 +40,12 @@ const mappers = { updated_at: (record) => record.SystemModstamp ? new Date(record.SystemModstamp) : null, name: 'Name', - isDeleted: 'IsDeleted', + is_deleted: 'IsDeleted', website: 'Website', industry: 'Industry', - numberOfEmployees: 'NumberOfEmployees', - ownerId: 'OwnerId', - createdAt: (record) => + number_of_employees: 'NumberOfEmployees', + owner_id: 'OwnerId', + created_at: (record) => record.CreatedDate ? new Date(record.CreatedDate) : null, }), opportunity: mapper( @@ -52,40 +53,41 @@ const mappers = { commonModels.opportunity, { id: 'Id', - updated_at: 'SystemModstamp', + updated_at: (record) => + record.SystemModstamp ? new Date(record.SystemModstamp) : null, name: 'Name', description: 'Description', - ownerId: 'OwnerId', + owner_id: 'OwnerId', status: (record) => (record.IsClosed ? 'Closed' : 'Open'), stage: 'StageName', - closeDate: (record) => + close_date: (record) => record.CloseDate ? new Date(record.CloseDate) : null, - accountId: 'AccountId', + account_id: 'AccountId', amount: 'Amount', - lastActivityAt: (record) => + last_activity_at: (record) => record.LastActivityDate ? new Date(record.LastActivityDate) : null, - createdAt: (record) => + created_at: (record) => record.CreatedDate ? new Date(record.CreatedDate) : null, - isDeleted: 'IsDeleted', - lastModifiedAt: (record) => + is_deleted: 'IsDeleted', + last_modified_at: (record) => record.LastModifiedDate ? new Date(record.LastModifiedDate) : null, - rawData: (record) => record, + raw_data: (record) => record, }, ), lead: mapper(zCast(), commonModels.lead, { id: 'Id', updated_at: 'SystemModstamp', name: 'Name', - firstName: 'FirstName', - lastName: 'LastName', - ownerId: 'OwnerId', + first_name: 'FirstName', + last_name: 'LastName', + owner_id: 'OwnerId', title: 'Title', company: 'Company', - convertedDate: (record) => + converted_date: (record) => record.ConvertedDate ? new Date(record.ConvertedDate) : null, - leadSource: 'LeadSource', - convertedAccountId: 'ConvertedAccountId', - convertedContactId: 'ConvertedContactId', + lead_source: 'LeadSource', + converted_account_id: 'ConvertedAccountId', + converted_contact_id: 'ConvertedContactId', addresses: (record) => record.Street || record.City || @@ -99,45 +101,45 @@ const mappers = { city: record.City ?? null, state: record.State ?? null, country: record.Country ?? null, - postalCode: record.PostalCode ?? null, - addressType: 'primary', + postal_code: record.PostalCode ?? null, + address_type: 'primary', }, ] : [], - emailAddresses: (record) => + email_addresses: (record) => record.Email - ? [{emailAddress: record.Email, emailAddressType: 'primary'}] + ? [{email_address: record.Email, email_address_type: 'primary'}] : [], - phoneNumbers: (record) => + phone_numbers: (record) => record.Phone ? [ { - phoneNumber: record.Phone ?? null, - phoneNumberType: 'primary', + phone_number: record.Phone ?? null, + phone_number_type: 'primary', }, ] : [], - createdAt: (record) => + created_at: (record) => record.CreatedDate ? new Date(record.CreatedDate) : null, - isDeleted: 'IsDeleted', - lastModifiedAt: (record) => + is_deleted: 'IsDeleted', + last_modified_at: (record) => record.SystemModstamp ? new Date(record.SystemModstamp) : new Date(0), - rawData: (record) => record, + raw_data: (record) => record, }), user: mapper(zCast(), commonModels.user, { id: 'Id', - updated_at: 'SystemModstamp', name: 'Name', email: 'Email', - isActive: 'IsActive', - createdAt: (record) => + is_active: 'IsActive', + created_at: (record) => record.CreatedDate ? new Date(record.CreatedDate) : null, - updatedAt: (record) => + updated_at: (record) => record.CreatedDate ? new Date(record.CreatedDate) : null, - lastModifiedAt: (record) => + last_modified_at: (record) => record.CreatedDate ? new Date(record.CreatedDate) : null, - // rawData: (rawData) => rawData, + // raw_data: (rawData) => rawData, }), + customObject: { parse: (rawData: any) => ({ id: rawData.Id, @@ -337,7 +339,7 @@ function validateCustomObject(params: CustomObjectSchemaCreateParams): void { ) } - if (!primaryField.is_required) { + if (!primaryField.isRequired) { throw new BadRequestError( `Primary field must be required, but was not with key name ${params.primaryFieldId}`, ) @@ -420,23 +422,21 @@ export const toSalesforceCustomObjectCreateParams = ( description: string | null, primaryField: PropertyUnified, nonPrimaryFieldsToUpdate: PropertyUnified[], -) => { - return { - deploymentStatus: 'Deployed', - sharingModel: 'ReadWrite', - fullName: objectName, - description, - label: labels.singular, - pluralLabel: labels.plural, - nameField: { - label: primaryField?.label, - type: 'Text', - }, - fields: nonPrimaryFieldsToUpdate.map((field) => - toSalesforceCustomFieldCreateParams(objectName, field), - ), - } -} +) => ({ + deploymentStatus: 'Deployed', + sharingModel: 'ReadWrite', + fullName: objectName, + description, + label: labels.singular, + pluralLabel: labels.plural, + nameField: { + label: primaryField?.label, + type: 'Text', + }, + fields: nonPrimaryFieldsToUpdate.map((field) => + toSalesforceCustomFieldCreateParams(objectName, field), + ), +}) const propertiesForCommonObject: Record = { account: [ @@ -538,7 +538,7 @@ type SalesforceSDK = _SalesforceSDK & { * 2) Allow it to be configured on a per request basis via a `x-salesforce-api-version` header. * Simpler but we would be forcing the consumer to have to worry about it. */ -const API_VERSION = '59.0' +export const API_VERSION = '59.0' function sdkExt(instance: SalesforceSDK) { /** NOTE: extract these into a helper functions inside sdk-salesforce */ @@ -755,6 +755,10 @@ export const salesforceProvider = { const primaryField = input.fields.find( (field) => field.id === input.primaryFieldId, ) + if (!primaryField) { + throw new Error('Primary field not found') + } + const nonPrimaryFields = input.fields.filter( (field) => field.id !== input.primaryFieldId, ) @@ -765,17 +769,17 @@ export const salesforceProvider = { objectName, input.label, input.description || null, - primaryField!, + primaryField, nonPrimaryFields, ), ) // const nonRequiredFields = nonPrimaryFields.filter( - // (field) => !field.is_required, + // (field) => !field.isRequired, // ) // await updateFieldPermissions( - // instance, + // sfdc, // objectName, // nonRequiredFields.map((field) => field.id), // ) @@ -804,9 +808,7 @@ export const salesforceProvider = { }, createCustomObjectRecord: async ({instance, input}) => { const sfdc = await instance.getJsForce() - const result = await sfdc - .sobject(input.id) - .create(input.record as Record) + const result = await sfdc.sobject(input.id).create(input.record) return {record: {id: result.id}} }, metadataCreateAssociation: async ({instance, input}) => { diff --git a/verticals/vertical-crm/providers/salesforce/updatePermissions.ts b/verticals/vertical-crm/providers/salesforce/updatePermissions.ts index 976dfb5..9c89559 100644 --- a/verticals/vertical-crm/providers/salesforce/updatePermissions.ts +++ b/verticals/vertical-crm/providers/salesforce/updatePermissions.ts @@ -1,13 +1,12 @@ import * as jsforce from 'jsforce' +import {API_VERSION} from '../salesforce-provider' interface SalesforceInstance { getJsForce: () => Promise } -const API_VERSION = '59.0' - export async function updateFieldPermissions( - instance: SalesforceInstance, + sfdc: jsforce.Connection, objectName: string, nonPrimaryFields: string[], ) { @@ -21,7 +20,6 @@ export async function updateFieldPermissions( // adding permissions, and we want the second call to this endpoint to fix that. // // TODO: do we want to make it visible for all profiles? - const sfdc = await instance.getJsForce() const {userInfo} = sfdc if (!userInfo) { diff --git a/verticals/vertical-crm/router.ts b/verticals/vertical-crm/router.ts index 84752a1..acb749c 100644 --- a/verticals/vertical-crm/router.ts +++ b/verticals/vertical-crm/router.ts @@ -126,12 +126,12 @@ export const crmRouter = trpc.router({ fields: z.array( z.object({ id: z.string(), - description: z.string().optional().nullish(), + description: z.string().nullish(), type: z.string(), label: z.string(), - is_required: z.boolean(), + isRequired: z.boolean(), default_value: z.string().nullish(), - group_name: z.string().nullish().nullish(), + group_name: z.string().nullish(), }), ), }),