From 19ae326097a0117bb7762228febf99d174555b4d Mon Sep 17 00:00:00 2001 From: sumanthreddyc <48516669+sumanthreddyc@users.noreply.github.com> Date: Thu, 29 Feb 2024 14:04:41 +0530 Subject: [PATCH] feat: salesforce mappers (#3) --- packages/sdk/openapi.json | 331 +++++++- verticals/vertical-crm/commonModels.ts | 106 ++- .../providers/hubspot-provider.ts | 277 ++++++- .../providers/salesforce-provider.ts | 751 +++++++++++++++++- .../providers/salesforce/updatePermissions.ts | 101 +++ verticals/vertical-crm/router.ts | 79 ++ 6 files changed, 1621 insertions(+), 24 deletions(-) create mode 100644 verticals/vertical-crm/providers/salesforce/updatePermissions.ts diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 678d654..9629e9a 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -2276,6 +2276,144 @@ } } }, + "/crm/v2/custom/{id}": { + "get": { + "operationId": "crm-listCustomObjectRecords", + "parameters": [ + { + "in": "query", + "name": "type", + "schema": { + "type": "string", + "enum": [ + "standard", + "custom" + ] + }, + "required": true + }, + { + "in": "query", + "name": "name", + "schema": { + "type": "string" + }, + "required": true + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/crm.metaProperty" + } + } + } + } + }, + "400": { + "description": "Invalid input data", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.BAD_REQUEST" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.NOT_FOUND" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.INTERNAL_SERVER_ERROR" + } + } + } + } + } + }, + "post": { + "operationId": "crm-createCustomObjectRecord", + "parameters": [ + { + "in": "path", + "name": "id", + "schema": { + "type": "string" + }, + "required": true, + "description": "The ID of the custom object" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/crm.CustomObjectRecord" + } + } + } + }, + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/crm.CustomObjectRecord" + } + } + } + }, + "400": { + "description": "Invalid input data", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.BAD_REQUEST" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.NOT_FOUND" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.INTERNAL_SERVER_ERROR" + } + } + } + } + } + } + }, "/crm/v2/metadata/objects/standard": { "get": { "operationId": "crm-metadataListStandardObjects", @@ -2334,6 +2472,51 @@ } } } + }, + "post": { + "operationId": "crm-metadataCreateObjectsSchema", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/crm.metaCustomObject" + } + } + } + }, + "responses": { + "201": { + "description": "Custom object created successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/crm.metaCustomObject" + } + } + } + }, + "400": { + "description": "Invalid input data", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.BAD_REQUEST" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.INTERNAL_SERVER_ERROR" + } + } + } + } + } } }, "/crm/v2/metadata/properties": { @@ -2407,7 +2590,153 @@ } } } - } + }, + "/crm/v2/metadata/associations": { + "get": { + "operationId": "crm-metadataListProperties", + "parameters": [ + { + "in": "query", + "name": "type", + "schema": { + "type": "string", + "enum": [ + "standard", + "custom" + ] + }, + "required": true + }, + { + "in": "query", + "name": "name", + "schema": { + "type": "string" + }, + "required": true + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/crm.metaProperty" + } + } + } + } + }, + "400": { + "description": "Invalid input data", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.BAD_REQUEST" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.NOT_FOUND" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.INTERNAL_SERVER_ERROR" + } + } + } + } + } + }, + "post": { + "operationId": "crm-metadataCreateAssociation", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["sourceObject", "targetObject", "id", "label"], + "properties": { + "sourceObject": { + "type": "string" + }, + "targetObject": { + "type": "string" + }, + "id": { + "type": "string" + }, + "label": { + "type": "string" + } + } + } + } + } + }, + "responses": { + "201": { + "description": "Association created successfully", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "sourceObject": { + "type": "string" + }, + "targetObject": { + "type": "string" + }, + "label": { + "type": "string" + } + } + } + } + } + }, + "400": { + "description": "Invalid input data", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.BAD_REQUEST" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.INTERNAL_SERVER_ERROR" + } + } + } + } + } + } + } }, "components": { "securitySchemes": { diff --git a/verticals/vertical-crm/commonModels.ts b/verticals/vertical-crm/commonModels.ts index a81e5d1..96f9763 100644 --- a/verticals/vertical-crm/commonModels.ts +++ b/verticals/vertical-crm/commonModels.ts @@ -3,6 +3,12 @@ import {z, zBaseRecord} from '@supaglue/vdk' 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(), }) .openapi({ref: 'crm.account'}) @@ -16,21 +22,117 @@ export const contact = zBaseRecord 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( + z.object({ + street1: z.string().nullish(), + street2: z.string().nullish(), + city: z.string().nullish(), + state: z.string().nullish(), + country: z.string().nullish(), + postal_code: z.string().nullish(), + address_type: z.string().nullish(), + }), + ) + .nullish(), + email_addresses: z + .array( + z.object({ + email_address: z.string().nullish(), + email_address_type: z.string().nullish(), + }), + ) + .nullish(), + phone_numbers: z + .array( + z.object({ + phone_number: z.string().nullish(), + phone_number_type: z.string().nullish(), + }), + ) + .nullish(), + created_at: z.string().nullish(), + is_deleted: z.boolean().nullish(), + last_modified_at: z.string().nullish(), }) .openapi({ref: 'crm.lead'}) - export const opportunity = zBaseRecord +export const opportunity = zBaseRecord .extend({ name: z.string().nullish(), + description: z.string().nullish(), + owner_id: z.string().nullish(), + status: z.string().nullish(), + stage: z.string().nullish(), + close_date: z.date().nullish(), + account_id: z.string().nullish(), + pipeline: z.string().nullish(), + amount: z.number().nullish(), + last_activity_at: z.date().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 +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'}) +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 52c7393..2fecc9d 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', @@ -53,33 +73,210 @@ const HSContact = z.object({ updatedAt: z.string(), archived: z.boolean(), }) +const HSOpportunity = z.object({ + id: z.string(), + properties: z.object({ + hs_object_id: z.string(), + createdate: z.string(), + lastmodifieddate: z.string().nullish(), + // properties specific to opportunities below... + name: z.string().nullish(), + description: z.string().nullish(), + owner_id: z.string().nullish(), + account_id: z.string().nullish(), + status: z.string().nullish(), + stage: z.string().nullish(), + closedate: z.string().nullish(), // Assuming closeDate is a string in HubSpot format + amount: z.string().nullish(), + last_activity_at: z.string().nullish(), // Assuming lastActivityAt is a string in HubSpot format + is_deleted: z.boolean().nullish(), + hs_is_closed_won: z.string().nullish(), + hs_is_closed: z.string().nullish(), + archivedAt: z.string().nullish(), + }), + createdAt: z.string(), + updatedAt: z.string(), + archived: z.boolean(), +}) +const HSUser = z.object({ + id: z.string(), + properties: z.object({ + hs_object_id: z.string(), + createdate: z.string(), + lastmodifieddate: z.string().nullish(), + // properties specific to opportunities below... + email: z.string().nullish(), + firstname: z.string().nullish(), + lastname: z.string().nullish(), + }), + createdAt: z.string(), + updatedAt: z.string(), + archived: z.boolean(), +}) +const HSAccount = z.object({ + id: z.string(), + properties: z.object({ + hs_object_id: z.string(), + createdate: z.string(), + lastmodifieddate: z.string().nullish(), + name: z.string().nullish(), + description: z.string().nullish(), + hubspot_owner_id: z.string().nullish(), + industry: z.string().nullish(), + website: z.string().nullish(), + numberofemployees: z.string().nullish(), + addresses: z.string().nullish(), // Assuming addresses is a string; adjust the type if needed + phonenumbers: z.string().nullish(), // Assuming phonenumbers is a string; adjust the type if needed + lifecyclestage: z.string().nullish(), + notes_last_updated: z.string().nullish(), + }), + createdAt: z.string(), + updatedAt: z.string(), + archived: z.boolean(), +}) + +const propertiesToFetch = { + company: [ + 'hubspot_owner_id', + 'description', + 'industry', + 'website', + 'domain', + 'hs_additional_domains', + 'numberofemployees', + 'address', + 'address2', + 'city', + 'state', + 'country', + 'zip', + 'phone', + 'notes_last_updated', + 'lifecyclestage', + 'createddate', + ], + contact: [ + 'address', // TODO: IP state/zip/country? + 'address2', + 'city', + 'country', + 'email', + 'fax', + 'firstname', + 'hs_createdate', // TODO: Use this or createdate? + 'hs_is_contact', // TODO: distinguish from "visitor"? + 'hubspot_owner_id', + 'lifecyclestage', + 'lastname', + 'mobilephone', + 'phone', + 'state', + 'work_email', + 'zip', + ], + deal: [ + 'dealname', + 'description', + 'dealstage', + 'amount', + 'hubspot_owner_id', + 'notes_last_updated', + 'closedate', + 'pipeline', + 'hs_is_closed_won', + 'hs_is_closed', + ], + user: ['Id', 'Name', 'Email', 'IsActive', 'CreatedDate', 'SystemModstamp'], + account: [ + 'Id', + 'Name', + 'Type', + 'ParentId', + 'BillingAddress', + 'ShippingAddress', + 'Phone', + 'Fax', + 'Website', + 'Industry', + 'NumberOfEmployees', + 'OwnerId', + 'CreatedDate', + 'LastModifiedDate', + ], +} const mappers = { - account: mapper(HSBase, commonModels.account, { + account: mapper(HSAccount, commonModels.account, { id: 'id', - updated_at: 'updatedAt', + name: 'properties.name', + updated_at: (record) => new Date(record.updatedAt).toISOString(), + is_deleted: (record) => !!record.archived, + website: 'properties.website', + industry: 'properties.industry', + number_of_employees: (record) => + record.properties.numberofemployees + ? Number.parseInt(record.properties.numberofemployees, 10) + : null, + owner_id: 'properties.hubspot_owner_id', + created_at: (record) => new Date(record.createdAt).toISOString(), }), contact: mapper(HSContact, commonModels.contact, { id: 'id', first_name: 'properties.firstname', last_name: 'properties.lastname', - updated_at: 'updatedAt', + updated_at: (record) => new Date(record.updatedAt).toISOString(), }), - opportunity: mapper(HSBase, commonModels.opportunity, { + opportunity: mapper(HSOpportunity, commonModels.opportunity, { id: 'id', - name: 'id', - updated_at: 'updatedAt', + name: 'properties.name', + description: 'properties.description', + owner_id: 'properties.owner_id', + status: (record) => + record.properties.hs_is_closed_won + ? 'WON' + : record.properties.hs_is_closed + ? 'LOST' + : 'Open', + stage: 'properties.stage', + close_date: (record) => + record.properties.closedate + ? new Date(record.properties.closedate) + : null, + account_id: 'properties.account_id', + amount: (record) => + record.properties.amount + ? Number.parseFloat(record.properties.amount) + : null, + last_activity_at: (record) => + record.properties.last_activity_at + ? new Date(record.properties.last_activity_at) + : null, + created_at: (record) => + new Date(record.properties.createdate).toISOString(), + updated_at: (record) => new Date(record.updatedAt).toISOString(), + last_modified_at: (record) => new Date(record.updatedAt).toISOString(), }), lead: mapper(HSBase, commonModels.lead, { id: 'id', - updated_at: 'updatedAt', + updated_at: (record) => new Date(record.updatedAt).toISOString(), }), - user: mapper(zCast(), commonModels.user, { + user: mapper(HSUser, commonModels.user, { id: 'id', - updated_at: 'updatedAt', - name: (o) => [o.firstName, o.lastName].filter((n) => !!n?.trim()).join(' '), + updated_at: (record) => new Date(record.updatedAt).toISOString(), + name: (record) => + [record.properties.firstname, record.properties.lastname] + .filter((n) => !!n?.trim()) + .join(' '), + email: 'properties.email', + is_active: (record) => !record.archived, // Assuming archived is a boolean + created_at: (record) => + new Date(record.properties.createdate).toISOString(), + is_deleted: (record) => !!record.archived, // Assuming archived is a boolean + last_modified_at: (record) => + record.updatedAt ? new Date(record.updatedAt).toISOString() : null, }), } + const _listEntityIncrementalThenMap = async ( instance: HubspotSDK, { @@ -88,7 +285,7 @@ const _listEntityIncrementalThenMap = async ( ...opts }: { entity: string - fields: Array> + fields: string[] mapper: {parse: (rawData: unknown) => TOut; _in: TIn} page_size?: number cursor?: string | null @@ -108,6 +305,7 @@ const _listEntityIncrementalThenMap = async ( 'createdate', 'lastmodifieddate', 'hs_lastmodifieddate', + 'name', ...fields, ], filterGroups: cursor?.last_updated_at @@ -204,21 +402,21 @@ export const hubspotProvider = { ...input, entity: 'contacts', mapper: mappers.contact, - fields: [], + fields: propertiesToFetch.contact, }), listAccounts: async ({instance, input}) => _listEntityIncrementalThenMap(instance, { ...input, entity: 'companies', mapper: mappers.account, - fields: [], + fields: propertiesToFetch.account, }), listOpportunities: async ({instance, input}) => _listEntityIncrementalThenMap(instance, { ...input, entity: 'deals', mapper: mappers.opportunity, - fields: [], + fields: propertiesToFetch.deal, }), // Original supaglue never implemented this, TODO: handle me... // listLeads: async ({instance, input}) => @@ -242,4 +440,55 @@ 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}`, + // }, + // ], + // }, + // }, + // ) + // return res.data + // }, } satisfies CRMProvider diff --git a/verticals/vertical-crm/providers/salesforce-provider.ts b/verticals/vertical-crm/providers/salesforce-provider.ts index ff65fb9..ebb67bc 100644 --- a/verticals/vertical-crm/providers/salesforce-provider.ts +++ b/verticals/vertical-crm/providers/salesforce-provider.ts @@ -4,18 +4,27 @@ import { mapper, modifyRequest, PLACEHOLDER_BASE_URL, + z, zCast, } from '@supaglue/vdk' import * as jsforce from 'jsforce' +import type { + CustomField as SalesforceCustomField, + CustomObject as SalesforceCustomObject, +} from 'jsforce/lib/api/metadata/schema' import type {SalesforceSDKTypes} from '@opensdks/sdk-salesforce' import { initSalesforceSDK, type SalesforceSDK as _SalesforceSDK, } from '@opensdks/sdk-salesforce' +import type {CustomObjectSchemaCreateParams} from '../../types/custom_object' +import type {PropertyType, PropertyUnified} from '../../types/property' import type {CRMProvider} from '../router' import {commonModels} from '../router' import {SALESFORCE_STANDARD_OBJECTS} from './salesforce/constants' +// import {updateFieldPermissions} from './salesforce/updatePermissions' + export type SFDC = SalesforceSDKTypes['oas']['components']['schemas'] const mappers = { @@ -29,6 +38,13 @@ const mappers = { id: 'Id', updated_at: 'SystemModstamp', name: 'Name', + is_deleted: 'IsDeleted', + website: 'Website', + industry: 'Industry', + number_of_employees: 'NumberOfEmployees', + owner_id: 'OwnerId', + created_at: (record) => + record.CreatedDate ? new Date(record.CreatedDate).toISOString() : '', }), opportunity: mapper( zCast(), @@ -37,18 +53,499 @@ const mappers = { id: 'Id', updated_at: 'SystemModstamp', name: 'Name', + description: 'Description', + owner_id: 'OwnerId', + status: (record) => (record.IsClosed ? 'Closed' : 'Open'), + stage: 'StageName', + close_date: (record) => + record.CloseDate ? new Date(record.CloseDate) : null, + account_id: 'AccountId', + amount: 'Amount', + last_activity_at: (record) => + record.LastActivityDate ? new Date(record.LastActivityDate) : null, + created_at: (record) => + record.CreatedDate ? new Date(record.CreatedDate).toISOString() : '', + is_deleted: 'IsDeleted', + last_modified_at: (record) => + record.LastModifiedDate + ? new Date(record.LastModifiedDate).toISOString() + : '', }, ), lead: mapper(zCast(), commonModels.lead, { id: 'Id', updated_at: 'SystemModstamp', name: 'Name', + first_name: 'FirstName', + last_name: 'LastName', + owner_id: 'OwnerId', + title: 'Title', + company: 'Company', + converted_date: (record) => + record.ConvertedDate ? new Date(record.ConvertedDate).toISOString() : '', + lead_source: 'LeadSource', + converted_account_id: 'ConvertedAccountId', + converted_contact_id: 'ConvertedContactId', + addresses: (record) => + record.Street || + record.City || + record.State || + record.Country || + record.PostalCode + ? [ + { + street1: record.Street ?? null, + street2: null, + city: record.City ?? null, + state: record.State ?? null, + country: record.Country ?? null, + postal_code: record.PostalCode ?? null, + address_type: 'primary', + }, + ] + : [], + email_addresses: (record) => + record.Email + ? [{email_address: record.Email, email_address_type: 'primary'}] + : [], + phone_numbers: (record) => + record.Phone + ? [ + { + phone_number: record.Phone ?? null, + phone_number_type: 'primary', + }, + ] + : [], + created_at: (record) => + record.CreatedDate ? new Date(record.CreatedDate).toISOString() : '', + is_deleted: 'IsDeleted', + last_modified_at: (record) => + record.SystemModstamp + ? new Date(record.SystemModstamp).toISOString() + : '', }), user: mapper(zCast(), commonModels.user, { id: 'Id', - updated_at: 'SystemModstamp', name: 'Name', + email: 'Email', + is_active: 'IsActive', + created_at: (record) => + record.CreatedDate ? new Date(record.CreatedDate).toISOString() : '', + updated_at: (record) => + record.CreatedDate ? new Date(record.CreatedDate).toISOString() : '', + last_modified_at: (record) => + record.CreatedDate ? new Date(record.CreatedDate).toISOString() : '', }), + + customObject: { + parse: (rawData: any) => ({ + id: rawData.Id, + updated_at: rawData.SystemModstamp + ? new Date(rawData.SystemModstamp).toISOString() + : '', + name: rawData.Name, + createdAt: rawData.CreatedDate + ? new Date(rawData.CreatedDate).toISOString() + : '', + updatedAt: rawData.CreatedDate + ? new Date(rawData.CreatedDate).toISOString() + : '', + lastModifiedAt: rawData.CreatedDate + ? new Date(rawData.CreatedDate).toISOString() + : '', + raw_data: rawData, + }), + _in: { + Name: true, + }, + }, +} + +function mapStringToPropertyType(type: string): PropertyType { + switch (type) { + case 'text': + case 'textarea': + case 'number': + case 'picklist': + case 'multipicklist': + case 'date': + case 'datetime': + case 'boolean': + case 'url': + return type + default: + return 'other' + } +} + +type ToolingAPIValueSet = { + restricted: boolean + valueSetDefinition: { + sorted: boolean + value: {label: string; valueName: string; description: string}[] + } +} +type ToolingAPICustomField = { + FullName: string + Metadata: ( + | { + type: 'DateTime' | 'Url' | 'Checkbox' | 'Date' + } + | { + type: 'Text' | 'TextArea' + length: number + } + | { + type: 'Number' + precision: number + scale: number + } + | { + type: 'MultiselectPicklist' + valueSet: ToolingAPIValueSet + visibleLines: number + } + | { + type: 'Picklist' + valueSet: ToolingAPIValueSet + } + ) & { + required: boolean + label: string + description?: string + defaultValue: string | null + } +} + +export function capitalizeString(str: string): string { + if (!str) { + return str + } + return str.charAt(0).toUpperCase() + str.slice(1) +} + +type AccountFields = + | 'OwnerId' + | 'Name' + | 'Description' + | 'Industry' + | 'Website' + | 'NumberOfEmployees' + | 'BillingCity' + | 'BillingCountry' + | 'BillingPostalCode' + | 'BillingState' + | 'BillingStreet' + | 'ShippingCity' + | 'ShippingCountry' + | 'ShippingPostalCode' + | 'ShippingState' + | 'ShippingStreet' + | 'Phone' + | 'Fax' + | 'LastActivityDate' + | 'CreatedDate' + | 'IsDeleted' +type ContactFields = + | 'OwnerId' + | 'AccountId' + | 'FirstName' + | 'LastName' + | 'Email' + | 'Phone' + | 'Fax' + | 'MobilePhone' + | 'LastActivityDate' + | 'MailingCity' + | 'MailingCountry' + | 'MailingPostalCode' + | 'MailingState' + | 'MailingStreet' + | 'OtherCity' + | 'OtherCountry' + | 'OtherPostalCode' + | 'OtherState' + | 'OtherStreet' + | 'IsDeleted' + | 'CreatedDate' +type OpportunityFields = + | 'OwnerId' + | 'Name' + | 'Description' + | 'LastActivityDate' + | 'Amount' + | 'IsClosed' + | 'IsDeleted' + | 'IsWon' + | 'StageName' + | 'CloseDate' + | 'CreatedDate' + | 'AccountId' +type LeadFields = + | 'OwnerId' + | 'Title' + | 'FirstName' + | 'LastName' + | 'ConvertedDate' + | 'CreatedDate' + | 'SystemModstamp' + | 'ConvertedContactId' + | 'ConvertedAccountId' + | 'Company' + | 'City' + | 'State' + | 'Street' + | 'Country' + | 'PostalCode' + | 'Phone' + | 'Email' + | 'IsDeleted' +type UserFields = 'Name' | 'Email' | 'IsActive' | 'CreatedDate' + +export const CRM_COMMON_OBJECT_TYPES = [ + 'account', + 'contact', + 'lead', + 'opportunity', + 'user', +] as const +export type CRMCommonObjectType = (typeof CRM_COMMON_OBJECT_TYPES)[number] + +// TODO: Figure out what to do with id and reference types +export const toSalesforceType = ( + property: PropertyUnified, +): ToolingAPICustomField['Metadata']['type'] => { + switch (property.type) { + case 'number': + return 'Number' + case 'text': + return 'Text' + case 'textarea': + return 'TextArea' + case 'boolean': + return 'Checkbox' + case 'picklist': + return 'Picklist' + case 'multipicklist': + return 'MultiselectPicklist' + case 'date': + return 'Date' + case 'datetime': + return 'DateTime' + case 'url': + return 'Url' + default: + return 'Text' + } +} + +function validateCustomObject(params: CustomObjectSchemaCreateParams): void { + if (!params.fields.length) { + throw new Error('Cannot create custom object with no fields') + } + + const primaryField = params.fields.find( + (field) => field.id === params.primaryFieldId, + ) + + if (!primaryField) { + throw new Error( + `Could not find primary field with key name ${params.primaryFieldId}`, + ) + } + + if (primaryField.type !== 'text') { + throw new Error( + `Primary field must be of type text, but was ${primaryField.type} with key name ${params.primaryFieldId}`, + ) + } + + if (!primaryField.isRequired) { + throw new Error( + `Primary field must be required, but was not with key name ${params.primaryFieldId}`, + ) + } + + if (capitalizeString(primaryField.id) !== 'Name') { + throw new Error( + `Primary field for salesforce must have key name 'Name', but was ${primaryField.id}`, + ) + } + + const nonPrimaryFields = params.fields.filter( + (field) => field.id !== params.primaryFieldId, + ) + + if (nonPrimaryFields.some((field) => !field.id.endsWith('__c'))) { + throw new Error('Custom object field key names must end with __c') + } + + if ( + nonPrimaryFields.some( + (field) => field.type === 'boolean' && field.isRequired, + ) + ) { + throw new Error('Boolean fields cannot be required in Salesforce') + } +} + +export const toSalesforceCustomFieldCreateParams = ( + objectName: string, + property: any, + prefixed = false, +): Partial => { + const base: Partial = { + // When calling the CustomObjects API, it does not need to be prefixed. + // However, when calling the CustomFields API, it needs to be prefixed. + fullName: prefixed ? `${objectName}.${property.id}` : property.id, + label: property.label, + type: toSalesforceType(property), + required: property.isRequired, + defaultValue: property.defaultValue?.toString() ?? null, + } + // if (property.defaultValue) { + // base = { ...base, defaultValue: property.defaultValue.toString() }; + // } + if (property.type === 'text') { + return { + ...base, + // TODO: Maybe textarea should be longer + length: 255, + } + } + if (property.type === 'number') { + return { + ...base, + scale: property.scale, + precision: property.precision, + } + } + if (property.type === 'boolean') { + return { + ...base, + // Salesforce does not support the concept of required boolean fields + required: false, + // JS Force (incorrectly) expects string here + // This is required for boolean fields + defaultValue: property.defaultValue?.toString() ?? 'false', + } + } + // TODO: Support picklist options + return base +} + +export const toSalesforceCustomObjectCreateParams = ( + objectName: string, + labels: { + singular: string + plural: string + }, + description: string | null, + primaryField: PropertyUnified, + nonPrimaryFieldsToUpdate: PropertyUnified[], +) => ({ + 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: [ + 'OwnerId', + 'Name', + 'Description', + 'Industry', + 'Website', + 'NumberOfEmployees', + // We may not need all of these fields in order to map to common object + 'BillingCity', + 'BillingCountry', + 'BillingPostalCode', + 'BillingState', + 'BillingStreet', + // We may not need all of these fields in order to map to common object + 'ShippingCity', + 'ShippingCountry', + 'ShippingPostalCode', + 'ShippingState', + 'ShippingStreet', + 'Phone', + 'Fax', + 'LastActivityDate', + 'CreatedDate', + 'IsDeleted', + ], + contact: [ + 'OwnerId', + 'AccountId', + 'FirstName', + 'LastName', + 'Email', + 'Phone', + 'Fax', + 'MobilePhone', + 'LastActivityDate', + // We may not need all of these fields in order to map to common object + 'MailingCity', + 'MailingCountry', + 'MailingPostalCode', + 'MailingState', + 'MailingStreet', + // We may not need all of these fields in order to map to common object + 'OtherCity', + 'OtherCountry', + 'OtherPostalCode', + 'OtherState', + 'OtherStreet', + 'IsDeleted', + 'CreatedDate', + ], + opportunity: [ + 'OwnerId', + 'Name', + 'Description', + 'LastActivityDate', + 'Amount', + 'IsClosed', + 'IsDeleted', + 'IsWon', + 'StageName', + 'CloseDate', + 'CreatedDate', + 'AccountId', + ], + lead: [ + 'OwnerId', + 'Title', + 'FirstName', + 'LastName', + 'ConvertedDate', + 'CreatedDate', + 'SystemModstamp', + 'ConvertedContactId', + 'ConvertedAccountId', + 'Company', + 'City', + 'State', + 'Street', + 'Country', + 'PostalCode', + 'Phone', + 'Email', + 'IsDeleted', + ], + user: ['Name', 'Email', 'IsActive', 'CreatedDate'], } type SalesforceSDK = _SalesforceSDK & { @@ -63,7 +560,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 */ @@ -88,7 +585,7 @@ function sdkExt(instance: SalesforceSDK) { : '' const limitStatement = opts.limit != null ? `LIMIT ${opts.limit}` : '' return instance.query(` - SELECT Id, SystemModstamp, ${opts.fields.join(', ')} + SELECT Id, SystemModstamp, ${opts.fields.join(', ')}, FIELDS(CUSTOM) FROM ${opts.entity} ${whereStatement} ORDER BY SystemModstamp ASC, Id ASC @@ -171,7 +668,7 @@ export const salesforceProvider = { listAccounts: async ({instance, input}) => sdkExt(instance)._listEntityThenMap({ entity: 'Account', - fields: ['Name'], + fields: propertiesForCommonObject.account as AccountFields[], mapper: mappers.account, cursor: input?.cursor, page_size: input?.page_size, @@ -191,7 +688,7 @@ export const salesforceProvider = { listContacts: async ({instance, input}) => sdkExt(instance)._listEntityThenMap({ entity: 'Contact', - fields: ['FirstName', 'LastName'], + fields: propertiesForCommonObject.contact as ContactFields[], mapper: mappers.contact, cursor: input?.cursor, page_size: input?.page_size, @@ -211,7 +708,7 @@ export const salesforceProvider = { listOpportunities: async ({instance, input}) => sdkExt(instance)._listEntityThenMap({ entity: 'Opportunity', - fields: ['Name'], + fields: propertiesForCommonObject.opportunity as OpportunityFields[], mapper: mappers.opportunity, cursor: input?.cursor, page_size: input?.page_size, @@ -233,12 +730,21 @@ export const salesforceProvider = { listUsers: async ({instance, input}) => sdkExt(instance)._listEntityThenMap({ entity: 'User', - fields: ['Name'], + fields: propertiesForCommonObject.user as UserFields[], mapper: mappers.user, cursor: input?.cursor, page_size: input?.page_size, }), + listCustomObjectRecords: async ({instance, input}) => + sdkExt(instance)._listEntityThenMap({ + entity: input.id, + fields: ['Name'], + mapper: mappers.customObject, + cursor: input?.cursor, + page_size: input?.page_size, + }), + // MARK: - Metadata metadataListStandardObjects: () => SALESFORCE_STANDARD_OBJECTS.map((name) => ({name})), @@ -253,4 +759,235 @@ export const salesforceProvider = { await sfdc.metadata.read('CustomObject', input.name) return [] }, + + metadataCreateObjectsSchema: async ({instance, input}) => { + validateCustomObject({ + ...input, + fields: input.fields.map((field) => ({ + ...field, + type: mapStringToPropertyType(field.type), + })), + }) + + const sfdc = await instance.getJsForce() + + const objectName = input.name.endsWith('__c') + ? input.name + : `${input.name}__c` + + const readResponse = await sfdc.metadata.read('CustomObject', objectName) + if (readResponse.fullName) { + console.log(`Custom object with name ${objectName} already exists`) + } + + 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, + ) + + const primaryFieldMapped = { + ...primaryField, + type: mapStringToPropertyType(primaryField.type), + } + + const nonPrimaryFieldsMapped = nonPrimaryFields.map((field) => ({ + ...field, + type: mapStringToPropertyType(field.type), + })) + + const result = await sfdc.metadata.create( + 'CustomObject', + toSalesforceCustomObjectCreateParams( + objectName, + input.label, + input.description || null, + primaryFieldMapped, + nonPrimaryFieldsMapped, + ), + ) + + // const nonRequiredFields = nonPrimaryFields.filter( + // (field) => !field.isRequired, + // ) + + // await updateFieldPermissions( + // sfdc, + // objectName, + // nonRequiredFields.map((field) => field.id), + // ) + + if (result.success) { + // throw new Error( + // `Failed to create custom object. Since creating a custom object with custom fields is not an atomic operation in Salesforce, you should use the custom object name ${ + // input.name + // } as the 'id' parameter in the Custom Object GET endpoint to check if it was already partially created. If so, use the PUT endpoint to update the existing object. Raw error message from Salesforce: ${JSON.stringify( + // result, + // null, + // 2, + // )}`, + // ) + return {id: input.name, name: input.name} + } + throw new Error( + `Failed to create custom object. Since creating a custom object with custom fields is not an atomic operation in Salesforce, you should use the custom object name ${ + input.name + } as the 'id' parameter in the Custom Object GET endpoint to check if it was already partially created. If so, use the PUT endpoint to update the existing object. Raw error message from Salesforce: ${JSON.stringify( + result, + null, + 2, + )}`, + ) + }, + createCustomObjectRecord: async ({instance, input}) => { + const sfdc = await instance.getJsForce() + const result = await sfdc.sobject(input.id).create(input.record) + return {record: {id: result.id}} + }, + metadataCreateAssociation: async ({instance, input}) => { + const sfdc = await instance.getJsForce() + // if id doesn't end with __c, we need to add it ourselves + if (!input.id.endsWith('__c')) { + input.id = `${input.id}__c` + } + + // Look up source custom object to figure out a relationship name + const sourceCustomObjectMetadata = await sfdc.metadata.read( + 'CustomObject', + input.sourceObject, + ) + + // If the relationship field doesn't already exist, create it + const existingField = sourceCustomObjectMetadata.fields?.find( + (field: any) => field.fullName === input.id, + ) + + const customFieldPayload = { + fullName: `${input.sourceObject}.${input.id}`, + label: input.label, + // The custom field name you provided Related Opportunity on object Opportunity can + // only contain alphanumeric characters, must begin with a letter, cannot end + // with an underscore or contain two consecutive underscore characters, and + // must be unique across all Opportunity fields + // TODO: allow developer to specify name? + relationshipName: + sourceCustomObjectMetadata.pluralLabel?.replace(/\s/g, '') ?? + 'relationshipName', + type: 'Lookup', + required: false, + referenceTo: input.targetObject, + } + + if (existingField) { + const result = await sfdc.metadata.update( + 'CustomField', + customFieldPayload, + ) + + console.log('result', result) + + if (!result.success) { + throw new Error( + `Failed to update custom field for association type: ${JSON.stringify( + result.errors, + null, + 2, + )}`, + ) + } + } else { + const result = await sfdc.metadata.create( + 'CustomField', + customFieldPayload, + ) + + if (!result.success) { + throw new Error( + `Failed to create custom field for association type: ${JSON.stringify( + result.errors, + null, + 2, + )}`, + ) + } + } + + const {userInfo} = sfdc + if (!userInfo) { + throw new Error('Could not get info of current user') + } + + // Get the user record + const user = await sfdc.retrieve('User', userInfo.id, { + fields: ['ProfileId'], + }) + + // Get the first permission set + // TODO: Is this the right thing to do? How do we know the first one is the best one? + const result = await sfdc.query( + `SELECT Id FROM PermissionSet WHERE ProfileId='${user['ProfileId']}' LIMIT 1`, + ) + if (!result.records.length) { + throw new Error( + `Could not find permission set for profile ${user['ProfileId']}`, + ) + } + + const permissionSetId = result.records[0]?.Id + + // Figure out which fields already have permissions + const {records: existingFieldPermissions} = await sfdc.query( + `SELECT Id,Field FROM FieldPermissions WHERE SobjectType='${input.sourceObject}' AND ParentId='${permissionSetId}' AND Field='${input.sourceObject}.${input.id}'`, + ) + if (existingFieldPermissions.length) { + // Update permission + const existingFieldPermission = existingFieldPermissions[0] + const result = await sfdc.update('FieldPermissions', { + Id: existingFieldPermission?.Id as string, + ParentId: permissionSetId, + SobjectType: input.sourceObject, + Field: `${input.sourceObject}.${input.id}`, + PermissionsEdit: true, + PermissionsRead: true, + }) + if (!result.success) { + throw new Error( + `Failed to update field permission for association type: ${JSON.stringify( + result.errors, + null, + 2, + )}`, + ) + } + } else { + // Create permission + const result = await sfdc.create('FieldPermissions', { + ParentId: permissionSetId, + SobjectType: input.sourceObject, + Field: `${input.sourceObject}.${input.id}`, + PermissionsEdit: true, + PermissionsRead: true, + }) + if (!result.success) { + throw new Error( + `Failed to create field permission for association type: ${JSON.stringify( + result.errors, + null, + 2, + )}`, + ) + } + } + return { + id: `${input.sourceObject}.${input.id}`, + sourceObject: input.sourceObject, + targetObject: input.targetObject, + label: input.label, + } + }, } satisfies CRMProvider diff --git a/verticals/vertical-crm/providers/salesforce/updatePermissions.ts b/verticals/vertical-crm/providers/salesforce/updatePermissions.ts new file mode 100644 index 0000000..9c89559 --- /dev/null +++ b/verticals/vertical-crm/providers/salesforce/updatePermissions.ts @@ -0,0 +1,101 @@ +import * as jsforce from 'jsforce' +import {API_VERSION} from '../salesforce-provider' + +interface SalesforceInstance { + getJsForce: () => Promise +} + +export async function updateFieldPermissions( + sfdc: jsforce.Connection, + objectName: string, + nonPrimaryFields: string[], +) { + // After custom fields are created, they're not automatically visible. We need to + // set the field-level security to Visible for profiles. + // Instead of updating all profiles, we'll just update it for the profile for the user + // in this connection. + // + // We're doing this all the time, even if there were no detected added fields, since + // the previous call to this endpoint could have failed after creating fields but before + // 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 {userInfo} = sfdc + if (!userInfo) { + throw new Error('Could not get info of current user') + } + + // Get the user record + const user = await sfdc.retrieve('User', userInfo.id, { + fields: ['ProfileId'], + }) + + // Get the Id for the standard user profile + const [standardUserId] = ( + await sfdc.query(`SELECT Id FROM Profile WHERE Name = 'Standard User'`) + ).records.map((record: any) => record.Id) + + const profileIdsToGrantPermissionsTo = [user['ProfileId'], standardUserId] + + // Get the permission set ids + const permissionSetIds = ( + await sfdc.query( + `SELECT Id FROM PermissionSet WHERE ProfileId IN ('${profileIdsToGrantPermissionsTo.join( + "','", + )}')`, + ) + ).records.map((record: any) => record.Id) + + // Figure out which fields already have permissions + // TODO: Paginate + const {records: existingFieldPermissions} = await sfdc.query( + `SELECT Field FROM FieldPermissions WHERE SobjectType='${objectName}' AND ParentId IN ('${permissionSetIds.join( + "','", + )}')`, + ) + const existingFieldPermissionFieldNames = existingFieldPermissions.map( + (fieldPermission: any) => fieldPermission.Field, + ) + const fieldsToAddPermissionsFor = nonPrimaryFields.filter( + (field) => + !existingFieldPermissionFieldNames.includes(`${objectName}.${field}`), + ) + + const {compositeResponse} = await sfdc.requestPost<{ + compositeResponse: {httpStatusCode: number}[] + }>(`/services/data/v${API_VERSION}/composite`, { + // We're doing this for all fields, not just the added ones, in case the previous + // call to this endpoint succeeded creating additional fields but failed to + // add permissions for them. + compositeRequest: fieldsToAddPermissionsFor.flatMap((field) => + permissionSetIds.map((permissionSetId) => ({ + referenceId: `${field}_${permissionSetId}`, + method: 'POST', + url: `/services/data/v${API_VERSION}/sobjects/FieldPermissions/`, + body: { + ParentId: permissionSetId, + SobjectType: objectName, + Field: `${objectName}.${field}`, + PermissionsEdit: true, + PermissionsRead: true, + }, + })), + ), + }) + // if not 2xx + if ( + compositeResponse.some( + (response: any) => + response.httpStatusCode < 200 || response.httpStatusCode >= 300, + ) + ) { + throw new Error( + `Failed to add field permissions: ${JSON.stringify( + compositeResponse, + null, + 2, + )}`, + ) + } +} diff --git a/verticals/vertical-crm/router.ts b/verticals/vertical-crm/router.ts index b22ac98..acb749c 100644 --- a/verticals/vertical-crm/router.ts +++ b/verticals/vertical-crm/router.ts @@ -80,6 +80,16 @@ export const crmRouter = trpc.router({ .input(z.object({id: z.string()})) .output(z.object({record: commonModels.user, raw: z.unknown()})) .query(async ({input, ctx}) => proxyCallProvider({input, ctx})), + listCustomObjectRecords: remoteProcedure + .meta(oapi({method: 'GET', path: '/custom/{id}'})) + .input( + z.object({ + id: z.string(), + ...zPaginationParams.shape, + }), + ) + .output(zPaginatedResult.extend({items: z.array(z.unknown())})) + .query(async ({input, ctx}) => proxyCallProvider({input, ctx})), // MARK: - Metadata metadataListStandardObjects: remoteProcedure @@ -102,6 +112,75 @@ export const crmRouter = trpc.router({ ) .output(z.array(commonModels.metaProperty)) .query(async ({input, ctx}) => proxyCallProvider({input, ctx})), + metadataCreateObjectsSchema: remoteProcedure + .meta(oapi({method: 'POST', path: '/metadata/objects/custom'})) + .input( + z.object({ + name: z.string(), + description: z.string().nullish(), + label: z.object({ + singular: z.string(), + plural: z.string(), + }), + primaryFieldId: z.string(), + fields: z.array( + z.object({ + id: z.string(), + description: z.string().nullish(), + type: z.string(), + label: z.string(), + isRequired: z.boolean(), + default_value: z.string().nullish(), + group_name: z.string().nullish(), + }), + ), + }), + ) + .output(commonModels.metaCustomObject) + .query(async ({input, ctx}) => proxyCallProvider({input, ctx})), + metadataCreateFieldsSchema: remoteProcedure + .meta(oapi({method: 'POST', path: '/metadata/objects/fields'})) + .input( + z.object({ + name: z.string(), + description: z.string().nullish(), + label: z.object({ + singular: z.string(), + plural: z.string(), + }), + }), + ) + .output(commonModels.metaCustomObject) + .query(async ({input, ctx}) => proxyCallProvider({input, ctx})), + createCustomObjectRecord: remoteProcedure + .meta(oapi({method: 'POST', path: '/custom/{id}'})) + .input( + z.object({ + id: z.string(), + record: z.record(z.any()), + }), + ) + .output(z.object({record: z.unknown()})) + .query(async ({input, ctx}) => proxyCallProvider({input, ctx})), + metadataCreateAssociation: remoteProcedure + .meta(oapi({method: 'POST', path: '/metadata/associations'})) + .input( + z.object({ + sourceObject: z.string(), + targetObject: z.string(), + id: z.string(), + label: z.string(), + }), + ) + .output( + z.object({ + sourceObject: z.string(), + targetObject: z.string(), + id: z.string(), + label: z.string(), + }), + ) + .query(async ({input, ctx}) => proxyCallProvider({input, ctx})), }) export type CRMProvider = ProviderFromRouter<