feat: contacts sync — add all missing fields + UDFs

Migration 057:
- ADD user_defined_fields JSONB (GIN indexed)
- ADD middle_initial, note, external_id, country_id, company_location_id
- ADD create_date, impersonator_creator_resource_id
- ADD is_opted_out_from_bulk_email, bulk_email_opt_out_time
- ADD solicitation_opt_out_time, survey_opt_out
- ADD receives_email_notifications, billing_contact

entity-mapper.ts mapContact():
- Fix broken snake_case field refs → correct camelCase API names:
  alternate_phone → alternatePhone, mobile_phone → mobilePhone
  name_prefix/suffix → namePrefix/nameSuffix
  facebook/twitter/linkedin_url → facebookUrl/twitterUrl/linkedInUrl
  primary_contact → primaryContact, solicitation_opt_out → solicitationOptOut
  last_activity/modified_date → lastActivityDate/lastModifiedDate
  api_vendor_id → apiVendorID, is_active → isActive, is_deleted → isDeleted
- Add UDF conversion: userDefinedFields[] → JSONB {name: value}
- 17 UDFs stored: UserID, Birthday, O365License, VIP User, Department,
  Password, Email Password, Archive Email, User System Profile, etc.

Results: 4234 contacts synced, 3318 with UDFs, 4140 with create_date
This commit is contained in:
lorentz 2026-03-26 13:16:13 -04:00
parent dd4cf68def
commit 9a448d111c
2 changed files with 64 additions and 25 deletions

View file

@ -564,41 +564,60 @@ function mapConfigurationItem(data: any): Record<string, any> {
* Map Contact entity
*/
function mapContact(data: any): Record<string, any> {
// Convert userDefinedFields array → JSONB object { name: value }
const udfs: Record<string, string | null> = {};
if (Array.isArray(data.userDefinedFields)) {
for (const udf of data.userDefinedFields) {
udfs[udf.name] = udf.value ?? null;
}
}
return {
id: data.id,
company_id: data.companyID,
first_name: data.firstName,
last_name: data.lastName,
middle_initial: data.middleInitial,
title: data.title,
email_address: data.emailAddress || data.email_address || data.email,
email_address2: data.emailAddress2 || data.email_address2,
email_address3: data.emailAddress3 || data.email_address3,
email_address: data.emailAddress,
email_address2: data.emailAddress2,
email_address3: data.emailAddress3,
phone: data.phone,
extension: data.extension,
alternate_phone: data.alternate_phone,
mobile_phone: data.mobile_phone,
fax: data.faxNumber || data.fax,
address_line: data.addressLine || data.address_line,
address_line1: data.addressLine1 || data.address_line1 || data.address_line_1,
alternate_phone: data.alternatePhone,
mobile_phone: data.mobilePhone,
fax: data.faxNumber,
address_line: data.addressLine,
address_line1: data.addressLine1,
city: data.city,
state: data.state,
zip_code: data.zipCode || data.zip_code || data.postal_code,
country: data.country,
is_active: data.is_active !== undefined ? data.is_active : true,
name_prefix: data.name_prefix,
name_suffix: data.name_suffix,
facebook_url: data.facebook_url,
twitter_url: data.twitter_url,
linked_in_url: data.linked_in_url,
primary_contact: data.primary_contact || false,
account_physical_location_id: data.account_physical_location_id,
solicitation_opt_out: data.solicitation_opt_out || false,
room_number: data.room_number,
last_activity_date: data.last_activity_date,
last_modified_date: data.last_modified_date,
api_vendor_id: data.api_vendor_id,
synced_at: data.synced_at,
is_deleted: data.is_deleted || false,
zip_code: data.zipCode,
country_id: data.countryID,
company_location_id: data.companyLocationID,
is_active: data.isActive !== undefined ? data.isActive : true,
name_prefix: data.namePrefix,
name_suffix: data.nameSuffix,
facebook_url: data.facebookUrl,
twitter_url: data.twitterUrl,
linked_in_url: data.linkedInUrl,
primary_contact: data.primaryContact || false,
billing_contact: data.billingContact || false,
solicitation_opt_out: data.solicitationOptOut || false,
solicitation_opt_out_time: data.solicitationOptOutTime,
survey_opt_out: data.surveyOptOut || false,
is_opted_out_from_bulk_email: data.isOptedOutFromBulkEmail || false,
bulk_email_opt_out_time: data.bulkEmailOptOutTime,
receives_email_notifications: data.receivesEmailNotifications !== undefined ? data.receivesEmailNotifications : true,
room_number: data.roomNumber,
note: data.note,
external_id: data.externalID,
api_vendor_id: data.apiVendorID,
impersonator_creator_resource_id: data.impersonatorCreatorResourceID,
create_date: data.createDate,
last_activity_date: data.lastActivityDate,
last_modified_date: data.lastModifiedDate,
user_defined_fields: Object.keys(udfs).length > 0 ? JSON.stringify(udfs) : null,
is_deleted: data.isDeleted || false,
};
}

View file

@ -0,0 +1,20 @@
-- Migration 057: Add missing contacts fields + UDF storage
ALTER TABLE contacts
ADD COLUMN IF NOT EXISTS user_defined_fields JSONB,
ADD COLUMN IF NOT EXISTS middle_initial VARCHAR(10),
ADD COLUMN IF NOT EXISTS note TEXT,
ADD COLUMN IF NOT EXISTS external_id VARCHAR(50),
ADD COLUMN IF NOT EXISTS country_id INTEGER,
ADD COLUMN IF NOT EXISTS company_location_id BIGINT,
ADD COLUMN IF NOT EXISTS create_date TIMESTAMP WITHOUT TIME ZONE,
ADD COLUMN IF NOT EXISTS is_opted_out_from_bulk_email BOOLEAN DEFAULT false,
ADD COLUMN IF NOT EXISTS bulk_email_opt_out_time TIMESTAMP WITHOUT TIME ZONE,
ADD COLUMN IF NOT EXISTS solicitation_opt_out_time TIMESTAMP WITHOUT TIME ZONE,
ADD COLUMN IF NOT EXISTS survey_opt_out BOOLEAN DEFAULT false,
ADD COLUMN IF NOT EXISTS receives_email_notifications BOOLEAN DEFAULT true,
ADD COLUMN IF NOT EXISTS billing_contact BOOLEAN DEFAULT false,
ADD COLUMN IF NOT EXISTS impersonator_creator_resource_id BIGINT;
-- GIN index for UDF queries (e.g. find by UserID, O365License)
CREATE INDEX IF NOT EXISTS idx_contacts_udfs ON contacts USING gin(user_defined_fields);