|
87917
|
Project: faVsco.js, menu
iTerm2ShellEditViewSessio Project: faVsco.js, menu
iTerm2ShellEditViewSessionScriptsProfilesWindowHelp‹ >0 lol100% CDOCKER₴81DEV (-zsh)O 82Version 2023.11.20260427:Version 2023.11.20260505:Version2023.11.20260509:Version2023.11.20260511:Version2023.11.20260514:Version 2023.11.20260526:Run"/usr/bin/dnf check-release-update"#_####_\ #####\\###||\#/v~'Amazon Linux 2023 (ECS Optimized)-zshN3ec2-user@ip-10-20-31-146:~screenpipe"-84-zshX5ec2-user@ip-10-30-129-...8• Thu 28 May 19:29:55181ec2-user@ip-10-20-31-1...·7for full release and version update infom/For documentation, visit [URL_WITH_CREDENTIALS] ~]$ docker exec -it $(docker ps --format "{{.ID}}" --filter "name-ecs-worker" | head -1) /bin/bash -c "cd /home/jiminny && bash"root@170a323e43a4:/home/jiminny# php artisan crm:sync-opportunity --teamId 555 --opportunityId 494058190045[2026-05-28 16:20:08] production.INF0: Jiminny\Console\Commands\Command: : run Memory usage before starting command {"command": "crm:sync-opportunity", "memoryBeforeCommandInMb" : 118.0, "memoryPeakBeforeCommandInMb":118.03 {"correlation_id":"0a531c45-bc24-4ea8-b3c9-d90f79590da8", "trace_id":"1723bcBc-9f67-4ec7-b8c3-d09361d5879d"}Syncing opportunity for Cognitive Credit[2026-05-28 16:20:08] production.INFO: [SocialAccountService] Fetching token {"socialAccountId":30591,"provider": "hubspot"} {"correlation_id":"0a531c45-bc24-4ea8-b3c9-d90f79590da8", "trace_id":"1723bcDc-9f67-4ec7-b8c3-d09361d5879d"'}[2026-05-28 16:20:08] production.INFO: [SocialAccountService] Token retrieved {"socialAccountId":30591, "provider": "hubspot"} {"correlation_id":"0a531c45-bc24-4ea8-b3c9-d90f79590da8", "trace_id":"1723bc0c-9f67-4ec7-b8c3-d09361d5879d"'}[2026-05-28 16:20:08] production.INFO: [EncryptedTokenManager] Generatingaccess token. {"mode":"legacy"} {"correlation_id":"0a531c45-bc24-4ea8-b3c9-d90f79590da8","trace_id" :"1723bc0c-9f67-4ec7-b8c3-d09361d5879d"}Syncing opportunity 494058190045...[2026-05-28 16:20:08] production.INF0: [ReindexForOpportunityListener] Schedule reindexing job {"opportunity_id":7601423} {"correlation_id":"0a531c45-bc24-4ea8-b3c9-d90f79590da8","trace_id":"1723bc0c-9f67-4ec7-b8c3-d09361d5879d"}Synced Centiva Capital - EU HY/IG to 7601423[2026-05-28 16:20:08] production.INF0: Jiminny\Console\Commands\Command: :run Memory usage for command {"command": "crm:sync-opportunity", "memoryBeforeCommandInMb" : 118.0, "memoryAfterCommandInMB": 126.0, "memoryPeakBeforeCommandInMb" : 118.0, "memoryPeakAfterCommandInMB":126.0} {"correlation_id":"0a531c45-bc24-4ea8-b3c9-d90f79590da8", "trace_id" : "1723bc0c-9f67-4ec7-b8c3-d09361d5879d"}root@170a323e43a4:/home/jiminny# |...
|
PhpStorm
|
faVsco.js – PropertyChangeManager.php
|
NULL
|
87917
|
|
87916
|
Project: faVsco.js, menu
#12121 on JY-20963-fix-im Project: faVsco.js, menu
#12121 on JY-20963-fix-import-on-deleted-entity, menu
Start Listening for PHP Debug Connections
ServiceTest
Run 'ServiceTest'
Debug 'ServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
8
25
Previous Highlighted Error
Next Highlighted Error
<?php
declare(strict_types=1);
namespace Jiminny\Services\Crm\Hubspot;
use Carbon\Carbon;
use Jiminny\Models\Crm\Configuration;
use Psr\Log\LoggerInterface;
class PayloadBuilder
{
public const int MAX_SEARCH_REQUEST_LIMIT = 200;
private const int MAX_FILTER_SIZE = 100;
private const string SORT_PROPERTY = 'hs_timestamp';
private const string ENGAGEMENT_MEETINGS = 'meetings';
public function getLinkToTaskPayload(string $objectType, string $objectId, string $engagementType): array
{
$payload = [
'sorts' => [
[
'propertyName' => self::SORT_PROPERTY,
'direction' => 'DESCENDING',
],
],
'limit' => self::MAX_SEARCH_REQUEST_LIMIT,
];
if ($engagementType === self::ENGAGEMENT_MEETINGS) {
$payload['filterGroups'] = $this->buildMeetingFiltersForLinkToTask($objectType, $objectId);
$payload['properties'] = [
'hs_meeting_title',
'hs_meeting_outcome',
'hs_timestamp',
'hs_activity_type',
];
} else {
$payload['filterGroups'] = $this->buildTaskFiltersForLinkToTask($objectType, $objectId);
$payload['properties'] = ['hs_task_subject', 'hs_timestamp'];
}
return $payload;
}
public function getRecentlyUpdatedSearchPayload(Carbon $since, ?Carbon $to, array $properties): array
{
$lastUpdateDate = in_array('firstname', $properties) ? 'lastmodifieddate' : 'hs_lastmodifieddate';
$payload = [
'filters' => [
[
'propertyName' => $lastUpdateDate,
'operator' => 'GT',
'value' => $since->getPreciseTimestamp(3),
],
],
'sorts' => [
[
'propertyName' => 'hs_object_id',
'direction' => 'DESCENDING',
],
],
'limit' => self::MAX_SEARCH_REQUEST_LIMIT,
];
if ($to) {
$payload['filters'][] = [
'propertyName' => $lastUpdateDate,
'operator' => 'LT',
'value' => $to->getPreciseTimestamp(3),
];
}
$payload['properties'] = $properties;
return $payload;
}
public function getByProfileSearchPayload(string $crmId, array $properties, Carbon $since, ?Carbon $to): array
{
$lastUpdateDate = array_key_exists('firstname', $properties) ? 'lastmodifieddate' : 'hs_lastmodifieddate';
$payload = [
'filters' => [
[
'propertyName' => $lastUpdateDate,
'operator' => 'GT',
'value' => $since->getPreciseTimestamp(3),
],
[
'propertyName' => 'hubspot_owner_id',
'operator' => 'EQ',
'value' => $crmId,
],
],
'sorts' => [
[
'propertyName' => 'hs_object_id',
'direction' => 'DESCENDING',
],
],
'limit' => self::MAX_SEARCH_REQUEST_LIMIT,
];
if ($to) {
$payload['filters'][] = [
'propertyName' => $lastUpdateDate,
'operator' => 'LT',
'value' => $to->getPreciseTimestamp(3),
];
}
$payload['properties'] = $properties;
return $payload;
}
private function buildMeetingFiltersForLinkToTask($objectType, $objectId): array
{
$filters = [];
$filters[] = [
'filters' => [
$this->getAssociatedObjectFilter($objectType, $objectId),
[
'propertyName' => 'hs_meeting_outcome',
'operator' => 'IN',
'values' => ['SCHEDULED', 'RESCHEDULED'],
],
],
];
$filters[] = [
'filters' => [
$this->getAssociatedObjectFilter($objectType, $objectId),
[
'propertyName' => 'hs_meeting_outcome',
'operator' => 'NOT_HAS_PROPERTY',
],
],
];
return $filters;
}
private function buildTaskFiltersForLinkToTask($objectType, $objectId): array
{
return [
[
'filters' => [
$this->getAssociatedObjectFilter($objectType, $objectId),
],
],
];
}
private function getAssociatedObjectFilter(string $objectType, string $objectId): array
{
return [
'propertyName' => 'associations.' . $objectType,
'operator' => 'EQ',
'value' => $objectId,
];
}
public function generatePlaybackURLSearchPayload(string $playbackURLToken): array
{
return [
'filters' => [
[
'propertyName' => 'hs_call_recording_url',
'operator' => 'CONTAINS_TOKEN',
'value' => $playbackURLToken,
],
],
'properties' => [
'hs_call_recording_url',
'hs_call_body',
],
'limit' => 1,
];
}
public function generateGetCallsPayload(Carbon $from, Carbon $to, string $activityProvider, $page): array
{
return [
'filters' => [
[
'propertyName' => 'hs_call_recording_url',
'operator' => 'CONTAINS_TOKEN',
'value' => $activityProvider,
],
[
'propertyName' => 'hs_timestamp',
'operator' => 'BETWEEN',
'value' => intval($from->getPreciseTimestamp(3)),
'highValue' => intval($to->getPreciseTimestamp(3)),
],
],
'sorts' => [
[
'propertyName' => 'hs_timestamp',
'direction' => 'DESCENDING',
],
],
'properties' => [
'hs_call_recording_url',
'hs_call_body',
],
'limit' => self::MAX_SEARCH_REQUEST_LIMIT,
'after' => ($page - 1) * self::MAX_SEARCH_REQUEST_LIMIT,
];
}
public function generateSearchCallsByPeriodPayload(Carbon $from, Carbon $to, $page): array
{
return [
'filters' => [
[
'propertyName' => 'hs_timestamp',
'operator' => 'BETWEEN',
'value' => intval($from->getPreciseTimestamp(3)),
'highValue' => intval($to->getPreciseTimestamp(3)),
],
],
'sorts' => [
[
'propertyName' => 'hs_timestamp',
'direction' => 'DESCENDING',
],
],
'properties' => $this->getSearchCallAttributes(),
'limit' => self::MAX_SEARCH_REQUEST_LIMIT,
'after' => ($page - 1) * self::MAX_SEARCH_REQUEST_LIMIT,
];
}
public function getSearchCallAttributes(): array
{
return [
'hs_timestamp',
'hs_call_recording_url',
'hs_call_body',
'hs_call_status',
'hs_call_to_number',
'hs_call_from_number',
'hs_call_duration',
'hs_call_disposition',
'hs_call_title',
'hs_call_direction',
'hubspot_owner_id',
'hs_activity_type',
'hs_call_external_id',
'hs_call_source',
];
}
public function generatePlaybackAddUrlBatchPayload(array $crmUpdateData): array
{
$updateObjectsData = [];
foreach ($crmUpdateData as $data) {
$updateObjectsData[] = [
'id' => $data['crm_id'],
'properties' => [
'hs_call_body' => $data['hs_call_body'] .
'<p><span style="font-size: 13.3333px; line-height: 16px;">' .
'<a href="' . $data['playback_url'] . '" target="_blank">Review in Jiminny</a> ▶️</span></p>',
],
];
}
return ['inputs' => $updateObjectsData];
}
public function generateSearchCallByTokenPayload(string $playbackURLToken): array
{
return [
'filters' => [
[
'propertyName' => 'hs_call_recording_url',
'operator' => 'CONTAINS_TOKEN',
'value' => $playbackURLToken,
],
],
'properties' => [
'hs_call_recording_url',
'hs_call_body',
],
'limit' => 1,
];
}
/**
* Generates a payload for phone search based on the specified parameters.
*
* @param string $phone The phone number to search.
* @param bool $isAlternativeSearch Indicates if an alternative search should be performed
* if the first search fails to find a match. The first search request should cover most of the cases.
*/
public function generatePhoneSearchPayload(string $phone, bool $isAlternativeSearch = false): array
{
$filterPropertyNames = $isAlternativeSearch ?
['hs_searchable_calculated_mobile_number', 'phone', 'mobilephone'] :
['hs_searchable_calculated_phone_number', 'hs_calculated_phone_number', 'hs_calculated_mobile_number'];
$filterGroups = array_map(function ($propertyName) use ($phone) {
return $this->createFilterGroup($propertyName, 'CONTAINS_TOKEN', $phone);
}, $filterPropertyNames);
$properties = [
'email',
'firstname',
'lastname',
'country',
'phone',
'mobilephone',
'jobtitle',
'hubspot_owner_id',
'associatedcompanyid',
'photo',
];
return $this->createPayload($filterGroups, $properties);
}
/**
* Creates a filter group with the specified parameters.
*/
private function createFilterGroup(string $propertyName, string $operator, $value): array
{
return [
'filters' => [
[
'propertyName' => $propertyName,
'operator' => $operator,
'value' => $value,
],
],
];
}
/**
* Creates a payload with the specified filter groups and properties.
*/
private function createPayload(array $filterGroups, array $properties): array
{
return [
'filterGroups' => $filterGroups,
'sorts' => [
[
'propertyName' => 'modifieddate',
'direction' => 'DESCENDING',
],
],
'properties' => $properties,
'limit' => 1,
];
}
/**
* Generates a payload to find related activities based on the specified data and object type.
* The search looks for activities that match the specified time range (starttime - endtime or only starttime with endtime not set)
* and are associated with any of the provided prospect types (contact or company).
*
* @param array $data An associative array containing the following keys:
* - 'from': The start time for the activity search (timestamp).
* - 'to': The end time for the activity search (timestamp).
* - 'contact': (optional) The ID of the associated contact.
* - 'company': (optional) The ID of the associated company.
* @param string $objectType The type of engagement to search for (meeting or other type like call).
*
* @return array The payload array to be used for searching related activities.
*/
public function getFindRelatedActivityPayload(array $data, string $objectType): array
{
$payload = [
'sorts' => [
[
'propertyName' => self::SORT_PROPERTY,
'direction' => 'DESCENDING',
],
],
'limit' => self::MAX_SEARCH_REQUEST_LIMIT,
];
if ($objectType === self::ENGAGEMENT_MEETINGS) {
$payload['properties'] = [
'hs_meeting_title',
'hs_meeting_outcome',
'hs_activity_type',
'hs_timestamp',
'hubspot_owner_id',
'hs_meeting_body',
'hs_internal_meeting_notes',
'hs_meeting_location',
'hs_meeting_start_time',
'hs_meeting_end_time',
];
} else {
$payload['properties'] = ['hs_task_subject', 'hs_timestamp', 'hubspot_owner_id', 'hs_call_body'];
}
$timeFiltersWithEndTime = [
[
'propertyName' => 'hs_meeting_start_time',
'operator' => 'GTE',
'value' => $data['from'],
],
[
'propertyName' => 'hs_meeting_end_time',
'operator' => 'LTE',
'value' => $data['to'],
],
];
$timeFiltersWithoutEndTime = [
[
'propertyName' => 'hs_meeting_start_time',
'operator' => 'GTE',
'value' => $data['from'],
],
[
'propertyName' => 'hs_meeting_end_time',
'operator' => 'NOT_HAS_PROPERTY',
],
];
$payload['filterGroups'] = [];
$associationTypes = ['contact', 'company'];
foreach ($associationTypes as $type) {
if (! empty($data[$type])) {
$filterGroupWithEndTime = [
'filters' => array_merge(
$timeFiltersWithEndTime,
[$this->getAssociatedObjectFilter($type, $data[$type])]
),
];
$payload['filterGroups'][] = $filterGroupWithEndTime;
$filterGroupWithoutEndTime = [
'filters' => array_merge(
$timeFiltersWithoutEndTime,
[$this->getAssociatedObjectFilter($type, $data[$type])]
),
];
$payload['filterGroups'][] = $filterGroupWithoutEndTime ;
}
}
return $payload;
}
/** Parameters
* [
* 'accountId' => $crmAccountId,
* 'sortBy' => $sortBy,
* 'sortDir' => $sortDir,
* 'onlyOpen' => $onlyOpen,
* 'closedStages' => $this->getClosedDealStages(),
* 'userId' => $userId,
* ];
*/
public function generateOpportunitiesSearchPayload(
Configuration $config,
string $crmAccountId,
array $closedStages,
): array {
$closedFilters = [];
$filterGroups = [];
$onlyOpen = true;
switch ($config->getOpportunityAssignmentRule()) {
case Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_UPDATED:
$sortBy = 'modifieddate';
$sortDir = 'DESCENDING';
break;
case Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_CREATED:
$sortBy = 'createdate';
$sortDir = 'DESCENDING';
break;
case Configuration::OPP_ASSIGNMENT_ALL_OPEN_OLDEST_CREATED:
$sortBy = 'createdate';
$sortDir = 'ASCENDING';
break;
case Configuration::OPP_ASSIGNMENT_ALL_RECENTLY_UPDATED:
default:
$sortBy = 'modifieddate';
$sortDir = 'DESCENDING';
$onlyOpen = false;
}
$baseFilters = [
[
'propertyName' => 'associations.company',
'operator' => 'EQ',
'value' => $crmAccountId,
],
];
// Handle closed stages in chunks
if ($onlyOpen) {
foreach (['won', 'lost'] as $key) {
if (! empty($closedStages[$key])) {
$chunks = array_chunk($closedStages[$key], self::MAX_FILTER_SIZE);
foreach ($chunks as $chunk) {
$closedFilters[] = [
'propertyName' => 'dealstage',
'operator' => 'NOT_IN',
'values' => $chunk,
];
}
}
}
}
$filterGroups[] = [
'filters' => array_merge($baseFilters, $closedFilters),
];
$payload = [
'filterGroups' => $filterGroups,
'sorts' => [
[
'propertyName' => $sortBy,
'direction' => $sortDir,
],
],
'properties' => [
'dealname',
'amount',
'hubspot_owner_id',
'pipeline',
'dealstage',
'closedate',
'deal_currency_code',
],
'limit' => self::MAX_SEARCH_REQUEST_LIMIT,
];
$logger = app(LoggerInterface::class);
$logger->info('[HubSpot] CRM Search requested', [
'request' => $payload,
]);
return $payload;
}
/**
* Converts v1 payload data to v3
*/
public function getV3MeetingPayload(array $engagement, array $metadata, array $associations = []): array
{
// Use this mapping until the whole Hubspot V1 is dropped.
$properties = [
'hubspot_owner_id' => $engagement['ownerId'],
'hs_timestamp' => $engagement['timestamp'],
];
// $engagement['activityType'] is $activity->category->name
if (isset($engagement['activityType'])) {
$properties['hs_activity_type'] = $engagement['activityType'];
}
$metadataKeyMap = [
'hs_meeting_outcome' => 'meetingOutcome',
'hs_meeting_title' => 'title',
'hs_meeting_start_time' => 'startTime',
'hs_meeting_end_time' => 'endTime',
'hs_meeting_body' => 'body',
'hs_internal_meeting_notes' => 'internalMeetingNotes',
];
foreach ($metadataKeyMap as $newKey => $oldKey) {
if (isset($metadata[$oldKey])) {
$properties[$newKey] = $metadata[$oldKey];
unset($metadata[$oldKey]);
}
}
$properties = [
...$properties,
...$metadata, // custom fields
];
$response = [
'properties' => $properties,
];
if (! empty($associations)) {
$response['associations'] = $associations;
}
return $response;
}
/**
* Generate a payload to search for contacts by name.
* The search is a token search in firstname or lastname
*/
public function generateSearchContactsByNamePayload(string $name, array $fields): array
{
$firstNameFilter = [
'propertyName' => 'firstname',
'operator' => 'CONTAINS_TOKEN',
'value' => $name,
];
$lastNameFilter = [
'propertyName' => 'lastname',
'operator' => 'CONTAINS_TOKEN',
'value' => $name,
];
return [
'filterGroups' => [
[
'filters' => [$firstNameFilter],
],
[
'filters' => [$lastNameFilter],
],
],
'properties' => $fields,
'sorts' => [
[
'propertyName' => 'lastmodifieddate',
'direction' => 'DESCENDING',
],
],
];
}
public function buildAddAssociationPayload(string $crmId, array $ids, int $associationType): array
{
$inputs = [];
foreach ($ids as $id) {
if ($id === null || $id === '') {
continue;
}
$inputs[] = [
'from' => [
'id' => $crmId,
],
'to' => [
'id' => (string) $id,
],
'types' => [
[
'associationCategory' => 'HUBSPOT_DEFINED',
'associationTypeId' => $associationType,
],
],
];
}
return ['inputs' => $inputs];
}
public function buildRemoveAssociationPayload(string $crmId, array $ids): array
{
$toArray = [];
foreach ($ids as $id) {
if ($id === null || $id === '') {
continue;
}
$toArray[] = ['id' => (string) $id];
}
return [
'inputs' => [
[
'from' => ['id' => $crmId],
'to' => $toArray,
],
],
];
}
public function addClosedStageFilters(array &$payload, array $closedStages): void
{
if (! empty($closedStages['won'])) {
$payload['filters'][] = [
'propertyName' => 'dealstage',
'operator' => 'NOT_IN',
'values' => $closedStages['won'],
];
}
if (! empty($closedStages['lost'])) {
$payload['filters'][] = [
'propertyName' => 'dealstage',
'operator' => 'NOT_IN',
'values' => $closedStages['lost'],
];
}
}
public function addCreatedDateFilters(array &$payload, Carbon $createdAfter): void
{
$payload['filters'][] = [
'propertyName' => 'createdate',
'operator' => 'GT',
'value' => $createdAfter->getPreciseTimestamp(3),
];
}
public function getDealsInBulkPayload(array $dealIds): array
{
return [
'filterGroups' => [
[
'filters' => [
[
'propertyName' => 'hs_object_id',
'operator' => 'IN',
'values' => $dealIds,
],
],
],
],
'limit' => self::MAX_FILTER_SIZE,
];
}
}
Sync Changes
Hide This Notification
Code changed:
Hide
22
8
Previous Highlighted Error
Next Highlighted Error
<?php
declare(strict_types=1);
namespace Jiminny\Services\Crm\Hubspot\Webhook;
use Illuminate\Support\Facades\Log;
use Jiminny\Models\Crm\Configuration;
use Jiminny\Services\Crm\Hubspot\BatchSyncCollector;
use Jiminny\Repositories\Crm\CrmEntityRepository;
use Jiminny\Repositories\Crm\OpportunityRepository;
use Jiminny\Repositories\Crm\FieldRepository;
use Jiminny\Repositories\Crm\FieldDataRepository;
use Jiminny\Models\Opportunity;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\LayoutEntity;
readonly class PropertyChangeManager
{
private const string OBJECT_TYPE_DEAL = 'deal';
private const string OBJECT_TYPE_CONTACT = 'contact';
private const string OBJECT_TYPE_COMPANY = 'company';
private const array SUPPORTED_OBJECT_TYPES = [
self::OBJECT_TYPE_DEAL,
self::OBJECT_TYPE_CONTACT,
self::OBJECT_TYPE_COMPANY,
];
/**
* Contact fields that webhooks are subscribed to in HubSpot.
*
* IMPORTANT: These fields MUST match the webhook subscriptions configured in HubSpot.
* When updating this list, you must also update the HubSpot webhook subscriptions:
* 1. Go to HubSpot App Settings → Webhooks
* 2. Update contact.propertyChange subscriptions to match this list
* 3. Ensure all fields listed here are subscribed, and vice versa
*
* This constant serves as documentation of the current webhook configuration.
* Since HubSpot filters webhooks server-side, we should only receive events for these properties.
*/
private const array APPLICABLE_CONTACT_FIELDS = [
'firstname',
'lastname',
'email',
'phone',
'mobilephone',
'jobtitle',
'country',
'hubspot_owner_id',
'associatedcompanyid',
'photo',
'hs_avatar_filemanager_key',
];
/**
* Company fields that webhooks are subscribed to in HubSpot.
*
* IMPORTANT: These fields MUST match the webhook subscriptions configured in HubSpot.
* When updating this list, you must also update the HubSpot webhook subscriptions:
* 1. Go to HubSpot App Settings → Webhooks
* 2. Update company.propertyChange subscriptions to match this list
* 3. Ensure all fields listed here are subscribed, and vice versa
*
* This constant serves as documentation of the current webhook configuration.
* Since HubSpot filters webhooks server-side, we should only receive events for these properties.
*/
private const array APPLICABLE_COMPANY_FIELDS = [
'name',
'phone',
'domain',
'country',
'industry',
'hubspot_owner_id',
'hs_avatar_filemanager_key',
];
/**
* Properties that support direct database updates (without full sync).
* These are simple properties that can be updated immediately without recalculating other fields.
*/
private const array DB_OBJECT_UPDATE_PROPERTIES = [
self::OBJECT_TYPE_DEAL => [
'dealname',
'description',
],
self::OBJECT_TYPE_CONTACT => [
// Add contact properties that support direct update
],
self::OBJECT_TYPE_COMPANY => [
// Add company properties that support direct update
],
];
/**
* Mapping from HubSpot property names to Jiminny model property names.
* Used when updating the main model (Opportunity, Contact, Account) directly.
* Only needed when HubSpot property name differs from Jiminny property name.
*/
private const array OBJECT_PROPERTY_MAPPINGS = [
self::OBJECT_TYPE_DEAL => [
'dealname' => 'name', // HubSpot 'dealname' maps to Opportunity 'name'
],
self::OBJECT_TYPE_CONTACT => [
// Add contact property mappings if needed
],
self::OBJECT_TYPE_COMPANY => [
// Add company property mappings if needed
],
];
// Field object types mapping
private const array FIELD_OBJECT_TYPES = [
self::OBJECT_TYPE_DEAL => Field::OBJECT_OPPORTUNITY,
self::OBJECT_TYPE_CONTACT => Field::OBJECT_CONTACT,
self::OBJECT_TYPE_COMPANY => Field::OBJECT_ACCOUNT,
];
public function __construct(
private BatchSyncCollector $batchCollector,
private OpportunityRepository $opportunityRepository,
private CrmEntityRepository $crmEntityRepository,
private FieldRepository $fieldRepository,
private FieldDataRepository $fieldDataRepository,
private HubspotWebhookMetricsService $metricsService,
) {
}
/**
* Get contact fields that webhooks are subscribed to in HubSpot.
* This serves as documentation of the current webhook configuration.
*
* @return array<int, string>
*/
public function getApplicableContactFields(): array
{
return self::APPLICABLE_CONTACT_FIELDS;
}
/**
* Get company fields that webhooks are subscribed to in HubSpot.
* This serves as documentation of the current webhook configuration.
*
* @return array<int, string>
*/
public function getApplicableCompanyFields(): array
{
return self::APPLICABLE_COMPANY_FIELDS;
}
/**
* Process property change with intelligent routing
*/
public function processPropertyChange(
string $objectType,
string $objectId,
Configuration $configuration,
string $propertyName,
string $propertyValue
): void {
if (! $this->isObjectTypeSupported($objectType)) {
Log::warning('[HubSpot Property Change Manager] Unsupported object type', [
'object_type' => $objectType,
'object_id' => $objectId,
'property_name' => $propertyName,
]);
return;
}
$this->metricsService->recordWebhookMetrics(
$configuration,
$objectType,
'property_change',
$propertyName
);
if ($this->isDirectUpdateSupported($objectType, $propertyName) &&
$this->handleDirectUpdate($objectType, $objectId, $configuration, $propertyName, $propertyValue)) {
return;
}
$this->handleObjectSync($objectType, $objectId, $configuration);
}
/**
* Legacy method for backward compatibility - assumes deal object type
*
* @deprecated Use processPropertyChange with objectType parameter
*/
public function processPropertyChangeForDeal(
string $objectId,
Configuration $configuration,
string $propertyName,
string $propertyValue
): void {
$this->processPropertyChange(self::OBJECT_TYPE_DEAL, $objectId, $configuration, $propertyName, $propertyValue);
}
/**
* Check if object type is supported
*/
private function isObjectTypeSupported(string $objectType): bool
{
return in_array($objectType, self::SUPPORTED_OBJECT_TYPES);
}
/**
* Check if property can be updated without additional calculation on other properties.
*/
private function isDirectUpdateSupported(string $objectType, string $propertyName): bool
{
$supportedProperties = self::DB_OBJECT_UPDATE_PROPERTIES[$objectType] ?? [];
return in_array($propertyName, $supportedProperties, true);
}
/**
* Handle properties that support direct database updates
*/
private function handleDirectUpdate(
string $objectType,
string $objectId,
Configuration $configuration,
string $propertyName,
string $propertyValue
): bool {
try {
return match ($objectType) {
self::OBJECT_TYPE_DEAL => $this->handleDealDirectUpdate($objectId, $configuration, $propertyName, $propertyValue),
self::OBJECT_TYPE_CONTACT => $this->handleContactDirectUpdate($objectId, $configuration, $propertyName, $propertyValue),
self::OBJECT_TYPE_COMPANY => $this->handleCompanyDirectUpdate($objectId, $configuration, $propertyName, $propertyValue),
default => false,
};
} catch (\Exception $e) {
Log::error('[HubSpot Property Change Manager] Error handling direct update property', [
'object_type' => $objectType,
'object_id' => $objectId,
'property_name' => $propertyName,
'error' => $e->getMessage(),
]);
return false;
}
}
/**
* Handle direct updates for deal objects
*/
private function handleDealDirectUpdate(
string $objectId,
Configuration $configuration,
string $propertyName,
string $propertyValue
): bool {
$opportunity = $this->opportunityRepository->findByConfigAndCrmProviderId(
$configuration,
$objectId
);
if (! $opportunity instanceof Opportunity) {
return false;
}
// Update model property if mapping exists
$propertyMappings = self::OBJECT_PROPERTY_MAPPINGS[self::OBJECT_TYPE_DEAL] ?? [];
if (isset($propertyMappings[$propertyName])) {
$this->opportunityRepository->updateOrCreate(
$configuration,
$objectId,
[$propertyMappings[$propertyName] => $propertyValue]
);
}
// Update field data
$this->importObjectFieldData(
self::OBJECT_TYPE_DEAL,
$opportunity->getId(),
$configuration,
[$propertyName => $propertyValue]
);
return true;
}
/**
* Handle direct updates for contact objects
*
* Updates Contact model properties via OBJECT_PROPERTY_MAPPINGS.
* Add mappings to OBJECT_PROPERTY_MAPPINGS[CONTACT] to enable direct updates.
*/
private function handleContactDirectUpdate(
string $objectId,
Configuration $configuration,
string $propertyName,
string $propertyValue
): bool {
$contact = $this->crmEntityRepository->findContactByExternalId($configuration, $objectId);
if ($contact === null) {
return false;
}
// Update model property if mapping exists
$propertyMappings = self::OBJECT_PROPERTY_MAPPINGS[self::OBJECT_TYPE_CONTACT] ?? [];
if (isset($propertyMappings[$propertyName])) {
$this->crmEntityRepository->importContact($configuration, [
'crm_provider_id' => $objectId,
$propertyMappings[$propertyName] => $propertyValue,
]);
}
return true;
}
/**
* Handle direct updates for company objects
*
* Updates Account model properties via OBJECT_PROPERTY_MAPPINGS.
* Add mappings to OBJECT_PROPERTY_MAPPINGS[COMPANY] to enable direct updates.
*/
private function handleCompanyDirectUpdate(
string $objectId,
Configuration $configuration,
string $propertyName,
string $propertyValue
): bool {
$company = $this->crmEntityRepository->findAccountByExternalId($configuration, $objectId);
if ($company === null) {
return false;
}
// Update model property if mapping exists
$propertyMappings = self::OBJECT_PROPERTY_MAPPINGS[self::OBJECT_TYPE_COMPANY] ?? [];
if (isset($propertyMappings[$propertyName])) {
$this->crmEntityRepository->importAccount($configuration, [
'crm_provider_id' => $objectId,
$propertyMappings[$propertyName] => $propertyValue,
]);
}
return true;
}
/**
* Import field data for any object type
*/
private function importObjectFieldData(
string $objectType,
int $objectId,
Configuration $configuration,
array $data
): void {
$fieldObjectType = self::FIELD_OBJECT_TYPES[$objectType] ?? null;
if (! $fieldObjectType) {
Log::warning('[HubSpot Property Change Manager] Unknown field object type', [
'object_type' => $objectType,
'object_id' => $objectId,
]);
return;
}
foreach ($data as $crmField => $value) {
$field = $this->fieldRepository->findFieldByCrmIdAndObjectType(
$configuration,
$crmField,
$fieldObjectType
);
if (! $field instanceof Field) {
continue;
}
$entities = $field->getEntities();
if ($entities->isEmpty()) {
continue;
}
if ($entities->count() > 1) {
continue;
}
/** @var LayoutEntity|null $entity */
$entity = $entities->first();
if (! $entity instanceof LayoutEntity) {
continue;
}
$this->fieldDataRepository->updateOrCreateFieldData(
$entity,
$objectId,
(string) $value,
);
}
}
private function handleObjectSync(
string $objectType,
string $objectId,
Configuration $configuration,
): void {
if (! $this->isObjectTypeSupported($objectType)) {
return;
}
try {
$this->batchCollector->collectForBatch(
objectType: $objectType,
crmProviderId: $objectId,
configurationId: $configuration->getId(),
eventType: 'property_change'
);
} catch (\Exception $e) {
Log::error('[HubSpot Property Change Manager] Error adding to sync batch', [
'object_type' => $objectType,
'object_id' => $objectId,
'error' => $e->getMessage(),
]);
}
}
}
Project
Project
New File or Directory…
Expand Selected
Collapse All
Options
Hide...
|
PhpStorm
|
faVsco.js – PropertyChangeManager.php
|
NULL
|
87916
|
|
87915
|
Project: faVsco.js, menu
#12121 on JY-20963-fix-im Project: faVsco.js, menu
#12121 on JY-20963-fix-import-on-deleted-entity, menu
Start Listening for PHP Debug Connections
ServiceTest
Run 'ServiceTest'
Debug 'ServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
8
25
Previous Highlighted Error
Next Highlighted Error
<?php
declare(strict_types=1);
namespace Jiminny\Services\Crm\Hubspot;
use Carbon\Carbon;
use Jiminny\Models\Crm\Configuration;
use Psr\Log\LoggerInterface;
class PayloadBuilder
{
public const int MAX_SEARCH_REQUEST_LIMIT = 200;
private const int MAX_FILTER_SIZE = 100;
private const string SORT_PROPERTY = 'hs_timestamp';
private const string ENGAGEMENT_MEETINGS = 'meetings';
public function getLinkToTaskPayload(string $objectType, string $objectId, string $engagementType): array
{
$payload = [
'sorts' => [
[
'propertyName' => self::SORT_PROPERTY,
'direction' => 'DESCENDING',
],
],
'limit' => self::MAX_SEARCH_REQUEST_LIMIT,
];
if ($engagementType === self::ENGAGEMENT_MEETINGS) {
$payload['filterGroups'] = $this->buildMeetingFiltersForLinkToTask($objectType, $objectId);
$payload['properties'] = [
'hs_meeting_title',
'hs_meeting_outcome',
'hs_timestamp',
'hs_activity_type',
];
} else {
$payload['filterGroups'] = $this->buildTaskFiltersForLinkToTask($objectType, $objectId);
$payload['properties'] = ['hs_task_subject', 'hs_timestamp'];
}
return $payload;
}
public function getRecentlyUpdatedSearchPayload(Carbon $since, ?Carbon $to, array $properties): array
{
$lastUpdateDate = in_array('firstname', $properties) ? 'lastmodifieddate' : 'hs_lastmodifieddate';
$payload = [
'filters' => [
[
'propertyName' => $lastUpdateDate,
'operator' => 'GT',
'value' => $since->getPreciseTimestamp(3),
],
],
'sorts' => [
[
'propertyName' => 'hs_object_id',
'direction' => 'DESCENDING',
],
],
'limit' => self::MAX_SEARCH_REQUEST_LIMIT,
];
if ($to) {
$payload['filters'][] = [
'propertyName' => $lastUpdateDate,
'operator' => 'LT',
'value' => $to->getPreciseTimestamp(3),
];
}
$payload['properties'] = $properties;
return $payload;
}
public function getByProfileSearchPayload(string $crmId, array $properties, Carbon $since, ?Carbon $to): array
{
$lastUpdateDate = array_key_exists('firstname', $properties) ? 'lastmodifieddate' : 'hs_lastmodifieddate';
$payload = [
'filters' => [
[
'propertyName' => $lastUpdateDate,
'operator' => 'GT',
'value' => $since->getPreciseTimestamp(3),
],
[
'propertyName' => 'hubspot_owner_id',
'operator' => 'EQ',
'value' => $crmId,
],
],
'sorts' => [
[
'propertyName' => 'hs_object_id',
'direction' => 'DESCENDING',
],
],
'limit' => self::MAX_SEARCH_REQUEST_LIMIT,
];
if ($to) {
$payload['filters'][] = [
'propertyName' => $lastUpdateDate,
'operator' => 'LT',
'value' => $to->getPreciseTimestamp(3),
];
}
$payload['properties'] = $properties;
return $payload;
}
private function buildMeetingFiltersForLinkToTask($objectType, $objectId): array
{
$filters = [];
$filters[] = [
'filters' => [
$this->getAssociatedObjectFilter($objectType, $objectId),
[
'propertyName' => 'hs_meeting_outcome',
'operator' => 'IN',
'values' => ['SCHEDULED', 'RESCHEDULED'],
],
],
];
$filters[] = [
'filters' => [
$this->getAssociatedObjectFilter($objectType, $objectId),
[
'propertyName' => 'hs_meeting_outcome',
'operator' => 'NOT_HAS_PROPERTY',
],
],
];
return $filters;
}
private function buildTaskFiltersForLinkToTask($objectType, $objectId): array
{
return [
[
'filters' => [
$this->getAssociatedObjectFilter($objectType, $objectId),
],
],
];
}
private function getAssociatedObjectFilter(string $objectType, string $objectId): array
{
return [
'propertyName' => 'associations.' . $objectType,
'operator' => 'EQ',
'value' => $objectId,
];
}
public function generatePlaybackURLSearchPayload(string $playbackURLToken): array
{
return [
'filters' => [
[
'propertyName' => 'hs_call_recording_url',
'operator' => 'CONTAINS_TOKEN',
'value' => $playbackURLToken,
],
],
'properties' => [
'hs_call_recording_url',
'hs_call_body',
],
'limit' => 1,
];
}
public function generateGetCallsPayload(Carbon $from, Carbon $to, string $activityProvider, $page): array
{
return [
'filters' => [
[
'propertyName' => 'hs_call_recording_url',
'operator' => 'CONTAINS_TOKEN',
'value' => $activityProvider,
],
[
'propertyName' => 'hs_timestamp',
'operator' => 'BETWEEN',
'value' => intval($from->getPreciseTimestamp(3)),
'highValue' => intval($to->getPreciseTimestamp(3)),
],
],
'sorts' => [
[
'propertyName' => 'hs_timestamp',
'direction' => 'DESCENDING',
],
],
'properties' => [
'hs_call_recording_url',
'hs_call_body',
],
'limit' => self::MAX_SEARCH_REQUEST_LIMIT,
'after' => ($page - 1) * self::MAX_SEARCH_REQUEST_LIMIT,
];
}
public function generateSearchCallsByPeriodPayload(Carbon $from, Carbon $to, $page): array
{
return [
'filters' => [
[
'propertyName' => 'hs_timestamp',
'operator' => 'BETWEEN',
'value' => intval($from->getPreciseTimestamp(3)),
'highValue' => intval($to->getPreciseTimestamp(3)),
],
],
'sorts' => [
[
'propertyName' => 'hs_timestamp',
'direction' => 'DESCENDING',
],
],
'properties' => $this->getSearchCallAttributes(),
'limit' => self::MAX_SEARCH_REQUEST_LIMIT,
'after' => ($page - 1) * self::MAX_SEARCH_REQUEST_LIMIT,
];
}
public function getSearchCallAttributes(): array
{
return [
'hs_timestamp',
'hs_call_recording_url',
'hs_call_body',
'hs_call_status',
'hs_call_to_number',
'hs_call_from_number',
'hs_call_duration',
'hs_call_disposition',
'hs_call_title',
'hs_call_direction',
'hubspot_owner_id',
'hs_activity_type',
'hs_call_external_id',
'hs_call_source',
];
}
public function generatePlaybackAddUrlBatchPayload(array $crmUpdateData): array
{
$updateObjectsData = [];
foreach ($crmUpdateData as $data) {
$updateObjectsData[] = [
'id' => $data['crm_id'],
'properties' => [
'hs_call_body' => $data['hs_call_body'] .
'<p><span style="font-size: 13.3333px; line-height: 16px;">' .
'<a href="' . $data['playback_url'] . '" target="_blank">Review in Jiminny</a> ▶️</span></p>',
],
];
}
return ['inputs' => $updateObjectsData];
}
public function generateSearchCallByTokenPayload(string $playbackURLToken): array
{
return [
'filters' => [
[
'propertyName' => 'hs_call_recording_url',
'operator' => 'CONTAINS_TOKEN',
'value' => $playbackURLToken,
],
],
'properties' => [
'hs_call_recording_url',
'hs_call_body',
],
'limit' => 1,
];
}
/**
* Generates a payload for phone search based on the specified parameters.
*
* @param string $phone The phone number to search.
* @param bool $isAlternativeSearch Indicates if an alternative search should be performed
* if the first search fails to find a match. The first search request should cover most of the cases.
*/
public function generatePhoneSearchPayload(string $phone, bool $isAlternativeSearch = false): array
{
$filterPropertyNames = $isAlternativeSearch ?
['hs_searchable_calculated_mobile_number', 'phone', 'mobilephone'] :
['hs_searchable_calculated_phone_number', 'hs_calculated_phone_number', 'hs_calculated_mobile_number'];
$filterGroups = array_map(function ($propertyName) use ($phone) {
return $this->createFilterGroup($propertyName, 'CONTAINS_TOKEN', $phone);
}, $filterPropertyNames);
$properties = [
'email',
'firstname',
'lastname',
'country',
'phone',
'mobilephone',
'jobtitle',
'hubspot_owner_id',
'associatedcompanyid',
'photo',
];
return $this->createPayload($filterGroups, $properties);
}
/**
* Creates a filter group with the specified parameters.
*/
private function createFilterGroup(string $propertyName, string $operator, $value): array
{
return [
'filters' => [
[
'propertyName' => $propertyName,
'operator' => $operator,
'value' => $value,
],
],
];
}
/**
* Creates a payload with the specified filter groups and properties.
*/
private function createPayload(array $filterGroups, array $properties): array
{
return [
'filterGroups' => $filterGroups,
'sorts' => [
[
'propertyName' => 'modifieddate',
'direction' => 'DESCENDING',
],
],
'properties' => $properties,
'limit' => 1,
];
}
/**
* Generates a payload to find related activities based on the specified data and object type.
* The search looks for activities that match the specified time range (starttime - endtime or only starttime with endtime not set)
* and are associated with any of the provided prospect types (contact or company).
*
* @param array $data An associative array containing the following keys:
* - 'from': The start time for the activity search (timestamp).
* - 'to': The end time for the activity search (timestamp).
* - 'contact': (optional) The ID of the associated contact.
* - 'company': (optional) The ID of the associated company.
* @param string $objectType The type of engagement to search for (meeting or other type like call).
*
* @return array The payload array to be used for searching related activities.
*/
public function getFindRelatedActivityPayload(array $data, string $objectType): array
{
$payload = [
'sorts' => [
[
'propertyName' => self::SORT_PROPERTY,
'direction' => 'DESCENDING',
],
],
'limit' => self::MAX_SEARCH_REQUEST_LIMIT,
];
if ($objectType === self::ENGAGEMENT_MEETINGS) {
$payload['properties'] = [
'hs_meeting_title',
'hs_meeting_outcome',
'hs_activity_type',
'hs_timestamp',
'hubspot_owner_id',
'hs_meeting_body',
'hs_internal_meeting_notes',
'hs_meeting_location',
'hs_meeting_start_time',
'hs_meeting_end_time',
];
} else {
$payload['properties'] = ['hs_task_subject', 'hs_timestamp', 'hubspot_owner_id', 'hs_call_body'];
}
$timeFiltersWithEndTime = [
[
'propertyName' => 'hs_meeting_start_time',
'operator' => 'GTE',
'value' => $data['from'],
],
[
'propertyName' => 'hs_meeting_end_time',
'operator' => 'LTE',
'value' => $data['to'],
],
];
$timeFiltersWithoutEndTime = [
[
'propertyName' => 'hs_meeting_start_time',
'operator' => 'GTE',
'value' => $data['from'],
],
[
'propertyName' => 'hs_meeting_end_time',
'operator' => 'NOT_HAS_PROPERTY',
],
];
$payload['filterGroups'] = [];
$associationTypes = ['contact', 'company'];
foreach ($associationTypes as $type) {
if (! empty($data[$type])) {
$filterGroupWithEndTime = [
'filters' => array_merge(
$timeFiltersWithEndTime,
[$this->getAssociatedObjectFilter($type, $data[$type])]
),
];
$payload['filterGroups'][] = $filterGroupWithEndTime;
$filterGroupWithoutEndTime = [
'filters' => array_merge(
$timeFiltersWithoutEndTime,
[$this->getAssociatedObjectFilter($type, $data[$type])]
),
];
$payload['filterGroups'][] = $filterGroupWithoutEndTime ;
}
}
return $payload;
}
/** Parameters
* [
* 'accountId' => $crmAccountId,
* 'sortBy' => $sortBy,
* 'sortDir' => $sortDir,
* 'onlyOpen' => $onlyOpen,
* 'closedStages' => $this->getClosedDealStages(),
* 'userId' => $userId,
* ];
*/
public function generateOpportunitiesSearchPayload(
Configuration $config,
string $crmAccountId,
array $closedStages,
): array {
$closedFilters = [];
$filterGroups = [];
$onlyOpen = true;
switch ($config->getOpportunityAssignmentRule()) {
case Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_UPDATED:
$sortBy = 'modifieddate';
$sortDir = 'DESCENDING';
break;
case Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_CREATED:
$sortBy = 'createdate';
$sortDir = 'DESCENDING';
break;
case Configuration::OPP_ASSIGNMENT_ALL_OPEN_OLDEST_CREATED:
$sortBy = 'createdate';
$sortDir = 'ASCENDING';
break;
case Configuration::OPP_ASSIGNMENT_ALL_RECENTLY_UPDATED:
default:
$sortBy = 'modifieddate';
$sortDir = 'DESCENDING';
$onlyOpen = false;
}
$baseFilters = [
[
'propertyName' => 'associations.company',
'operator' => 'EQ',
'value' => $crmAccountId,
],
];
// Handle closed stages in chunks
if ($onlyOpen) {
foreach (['won', 'lost'] as $key) {
if (! empty($closedStages[$key])) {
$chunks = array_chunk($closedStages[$key], self::MAX_FILTER_SIZE);
foreach ($chunks as $chunk) {
$closedFilters[] = [
'propertyName' => 'dealstage',
'operator' => 'NOT_IN',
'values' => $chunk,
];
}
}
}
}
$filterGroups[] = [
'filters' => array_merge($baseFilters, $closedFilters),
];
$payload = [
'filterGroups' => $filterGroups,
'sorts' => [
[
'propertyName' => $sortBy,
'direction' => $sortDir,
],
],
'properties' => [
'dealname',
'amount',
'hubspot_owner_id',
'pipeline',
'dealstage',
'closedate',
'deal_currency_code',
],
'limit' => self::MAX_SEARCH_REQUEST_LIMIT,
];
$logger = app(LoggerInterface::class);
$logger->info('[HubSpot] CRM Search requested', [
'request' => $payload,
]);
return $payload;
}
/**
* Converts v1 payload data to v3
*/
public function getV3MeetingPayload(array $engagement, array $metadata, array $associations = []): array
{
// Use this mapping until the whole Hubspot V1 is dropped.
$properties = [
'hubspot_owner_id' => $engagement['ownerId'],
'hs_timestamp' => $engagement['timestamp'],
];
// $engagement['activityType'] is $activity->category->name
if (isset($engagement['activityType'])) {
$properties['hs_activity_type'] = $engagement['activityType'];
}
$metadataKeyMap = [
'hs_meeting_outcome' => 'meetingOutcome',
'hs_meeting_title' => 'title',
'hs_meeting_start_time' => 'startTime',
'hs_meeting_end_time' => 'endTime',
'hs_meeting_body' => 'body',
'hs_internal_meeting_notes' => 'internalMeetingNotes',
];
foreach ($metadataKeyMap as $newKey => $oldKey) {
if (isset($metadata[$oldKey])) {
$properties[$newKey] = $metadata[$oldKey];
unset($metadata[$oldKey]);
}
}
$properties = [
...$properties,
...$metadata, // custom fields
];
$response = [
'properties' => $properties,
];
if (! empty($associations)) {
$response['associations'] = $associations;
}
return $response;
}
/**
* Generate a payload to search for contacts by name.
* The search is a token search in firstname or lastname
*/
public function generateSearchContactsByNamePayload(string $name, array $fields): array
{
$firstNameFilter = [
'propertyName' => 'firstname',
'operator' => 'CONTAINS_TOKEN',
'value' => $name,
];
$lastNameFilter = [
'propertyName' => 'lastname',
'operator' => 'CONTAINS_TOKEN',
'value' => $name,
];
return [
'filterGroups' => [
[
'filters' => [$firstNameFilter],
],
[
'filters' => [$lastNameFilter],
],
],
'properties' => $fields,
'sorts' => [
[
'propertyName' => 'lastmodifieddate',
'direction' => 'DESCENDING',
],
],
];
}
public function buildAddAssociationPayload(string $crmId, array $ids, int $associationType): array
{
$inputs = [];
foreach ($ids as $id) {
if ($id === null || $id === '') {
continue;
}
$inputs[] = [
'from' => [
'id' => $crmId,
],
'to' => [
'id' => (string) $id,
],
'types' => [
[
'associationCategory' => 'HUBSPOT_DEFINED',
'associationTypeId' => $associationType,
],
],
];
}
return ['inputs' => $inputs];
}
public function buildRemoveAssociationPayload(string $crmId, array $ids): array
{
$toArray = [];
foreach ($ids as $id) {
if ($id === null || $id === '') {
continue;
}
$toArray[] = ['id' => (string) $id];
}
return [
'inputs' => [
[
'from' => ['id' => $crmId],
'to' => $toArray,
],
],
];
}
public function addClosedStageFilters(array &$payload, array $closedStages): void
{
if (! empty($closedStages['won'])) {
$payload['filters'][] = [
'propertyName' => 'dealstage',
'operator' => 'NOT_IN',
'values' => $closedStages['won'],
];
}
if (! empty($closedStages['lost'])) {
$payload['filters'][] = [
'propertyName' => 'dealstage',
'operator' => 'NOT_IN',
'values' => $closedStages['lost'],
];
}
}
public function addCreatedDateFilters(array &$payload, Carbon $createdAfter): void
{
$payload['filters'][] = [
'propertyName' => 'createdate',
'operator' => 'GT',
'value' => $createdAfter->getPreciseTimestamp(3),
];
}
public function getDealsInBulkPayload(array $dealIds): array
{
return [
'filterGroups' => [
[
'filters' => [
[
'propertyName' => 'hs_object_id',
'operator' => 'IN',
'values' => $dealIds,
],
],
],
],
'limit' => self::MAX_FILTER_SIZE,
];
}
}
Sync Changes...
|
PhpStorm
|
faVsco.js – PropertyChangeManager.php
|
NULL
|
87915
|
|
87914
|
Project: faVsco.js, menu
#12121 on JY-20963-fix-im Project: faVsco.js, menu
#12121 on JY-20963-fix-import-on-deleted-entity, menu
Start Listening for PHP Debug Connections
ServiceTest
Run 'ServiceTest'
Debug 'ServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
8
25
Previous Highlighted Error
Next Highlighted Error
<?php
declare(strict_types=1);
namespace Jiminny\Services\Crm\Hubspot;
use Carbon\Carbon;
use Jiminny\Models\Crm\Configuration;
use Psr\Log\LoggerInterface;
class PayloadBuilder
{
public const int MAX_SEARCH_REQUEST_LIMIT = 200;
private const int MAX_FILTER_SIZE = 100;
private const string SORT_PROPERTY = 'hs_timestamp';
private const string ENGAGEMENT_MEETINGS = 'meetings';
public function getLinkToTaskPayload(string $objectType, string $objectId, string $engagementType): array
{
$payload = [
'sorts' => [
[
'propertyName' => self::SORT_PROPERTY,
'direction' => 'DESCENDING',
],
],
'limit' => self::MAX_SEARCH_REQUEST_LIMIT,
];
if ($engagementType === self::ENGAGEMENT_MEETINGS) {
$payload['filterGroups'] = $this->buildMeetingFiltersForLinkToTask($objectType, $objectId);
$payload['properties'] = [
'hs_meeting_title',
'hs_meeting_outcome',
'hs_timestamp',
'hs_activity_type',
];
} else {
$payload['filterGroups'] = $this->buildTaskFiltersForLinkToTask($objectType, $objectId);
$payload['properties'] = ['hs_task_subject', 'hs_timestamp'];
}
return $payload;
}
public function getRecentlyUpdatedSearchPayload(Carbon $since, ?Carbon $to, array $properties): array
{
$lastUpdateDate = in_array('firstname', $properties) ? 'lastmodifieddate' : 'hs_lastmodifieddate';
$payload = [
'filters' => [
[
'propertyName' => $lastUpdateDate,
'operator' => 'GT',
'value' => $since->getPreciseTimestamp(3),
],
],
'sorts' => [
[
'propertyName' => 'hs_object_id',
'direction' => 'DESCENDING',
],
],
'limit' => self::MAX_SEARCH_REQUEST_LIMIT,
];
if ($to) {
$payload['filters'][] = [
'propertyName' => $lastUpdateDate,
'operator' => 'LT',
'value' => $to->getPreciseTimestamp(3),
];
}
$payload['properties'] = $properties;
return $payload;
}
public function getByProfileSearchPayload(string $crmId, array $properties, Carbon $since, ?Carbon $to): array
{
$lastUpdateDate = array_key_exists('firstname', $properties) ? 'lastmodifieddate' : 'hs_lastmodifieddate';
$payload = [
'filters' => [
[
'propertyName' => $lastUpdateDate,
'operator' => 'GT',
'value' => $since->getPreciseTimestamp(3),
],
[
'propertyName' => 'hubspot_owner_id',
'operator' => 'EQ',
'value' => $crmId,
],
],
'sorts' => [
[
'propertyName' => 'hs_object_id',
'direction' => 'DESCENDING',
],
],
'limit' => self::MAX_SEARCH_REQUEST_LIMIT,
];
if ($to) {
$payload['filters'][] = [
'propertyName' => $lastUpdateDate,
'operator' => 'LT',
'value' => $to->getPreciseTimestamp(3),
];
}
$payload['properties'] = $properties;
return $payload;
}
private function buildMeetingFiltersForLinkToTask($objectType, $objectId): array
{
$filters = [];
$filters[] = [
'filters' => [
$this->getAssociatedObjectFilter($objectType, $objectId),
[
'propertyName' => 'hs_meeting_outcome',
'operator' => 'IN',
'values' => ['SCHEDULED', 'RESCHEDULED'],
],
],
];
$filters[] = [
'filters' => [
$this->getAssociatedObjectFilter($objectType, $objectId),
[
'propertyName' => 'hs_meeting_outcome',
'operator' => 'NOT_HAS_PROPERTY',
],
],
];
return $filters;
}
private function buildTaskFiltersForLinkToTask($objectType, $objectId): array
{
return [
[
'filters' => [
$this->getAssociatedObjectFilter($objectType, $objectId),
],
],
];
}
private function getAssociatedObjectFilter(string $objectType, string $objectId): array
{
return [
'propertyName' => 'associations.' . $objectType,
'operator' => 'EQ',
'value' => $objectId,
];
}
public function generatePlaybackURLSearchPayload(string $playbackURLToken): array
{
return [
'filters' => [
[
'propertyName' => 'hs_call_recording_url',
'operator' => 'CONTAINS_TOKEN',
'value' => $playbackURLToken,
],
],
'properties' => [
'hs_call_recording_url',
'hs_call_body',
],
'limit' => 1,
];
}
public function generateGetCallsPayload(Carbon $from, Carbon $to, string $activityProvider, $page): array
{
return [
'filters' => [
[
'propertyName' => 'hs_call_recording_url',
'operator' => 'CONTAINS_TOKEN',
'value' => $activityProvider,
],
[
'propertyName' => 'hs_timestamp',
'operator' => 'BETWEEN',
'value' => intval($from->getPreciseTimestamp(3)),
'highValue' => intval($to->getPreciseTimestamp(3)),
],
],
'sorts' => [
[
'propertyName' => 'hs_timestamp',
'direction' => 'DESCENDING',
],
],
'properties' => [
'hs_call_recording_url',
'hs_call_body',
],
'limit' => self::MAX_SEARCH_REQUEST_LIMIT,
'after' => ($page - 1) * self::MAX_SEARCH_REQUEST_LIMIT,
];
}
public function generateSearchCallsByPeriodPayload(Carbon $from, Carbon $to, $page): array
{
return [
'filters' => [
[
'propertyName' => 'hs_timestamp',
'operator' => 'BETWEEN',
'value' => intval($from->getPreciseTimestamp(3)),
'highValue' => intval($to->getPreciseTimestamp(3)),
],
],
'sorts' => [
[
'propertyName' => 'hs_timestamp',
'direction' => 'DESCENDING',
],
],
'properties' => $this->getSearchCallAttributes(),
'limit' => self::MAX_SEARCH_REQUEST_LIMIT,
'after' => ($page - 1) * self::MAX_SEARCH_REQUEST_LIMIT,
];
}
public function getSearchCallAttributes(): array
{
return [
'hs_timestamp',
'hs_call_recording_url',
'hs_call_body',
'hs_call_status',
'hs_call_to_number',
'hs_call_from_number',
'hs_call_duration',
'hs_call_disposition',
'hs_call_title',
'hs_call_direction',
'hubspot_owner_id',
'hs_activity_type',
'hs_call_external_id',
'hs_call_source',
];
}
public function generatePlaybackAddUrlBatchPayload(array $crmUpdateData): array
{
$updateObjectsData = [];
foreach ($crmUpdateData as $data) {
$updateObjectsData[] = [
'id' => $data['crm_id'],
'properties' => [
'hs_call_body' => $data['hs_call_body'] .
'<p><span style="font-size: 13.3333px; line-height: 16px;">' .
'<a href="' . $data['playback_url'] . '" target="_blank">Review in Jiminny</a> ▶️</span></p>',
],
];
}
return ['inputs' => $updateObjectsData];
}
public function generateSearchCallByTokenPayload(string $playbackURLToken): array
{
return [
'filters' => [
[
'propertyName' => 'hs_call_recording_url',
'operator' => 'CONTAINS_TOKEN',
'value' => $playbackURLToken,
],
],
'properties' => [
'hs_call_recording_url',
'hs_call_body',
],
'limit' => 1,
];
}
/**
* Generates a payload for phone search based on the specified parameters.
*
* @param string $phone The phone number to search.
* @param bool $isAlternativeSearch Indicates if an alternative search should be performed
* if the first search fails to find a match. The first search request should cover most of the cases.
*/
public function generatePhoneSearchPayload(string $phone, bool $isAlternativeSearch = false): array
{
$filterPropertyNames = $isAlternativeSearch ?
['hs_searchable_calculated_mobile_number', 'phone', 'mobilephone'] :
['hs_searchable_calculated_phone_number', 'hs_calculated_phone_number', 'hs_calculated_mobile_number'];
$filterGroups = array_map(function ($propertyName) use ($phone) {
return $this->createFilterGroup($propertyName, 'CONTAINS_TOKEN', $phone);
}, $filterPropertyNames);
$properties = [
'email',
'firstname',
'lastname',
'country',
'phone',
'mobilephone',
'jobtitle',
'hubspot_owner_id',
'associatedcompanyid',
'photo',
];
return $this->createPayload($filterGroups, $properties);
}
/**
* Creates a filter group with the specified parameters.
*/
private function createFilterGroup(string $propertyName, string $operator, $value): array
{
return [
'filters' => [
[
'propertyName' => $propertyName,
'operator' => $operator,
'value' => $value,
],
],
];
}
/**
* Creates a payload with the specified filter groups and properties.
*/
private function createPayload(array $filterGroups, array $properties): array
{
return [
'filterGroups' => $filterGroups,
'sorts' => [
[
'propertyName' => 'modifieddate',
'direction' => 'DESCENDING',
],
],
'properties' => $properties,
'limit' => 1,
];
}
/**
* Generates a payload to find related activities based on the specified data and object type.
* The search looks for activities that match the specified time range (starttime - endtime or only starttime with endtime not set)
* and are associated with any of the provided prospect types (contact or company).
*
* @param array $data An associative array containing the following keys:
* - 'from': The start time for the activity search (timestamp).
* - 'to': The end time for the activity search (timestamp).
* - 'contact': (optional) The ID of the associated contact.
* - 'company': (optional) The ID of the associated company.
* @param string $objectType The type of engagement to search for (meeting or other type like call).
*
* @return array The payload array to be used for searching related activities.
*/
public function getFindRelatedActivityPayload(array $data, string $objectType): array
{
$payload = [
'sorts' => [
[
'propertyName' => self::SORT_PROPERTY,
'direction' => 'DESCENDING',
],
],
'limit' => self::MAX_SEARCH_REQUEST_LIMIT,
];
if ($objectType === self::ENGAGEMENT_MEETINGS) {
$payload['properties'] = [
'hs_meeting_title',
'hs_meeting_outcome',
'hs_activity_type',
'hs_timestamp',
'hubspot_owner_id',
'hs_meeting_body',
'hs_internal_meeting_notes',
'hs_meeting_location',
'hs_meeting_start_time',
'hs_meeting_end_time',
];
} else {
$payload['properties'] = ['hs_task_subject', 'hs_timestamp', 'hubspot_owner_id', 'hs_call_body'];
}
$timeFiltersWithEndTime = [
[
'propertyName' => 'hs_meeting_start_time',
'operator' => 'GTE',
'value' => $data['from'],
],
[
'propertyName' => 'hs_meeting_end_time',
'operator' => 'LTE',
'value' => $data['to'],
],
];
$timeFiltersWithoutEndTime = [
[
'propertyName' => 'hs_meeting_start_time',
'operator' => 'GTE',
'value' => $data['from'],
],
[
'propertyName' => 'hs_meeting_end_time',
'operator' => 'NOT_HAS_PROPERTY',
],
];
$payload['filterGroups'] = [];
$associationTypes = ['contact', 'company'];
foreach ($associationTypes as $type) {
if (! empty($data[$type])) {
$filterGroupWithEndTime = [
'filters' => array_merge(
$timeFiltersWithEndTime,
[$this->getAssociatedObjectFilter($type, $data[$type])]
),
];
$payload['filterGroups'][] = $filterGroupWithEndTime;
$filterGroupWithoutEndTime = [
'filters' => array_merge(
$timeFiltersWithoutEndTime,
[$this->getAssociatedObjectFilter($type, $data[$type])]
),
];
$payload['filterGroups'][] = $filterGroupWithoutEndTime ;
}
}
return $payload;
}
/** Parameters
* [
* 'accountId' => $crmAccountId,
* 'sortBy' => $sortBy,
* 'sortDir' => $sortDir,
* 'onlyOpen' => $onlyOpen,
* 'closedStages' => $this->getClosedDealStages(),
* 'userId' => $userId,
* ];
*/
public function generateOpportunitiesSearchPayload(
Configuration $config,
string $crmAccountId,
array $closedStages,
): array {
$closedFilters = [];
$filterGroups = [];
$onlyOpen = true;
switch ($config->getOpportunityAssignmentRule()) {
case Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_UPDATED:
$sortBy = 'modifieddate';
$sortDir = 'DESCENDING';
break;
case Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_CREATED:
$sortBy = 'createdate';
$sortDir = 'DESCENDING';
break;
case Configuration::OPP_ASSIGNMENT_ALL_OPEN_OLDEST_CREATED:
$sortBy = 'createdate';
$sortDir = 'ASCENDING';
break;
case Configuration::OPP_ASSIGNMENT_ALL_RECENTLY_UPDATED:
default:
$sortBy = 'modifieddate';
$sortDir = 'DESCENDING';
$onlyOpen = false;
}
$baseFilters = [
[
'propertyName' => 'associations.company',
'operator' => 'EQ',
'value' => $crmAccountId,
],
];
// Handle closed stages in chunks
if ($onlyOpen) {
foreach (['won', 'lost'] as $key) {
if (! empty($closedStages[$key])) {
$chunks = array_chunk($closedStages[$key], self::MAX_FILTER_SIZE);
foreach ($chunks as $chunk) {
$closedFilters[] = [
'propertyName' => 'dealstage',
'operator' => 'NOT_IN',
'values' => $chunk,
];
}
}
}
}
$filterGroups[] = [
'filters' => array_merge($baseFilters, $closedFilters),
];
$payload = [
'filterGroups' => $filterGroups,
'sorts' => [
[
'propertyName' => $sortBy,
'direction' => $sortDir,
],
],
'properties' => [
'dealname',
'amount',
'hubspot_owner_id',
'pipeline',
'dealstage',
'closedate',
'deal_currency_code',
],
'limit' => self::MAX_SEARCH_REQUEST_LIMIT,
];
$logger = app(LoggerInterface::class);
$logger->info('[HubSpot] CRM Search requested', [
'request' => $payload,
]);
return $payload;
}
/**
* Converts v1 payload data to v3
*/
public function getV3MeetingPayload(array $engagement, array $metadata, array $associations = []): array
{
// Use this mapping until the whole Hubspot V1 is dropped.
$properties = [
'hubspot_owner_id' => $engagement['ownerId'],
'hs_timestamp' => $engagement['timestamp'],
];
// $engagement['activityType'] is $activity->category->name
if (isset($engagement['activityType'])) {
$properties['hs_activity_type'] = $engagement['activityType'];
}
$metadataKeyMap = [
'hs_meeting_outcome' => 'meetingOutcome',
'hs_meeting_title' => 'title',
'hs_meeting_start_time' => 'startTime',
'hs_meeting_end_time' => 'endTime',
'hs_meeting_body' => 'body',
'hs_internal_meeting_notes' => 'internalMeetingNotes',
];
foreach ($metadataKeyMap as $newKey => $oldKey) {
if (isset($metadata[$oldKey])) {
$properties[$newKey] = $metadata[$oldKey];
unset($metadata[$oldKey]);
}
}
$properties = [
...$properties,
...$metadata, // custom fields
];
$response = [
'properties' => $properties,
];
if (! empty($associations)) {
$response['associations'] = $associations;
}
return $response;
}
/**
* Generate a payload to search for contacts by name.
* The search is a token search in firstname or lastname
*/
public function generateSearchContactsByNamePayload(string $name, array $fields): array
{
$firstNameFilter = [
'propertyName' => 'firstname',
'operator' => 'CONTAINS_TOKEN',
'value' => $name,
];
$lastNameFilter = [
'propertyName' => 'lastname',
'operator' => 'CONTAINS_TOKEN',
'value' => $name,
];
return [
'filterGroups' => [
[
'filters' => [$firstNameFilter],
],
[
'filters' => [$lastNameFilter],
],
],
'properties' => $fields,
'sorts' => [
[
'propertyName' => 'lastmodifieddate',
'direction' => 'DESCENDING',
],
],
];
}
public function buildAddAssociationPayload(string $crmId, array $ids, int $associationType): array
{
$inputs = [];
foreach ($ids as $id) {
if ($id === null || $id === '') {
continue;
}
$inputs[] = [
'from' => [
'id' => $crmId,
],
'to' => [
'id' => (string) $id,
],
'types' => [
[
'associationCategory' => 'HUBSPOT_DEFINED',
'associationTypeId' => $associationType,
],
],
];
}
return ['inputs' => $inputs];
}
public function buildRemoveAssociationPayload(string $crmId, array $ids): array
{
$toArray = [];
foreach ($ids as $id) {
if ($id === null || $id === '') {
continue;
}
$toArray[] = ['id' => (string) $id];
}
return [
'inputs' => [
[
'from' => ['id' => $crmId],
'to' => $toArray,
],
],
];
}
public function addClosedStageFilters(array &$payload, array $closedStages): void
{
if (! empty($closedStages['won'])) {
$payload['filters'][] = [
'propertyName' => 'dealstage',
'operator' => 'NOT_IN',
'values' => $closedStages['won'],
];
}
if (! empty($closedStages['lost'])) {
$payload['filters'][] = [
'propertyName' => 'dealstage',
'operator' => 'NOT_IN',
'values' => $closedStages['lost'],
];
}
}
public function addCreatedDateFilters(array &$payload, Carbon $createdAfter): void
{
$payload['filters'][] = [
'propertyName' => 'createdate',
'operator' => 'GT',
'value' => $createdAfter->getPreciseTimestamp(3),
];
}
public function getDealsInBulkPayload(array $dealIds): array
{
return [
'filterGroups' => [
[
'filters' => [
[
'propertyName' => 'hs_object_id',
'operator' => 'IN',
'values' => $dealIds,
],
],
],
],
'limit' => self::MAX_FILTER_SIZE,
];
}
}
Sync Changes
Hide This Notification
Code changed:
Hide
22
8
Previous Highlighted Error
Next Highlighted Error
<?php
declare(strict_types=1);
namespace Jiminny\Services\Crm\Hubspot\Webhook;
use Illuminate\Support\Facades\Log;
use Jiminny\Models\Crm\Configuration;
use Jiminny\Services\Crm\Hubspot\BatchSyncCollector;
use Jiminny\Repositories\Crm\CrmEntityRepository;
use Jiminny\Repositories\Crm\OpportunityRepository;
use Jiminny\Repositories\Crm\FieldRepository;
use Jiminny\Repositories\Crm\FieldDataRepository;
use Jiminny\Models\Opportunity;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\LayoutEntity;
readonly class PropertyChangeManager
{
private const string OBJECT_TYPE_DEAL = 'deal';
private const string OBJECT_TYPE_CONTACT = 'contact';
private const string OBJECT_TYPE_COMPANY = 'company';
private const array SUPPORTED_OBJECT_TYPES = [
self::OBJECT_TYPE_DEAL,
self::OBJECT_TYPE_CONTACT,
self::OBJECT_TYPE_COMPANY,
];
/**
* Contact fields that webhooks are subscribed to in HubSpot.
*
* IMPORTANT: These fields MUST match the webhook subscriptions configured in HubSpot.
* When updating this list, you must also update the HubSpot webhook subscriptions:
* 1. Go to HubSpot App Settings → Webhooks
* 2. Update contact.propertyChange subscriptions to match this list
* 3. Ensure all fields listed here are subscribed, and vice versa
*
* This constant serves as documentation of the current webhook configuration.
* Since HubSpot filters webhooks server-side, we should only receive events for these properties.
*/
private const array APPLICABLE_CONTACT_FIELDS = [
'firstname',
'lastname',
'email',
'phone',
'mobilephone',
'jobtitle',
'country',
'hubspot_owner_id',
'associatedcompanyid',
'photo',
'hs_avatar_filemanager_key',
];
/**
* Company fields that webhooks are subscribed to in HubSpot.
*
* IMPORTANT: These fields MUST match the webhook subscriptions configured in HubSpot.
* When updating this list, you must also update the HubSpot webhook subscriptions:
* 1. Go to HubSpot App Settings → Webhooks
* 2. Update company.propertyChange subscriptions to match this list
* 3. Ensure all fields listed here are subscribed, and vice versa
*
* This constant serves as documentation of the current webhook configuration.
* Since HubSpot filters webhooks server-side, we should only receive events for these properties.
*/
private const array APPLICABLE_COMPANY_FIELDS = [
'name',
'phone',
'domain',
'country',
'industry',
'hubspot_owner_id',
'hs_avatar_filemanager_key',
];
/**
* Properties that support direct database updates (without full sync).
* These are simple properties that can be updated immediately without recalculating other fields.
*/
private const array DB_OBJECT_UPDATE_PROPERTIES = [
self::OBJECT_TYPE_DEAL => [
'dealname',
'description',
],
self::OBJECT_TYPE_CONTACT => [
// Add contact properties that support direct update
],
self::OBJECT_TYPE_COMPANY => [
// Add company properties that support direct update
],
];
/**
* Mapping from HubSpot property names to Jiminny model property names.
* Used when updating the main model (Opportunity, Contact, Account) directly.
* Only needed when HubSpot property name differs from Jiminny property name.
*/
private const array OBJECT_PROPERTY_MAPPINGS = [
self::OBJECT_TYPE_DEAL => [
'dealname' => 'name', // HubSpot 'dealname' maps to Opportunity 'name'
],
self::OBJECT_TYPE_CONTACT => [
// Add contact property mappings if needed
],
self::OBJECT_TYPE_COMPANY => [
// Add company property mappings if needed
],
];
// Field object types mapping
private const array FIELD_OBJECT_TYPES = [
self::OBJECT_TYPE_DEAL => Field::OBJECT_OPPORTUNITY,
self::OBJECT_TYPE_CONTACT => Field::OBJECT_CONTACT,
self::OBJECT_TYPE_COMPANY => Field::OBJECT_ACCOUNT,
];
public function __construct(
private BatchSyncCollector $batchCollector,
private OpportunityRepository $opportunityRepository,
private CrmEntityRepository $crmEntityRepository,
private FieldRepository $fieldRepository,
private FieldDataRepository $fieldDataRepository,
private HubspotWebhookMetricsService $metricsService,
) {
}
/**
* Get contact fields that webhooks are subscribed to in HubSpot.
* This serves as documentation of the current webhook configuration.
*
* @return array<int, string>
*/
public function getApplicableContactFields(): array
{
return self::APPLICABLE_CONTACT_FIELDS;
}
/**
* Get company fields that webhooks are subscribed to in HubSpot.
* This serves as documentation of the current webhook configuration.
*
* @return array<int, string>
*/
public function getApplicableCompanyFields(): array
{
return self::APPLICABLE_COMPANY_FIELDS;
}
/**
* Process property change with intelligent routing
*/
public function processPropertyChange(
string $objectType,
string $objectId,
Configuration $configuration,
string $propertyName,
string $propertyValue
): void {
if (! $this->isObjectTypeSupported($objectType)) {
Log::warning('[HubSpot Property Change Manager] Unsupported object type', [
'object_type' => $objectType,
'object_id' => $objectId,
'property_name' => $propertyName,
]);
return;
}
$this->metricsService->recordWebhookMetrics(
$configuration,
$objectType,
'property_change',
$propertyName
);
if ($this->isDirectUpdateSupported($objectType, $propertyName) &&
$this->handleDirectUpdate($objectType, $objectId, $configuration, $propertyName, $propertyValue)) {
return;
}
$this->handleObjectSync($objectType, $objectId, $configuration);
}
/**
* Legacy method for backward compatibility - assumes deal object type
*
* @deprecated Use processPropertyChange with objectType parameter
*/
public function processPropertyChangeForDeal(
string $objectId,
Configuration $configuration,
string $propertyName,
string $propertyValue
): void {
$this->processPropertyChange(self::OBJECT_TYPE_DEAL, $objectId, $configuration, $propertyName, $propertyValue);
}
/**
* Check if object type is supported
*/
private function isObjectTypeSupported(string $objectType): bool
{
return in_array($objectType, self::SUPPORTED_OBJECT_TYPES);
}
/**
* Check if property can be updated without additional calculation on other properties.
*/
private function isDirectUpdateSupported(string $objectType, string $propertyName): bool
{
$supportedProperties = self::DB_OBJECT_UPDATE_PROPERTIES[$objectType] ?? [];
return in_array($propertyName, $supportedProperties, true);
}
/**
* Handle properties that support direct database updates
*/
private function handleDirectUpdate(
string $objectType,
string $objectId,
Configuration $configuration,
string $propertyName,
string $propertyValue
): bool {
try {
return match ($objectType) {
self::OBJECT_TYPE_DEAL => $this->handleDealDirectUpdate($objectId, $configuration, $propertyName, $propertyValue),
self::OBJECT_TYPE_CONTACT => $this->handleContactDirectUpdate($objectId, $configuration, $propertyName, $propertyValue),
self::OBJECT_TYPE_COMPANY => $this->handleCompanyDirectUpdate($objectId, $configuration, $propertyName, $propertyValue),
default => false,
};
} catch (\Exception $e) {
Log::error('[HubSpot Property Change Manager] Error handling direct update property', [
'object_type' => $objectType,
'object_id' => $objectId,
'property_name' => $propertyName,
'error' => $e->getMessage(),
]);
return false;
}
}
/**
* Handle direct updates for deal objects
*/
private function handleDealDirectUpdate(
string $objectId,
Configuration $configuration,
string $propertyName,
string $propertyValue
): bool {
$opportunity = $this->opportunityRepository->findByConfigAndCrmProviderId(
$configuration,
$objectId
);
if (! $opportunity instanceof Opportunity) {
return false;
}
// Update model property if mapping exists
$propertyMappings = self::OBJECT_PROPERTY_MAPPINGS[self::OBJECT_TYPE_DEAL] ?? [];
if (isset($propertyMappings[$propertyName])) {
$this->opportunityRepository->updateOrCreate(
$configuration,
$objectId,
[$propertyMappings[$propertyName] => $propertyValue]
);
}
// Update field data
$this->importObjectFieldData(
self::OBJECT_TYPE_DEAL,
$opportunity->getId(),
$configuration,
[$propertyName => $propertyValue]
);
return true;
}
/**
* Handle direct updates for contact objects
*
* Updates Contact model properties via OBJECT_PROPERTY_MAPPINGS.
* Add mappings to OBJECT_PROPERTY_MAPPINGS[CONTACT] to enable direct updates.
*/
private function handleContactDirectUpdate(
string $objectId,
Configuration $configuration,
string $propertyName,
string $propertyValue
): bool {
$contact = $this->crmEntityRepository->findContactByExternalId($configuration, $objectId);
if ($contact === null) {
return false;
}
// Update model property if mapping exists
$propertyMappings = self::OBJECT_PROPERTY_MAPPINGS[self::OBJECT_TYPE_CONTACT] ?? [];
if (isset($propertyMappings[$propertyName])) {
$this->crmEntityRepository->importContact($configuration, [
'crm_provider_id' => $objectId,
$propertyMappings[$propertyName] => $propertyValue,
]);
}
return true;
}
/**
* Handle direct updates for company objects
*
* Updates Account model properties via OBJECT_PROPERTY_MAPPINGS.
* Add mappings to OBJECT_PROPERTY_MAPPINGS[COMPANY] to enable direct updates.
*/
private function handleCompanyDirectUpdate(
string $objectId,
Configuration $configuration,
string $propertyName,
string $propertyValue
): bool {
$company = $this->crmEntityRepository->findAccountByExternalId($configuration, $objectId);
if ($company === null) {
return false;
}
// Update model property if mapping exists
$propertyMappings = self::OBJECT_PROPERTY_MAPPINGS[self::OBJECT_TYPE_COMPANY] ?? [];
if (isset($propertyMappings[$propertyName])) {
$this->crmEntityRepository->importAccount($configuration, [
'crm_provider_id' => $objectId,
$propertyMappings[$propertyName] => $propertyValue,
]);
}
return true;
}
/**
* Import field data for any object type
*/
private function importObjectFieldData(
string $objectType,
int $objectId,
Configuration $configuration,
array $data
): void {
$fieldObjectType = self::FIELD_OBJECT_TYPES[$objectType] ?? null;
if (! $fieldObjectType) {
Log::warning('[HubSpot Property Change Manager] Unknown field object type', [
'object_type' => $objectType,
'object_id' => $objectId,
]);
return;
}
foreach ($data as $crmField => $value) {
$field = $this->fieldRepository->findFieldByCrmIdAndObjectType(
$configuration,
$crmField,
$fieldObjectType
);
if (! $field instanceof Field) {
continue;
}
$entities = $field->getEntities();
if ($entities->isEmpty()) {
continue;
}
if ($entities->count() > 1) {
continue;
}
/** @var LayoutEntity|null $entity */
$entity = $entities->first();
if (! $entity instanceof LayoutEntity) {
continue;
}
$this->fieldDataRepository->updateOrCreateFieldData(
$entity,
$objectId,
(string) $value,
);
}
}
private function handleObjectSync(
string $objectType,
string $objectId,
Configuration $configuration,
): void {
if (! $this->isObjectTypeSupported($objectType)) {
return;
}
try {
$this->batchCollector->collectForBatch(
objectType: $objectType,
crmProviderId: $objectId,
configurationId: $configuration->getId(),
eventType: 'property_change'
);
} catch (\Exception $e) {
Log::error('[HubSpot Property Change Manager] Error adding to sync batch', [
'object_type' => $objectType,
'object_id' => $objectId,
'error' => $e->getMessage(),
]);
}
}
}
Project
Project
New File or Directory…
Expand Selected
Collapse All
Options
Hide...
|
PhpStorm
|
faVsco.js – PropertyChangeManager.php
|
NULL
|
87914
|
|
87913
|
Project: faVsco.js, menu
#12121 on JY-20963-fix-im Project: faVsco.js, menu
#12121 on JY-20963-fix-import-on-deleted-entity, menu
Start Listening for PHP Debug Connections
ServiceTest
Run 'ServiceTest'
Debug 'ServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
8
25
Previous Highlighted Error
Next Highlighted Error
<?php
declare(strict_types=1);
namespace Jiminny\Services\Crm\Hubspot;
use Carbon\Carbon;
use Jiminny\Models\Crm\Configuration;
use Psr\Log\LoggerInterface;
class PayloadBuilder
{
public const int MAX_SEARCH_REQUEST_LIMIT = 200;
private const int MAX_FILTER_SIZE = 100;
private const string SORT_PROPERTY = 'hs_timestamp';
private const string ENGAGEMENT_MEETINGS = 'meetings';
public function getLinkToTaskPayload(string $objectType, string $objectId, string $engagementType): array
{
$payload = [
'sorts' => [
[
'propertyName' => self::SORT_PROPERTY,
'direction' => 'DESCENDING',
],
],
'limit' => self::MAX_SEARCH_REQUEST_LIMIT,
];
if ($engagementType === self::ENGAGEMENT_MEETINGS) {
$payload['filterGroups'] = $this->buildMeetingFiltersForLinkToTask($objectType, $objectId);
$payload['properties'] = [
'hs_meeting_title',
'hs_meeting_outcome',
'hs_timestamp',
'hs_activity_type',
];
} else {
$payload['filterGroups'] = $this->buildTaskFiltersForLinkToTask($objectType, $objectId);
$payload['properties'] = ['hs_task_subject', 'hs_timestamp'];
}
return $payload;
}
public function getRecentlyUpdatedSearchPayload(Carbon $since, ?Carbon $to, array $properties): array
{
$lastUpdateDate = in_array('firstname', $properties) ? 'lastmodifieddate' : 'hs_lastmodifieddate';
$payload = [
'filters' => [
[
'propertyName' => $lastUpdateDate,
'operator' => 'GT',
'value' => $since->getPreciseTimestamp(3),
],
],
'sorts' => [
[
'propertyName' => 'hs_object_id',
'direction' => 'DESCENDING',
],
],
'limit' => self::MAX_SEARCH_REQUEST_LIMIT,
];
if ($to) {
$payload['filters'][] = [
'propertyName' => $lastUpdateDate,
'operator' => 'LT',
'value' => $to->getPreciseTimestamp(3),
];
}
$payload['properties'] = $properties;
return $payload;
}
public function getByProfileSearchPayload(string $crmId, array $properties, Carbon $since, ?Carbon $to): array
{
$lastUpdateDate = array_key_exists('firstname', $properties) ? 'lastmodifieddate' : 'hs_lastmodifieddate';
$payload = [
'filters' => [
[
'propertyName' => $lastUpdateDate,
'operator' => 'GT',
'value' => $since->getPreciseTimestamp(3),
],
[
'propertyName' => 'hubspot_owner_id',
'operator' => 'EQ',
'value' => $crmId,
],
],
'sorts' => [
[
'propertyName' => 'hs_object_id',
'direction' => 'DESCENDING',
],
],
'limit' => self::MAX_SEARCH_REQUEST_LIMIT,
];
if ($to) {
$payload['filters'][] = [
'propertyName' => $lastUpdateDate,
'operator' => 'LT',
'value' => $to->getPreciseTimestamp(3),
];
}
$payload['properties'] = $properties;
return $payload;
}
private function buildMeetingFiltersForLinkToTask($objectType, $objectId): array
{
$filters = [];
$filters[] = [
'filters' => [
$this->getAssociatedObjectFilter($objectType, $objectId),
[
'propertyName' => 'hs_meeting_outcome',
'operator' => 'IN',
'values' => ['SCHEDULED', 'RESCHEDULED'],
],
],
];
$filters[] = [
'filters' => [
$this->getAssociatedObjectFilter($objectType, $objectId),
[
'propertyName' => 'hs_meeting_outcome',
'operator' => 'NOT_HAS_PROPERTY',
],
],
];
return $filters;
}
private function buildTaskFiltersForLinkToTask($objectType, $objectId): array
{
return [
[
'filters' => [
$this->getAssociatedObjectFilter($objectType, $objectId),
],
],
];
}
private function getAssociatedObjectFilter(string $objectType, string $objectId): array
{
return [
'propertyName' => 'associations.' . $objectType,
'operator' => 'EQ',
'value' => $objectId,
];
}
public function generatePlaybackURLSearchPayload(string $playbackURLToken): array
{
return [
'filters' => [
[
'propertyName' => 'hs_call_recording_url',
'operator' => 'CONTAINS_TOKEN',
'value' => $playbackURLToken,
],
],
'properties' => [
'hs_call_recording_url',
'hs_call_body',
],
'limit' => 1,
];
}
public function generateGetCallsPayload(Carbon $from, Carbon $to, string $activityProvider, $page): array
{
return [
'filters' => [
[
'propertyName' => 'hs_call_recording_url',
'operator' => 'CONTAINS_TOKEN',
'value' => $activityProvider,
],
[
'propertyName' => 'hs_timestamp',
'operator' => 'BETWEEN',
'value' => intval($from->getPreciseTimestamp(3)),
'highValue' => intval($to->getPreciseTimestamp(3)),
],
],
'sorts' => [
[
'propertyName' => 'hs_timestamp',
'direction' => 'DESCENDING',
],
],
'properties' => [
'hs_call_recording_url',
'hs_call_body',
],
'limit' => self::MAX_SEARCH_REQUEST_LIMIT,
'after' => ($page - 1) * self::MAX_SEARCH_REQUEST_LIMIT,
];
}
public function generateSearchCallsByPeriodPayload(Carbon $from, Carbon $to, $page): array
{
return [
'filters' => [
[
'propertyName' => 'hs_timestamp',
'operator' => 'BETWEEN',
'value' => intval($from->getPreciseTimestamp(3)),
'highValue' => intval($to->getPreciseTimestamp(3)),
],
],
'sorts' => [
[
'propertyName' => 'hs_timestamp',
'direction' => 'DESCENDING',
],
],
'properties' => $this->getSearchCallAttributes(),
'limit' => self::MAX_SEARCH_REQUEST_LIMIT,
'after' => ($page - 1) * self::MAX_SEARCH_REQUEST_LIMIT,
];
}
public function getSearchCallAttributes(): array
{
return [
'hs_timestamp',
'hs_call_recording_url',
'hs_call_body',
'hs_call_status',
'hs_call_to_number',
'hs_call_from_number',
'hs_call_duration',
'hs_call_disposition',
'hs_call_title',
'hs_call_direction',
'hubspot_owner_id',
'hs_activity_type',
'hs_call_external_id',
'hs_call_source',
];
}
public function generatePlaybackAddUrlBatchPayload(array $crmUpdateData): array
{
$updateObjectsData = [];
foreach ($crmUpdateData as $data) {
$updateObjectsData[] = [
'id' => $data['crm_id'],
'properties' => [
'hs_call_body' => $data['hs_call_body'] .
'<p><span style="font-size: 13.3333px; line-height: 16px;">' .
'<a href="' . $data['playback_url'] . '" target="_blank">Review in Jiminny</a> ▶️</span></p>',
],
];
}
return ['inputs' => $updateObjectsData];
}
public function generateSearchCallByTokenPayload(string $playbackURLToken): array
{
return [
'filters' => [
[
'propertyName' => 'hs_call_recording_url',
'operator' => 'CONTAINS_TOKEN',
'value' => $playbackURLToken,
],
],
'properties' => [
'hs_call_recording_url',
'hs_call_body',
],
'limit' => 1,
];
}
/**
* Generates a payload for phone search based on the specified parameters.
*
* @param string $phone The phone number to search.
* @param bool $isAlternativeSearch Indicates if an alternative search should be performed
* if the first search fails to find a match. The first search request should cover most of the cases.
*/
public function generatePhoneSearchPayload(string $phone, bool $isAlternativeSearch = false): array
{
$filterPropertyNames = $isAlternativeSearch ?
['hs_searchable_calculated_mobile_number', 'phone', 'mobilephone'] :
['hs_searchable_calculated_phone_number', 'hs_calculated_phone_number', 'hs_calculated_mobile_number'];
$filterGroups = array_map(function ($propertyName) use ($phone) {
return $this->createFilterGroup($propertyName, 'CONTAINS_TOKEN', $phone);
}, $filterPropertyNames);
$properties = [
'email',
'firstname',
'lastname',
'country',
'phone',
'mobilephone',
'jobtitle',
'hubspot_owner_id',
'associatedcompanyid',
'photo',
];
return $this->createPayload($filterGroups, $properties);
}
/**
* Creates a filter group with the specified parameters.
*/
private function createFilterGroup(string $propertyName, string $operator, $value): array
{
return [
'filters' => [
[
'propertyName' => $propertyName,
'operator' => $operator,
'value' => $value,
],
],
];
}
/**
* Creates a payload with the specified filter groups and properties.
*/
private function createPayload(array $filterGroups, array $properties): array
{
return [
'filterGroups' => $filterGroups,
'sorts' => [
[
'propertyName' => 'modifieddate',
'direction' => 'DESCENDING',
],
],
'properties' => $properties,
'limit' => 1,
];
}
/**
* Generates a payload to find related activities based on the specified data and object type.
* The search looks for activities that match the specified time range (starttime - endtime or only starttime with endtime not set)
* and are associated with any of the provided prospect types (contact or company).
*
* @param array $data An associative array containing the following keys:
* - 'from': The start time for the activity search (timestamp).
* - 'to': The end time for the activity search (timestamp).
* - 'contact': (optional) The ID of the associated contact.
* - 'company': (optional) The ID of the associated company.
* @param string $objectType The type of engagement to search for (meeting or other type like call).
*
* @return array The payload array to be used for searching related activities.
*/
public function getFindRelatedActivityPayload(array $data, string $objectType): array
{
$payload = [
'sorts' => [
[
'propertyName' => self::SORT_PROPERTY,
'direction' => 'DESCENDING',
],
],
'limit' => self::MAX_SEARCH_REQUEST_LIMIT,
];
if ($objectType === self::ENGAGEMENT_MEETINGS) {
$payload['properties'] = [
'hs_meeting_title',
'hs_meeting_outcome',
'hs_activity_type',
'hs_timestamp',
'hubspot_owner_id',
'hs_meeting_body',
'hs_internal_meeting_notes',
'hs_meeting_location',
'hs_meeting_start_time',
'hs_meeting_end_time',
];
} else {
$payload['properties'] = ['hs_task_subject', 'hs_timestamp', 'hubspot_owner_id', 'hs_call_body'];
}
$timeFiltersWithEndTime = [
[
'propertyName' => 'hs_meeting_start_time',
'operator' => 'GTE',
'value' => $data['from'],
],
[
'propertyName' => 'hs_meeting_end_time',
'operator' => 'LTE',
'value' => $data['to'],
],
];
$timeFiltersWithoutEndTime = [
[
'propertyName' => 'hs_meeting_start_time',
'operator' => 'GTE',
'value' => $data['from'],
],
[
'propertyName' => 'hs_meeting_end_time',
'operator' => 'NOT_HAS_PROPERTY',
],
];
$payload['filterGroups'] = [];
$associationTypes = ['contact', 'company'];
foreach ($associationTypes as $type) {
if (! empty($data[$type])) {
$filterGroupWithEndTime = [
'filters' => array_merge(
$timeFiltersWithEndTime,
[$this->getAssociatedObjectFilter($type, $data[$type])]
),
];
$payload['filterGroups'][] = $filterGroupWithEndTime;
$filterGroupWithoutEndTime = [
'filters' => array_merge(
$timeFiltersWithoutEndTime,
[$this->getAssociatedObjectFilter($type, $data[$type])]
),
];
$payload['filterGroups'][] = $filterGroupWithoutEndTime ;
}
}
return $payload;
}
/** Parameters
* [
* 'accountId' => $crmAccountId,
* 'sortBy' => $sortBy,
* 'sortDir' => $sortDir,
* 'onlyOpen' => $onlyOpen,
* 'closedStages' => $this->getClosedDealStages(),
* 'userId' => $userId,
* ];
*/
public function generateOpportunitiesSearchPayload(
Configuration $config,
string $crmAccountId,
array $closedStages,
): array {
$closedFilters = [];
$filterGroups = [];
$onlyOpen = true;
switch ($config->getOpportunityAssignmentRule()) {
case Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_UPDATED:
$sortBy = 'modifieddate';
$sortDir = 'DESCENDING';
break;
case Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_CREATED:
$sortBy = 'createdate';
$sortDir = 'DESCENDING';
break;
case Configuration::OPP_ASSIGNMENT_ALL_OPEN_OLDEST_CREATED:
$sortBy = 'createdate';
$sortDir = 'ASCENDING';
break;
case Configuration::OPP_ASSIGNMENT_ALL_RECENTLY_UPDATED:
default:
$sortBy = 'modifieddate';
$sortDir = 'DESCENDING';
$onlyOpen = false;
}
$baseFilters = [
[
'propertyName' => 'associations.company',
'operator' => 'EQ',
'value' => $crmAccountId,
],
];
// Handle closed stages in chunks
if ($onlyOpen) {
foreach (['won', 'lost'] as $key) {
if (! empty($closedStages[$key])) {
$chunks = array_chunk($closedStages[$key], self::MAX_FILTER_SIZE);
foreach ($chunks as $chunk) {
$closedFilters[] = [
'propertyName' => 'dealstage',
'operator' => 'NOT_IN',
'values' => $chunk,
];
}
}
}
}
$filterGroups[] = [
'filters' => array_merge($baseFilters, $closedFilters),
];
$payload = [
'filterGroups' => $filterGroups,
'sorts' => [
[
'propertyName' => $sortBy,
'direction' => $sortDir,
],
],
'properties' => [
'dealname',
'amount',
'hubspot_owner_id',
'pipeline',
'dealstage',
'closedate',
'deal_currency_code',
],
'limit' => self::MAX_SEARCH_REQUEST_LIMIT,
];
$logger = app(LoggerInterface::class);
$logger->info('[HubSpot] CRM Search requested', [
'request' => $payload,
]);
return $payload;
}
/**
* Converts v1 payload data to v3
*/
public function getV3MeetingPayload(array $engagement, array $metadata, array $associations = []): array
{
// Use this mapping until the whole Hubspot V1 is dropped.
$properties = [
'hubspot_owner_id' => $engagement['ownerId'],
'hs_timestamp' => $engagement['timestamp'],
];
// $engagement['activityType'] is $activity->category->name
if (isset($engagement['activityType'])) {
$properties['hs_activity_type'] = $engagement['activityType'];
}
$metadataKeyMap = [
'hs_meeting_outcome' => 'meetingOutcome',
'hs_meeting_title' => 'title',
'hs_meeting_start_time' => 'startTime',
'hs_meeting_end_time' => 'endTime',
'hs_meeting_body' => 'body',
'hs_internal_meeting_notes' => 'internalMeetingNotes',
];
foreach ($metadataKeyMap as $newKey => $oldKey) {
if (isset($metadata[$oldKey])) {
$properties[$newKey] = $metadata[$oldKey];
unset($metadata[$oldKey]);
}
}
$properties = [
...$properties,
...$metadata, // custom fields
];
$response = [
'properties' => $properties,
];
if (! empty($associations)) {
$response['associations'] = $associations;
}
return $response;
}
/**
* Generate a payload to search for contacts by name.
* The search is a token search in firstname or lastname
*/
public function generateSearchContactsByNamePayload(string $name, array $fields): array
{
$firstNameFilter = [
'propertyName' => 'firstname',
'operator' => 'CONTAINS_TOKEN',
'value' => $name,
];
$lastNameFilter = [
'propertyName' => 'lastname',
'operator' => 'CONTAINS_TOKEN',
'value' => $name,
];
return [
'filterGroups' => [
[
'filters' => [$firstNameFilter],
],
[
'filters' => [$lastNameFilter],
],
],
'properties' => $fields,
'sorts' => [
[
'propertyName' => 'lastmodifieddate',
'direction' => 'DESCENDING',
],
],
];
}
public function buildAddAssociationPayload(string $crmId, array $ids, int $associationType): array
{
$inputs = [];
foreach ($ids as $id) {
if ($id === null || $id === '') {
continue;
}
$inputs[] = [
'from' => [
'id' => $crmId,
],
'to' => [
'id' => (string) $id,
],
'types' => [
[
'associationCategory' => 'HUBSPOT_DEFINED',
'associationTypeId' => $associationType,
],
],
];
}
return ['inputs' => $inputs];
}
public function buildRemoveAssociationPayload(string $crmId, array $ids): array
{
$toArray = [];
foreach ($ids as $id) {
if ($id === null || $id === '') {
continue;
}
$toArray[] = ['id' => (string) $id];
}
return [
'inputs' => [
[
'from' => ['id' => $crmId],
'to' => $toArray,
],
],
];
}
public function addClosedStageFilters(array &$payload, array $closedStages): void
{
if (! empty($closedStages['won'])) {
$payload['filters'][] = [
'propertyName' => 'dealstage',
'operator' => 'NOT_IN',
'values' => $closedStages['won'],
];
}
if (! empty($closedStages['lost'])) {
$payload['filters'][] = [
'propertyName' => 'dealstage',
'operator' => 'NOT_IN',
'values' => $closedStages['lost'],
];
}
}
public function addCreatedDateFilters(array &$payload, Carbon $createdAfter): void
{
$payload['filters'][] = [
'propertyName' => 'createdate',
'operator' => 'GT',
'value' => $createdAfter->getPreciseTimestamp(3),
];
}
public function getDealsInBulkPayload(array $dealIds): array
{
return [
'filterGroups' => [
[
'filters' => [
[
'propertyName' => 'hs_object_id',
'operator' => 'IN',
'values' => $dealIds,
],
],
],
],
'limit' => self::MAX_FILTER_SIZE,
];
}
}
Sync Changes
Hide This Notification
Code changed:
Hide
22
8
Previous Highlighted Error
Next Highlighted Error
<?php
declare(strict_types=1);
namespace Jiminny\Services\Crm\Hubspot\Webhook;
use Illuminate\Support\Facades\Log;
use Jiminny\Models\Crm\Configuration;
use Jiminny\Services\Crm\Hubspot\BatchSyncCollector;
use Jiminny\Repositories\Crm\CrmEntityRepository;
use Jiminny\Repositories\Crm\OpportunityRepository;
use Jiminny\Repositories\Crm\FieldRepository;
use Jiminny\Repositories\Crm\FieldDataRepository;
use Jiminny\Models\Opportunity;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\LayoutEntity;
readonly class PropertyChangeManager
{
private const string OBJECT_TYPE_DEAL = 'deal';
private const string OBJECT_TYPE_CONTACT = 'contact';
private const string OBJECT_TYPE_COMPANY = 'company';
private const array SUPPORTED_OBJECT_TYPES = [
self::OBJECT_TYPE_DEAL,
self::OBJECT_TYPE_CONTACT,
self::OBJECT_TYPE_COMPANY,
];
/**
* Contact fields that webhooks are subscribed to in HubSpot.
*
* IMPORTANT: These fields MUST match the webhook subscriptions configured in HubSpot.
* When updating this list, you must also update the HubSpot webhook subscriptions:
* 1. Go to HubSpot App Settings → Webhooks
* 2. Update contact.propertyChange subscriptions to match this list
* 3. Ensure all fields listed here are subscribed, and vice versa
*
* This constant serves as documentation of the current webhook configuration.
* Since HubSpot filters webhooks server-side, we should only receive events for these properties.
*/
private const array APPLICABLE_CONTACT_FIELDS = [
'firstname',
'lastname',
'email',
'phone',
'mobilephone',
'jobtitle',
'country',
'hubspot_owner_id',
'associatedcompanyid',
'photo',
'hs_avatar_filemanager_key',
];
/**
* Company fields that webhooks are subscribed to in HubSpot.
*
* IMPORTANT: These fields MUST match the webhook subscriptions configured in HubSpot.
* When updating this list, you must also update the HubSpot webhook subscriptions:
* 1. Go to HubSpot App Settings → Webhooks
* 2. Update company.propertyChange subscriptions to match this list
* 3. Ensure all fields listed here are subscribed, and vice versa
*
* This constant serves as documentation of the current webhook configuration.
* Since HubSpot filters webhooks server-side, we should only receive events for these properties.
*/
private const array APPLICABLE_COMPANY_FIELDS = [
'name',
'phone',
'domain',
'country',
'industry',
'hubspot_owner_id',
'hs_avatar_filemanager_key',
];
/**
* Properties that support direct database updates (without full sync).
* These are simple properties that can be updated immediately without recalculating other fields.
*/
private const array DB_OBJECT_UPDATE_PROPERTIES = [
self::OBJECT_TYPE_DEAL => [
'dealname',
'description',
],
self::OBJECT_TYPE_CONTACT => [
// Add contact properties that support direct update
],
self::OBJECT_TYPE_COMPANY => [
// Add company properties that support direct update
],
];
/**
* Mapping from HubSpot property names to Jiminny model property names.
* Used when updating the main model (Opportunity, Contact, Account) directly.
* Only needed when HubSpot property name differs from Jiminny property name.
*/
private const array OBJECT_PROPERTY_MAPPINGS = [
self::OBJECT_TYPE_DEAL => [
'dealname' => 'name', // HubSpot 'dealname' maps to Opportunity 'name'
],
self::OBJECT_TYPE_CONTACT => [
// Add contact property mappings if needed
],
self::OBJECT_TYPE_COMPANY => [
// Add company property mappings if needed
],
];
// Field object types mapping
private const array FIELD_OBJECT_TYPES = [
self::OBJECT_TYPE_DEAL => Field::OBJECT_OPPORTUNITY,
self::OBJECT_TYPE_CONTACT => Field::OBJECT_CONTACT,
self::OBJECT_TYPE_COMPANY => Field::OBJECT_ACCOUNT,
];
public function __construct(
private BatchSyncCollector $batchCollector,
private OpportunityRepository $opportunityRepository,
private CrmEntityRepository $crmEntityRepository,
private FieldRepository $fieldRepository,
private FieldDataRepository $fieldDataRepository,
private HubspotWebhookMetricsService $metricsService,
) {
}
/**
* Get contact fields that webhooks are subscribed to in HubSpot.
* This serves as documentation of the current webhook configuration.
*
* @return array<int, string>
*/
public function getApplicableContactFields(): array
{
return self::APPLICABLE_CONTACT_FIELDS;
}
/**
* Get company fields that webhooks are subscribed to in HubSpot.
* This serves as documentation of the current webhook configuration.
*
* @return array<int, string>
*/
public function getApplicableCompanyFields(): array
{
return self::APPLICABLE_COMPANY_FIELDS;
}
/**
* Process property change with intelligent routing
*/
public function processPropertyChange(
string $objectType,
string $objectId,
Configuration $configuration,
string $propertyName,
string $propertyValue
): void {
if (! $this->isObjectTypeSupported($objectType)) {
Log::warning('[HubSpot Property Change Manager] Unsupported object type', [
'object_type' => $objectType,
'object_id' => $objectId,
'property_name' => $propertyName,
]);
return;
}
$this->metricsService->recordWebhookMetrics(
$configuration,
$objectType,
'property_change',
$propertyName
);
if ($this->isDirectUpdateSupported($objectType, $propertyName) &&
$this->handleDirectUpdate($objectType, $objectId, $configuration, $propertyName, $propertyValue)) {
return;
}
$this->handleObjectSync($objectType, $objectId, $configuration);
}
/**
* Legacy method for backward compatibility - assumes deal object type
*
* @deprecated Use processPropertyChange with objectType parameter
*/
public function processPropertyChangeForDeal(
string $objectId,
Configuration $configuration,
string $propertyName,
string $propertyValue
): void {
$this->processPropertyChange(self::OBJECT_TYPE_DEAL, $objectId, $configuration, $propertyName, $propertyValue);
}
/**
* Check if object type is supported
*/
private function isObjectTypeSupported(string $objectType): bool
{
return in_array($objectType, self::SUPPORTED_OBJECT_TYPES);
}
/**
* Check if property can be updated without additional calculation on other properties.
*/
private function isDirectUpdateSupported(string $objectType, string $propertyName): bool
{
$supportedProperties = self::DB_OBJECT_UPDATE_PROPERTIES[$objectType] ?? [];
return in_array($propertyName, $supportedProperties, true);
}
/**
* Handle properties that support direct database updates
*/
private function handleDirectUpdate(
string $objectType,
string $objectId,
Configuration $configuration,
string $propertyName,
string $propertyValue
): bool {
try {
return match ($objectType) {
self::OBJECT_TYPE_DEAL => $this->handleDealDirectUpdate($objectId, $configuration, $propertyName, $propertyValue),
self::OBJECT_TYPE_CONTACT => $this->handleContactDirectUpdate($objectId, $configuration, $propertyName, $propertyValue),
self::OBJECT_TYPE_COMPANY => $this->handleCompanyDirectUpdate($objectId, $configuration, $propertyName, $propertyValue),
default => false,
};
} catch (\Exception $e) {
Log::error('[HubSpot Property Change Manager] Error handling direct update property', [
'object_type' => $objectType,
'object_id' => $objectId,
'property_name' => $propertyName,
'error' => $e->getMessage(),
]);
return false;
}
}
/**
* Handle direct updates for deal objects
*/
private function handleDealDirectUpdate(
string $objectId,
Configuration $configuration,
string $propertyName,
string $propertyValue
): bool {
$opportunity = $this->opportunityRepository->findByConfigAndCrmProviderId(
$configuration,
$objectId
);
if (! $opportunity instanceof Opportunity) {
return false;
}
// Update model property if mapping exists
$propertyMappings = self::OBJECT_PROPERTY_MAPPINGS[self::OBJECT_TYPE_DEAL] ?? [];
if (isset($propertyMappings[$propertyName])) {
$this->opportunityRepository->updateOrCreate(
$configuration,
$objectId,
[$propertyMappings[$propertyName] => $propertyValue]
);
}
// Update field data
$this->importObjectFieldData(
self::OBJECT_TYPE_DEAL,
$opportunity->getId(),
$configuration,
[$propertyName => $propertyValue]
);
return true;
}
/**
* Handle direct updates for contact objects
*
* Updates Contact model properties via OBJECT_PROPERTY_MAPPINGS.
* Add mappings to OBJECT_PROPERTY_MAPPINGS[CONTACT] to enable direct updates.
*/
private function handleContactDirectUpdate(
string $objectId,
Configuration $configuration,
string $propertyName,
string $propertyValue
): bool {
$contact = $this->crmEntityRepository->findContactByExternalId($configuration, $objectId);
if ($contact === null) {
return false;
}
// Update model property if mapping exists
$propertyMappings = self::OBJECT_PROPERTY_MAPPINGS[self::OBJECT_TYPE_CONTACT] ?? [];
if (isset($propertyMappings[$propertyName])) {
$this->crmEntityRepository->importContact($configuration, [
'crm_provider_id' => $objectId,
$propertyMappings[$propertyName] => $propertyValue,
]);
}
return true;
}
/**
* Handle direct updates for company objects
*
* Updates Account model properties via OBJECT_PROPERTY_MAPPINGS.
* Add mappings to OBJECT_PROPERTY_MAPPINGS[COMPANY] to enable direct updates.
*/
private function handleCompanyDirectUpdate(
string $objectId,
Configuration $configuration,
string $propertyName,
string $propertyValue
): bool {
$company = $this->crmEntityRepository->findAccountByExternalId($configuration, $objectId);
if ($company === null) {
return false;
}
// Update model property if mapping exists
$propertyMappings = self::OBJECT_PROPERTY_MAPPINGS[self::OBJECT_TYPE_COMPANY] ?? [];
if (isset($propertyMappings[$propertyName])) {
$this->crmEntityRepository->importAccount($configuration, [
'crm_provider_id' => $objectId,
$propertyMappings[$propertyName] => $propertyValue,
]);
}
return true;
}
/**
* Import field data for any object type
*/
private function importObjectFieldData(
string $objectType,
int $objectId,
Configuration $configuration,
array $data
): void {
$fieldObjectType = self::FIELD_OBJECT_TYPES[$objectType] ?? null;
if (! $fieldObjectType) {
Log::warning('[HubSpot Property Change Manager] Unknown field object type', [
'object_type' => $objectType,
'object_id' => $objectId,
]);
return;
}
foreach ($data as $crmField => $value) {
$field = $this->fieldRepository->findFieldByCrmIdAndObjectType(
$configuration,
$crmField,
$fieldObjectType
);
if (! $field instanceof Field) {
continue;
}
$entities = $field->getEntities();
if ($entities->isEmpty()) {
continue;
}
if ($entities->count() > 1) {
continue;
}
/** @var LayoutEntity|null $entity */
$entity = $entities->first();
if (! $entity instanceof LayoutEntity) {
continue;
}
$this->fieldDataRepository->updateOrCreateFieldData(
$entity,
$objectId,
(string) $value,
);
}
}
private function handleObjectSync(
string $objectType,
string $objectId,
Configuration $configuration,
): void {
if (! $this->isObjectTypeSupported($objectType)) {
return;
}
try {
$this->batchCollector->collectForBatch(
objectType: $objectType,
crmProviderId: $objectId,
configurationId: $configuration->getId(),
eventType: 'property_change'
);
} catch (\Exception $e) {
Log::error('[HubSpot Property Change Manager] Error adding to sync batch', [
'object_type' => $objectType,
'object_id' => $objectId,
'error' => $e->getMessage(),
]);
}
}
}
Project
Project...
|
PhpStorm
|
faVsco.js – PropertyChangeManager.php
|
NULL
|
87913
|
|
87912
|
Project: faVsco.js, menu
#12121 on JY-20963-fix-im Project: faVsco.js, menu
#12121 on JY-20963-fix-import-on-deleted-entity, menu
Start Listening for PHP Debug Connections
ServiceTest
Run 'ServiceTest'
Debug 'ServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
8
25
Previous Highlighted Error
Next Highlighted Error
<?php
declare(strict_types=1);
namespace Jiminny\Services\Crm\Hubspot;
use Carbon\Carbon;
use Jiminny\Models\Crm\Configuration;
use Psr\Log\LoggerInterface;
class PayloadBuilder
{
public const int MAX_SEARCH_REQUEST_LIMIT = 200;
private const int MAX_FILTER_SIZE = 100;
private const string SORT_PROPERTY = 'hs_timestamp';
private const string ENGAGEMENT_MEETINGS = 'meetings';
public function getLinkToTaskPayload(string $objectType, string $objectId, string $engagementType): array
{
$payload = [
'sorts' => [
[
'propertyName' => self::SORT_PROPERTY,
'direction' => 'DESCENDING',
],
],
'limit' => self::MAX_SEARCH_REQUEST_LIMIT,
];
if ($engagementType === self::ENGAGEMENT_MEETINGS) {
$payload['filterGroups'] = $this->buildMeetingFiltersForLinkToTask($objectType, $objectId);
$payload['properties'] = [
'hs_meeting_title',
'hs_meeting_outcome',
'hs_timestamp',
'hs_activity_type',
];
} else {
$payload['filterGroups'] = $this->buildTaskFiltersForLinkToTask($objectType, $objectId);
$payload['properties'] = ['hs_task_subject', 'hs_timestamp'];
}
return $payload;
}
public function getRecentlyUpdatedSearchPayload(Carbon $since, ?Carbon $to, array $properties): array
{
$lastUpdateDate = in_array('firstname', $properties) ? 'lastmodifieddate' : 'hs_lastmodifieddate';
$payload = [
'filters' => [
[
'propertyName' => $lastUpdateDate,
'operator' => 'GT',
'value' => $since->getPreciseTimestamp(3),
],
],
'sorts' => [
[
'propertyName' => 'hs_object_id',
'direction' => 'DESCENDING',
],
],
'limit' => self::MAX_SEARCH_REQUEST_LIMIT,
];
if ($to) {
$payload['filters'][] = [
'propertyName' => $lastUpdateDate,
'operator' => 'LT',
'value' => $to->getPreciseTimestamp(3),
];
}
$payload['properties'] = $properties;
return $payload;
}
public function getByProfileSearchPayload(string $crmId, array $properties, Carbon $since, ?Carbon $to): array
{
$lastUpdateDate = array_key_exists('firstname', $properties) ? 'lastmodifieddate' : 'hs_lastmodifieddate';
$payload = [
'filters' => [
[
'propertyName' => $lastUpdateDate,
'operator' => 'GT',
'value' => $since->getPreciseTimestamp(3),
],
[
'propertyName' => 'hubspot_owner_id',
'operator' => 'EQ',
'value' => $crmId,
],
],
'sorts' => [
[
'propertyName' => 'hs_object_id',
'direction' => 'DESCENDING',
],
],
'limit' => self::MAX_SEARCH_REQUEST_LIMIT,
];
if ($to) {
$payload['filters'][] = [
'propertyName' => $lastUpdateDate,
'operator' => 'LT',
'value' => $to->getPreciseTimestamp(3),
];
}
$payload['properties'] = $properties;
return $payload;
}
private function buildMeetingFiltersForLinkToTask($objectType, $objectId): array
{
$filters = [];
$filters[] = [
'filters' => [
$this->getAssociatedObjectFilter($objectType, $objectId),
[
'propertyName' => 'hs_meeting_outcome',
'operator' => 'IN',
'values' => ['SCHEDULED', 'RESCHEDULED'],
],
],
];
$filters[] = [
'filters' => [
$this->getAssociatedObjectFilter($objectType, $objectId),
[
'propertyName' => 'hs_meeting_outcome',
'operator' => 'NOT_HAS_PROPERTY',
],
],
];
return $filters;
}
private function buildTaskFiltersForLinkToTask($objectType, $objectId): array
{
return [
[
'filters' => [
$this->getAssociatedObjectFilter($objectType, $objectId),
],
],
];
}
private function getAssociatedObjectFilter(string $objectType, string $objectId): array
{
return [
'propertyName' => 'associations.' . $objectType,
'operator' => 'EQ',
'value' => $objectId,
];
}
public function generatePlaybackURLSearchPayload(string $playbackURLToken): array
{
return [
'filters' => [
[
'propertyName' => 'hs_call_recording_url',
'operator' => 'CONTAINS_TOKEN',
'value' => $playbackURLToken,
],
],
'properties' => [
'hs_call_recording_url',
'hs_call_body',
],
'limit' => 1,
];
}
public function generateGetCallsPayload(Carbon $from, Carbon $to, string $activityProvider, $page): array
{
return [
'filters' => [
[
'propertyName' => 'hs_call_recording_url',
'operator' => 'CONTAINS_TOKEN',
'value' => $activityProvider,
],
[
'propertyName' => 'hs_timestamp',
'operator' => 'BETWEEN',
'value' => intval($from->getPreciseTimestamp(3)),
'highValue' => intval($to->getPreciseTimestamp(3)),
],
],
'sorts' => [
[
'propertyName' => 'hs_timestamp',
'direction' => 'DESCENDING',
],
],
'properties' => [
'hs_call_recording_url',
'hs_call_body',
],
'limit' => self::MAX_SEARCH_REQUEST_LIMIT,
'after' => ($page - 1) * self::MAX_SEARCH_REQUEST_LIMIT,
];
}
public function generateSearchCallsByPeriodPayload(Carbon $from, Carbon $to, $page): array
{
return [
'filters' => [
[
'propertyName' => 'hs_timestamp',
'operator' => 'BETWEEN',
'value' => intval($from->getPreciseTimestamp(3)),
'highValue' => intval($to->getPreciseTimestamp(3)),
],
],
'sorts' => [
[
'propertyName' => 'hs_timestamp',
'direction' => 'DESCENDING',
],
],
'properties' => $this->getSearchCallAttributes(),
'limit' => self::MAX_SEARCH_REQUEST_LIMIT,
'after' => ($page - 1) * self::MAX_SEARCH_REQUEST_LIMIT,
];
}
public function getSearchCallAttributes(): array
{
return [
'hs_timestamp',
'hs_call_recording_url',
'hs_call_body',
'hs_call_status',
'hs_call_to_number',
'hs_call_from_number',
'hs_call_duration',
'hs_call_disposition',
'hs_call_title',
'hs_call_direction',
'hubspot_owner_id',
'hs_activity_type',
'hs_call_external_id',
'hs_call_source',
];
}
public function generatePlaybackAddUrlBatchPayload(array $crmUpdateData): array
{
$updateObjectsData = [];
foreach ($crmUpdateData as $data) {
$updateObjectsData[] = [
'id' => $data['crm_id'],
'properties' => [
'hs_call_body' => $data['hs_call_body'] .
'<p><span style="font-size: 13.3333px; line-height: 16px;">' .
'<a href="' . $data['playback_url'] . '" target="_blank">Review in Jiminny</a> ▶️</span></p>',
],
];
}
return ['inputs' => $updateObjectsData];
}
public function generateSearchCallByTokenPayload(string $playbackURLToken): array
{
return [
'filters' => [
[
'propertyName' => 'hs_call_recording_url',
'operator' => 'CONTAINS_TOKEN',
'value' => $playbackURLToken,
],
],
'properties' => [
'hs_call_recording_url',
'hs_call_body',
],
'limit' => 1,
];
}
/**
* Generates a payload for phone search based on the specified parameters.
*
* @param string $phone The phone number to search.
* @param bool $isAlternativeSearch Indicates if an alternative search should be performed
* if the first search fails to find a match. The first search request should cover most of the cases.
*/
public function generatePhoneSearchPayload(string $phone, bool $isAlternativeSearch = false): array
{
$filterPropertyNames = $isAlternativeSearch ?
['hs_searchable_calculated_mobile_number', 'phone', 'mobilephone'] :
['hs_searchable_calculated_phone_number', 'hs_calculated_phone_number', 'hs_calculated_mobile_number'];
$filterGroups = array_map(function ($propertyName) use ($phone) {
return $this->createFilterGroup($propertyName, 'CONTAINS_TOKEN', $phone);
}, $filterPropertyNames);
$properties = [
'email',
'firstname',
'lastname',
'country',
'phone',
'mobilephone',
'jobtitle',
'hubspot_owner_id',
'associatedcompanyid',
'photo',
];
return $this->createPayload($filterGroups, $properties);
}
/**
* Creates a filter group with the specified parameters.
*/
private function createFilterGroup(string $propertyName, string $operator, $value): array
{
return [
'filters' => [
[
'propertyName' => $propertyName,
'operator' => $operator,
'value' => $value,
],
],
];
}
/**
* Creates a payload with the specified filter groups and properties.
*/
private function createPayload(array $filterGroups, array $properties): array
{
return [
'filterGroups' => $filterGroups,
'sorts' => [
[
'propertyName' => 'modifieddate',
'direction' => 'DESCENDING',
],
],
'properties' => $properties,
'limit' => 1,
];
}
/**
* Generates a payload to find related activities based on the specified data and object type.
* The search looks for activities that match the specified time range (starttime - endtime or only starttime with endtime not set)
* and are associated with any of the provided prospect types (contact or company).
*
* @param array $data An associative array containing the following keys:
* - 'from': The start time for the activity search (timestamp).
* - 'to': The end time for the activity search (timestamp).
* - 'contact': (optional) The ID of the associated contact.
* - 'company': (optional) The ID of the associated company.
* @param string $objectType The type of engagement to search for (meeting or other type like call).
*
* @return array The payload array to be used for searching related activities.
*/
public function getFindRelatedActivityPayload(array $data, string $objectType): array
{
$payload = [
'sorts' => [
[
'propertyName' => self::SORT_PROPERTY,
'direction' => 'DESCENDING',
],
],
'limit' => self::MAX_SEARCH_REQUEST_LIMIT,
];
if ($objectType === self::ENGAGEMENT_MEETINGS) {
$payload['properties'] = [
'hs_meeting_title',
'hs_meeting_outcome',
'hs_activity_type',
'hs_timestamp',
'hubspot_owner_id',
'hs_meeting_body',
'hs_internal_meeting_notes',
'hs_meeting_location',
'hs_meeting_start_time',
'hs_meeting_end_time',
];
} else {
$payload['properties'] = ['hs_task_subject', 'hs_timestamp', 'hubspot_owner_id', 'hs_call_body'];
}
$timeFiltersWithEndTime = [
[
'propertyName' => 'hs_meeting_start_time',
'operator' => 'GTE',
'value' => $data['from'],
],
[
'propertyName' => 'hs_meeting_end_time',
'operator' => 'LTE',
'value' => $data['to'],
],
];
$timeFiltersWithoutEndTime = [
[
'propertyName' => 'hs_meeting_start_time',
'operator' => 'GTE',
'value' => $data['from'],
],
[
'propertyName' => 'hs_meeting_end_time',
'operator' => 'NOT_HAS_PROPERTY',
],
];
$payload['filterGroups'] = [];
$associationTypes = ['contact', 'company'];
foreach ($associationTypes as $type) {
if (! empty($data[$type])) {
$filterGroupWithEndTime = [
'filters' => array_merge(
$timeFiltersWithEndTime,
[$this->getAssociatedObjectFilter($type, $data[$type])]
),
];
$payload['filterGroups'][] = $filterGroupWithEndTime;
$filterGroupWithoutEndTime = [
'filters' => array_merge(
$timeFiltersWithoutEndTime,
[$this->getAssociatedObjectFilter($type, $data[$type])]
),
];
$payload['filterGroups'][] = $filterGroupWithoutEndTime ;
}
}
return $payload;
}
/** Parameters
* [
* 'accountId' => $crmAccountId,
* 'sortBy' => $sortBy,
* 'sortDir' => $sortDir,
* 'onlyOpen' => $onlyOpen,
* 'closedStages' => $this->getClosedDealStages(),
* 'userId' => $userId,
* ];
*/
public function generateOpportunitiesSearchPayload(
Configuration $config,
string $crmAccountId,
array $closedStages,
): array {
$closedFilters = [];
$filterGroups = [];
$onlyOpen = true;
switch ($config->getOpportunityAssignmentRule()) {
case Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_UPDATED:
$sortBy = 'modifieddate';
$sortDir = 'DESCENDING';
break;
case Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_CREATED:
$sortBy = 'createdate';
$sortDir = 'DESCENDING';
break;
case Configuration::OPP_ASSIGNMENT_ALL_OPEN_OLDEST_CREATED:
$sortBy = 'createdate';
$sortDir = 'ASCENDING';
break;
case Configuration::OPP_ASSIGNMENT_ALL_RECENTLY_UPDATED:
default:
$sortBy = 'modifieddate';
$sortDir = 'DESCENDING';
$onlyOpen = false;
}
$baseFilters = [
[
'propertyName' => 'associations.company',
'operator' => 'EQ',
'value' => $crmAccountId,
],
];
// Handle closed stages in chunks
if ($onlyOpen) {
foreach (['won', 'lost'] as $key) {
if (! empty($closedStages[$key])) {
$chunks = array_chunk($closedStages[$key], self::MAX_FILTER_SIZE);
foreach ($chunks as $chunk) {
$closedFilters[] = [
'propertyName' => 'dealstage',
'operator' => 'NOT_IN',
'values' => $chunk,
];
}
}
}
}
$filterGroups[] = [
'filters' => array_merge($baseFilters, $closedFilters),
];
$payload = [
'filterGroups' => $filterGroups,
'sorts' => [
[
'propertyName' => $sortBy,
'direction' => $sortDir,
],
],
'properties' => [
'dealname',
'amount',
'hubspot_owner_id',
'pipeline',
'dealstage',
'closedate',
'deal_currency_code',
],
'limit' => self::MAX_SEARCH_REQUEST_LIMIT,
];
$logger = app(LoggerInterface::class);
$logger->info('[HubSpot] CRM Search requested', [
'request' => $payload,
]);
return $payload;
}
/**
* Converts v1 payload data to v3
*/
public function getV3MeetingPayload(array $engagement, array $metadata, array $associations = []): array
{
// Use this mapping until the whole Hubspot V1 is dropped.
$properties = [
'hubspot_owner_id' => $engagement['ownerId'],
'hs_timestamp' => $engagement['timestamp'],
];
// $engagement['activityType'] is $activity->category->name
if (isset($engagement['activityType'])) {
$properties['hs_activity_type'] = $engagement['activityType'];
}
$metadataKeyMap = [
'hs_meeting_outcome' => 'meetingOutcome',
'hs_meeting_title' => 'title',
'hs_meeting_start_time' => 'startTime',
'hs_meeting_end_time' => 'endTime',
'hs_meeting_body' => 'body',
'hs_internal_meeting_notes' => 'internalMeetingNotes',
];
foreach ($metadataKeyMap as $newKey => $oldKey) {
if (isset($metadata[$oldKey])) {
$properties[$newKey] = $metadata[$oldKey];
unset($metadata[$oldKey]);
}
}
$properties = [
...$properties,
...$metadata, // custom fields
];
$response = [
'properties' => $properties,
];
if (! empty($associations)) {
$response['associations'] = $associations;
}
return $response;
}
/**
* Generate a payload to search for contacts by name.
* The search is a token search in firstname or lastname
*/
public function generateSearchContactsByNamePayload(string $name, array $fields): array
{
$firstNameFilter = [
'propertyName' => 'firstname',
'operator' => 'CONTAINS_TOKEN',
'value' => $name,
];
$lastNameFilter = [
'propertyName' => 'lastname',
'operator' => 'CONTAINS_TOKEN',
'value' => $name,
];
return [
'filterGroups' => [
[
'filters' => [$firstNameFilter],
],
[
'filters' => [$lastNameFilter],
],
],
'properties' => $fields,
'sorts' => [
[
'propertyName' => 'lastmodifieddate',
'direction' => 'DESCENDING',
],
],
];
}
public function buildAddAssociationPayload(string $crmId, array $ids, int $associationType): array
{
$inputs = [];
foreach ($ids as $id) {
if ($id === null || $id === '') {
continue;
}
$inputs[] = [
'from' => [
'id' => $crmId,
],
'to' => [
'id' => (string) $id,
],
'types' => [
[
'associationCategory' => 'HUBSPOT_DEFINED',
'associationTypeId' => $associationType,
],
],
];
}
return ['inputs' => $inputs];
}
public function buildRemoveAssociationPayload(string $crmId, array $ids): array
{
$toArray = [];
foreach ($ids as $id) {
if ($id === null || $id === '') {
continue;
}
$toArray[] = ['id' => (string) $id];
}
return [
'inputs' => [
[
'from' => ['id' => $crmId],
'to' => $toArray,
],
],
];
}
public function addClosedStageFilters(array &$payload, array $closedStages): void
{
if (! empty($closedStages['won'])) {
$payload['filters'][] = [
'propertyName' => 'dealstage',
'operator' => 'NOT_IN',
'values' => $closedStages['won'],
];
}
if (! empty($closedStages['lost'])) {
$payload['filters'][] = [
'propertyName' => 'dealstage',
'operator' => 'NOT_IN',
'values' => $closedStages['lost'],
];
}
}
public function addCreatedDateFilters(array &$payload, Carbon $createdAfter): void
{
$payload['filters'][] = [
'propertyName' => 'createdate',
'operator' => 'GT',
'value' => $createdAfter->getPreciseTimestamp(3),
];
}
public function getDealsInBulkPayload(array $dealIds): array
{
return [
'filterGroups' => [
[
'filters' => [
[
'propertyName' => 'hs_object_id',
'operator' => 'IN',
'values' => $dealIds,
],
],
],
],
'limit' => self::MAX_FILTER_SIZE,
];
}
}
Sync Changes
Hide This Notification
Code changed:
Hide
22
8
Previous Highlighted Error
Next Highlighted Error
<?php
declare(strict_types=1);
namespace Jiminny\Services\Crm\Hubspot\Webhook;
use Illuminate\Support\Facades\Log;
use Jiminny\Models\Crm\Configuration;
use Jiminny\Services\Crm\Hubspot\BatchSyncCollector;
use Jiminny\Repositories\Crm\CrmEntityRepository;
use Jiminny\Repositories\Crm\OpportunityRepository;
use Jiminny\Repositories\Crm\FieldRepository;
use Jiminny\Repositories\Crm\FieldDataRepository;
use Jiminny\Models\Opportunity;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\LayoutEntity;
readonly class PropertyChangeManager
{
private const string OBJECT_TYPE_DEAL = 'deal';
private const string OBJECT_TYPE_CONTACT = 'contact';
private const string OBJECT_TYPE_COMPANY = 'company';
private const array SUPPORTED_OBJECT_TYPES = [
self::OBJECT_TYPE_DEAL,
self::OBJECT_TYPE_CONTACT,
self::OBJECT_TYPE_COMPANY,
];
/**
* Contact fields that webhooks are subscribed to in HubSpot.
*
* IMPORTANT: These fields MUST match the webhook subscriptions configured in HubSpot.
* When updating this list, you must also update the HubSpot webhook subscriptions:
* 1. Go to HubSpot App Settings → Webhooks
* 2. Update contact.propertyChange subscriptions to match this list
* 3. Ensure all fields listed here are subscribed, and vice versa
*
* This constant serves as documentation of the current webhook configuration.
* Since HubSpot filters webhooks server-side, we should only receive events for these properties.
*/
private const array APPLICABLE_CONTACT_FIELDS = [
'firstname',
'lastname',
'email',
'phone',
'mobilephone',
'jobtitle',
'country',
'hubspot_owner_id',
'associatedcompanyid',
'photo',
'hs_avatar_filemanager_key',
];
/**
* Company fields that webhooks are subscribed to in HubSpot.
*
* IMPORTANT: These fields MUST match the webhook subscriptions configured in HubSpot.
* When updating this list, you must also update the HubSpot webhook subscriptions:
* 1. Go to HubSpot App Settings → Webhooks
* 2. Update company.propertyChange subscriptions to match this list
* 3. Ensure all fields listed here are subscribed, and vice versa
*
* This constant serves as documentation of the current webhook configuration.
* Since HubSpot filters webhooks server-side, we should only receive events for these properties.
*/
private const array APPLICABLE_COMPANY_FIELDS = [
'name',
'phone',
'domain',
'country',
'industry',
'hubspot_owner_id',
'hs_avatar_filemanager_key',
];
/**
* Properties that support direct database updates (without full sync).
* These are simple properties that can be updated immediately without recalculating other fields.
*/
private const array DB_OBJECT_UPDATE_PROPERTIES = [
self::OBJECT_TYPE_DEAL => [
'dealname',
'description',
],
self::OBJECT_TYPE_CONTACT => [
// Add contact properties that support direct update
],
self::OBJECT_TYPE_COMPANY => [
// Add company properties that support direct update
],
];
/**
* Mapping from HubSpot property names to Jiminny model property names.
* Used when updating the main model (Opportunity, Contact, Account) directly.
* Only needed when HubSpot property name differs from Jiminny property name.
*/
private const array OBJECT_PROPERTY_MAPPINGS = [
self::OBJECT_TYPE_DEAL => [
'dealname' => 'name', // HubSpot 'dealname' maps to Opportunity 'name'
],
self::OBJECT_TYPE_CONTACT => [
// Add contact property mappings if needed
],
self::OBJECT_TYPE_COMPANY => [
// Add company property mappings if needed
],
];
// Field object types mapping
private const array FIELD_OBJECT_TYPES = [
self::OBJECT_TYPE_DEAL => Field::OBJECT_OPPORTUNITY,
self::OBJECT_TYPE_CONTACT => Field::OBJECT_CONTACT,
self::OBJECT_TYPE_COMPANY => Field::OBJECT_ACCOUNT,
];
public function __construct(
private BatchSyncCollector $batchCollector,
private OpportunityRepository $opportunityRepository,
private CrmEntityRepository $crmEntityRepository,
private FieldRepository $fieldRepository,
private FieldDataRepository $fieldDataRepository,
private HubspotWebhookMetricsService $metricsService,
) {
}
/**
* Get contact fields that webhooks are subscribed to in HubSpot.
* This serves as documentation of the current webhook configuration.
*
* @return array<int, string>
*/
public function getApplicableContactFields(): array
{
return self::APPLICABLE_CONTACT_FIELDS;
}
/**
* Get company fields that webhooks are subscribed to in HubSpot.
* This serves as documentation of the current webhook configuration.
*
* @return array<int, string>
*/
public function getApplicableCompanyFields(): array
{
return self::APPLICABLE_COMPANY_FIELDS;
}
/**
* Process property change with intelligent routing
*/
public function processPropertyChange(
string $objectType,
string $objectId,
Configuration $configuration,
string $propertyName,
string $propertyValue
): void {
if (! $this->isObjectTypeSupported($objectType)) {
Log::warning('[HubSpot Property Change Manager] Unsupported object type', [
'object_type' => $objectType,
'object_id' => $objectId,
'property_name' => $propertyName,
]);
return;
}
$this->metricsService->recordWebhookMetrics(
$configuration,
$objectType,
'property_change',
$propertyName
);
if ($this->isDirectUpdateSupported($objectType, $propertyName) &&
$this->handleDirectUpdate($objectType, $objectId, $configuration, $propertyName, $propertyValue)) {
return;
}
$this->handleObjectSync($objectType, $objectId, $configuration);
}
/**
* Legacy method for backward compatibility - assumes deal object type
*
* @deprecated Use processPropertyChange with objectType parameter
*/
public function processPropertyChangeForDeal(
string $objectId,
Configuration $configuration,
string $propertyName,
string $propertyValue
): void {
$this->processPropertyChange(self::OBJECT_TYPE_DEAL, $objectId, $configuration, $propertyName, $propertyValue);
}
/**
* Check if object type is supported
*/
private function isObjectTypeSupported(string $objectType): bool
{
return in_array($objectType, self::SUPPORTED_OBJECT_TYPES);
}
/**
* Check if property can be updated without additional calculation on other properties.
*/
private function isDirectUpdateSupported(string $objectType, string $propertyName): bool
{
$supportedProperties = self::DB_OBJECT_UPDATE_PROPERTIES[$objectType] ?? [];
return in_array($propertyName, $supportedProperties, true);
}
/**
* Handle properties that support direct database updates
*/
private function handleDirectUpdate(
string $objectType,
string $objectId,
Configuration $configuration,
string $propertyName,
string $propertyValue
): bool {
try {
return match ($objectType) {
self::OBJECT_TYPE_DEAL => $this->handleDealDirectUpdate($objectId, $configuration, $propertyName, $propertyValue),
self::OBJECT_TYPE_CONTACT => $this->handleContactDirectUpdate($objectId, $configuration, $propertyName, $propertyValue),
self::OBJECT_TYPE_COMPANY => $this->handleCompanyDirectUpdate($objectId, $configuration, $propertyName, $propertyValue),
default => false,
};
} catch (\Exception $e) {
Log::error('[HubSpot Property Change Manager] Error handling direct update property', [
'object_type' => $objectType,
'object_id' => $objectId,
'property_name' => $propertyName,
'error' => $e->getMessage(),
]);
return false;
}
}
/**
* Handle direct updates for deal objects
*/
private function handleDealDirectUpdate(
string $objectId,
Configuration $configuration,
string $propertyName,
string $propertyValue
): bool {
$opportunity = $this->opportunityRepository->findByConfigAndCrmProviderId(
$configuration,
$objectId
);
if (! $opportunity instanceof Opportunity) {
return false;
}
// Update model property if mapping exists
$propertyMappings = self::OBJECT_PROPERTY_MAPPINGS[self::OBJECT_TYPE_DEAL] ?? [];
if (isset($propertyMappings[$propertyName])) {
$this->opportunityRepository->updateOrCreate(
$configuration,
$objectId,
[$propertyMappings[$propertyName] => $propertyValue]
);
}
// Update field data
$this->importObjectFieldData(
self::OBJECT_TYPE_DEAL,
$opportunity->getId(),
$configuration,
[$propertyName => $propertyValue]
);
return true;
}
/**
* Handle direct updates for contact objects
*
* Updates Contact model properties via OBJECT_PROPERTY_MAPPINGS.
* Add mappings to OBJECT_PROPERTY_MAPPINGS[CONTACT] to enable direct updates.
*/
private function handleContactDirectUpdate(
string $objectId,
Configuration $configuration,
string $propertyName,
string $propertyValue
): bool {
$contact = $this->crmEntityRepository->findContactByExternalId($configuration, $objectId);
if ($contact === null) {
return false;
}
// Update model property if mapping exists
$propertyMappings = self::OBJECT_PROPERTY_MAPPINGS[self::OBJECT_TYPE_CONTACT] ?? [];
if (isset($propertyMappings[$propertyName])) {
$this->crmEntityRepository->importContact($configuration, [
'crm_provider_id' => $objectId,
$propertyMappings[$propertyName] => $propertyValue,
]);
}
return true;
}
/**
* Handle direct updates for company objects
*
* Updates Account model properties via OBJECT_PROPERTY_MAPPINGS.
* Add mappings to OBJECT_PROPERTY_MAPPINGS[COMPANY] to enable direct updates.
*/
private function handleCompanyDirectUpdate(
string $objectId,
Configuration $configuration,
string $propertyName,
string $propertyValue
): bool {
$company = $this->crmEntityRepository->findAccountByExternalId($configuration, $objectId);
if ($company === null) {
return false;
}
// Update model property if mapping exists
$propertyMappings = self::OBJECT_PROPERTY_MAPPINGS[self::OBJECT_TYPE_COMPANY] ?? [];
if (isset($propertyMappings[$propertyName])) {
$this->crmEntityRepository->importAccount($configuration, [
'crm_provider_id' => $objectId,
$propertyMappings[$propertyName] => $propertyValue,
]);
}
return true;
}
/**
* Import field data for any object type
*/
private function importObjectFieldData(
string $objectType,
int $objectId,
Configuration $configuration,
array $data
): void {
$fieldObjectType = self::FIELD_OBJECT_TYPES[$objectType] ?? null;
if (! $fieldObjectType) {
Log::warning('[HubSpot Property Change Manager] Unknown field object type', [
'object_type' => $objectType,
'object_id' => $objectId,
]);
return;
}
foreach ($data as $crmField => $value) {
$field = $this->fieldRepository->findFieldByCrmIdAndObjectType(
$configuration,
$crmField,
$fieldObjectType
);
if (! $field instanceof Field) {
continue;
}
$entities = $field->getEntities();
if ($entities->isEmpty()) {
continue;
}
if ($entities->count() > 1) {
continue;
}
/** @var LayoutEntity|null $entity */
$entity = $entities->first();
if (! $entity instanceof LayoutEntity) {
continue;
}
$this->fieldDataRepository->updateOrCreateFieldData(
$entity,
$objectId,
(string) $value,
);
}
}
private function handleObjectSync(
string $objectType,
string $objectId,
Configuration $configuration,
): void {
if (! $this->isObjectTypeSupported($objectType)) {
return;
}
try {
$this->batchCollector->collectForBatch(
objectType: $objectType,
crmProviderId: $objectId,
configurationId: $configuration->getId(),
eventType: 'property_change'
);
} catch (\Exception $e) {
Log::error('[HubSpot Property Change Manager] Error adding to sync batch', [
'object_type' => $objectType,
'object_id' => $objectId,
'error' => $e->getMessage(),
]);
}
}
}
Project
Project
New File or Directory…
Expand Selected
Collapse All
Options
Hide...
|
PhpStorm
|
faVsco.js – PropertyChangeManager.php
|
NULL
|
87912
|
|
87911
|
Project: faVsco.js, menu
#12121 on JY-20963-fix-im Project: faVsco.js, menu
#12121 on JY-20963-fix-import-on-deleted-entity, menu
Start Listening for PHP Debug Connections
ServiceTest
Run 'ServiceTest'
Debug 'ServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
8
25
Previous Highlighted Error
Next Highlighted Error
<?php
declare(strict_types=1);
namespace Jiminny\Services\Crm\Hubspot;
use Carbon\Carbon;
use Jiminny\Models\Crm\Configuration;
use Psr\Log\LoggerInterface;
class PayloadBuilder
{
public const int MAX_SEARCH_REQUEST_LIMIT = 200;
private const int MAX_FILTER_SIZE = 100;
private const string SORT_PROPERTY = 'hs_timestamp';
private const string ENGAGEMENT_MEETINGS = 'meetings';
public function getLinkToTaskPayload(string $objectType, string $objectId, string $engagementType): array
{
$payload = [
'sorts' => [
[
'propertyName' => self::SORT_PROPERTY,
'direction' => 'DESCENDING',
],
],
'limit' => self::MAX_SEARCH_REQUEST_LIMIT,
];
if ($engagementType === self::ENGAGEMENT_MEETINGS) {
$payload['filterGroups'] = $this->buildMeetingFiltersForLinkToTask($objectType, $objectId);
$payload['properties'] = [
'hs_meeting_title',
'hs_meeting_outcome',
'hs_timestamp',
'hs_activity_type',
];
} else {
$payload['filterGroups'] = $this->buildTaskFiltersForLinkToTask($objectType, $objectId);
$payload['properties'] = ['hs_task_subject', 'hs_timestamp'];
}
return $payload;
}
public function getRecentlyUpdatedSearchPayload(Carbon $since, ?Carbon $to, array $properties): array
{
$lastUpdateDate = in_array('firstname', $properties) ? 'lastmodifieddate' : 'hs_lastmodifieddate';
$payload = [
'filters' => [
[
'propertyName' => $lastUpdateDate,
'operator' => 'GT',
'value' => $since->getPreciseTimestamp(3),
],
],
'sorts' => [
[
'propertyName' => 'hs_object_id',
'direction' => 'DESCENDING',
],
],
'limit' => self::MAX_SEARCH_REQUEST_LIMIT,
];
if ($to) {
$payload['filters'][] = [
'propertyName' => $lastUpdateDate,
'operator' => 'LT',
'value' => $to->getPreciseTimestamp(3),
];
}
$payload['properties'] = $properties;
return $payload;
}
public function getByProfileSearchPayload(string $crmId, array $properties, Carbon $since, ?Carbon $to): array
{
$lastUpdateDate = array_key_exists('firstname', $properties) ? 'lastmodifieddate' : 'hs_lastmodifieddate';
$payload = [
'filters' => [
[
'propertyName' => $lastUpdateDate,
'operator' => 'GT',
'value' => $since->getPreciseTimestamp(3),
],
[
'propertyName' => 'hubspot_owner_id',
'operator' => 'EQ',
'value' => $crmId,
],
],
'sorts' => [
[
'propertyName' => 'hs_object_id',
'direction' => 'DESCENDING',
],
],
'limit' => self::MAX_SEARCH_REQUEST_LIMIT,
];
if ($to) {
$payload['filters'][] = [
'propertyName' => $lastUpdateDate,
'operator' => 'LT',
'value' => $to->getPreciseTimestamp(3),
];
}
$payload['properties'] = $properties;
return $payload;
}
private function buildMeetingFiltersForLinkToTask($objectType, $objectId): array
{
$filters = [];
$filters[] = [
'filters' => [
$this->getAssociatedObjectFilter($objectType, $objectId),
[
'propertyName' => 'hs_meeting_outcome',
'operator' => 'IN',
'values' => ['SCHEDULED', 'RESCHEDULED'],
],
],
];
$filters[] = [
'filters' => [
$this->getAssociatedObjectFilter($objectType, $objectId),
[
'propertyName' => 'hs_meeting_outcome',
'operator' => 'NOT_HAS_PROPERTY',
],
],
];
return $filters;
}
private function buildTaskFiltersForLinkToTask($objectType, $objectId): array
{
return [
[
'filters' => [
$this->getAssociatedObjectFilter($objectType, $objectId),
],
],
];
}
private function getAssociatedObjectFilter(string $objectType, string $objectId): array
{
return [
'propertyName' => 'associations.' . $objectType,
'operator' => 'EQ',
'value' => $objectId,
];
}
public function generatePlaybackURLSearchPayload(string $playbackURLToken): array
{
return [
'filters' => [
[
'propertyName' => 'hs_call_recording_url',
'operator' => 'CONTAINS_TOKEN',
'value' => $playbackURLToken,
],
],
'properties' => [
'hs_call_recording_url',
'hs_call_body',
],
'limit' => 1,
];
}
public function generateGetCallsPayload(Carbon $from, Carbon $to, string $activityProvider, $page): array
{
return [
'filters' => [
[
'propertyName' => 'hs_call_recording_url',
'operator' => 'CONTAINS_TOKEN',
'value' => $activityProvider,
],
[
'propertyName' => 'hs_timestamp',
'operator' => 'BETWEEN',
'value' => intval($from->getPreciseTimestamp(3)),
'highValue' => intval($to->getPreciseTimestamp(3)),
],
],
'sorts' => [
[
'propertyName' => 'hs_timestamp',
'direction' => 'DESCENDING',
],
],
'properties' => [
'hs_call_recording_url',
'hs_call_body',
],
'limit' => self::MAX_SEARCH_REQUEST_LIMIT,
'after' => ($page - 1) * self::MAX_SEARCH_REQUEST_LIMIT,
];
}
public function generateSearchCallsByPeriodPayload(Carbon $from, Carbon $to, $page): array
{
return [
'filters' => [
[
'propertyName' => 'hs_timestamp',
'operator' => 'BETWEEN',
'value' => intval($from->getPreciseTimestamp(3)),
'highValue' => intval($to->getPreciseTimestamp(3)),
],
],
'sorts' => [
[
'propertyName' => 'hs_timestamp',
'direction' => 'DESCENDING',
],
],
'properties' => $this->getSearchCallAttributes(),
'limit' => self::MAX_SEARCH_REQUEST_LIMIT,
'after' => ($page - 1) * self::MAX_SEARCH_REQUEST_LIMIT,
];
}
public function getSearchCallAttributes(): array
{
return [
'hs_timestamp',
'hs_call_recording_url',
'hs_call_body',
'hs_call_status',
'hs_call_to_number',
'hs_call_from_number',
'hs_call_duration',
'hs_call_disposition',
'hs_call_title',
'hs_call_direction',
'hubspot_owner_id',
'hs_activity_type',
'hs_call_external_id',
'hs_call_source',
];
}
public function generatePlaybackAddUrlBatchPayload(array $crmUpdateData): array
{
$updateObjectsData = [];
foreach ($crmUpdateData as $data) {
$updateObjectsData[] = [
'id' => $data['crm_id'],
'properties' => [
'hs_call_body' => $data['hs_call_body'] .
'<p><span style="font-size: 13.3333px; line-height: 16px;">' .
'<a href="' . $data['playback_url'] . '" target="_blank">Review in Jiminny</a> ▶️</span></p>',
],
];
}
return ['inputs' => $updateObjectsData];
}
public function generateSearchCallByTokenPayload(string $playbackURLToken): array
{
return [
'filters' => [
[
'propertyName' => 'hs_call_recording_url',
'operator' => 'CONTAINS_TOKEN',
'value' => $playbackURLToken,
],
],
'properties' => [
'hs_call_recording_url',
'hs_call_body',
],
'limit' => 1,
];
}
/**
* Generates a payload for phone search based on the specified parameters.
*
* @param string $phone The phone number to search.
* @param bool $isAlternativeSearch Indicates if an alternative search should be performed
* if the first search fails to find a match. The first search request should cover most of the cases.
*/
public function generatePhoneSearchPayload(string $phone, bool $isAlternativeSearch = false): array
{
$filterPropertyNames = $isAlternativeSearch ?
['hs_searchable_calculated_mobile_number', 'phone', 'mobilephone'] :
['hs_searchable_calculated_phone_number', 'hs_calculated_phone_number', 'hs_calculated_mobile_number'];
$filterGroups = array_map(function ($propertyName) use ($phone) {
return $this->createFilterGroup($propertyName, 'CONTAINS_TOKEN', $phone);
}, $filterPropertyNames);
$properties = [
'email',
'firstname',
'lastname',
'country',
'phone',
'mobilephone',
'jobtitle',
'hubspot_owner_id',
'associatedcompanyid',
'photo',
];
return $this->createPayload($filterGroups, $properties);
}
/**
* Creates a filter group with the specified parameters.
*/
private function createFilterGroup(string $propertyName, string $operator, $value): array
{
return [
'filters' => [
[
'propertyName' => $propertyName,
'operator' => $operator,
'value' => $value,
],
],
];
}
/**
* Creates a payload with the specified filter groups and properties.
*/
private function createPayload(array $filterGroups, array $properties): array
{
return [
'filterGroups' => $filterGroups,
'sorts' => [
[
'propertyName' => 'modifieddate',
'direction' => 'DESCENDING',
],
],
'properties' => $properties,
'limit' => 1,
];
}
/**
* Generates a payload to find related activities based on the specified data and object type.
* The search looks for activities that match the specified time range (starttime - endtime or only starttime with endtime not set)
* and are associated with any of the provided prospect types (contact or company).
*
* @param array $data An associative array containing the following keys:
* - 'from': The start time for the activity search (timestamp).
* - 'to': The end time for the activity search (timestamp).
* - 'contact': (optional) The ID of the associated contact.
* - 'company': (optional) The ID of the associated company.
* @param string $objectType The type of engagement to search for (meeting or other type like call).
*
* @return array The payload array to be used for searching related activities.
*/
public function getFindRelatedActivityPayload(array $data, string $objectType): array
{
$payload = [
'sorts' => [
[
'propertyName' => self::SORT_PROPERTY,
'direction' => 'DESCENDING',
],
],
'limit' => self::MAX_SEARCH_REQUEST_LIMIT,
];
if ($objectType === self::ENGAGEMENT_MEETINGS) {
$payload['properties'] = [
'hs_meeting_title',
'hs_meeting_outcome',
'hs_activity_type',
'hs_timestamp',
'hubspot_owner_id',
'hs_meeting_body',
'hs_internal_meeting_notes',
'hs_meeting_location',
'hs_meeting_start_time',
'hs_meeting_end_time',
];
} else {
$payload['properties'] = ['hs_task_subject', 'hs_timestamp', 'hubspot_owner_id', 'hs_call_body'];
}
$timeFiltersWithEndTime = [
[
'propertyName' => 'hs_meeting_start_time',
'operator' => 'GTE',
'value' => $data['from'],
],
[
'propertyName' => 'hs_meeting_end_time',
'operator' => 'LTE',
'value' => $data['to'],
],
];
$timeFiltersWithoutEndTime = [
[
'propertyName' => 'hs_meeting_start_time',
'operator' => 'GTE',
'value' => $data['from'],
],
[
'propertyName' => 'hs_meeting_end_time',
'operator' => 'NOT_HAS_PROPERTY',
],
];
$payload['filterGroups'] = [];
$associationTypes = ['contact', 'company'];
foreach ($associationTypes as $type) {
if (! empty($data[$type])) {
$filterGroupWithEndTime = [
'filters' => array_merge(
$timeFiltersWithEndTime,
[$this->getAssociatedObjectFilter($type, $data[$type])]
),
];
$payload['filterGroups'][] = $filterGroupWithEndTime;
$filterGroupWithoutEndTime = [
'filters' => array_merge(
$timeFiltersWithoutEndTime,
[$this->getAssociatedObjectFilter($type, $data[$type])]
),
];
$payload['filterGroups'][] = $filterGroupWithoutEndTime ;
}
}
return $payload;
}
/** Parameters
* [
* 'accountId' => $crmAccountId,
* 'sortBy' => $sortBy,
* 'sortDir' => $sortDir,
* 'onlyOpen' => $onlyOpen,
* 'closedStages' => $this->getClosedDealStages(),
* 'userId' => $userId,
* ];
*/
public function generateOpportunitiesSearchPayload(
Configuration $config,
string $crmAccountId,
array $closedStages,
): array {
$closedFilters = [];
$filterGroups = [];
$onlyOpen = true;
switch ($config->getOpportunityAssignmentRule()) {
case Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_UPDATED:
$sortBy = 'modifieddate';
$sortDir = 'DESCENDING';
break;
case Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_CREATED:
$sortBy = 'createdate';
$sortDir = 'DESCENDING';
break;
case Configuration::OPP_ASSIGNMENT_ALL_OPEN_OLDEST_CREATED:
$sortBy = 'createdate';
$sortDir = 'ASCENDING';
break;
case Configuration::OPP_ASSIGNMENT_ALL_RECENTLY_UPDATED:
default:
$sortBy = 'modifieddate';
$sortDir = 'DESCENDING';
$onlyOpen = false;
}
$baseFilters = [
[
'propertyName' => 'associations.company',
'operator' => 'EQ',
'value' => $crmAccountId,
],
];
// Handle closed stages in chunks
if ($onlyOpen) {
foreach (['won', 'lost'] as $key) {
if (! empty($closedStages[$key])) {
$chunks = array_chunk($closedStages[$key], self::MAX_FILTER_SIZE);
foreach ($chunks as $chunk) {
$closedFilters[] = [
'propertyName' => 'dealstage',
'operator' => 'NOT_IN',
'values' => $chunk,
];
}
}
}
}
$filterGroups[] = [
'filters' => array_merge($baseFilters, $closedFilters),
];
$payload = [
'filterGroups' => $filterGroups,
'sorts' => [
[
'propertyName' => $sortBy,
'direction' => $sortDir,
],
],
'properties' => [
'dealname',
'amount',
'hubspot_owner_id',
'pipeline',
'dealstage',
'closedate',
'deal_currency_code',
],
'limit' => self::MAX_SEARCH_REQUEST_LIMIT,
];
$logger = app(LoggerInterface::class);
$logger->info('[HubSpot] CRM Search requested', [
'request' => $payload,
]);
return $payload;
}
/**
* Converts v1 payload data to v3
*/
public function getV3MeetingPayload(array $engagement, array $metadata, array $associations = []): array
{
// Use this mapping until the whole Hubspot V1 is dropped.
$properties = [
'hubspot_owner_id' => $engagement['ownerId'],
'hs_timestamp' => $engagement['timestamp'],
];
// $engagement['activityType'] is $activity->category->name
if (isset($engagement['activityType'])) {
$properties['hs_activity_type'] = $engagement['activityType'];
}
$metadataKeyMap = [
'hs_meeting_outcome' => 'meetingOutcome',
'hs_meeting_title' => 'title',
'hs_meeting_start_time' => 'startTime',
'hs_meeting_end_time' => 'endTime',
'hs_meeting_body' => 'body',
'hs_internal_meeting_notes' => 'internalMeetingNotes',
];
foreach ($metadataKeyMap as $newKey => $oldKey) {
if (isset($metadata[$oldKey])) {
$properties[$newKey] = $metadata[$oldKey];
unset($metadata[$oldKey]);
}
}
$properties = [
...$properties,
...$metadata, // custom fields
];
$response = [
'properties' => $properties,
];
if (! empty($associations)) {
$response['associations'] = $associations;
}
return $response;
}
/**
* Generate a payload to search for contacts by name.
* The search is a token search in firstname or lastname
*/
public function generateSearchContactsByNamePayload(string $name, array $fields): array
{
$firstNameFilter = [
'propertyName' => 'firstname',
'operator' => 'CONTAINS_TOKEN',
'value' => $name,
];
$lastNameFilter = [
'propertyName' => 'lastname',
'operator' => 'CONTAINS_TOKEN',
'value' => $name,
];
return [
'filterGroups' => [
[
'filters' => [$firstNameFilter],
],
[
'filters' => [$lastNameFilter],
],
],
'properties' => $fields,
'sorts' => [
[
'propertyName' => 'lastmodifieddate',
'direction' => 'DESCENDING',
],
],
];
}
public function buildAddAssociationPayload(string $crmId, array $ids, int $associationType): array
{
$inputs = [];
foreach ($ids as $id) {
if ($id === null || $id === '') {
continue;
}
$inputs[] = [
'from' => [
'id' => $crmId,
],
'to' => [
'id' => (string) $id,
],
'types' => [
[
'associationCategory' => 'HUBSPOT_DEFINED',
'associationTypeId' => $associationType,
],
],
];
}
return ['inputs' => $inputs];
}
public function buildRemoveAssociationPayload(string $crmId, array $ids): array
{
$toArray = [];
foreach ($ids as $id) {
if ($id === null || $id === '') {
continue;
}
$toArray[] = ['id' => (string) $id];
}
return [
'inputs' => [
[
'from' => ['id' => $crmId],
'to' => $toArray,
],
],
];
}
public function addClosedStageFilters(array &$payload, array $closedStages): void
{
if (! empty($closedStages['won'])) {
$payload['filters'][] = [
'propertyName' => 'dealstage',
'operator' => 'NOT_IN',
'values' => $closedStages['won'],
];
}
if (! empty($closedStages['lost'])) {
$payload['filters'][] = [
'propertyName' => 'dealstage',
'operator' => 'NOT_IN',
'values' => $closedStages['lost'],
];
}
}
public function addCreatedDateFilters(array &$payload, Carbon $createdAfter): void
{
$payload['filters'][] = [
'propertyName' => 'createdate',
'operator' => 'GT',
'value' => $createdAfter->getPreciseTimestamp(3),
];
}
public function getDealsInBulkPayload(array $dealIds): array
{
return [
'filterGroups' => [
[
'filters' => [
[
'propertyName' => 'hs_object_id',
'operator' => 'IN',
'values' => $dealIds,
],
],
],
],
'limit' => self::MAX_FILTER_SIZE,
];
}
}
Sync Changes
Hide This Notification
Code changed:
Hide
22
8
Previous Highlighted Error
Next Highlighted Error
<?php
declare(strict_types=1);
namespace Jiminny\Services\Crm\Hubspot\Webhook;
use Illuminate\Support\Facades\Log;
use Jiminny\Models\Crm\Configuration;
use Jiminny\Services\Crm\Hubspot\BatchSyncCollector;
use Jiminny\Repositories\Crm\CrmEntityRepository;
use Jiminny\Repositories\Crm\OpportunityRepository;
use Jiminny\Repositories\Crm\FieldRepository;
use Jiminny\Repositories\Crm\FieldDataRepository;
use Jiminny\Models\Opportunity;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\LayoutEntity;
readonly class PropertyChangeManager
{
private const string OBJECT_TYPE_DEAL = 'deal';
private const string OBJECT_TYPE_CONTACT = 'contact';
private const string OBJECT_TYPE_COMPANY = 'company';
private const array SUPPORTED_OBJECT_TYPES = [
self::OBJECT_TYPE_DEAL,
self::OBJECT_TYPE_CONTACT,
self::OBJECT_TYPE_COMPANY,
];
/**
* Contact fields that webhooks are subscribed to in HubSpot.
*
* IMPORTANT: These fields MUST match the webhook subscriptions configured in HubSpot.
* When updating this list, you must also update the HubSpot webhook subscriptions:
* 1. Go to HubSpot App Settings → Webhooks
* 2. Update contact.propertyChange subscriptions to match this list
* 3. Ensure all fields listed here are subscribed, and vice versa
*
* This constant serves as documentation of the current webhook configuration.
* Since HubSpot filters webhooks server-side, we should only receive events for these properties.
*/
private const array APPLICABLE_CONTACT_FIELDS = [
'firstname',
'lastname',
'email',
'phone',
'mobilephone',
'jobtitle',
'country',
'hubspot_owner_id',
'associatedcompanyid',
'photo',
'hs_avatar_filemanager_key',
];
/**
* Company fields that webhooks are subscribed to in HubSpot.
*
* IMPORTANT: These fields MUST match the webhook subscriptions configured in HubSpot.
* When updating this list, you must also update the HubSpot webhook subscriptions:
* 1. Go to HubSpot App Settings → Webhooks
* 2. Update company.propertyChange subscriptions to match this list
* 3. Ensure all fields listed here are subscribed, and vice versa
*
* This constant serves as documentation of the current webhook configuration.
* Since HubSpot filters webhooks server-side, we should only receive events for these properties.
*/
private const array APPLICABLE_COMPANY_FIELDS = [
'name',
'phone',
'domain',
'country',
'industry',
'hubspot_owner_id',
'hs_avatar_filemanager_key',
];
/**
* Properties that support direct database updates (without full sync).
* These are simple properties that can be updated immediately without recalculating other fields.
*/
private const array DB_OBJECT_UPDATE_PROPERTIES = [
self::OBJECT_TYPE_DEAL => [
'dealname',
'description',
],
self::OBJECT_TYPE_CONTACT => [
// Add contact properties that support direct update
],
self::OBJECT_TYPE_COMPANY => [
// Add company properties that support direct update
],
];
/**
* Mapping from HubSpot property names to Jiminny model property names.
* Used when updating the main model (Opportunity, Contact, Account) directly.
* Only needed when HubSpot property name differs from Jiminny property name.
*/
private const array OBJECT_PROPERTY_MAPPINGS = [
self::OBJECT_TYPE_DEAL => [
'dealname' => 'name', // HubSpot 'dealname' maps to Opportunity 'name'
],
self::OBJECT_TYPE_CONTACT => [
// Add contact property mappings if needed
],
self::OBJECT_TYPE_COMPANY => [
// Add company property mappings if needed
],
];
// Field object types mapping
private const array FIELD_OBJECT_TYPES = [
self::OBJECT_TYPE_DEAL => Field::OBJECT_OPPORTUNITY,
self::OBJECT_TYPE_CONTACT => Field::OBJECT_CONTACT,
self::OBJECT_TYPE_COMPANY => Field::OBJECT_ACCOUNT,
];
public function __construct(
private BatchSyncCollector $batchCollector,
private OpportunityRepository $opportunityRepository,
private CrmEntityRepository $crmEntityRepository,
private FieldRepository $fieldRepository,
private FieldDataRepository $fieldDataRepository,
private HubspotWebhookMetricsService $metricsService,
) {
}
/**
* Get contact fields that webhooks are subscribed to in HubSpot.
* This serves as documentation of the current webhook configuration.
*
* @return array<int, string>
*/
public function getApplicableContactFields(): array
{
return self::APPLICABLE_CONTACT_FIELDS;
}
/**
* Get company fields that webhooks are subscribed to in HubSpot.
* This serves as documentation of the current webhook configuration.
*
* @return array<int, string>
*/
public function getApplicableCompanyFields(): array
{
return self::APPLICABLE_COMPANY_FIELDS;
}
/**
* Process property change with intelligent routing
*/
public function processPropertyChange(
string $objectType,
string $objectId,
Configuration $configuration,
string $propertyName,
string $propertyValue
): void {
if (! $this->isObjectTypeSupported($objectType)) {
Log::warning('[HubSpot Property Change Manager] Unsupported object type', [
'object_type' => $objectType,
'object_id' => $objectId,
'property_name' => $propertyName,
]);
return;
}
$this->metricsService->recordWebhookMetrics(
$configuration,
$objectType,
'property_change',
$propertyName
);
if ($this->isDirectUpdateSupported($objectType, $propertyName) &&
$this->handleDirectUpdate($objectType, $objectId, $configuration, $propertyName, $propertyValue)) {
return;
}
$this->handleObjectSync($objectType, $objectId, $configuration);
}
/**
* Legacy method for backward compatibility - assumes deal object type
*
* @deprecated Use processPropertyChange with objectType parameter
*/
public function processPropertyChangeForDeal(
string $objectId,
Configuration $configuration,
string $propertyName,
string $propertyValue
): void {
$this->processPropertyChange(self::OBJECT_TYPE_DEAL, $objectId, $configuration, $propertyName, $propertyValue);
}
/**
* Check if object type is supported
*/
private function isObjectTypeSupported(string $objectType): bool
{
return in_array($objectType, self::SUPPORTED_OBJECT_TYPES);
}
/**
* Check if property can be updated without additional calculation on other properties.
*/
private function isDirectUpdateSupported(string $objectType, string $propertyName): bool
{
$supportedProperties = self::DB_OBJECT_UPDATE_PROPERTIES[$objectType] ?? [];
return in_array($propertyName, $supportedProperties, true);
}
/**
* Handle properties that support direct database updates
*/
private function handleDirectUpdate(
string $objectType,
string $objectId,
Configuration $configuration,
string $propertyName,
string $propertyValue
): bool {
try {
return match ($objectType) {
self::OBJECT_TYPE_DEAL => $this->handleDealDirectUpdate($objectId, $configuration, $propertyName, $propertyValue),
self::OBJECT_TYPE_CONTACT => $this->handleContactDirectUpdate($objectId, $configuration, $propertyName, $propertyValue),
self::OBJECT_TYPE_COMPANY => $this->handleCompanyDirectUpdate($objectId, $configuration, $propertyName, $propertyValue),
default => false,
};
} catch (\Exception $e) {
Log::error('[HubSpot Property Change Manager] Error handling direct update property', [
'object_type' => $objectType,
'object_id' => $objectId,
'property_name' => $propertyName,
'error' => $e->getMessage(),
]);
return false;
}
}
/**
* Handle direct updates for deal objects
*/
private function handleDealDirectUpdate(
string $objectId,
Configuration $configuration,
string $propertyName,
string $propertyValue
): bool {
$opportunity = $this->opportunityRepository->findByConfigAndCrmProviderId(
$configuration,
$objectId
);
if (! $opportunity instanceof Opportunity) {
return false;
}
// Update model property if mapping exists
$propertyMappings = self::OBJECT_PROPERTY_MAPPINGS[self::OBJECT_TYPE_DEAL] ?? [];
if (isset($propertyMappings[$propertyName])) {
$this->opportunityRepository->updateOrCreate(
$configuration,
$objectId,
[$propertyMappings[$propertyName] => $propertyValue]
);
}
// Update field data
$this->importObjectFieldData(
self::OBJECT_TYPE_DEAL,
$opportunity->getId(),
$configuration,
[$propertyName => $propertyValue]
);
return true;
}
/**
* Handle direct updates for contact objects
*
* Updates Contact model properties via OBJECT_PROPERTY_MAPPINGS.
* Add mappings to OBJECT_PROPERTY_MAPPINGS[CONTACT] to enable direct updates.
*/
private function handleContactDirectUpdate(
string $objectId,
Configuration $configuration,
string $propertyName,
string $propertyValue
): bool {
$contact = $this->crmEntityRepository->findContactByExternalId($configuration, $objectId);
if ($contact === null) {
return false;
}
// Update model property if mapping exists
$propertyMappings = self::OBJECT_PROPERTY_MAPPINGS[self::OBJECT_TYPE_CONTACT] ?? [];
if (isset($propertyMappings[$propertyName])) {
$this->crmEntityRepository->importContact($configuration, [
'crm_provider_id' => $objectId,
$propertyMappings[$propertyName] => $propertyValue,
]);
}
return true;
}
/**
* Handle direct updates for company objects
*
* Updates Account model properties via OBJECT_PROPERTY_MAPPINGS.
* Add mappings to OBJECT_PROPERTY_MAPPINGS[COMPANY] to enable direct updates.
*/
private function handleCompanyDirectUpdate(
string $objectId,
Configuration $configuration,
string $propertyName,
string $propertyValue
): bool {
$company = $this->crmEntityRepository->findAccountByExternalId($configuration, $objectId);
if ($company === null) {
return false;
}
// Update model property if mapping exists
$propertyMappings = self::OBJECT_PROPERTY_MAPPINGS[self::OBJECT_TYPE_COMPANY] ?? [];
if (isset($propertyMappings[$propertyName])) {
$this->crmEntityRepository->importAccount($configuration, [
'crm_provider_id' => $objectId,
$propertyMappings[$propertyName] => $propertyValue,
]);
}
return true;
}
/**
* Import field data for any object type
*/
private function importObjectFieldData(
string $objectType,
int $objectId,
Configuration $configuration,
array $data
): void {
$fieldObjectType = self::FIELD_OBJECT_TYPES[$objectType] ?? null;
if (! $fieldObjectType) {
Log::warning('[HubSpot Property Change Manager] Unknown field object type', [
'object_type' => $objectType,
'object_id' => $objectId,
]);
return;
}
foreach ($data as $crmField => $value) {
$field = $this->fieldRepository->findFieldByCrmIdAndObjectType(
$configuration,
$crmField,
$fieldObjectType
);
if (! $field instanceof Field) {
continue;
}
$entities = $field->getEntities();
if ($entities->isEmpty()) {
continue;
}
if ($entities->count() > 1) {
continue;
}
/** @var LayoutEntity|null $entity */
$entity = $entities->first();
if (! $entity instanceof LayoutEntity) {
continue;
}
$this->fieldDataRepository->updateOrCreateFieldData(
$entity,
$objectId,
(string) $value,
);
}
}
private function handleObjectSync(
string $objectType,
string $objectId,
Configuration $configuration,
): void {
if (! $this->isObjectTypeSupported($objectType)) {
return;
}
try {
$this->batchCollector->collectForBatch(
objectType: $objectType,
crmProviderId: $objectId,
configurationId: $configuration->getId(),
eventType: 'property_change'
);
} catch (\Exception $e) {
Log::error('[HubSpot Property Change Manager] Error adding to sync batch', [
'object_type' => $objectType,
'object_id' => $objectId,
'error' => $e->getMessage(),
]);
}
}
}
Project
Project
New File or Directory…
Expand Selected
Collapse All
Options
Hide...
|
PhpStorm
|
faVsco.js – PropertyChangeManager.php
|
NULL
|
87911
|
|
87910
|
Project: faVsco.js, menu
#12121 on JY-20963-fix-im Project: faVsco.js, menu
#12121 on JY-20963-fix-import-on-deleted-entity, menu
Start Listening for PHP Debug Connections
ServiceTest
Run 'ServiceTest'
Debug 'ServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
8
25
Previous Highlighted Error
Next Highlighted Error
<?php
declare(strict_types=1);
namespace Jiminny\Services\Crm\Hubspot;
use Carbon\Carbon;
use Jiminny\Models\Crm\Configuration;
use Psr\Log\LoggerInterface;
class PayloadBuilder
{
public const int MAX_SEARCH_REQUEST_LIMIT = 200;
private const int MAX_FILTER_SIZE = 100;
private const string SORT_PROPERTY = 'hs_timestamp';
private const string ENGAGEMENT_MEETINGS = 'meetings';
public function getLinkToTaskPayload(string $objectType, string $objectId, string $engagementType): array
{
$payload = [
'sorts' => [
[
'propertyName' => self::SORT_PROPERTY,
'direction' => 'DESCENDING',
],
],
'limit' => self::MAX_SEARCH_REQUEST_LIMIT,
];
if ($engagementType === self::ENGAGEMENT_MEETINGS) {
$payload['filterGroups'] = $this->buildMeetingFiltersForLinkToTask($objectType, $objectId);
$payload['properties'] = [
'hs_meeting_title',
'hs_meeting_outcome',
'hs_timestamp',
'hs_activity_type',
];
} else {
$payload['filterGroups'] = $this->buildTaskFiltersForLinkToTask($objectType, $objectId);
$payload['properties'] = ['hs_task_subject', 'hs_timestamp'];
}
return $payload;
}
public function getRecentlyUpdatedSearchPayload(Carbon $since, ?Carbon $to, array $properties): array
{
$lastUpdateDate = in_array('firstname', $properties) ? 'lastmodifieddate' : 'hs_lastmodifieddate';
$payload = [
'filters' => [
[
'propertyName' => $lastUpdateDate,
'operator' => 'GT',
'value' => $since->getPreciseTimestamp(3),
],
],
'sorts' => [
[
'propertyName' => 'hs_object_id',
'direction' => 'DESCENDING',
],
],
'limit' => self::MAX_SEARCH_REQUEST_LIMIT,
];
if ($to) {
$payload['filters'][] = [
'propertyName' => $lastUpdateDate,
'operator' => 'LT',
'value' => $to->getPreciseTimestamp(3),
];
}
$payload['properties'] = $properties;
return $payload;
}
public function getByProfileSearchPayload(string $crmId, array $properties, Carbon $since, ?Carbon $to): array
{
$lastUpdateDate = array_key_exists('firstname', $properties) ? 'lastmodifieddate' : 'hs_lastmodifieddate';
$payload = [
'filters' => [
[
'propertyName' => $lastUpdateDate,
'operator' => 'GT',
'value' => $since->getPreciseTimestamp(3),
],
[
'propertyName' => 'hubspot_owner_id',
'operator' => 'EQ',
'value' => $crmId,
],
],
'sorts' => [
[
'propertyName' => 'hs_object_id',
'direction' => 'DESCENDING',
],
],
'limit' => self::MAX_SEARCH_REQUEST_LIMIT,
];
if ($to) {
$payload['filters'][] = [
'propertyName' => $lastUpdateDate,
'operator' => 'LT',
'value' => $to->getPreciseTimestamp(3),
];
}
$payload['properties'] = $properties;
return $payload;
}
private function buildMeetingFiltersForLinkToTask($objectType, $objectId): array
{
$filters = [];
$filters[] = [
'filters' => [
$this->getAssociatedObjectFilter($objectType, $objectId),
[
'propertyName' => 'hs_meeting_outcome',
'operator' => 'IN',
'values' => ['SCHEDULED', 'RESCHEDULED'],
],
],
];
$filters[] = [
'filters' => [
$this->getAssociatedObjectFilter($objectType, $objectId),
[
'propertyName' => 'hs_meeting_outcome',
'operator' => 'NOT_HAS_PROPERTY',
],
],
];
return $filters;
}
private function buildTaskFiltersForLinkToTask($objectType, $objectId): array
{
return [
[
'filters' => [
$this->getAssociatedObjectFilter($objectType, $objectId),
],
],
];
}
private function getAssociatedObjectFilter(string $objectType, string $objectId): array
{
return [
'propertyName' => 'associations.' . $objectType,
'operator' => 'EQ',
'value' => $objectId,
];
}
public function generatePlaybackURLSearchPayload(string $playbackURLToken): array
{
return [
'filters' => [
[
'propertyName' => 'hs_call_recording_url',
'operator' => 'CONTAINS_TOKEN',
'value' => $playbackURLToken,
],
],
'properties' => [
'hs_call_recording_url',
'hs_call_body',
],
'limit' => 1,
];
}
public function generateGetCallsPayload(Carbon $from, Carbon $to, string $activityProvider, $page): array
{
return [
'filters' => [
[
'propertyName' => 'hs_call_recording_url',
'operator' => 'CONTAINS_TOKEN',
'value' => $activityProvider,
],
[
'propertyName' => 'hs_timestamp',
'operator' => 'BETWEEN',
'value' => intval($from->getPreciseTimestamp(3)),
'highValue' => intval($to->getPreciseTimestamp(3)),
],
],
'sorts' => [
[
'propertyName' => 'hs_timestamp',
'direction' => 'DESCENDING',
],
],
'properties' => [
'hs_call_recording_url',
'hs_call_body',
],
'limit' => self::MAX_SEARCH_REQUEST_LIMIT,
'after' => ($page - 1) * self::MAX_SEARCH_REQUEST_LIMIT,
];
}
public function generateSearchCallsByPeriodPayload(Carbon $from, Carbon $to, $page): array
{
return [
'filters' => [
[
'propertyName' => 'hs_timestamp',
'operator' => 'BETWEEN',
'value' => intval($from->getPreciseTimestamp(3)),
'highValue' => intval($to->getPreciseTimestamp(3)),
],
],
'sorts' => [
[
'propertyName' => 'hs_timestamp',
'direction' => 'DESCENDING',
],
],
'properties' => $this->getSearchCallAttributes(),
'limit' => self::MAX_SEARCH_REQUEST_LIMIT,
'after' => ($page - 1) * self::MAX_SEARCH_REQUEST_LIMIT,
];
}
public function getSearchCallAttributes(): array
{
return [
'hs_timestamp',
'hs_call_recording_url',
'hs_call_body',
'hs_call_status',
'hs_call_to_number',
'hs_call_from_number',
'hs_call_duration',
'hs_call_disposition',
'hs_call_title',
'hs_call_direction',
'hubspot_owner_id',
'hs_activity_type',
'hs_call_external_id',
'hs_call_source',
];
}
public function generatePlaybackAddUrlBatchPayload(array $crmUpdateData): array
{
$updateObjectsData = [];
foreach ($crmUpdateData as $data) {
$updateObjectsData[] = [
'id' => $data['crm_id'],
'properties' => [
'hs_call_body' => $data['hs_call_body'] .
'<p><span style="font-size: 13.3333px; line-height: 16px;">' .
'<a href="' . $data['playback_url'] . '" target="_blank">Review in Jiminny</a> ▶️</span></p>',
],
];
}
return ['inputs' => $updateObjectsData];
}
public function generateSearchCallByTokenPayload(string $playbackURLToken): array
{
return [
'filters' => [
[
'propertyName' => 'hs_call_recording_url',
'operator' => 'CONTAINS_TOKEN',
'value' => $playbackURLToken,
],
],
'properties' => [
'hs_call_recording_url',
'hs_call_body',
],
'limit' => 1,
];
}
/**
* Generates a payload for phone search based on the specified parameters.
*
* @param string $phone The phone number to search.
* @param bool $isAlternativeSearch Indicates if an alternative search should be performed
* if the first search fails to find a match. The first search request should cover most of the cases.
*/
public function generatePhoneSearchPayload(string $phone, bool $isAlternativeSearch = false): array
{
$filterPropertyNames = $isAlternativeSearch ?
['hs_searchable_calculated_mobile_number', 'phone', 'mobilephone'] :
['hs_searchable_calculated_phone_number', 'hs_calculated_phone_number', 'hs_calculated_mobile_number'];
$filterGroups = array_map(function ($propertyName) use ($phone) {
return $this->createFilterGroup($propertyName, 'CONTAINS_TOKEN', $phone);
}, $filterPropertyNames);
$properties = [
'email',
'firstname',
'lastname',
'country',
'phone',
'mobilephone',
'jobtitle',
'hubspot_owner_id',
'associatedcompanyid',
'photo',
];
return $this->createPayload($filterGroups, $properties);
}
/**
* Creates a filter group with the specified parameters.
*/
private function createFilterGroup(string $propertyName, string $operator, $value): array
{
return [
'filters' => [
[
'propertyName' => $propertyName,
'operator' => $operator,
'value' => $value,
],
],
];
}
/**
* Creates a payload with the specified filter groups and properties.
*/
private function createPayload(array $filterGroups, array $properties): array
{
return [
'filterGroups' => $filterGroups,
'sorts' => [
[
'propertyName' => 'modifieddate',
'direction' => 'DESCENDING',
],
],
'properties' => $properties,
'limit' => 1,
];
}
/**
* Generates a payload to find related activities based on the specified data and object type.
* The search looks for activities that match the specified time range (starttime - endtime or only starttime with endtime not set)
* and are associated with any of the provided prospect types (contact or company).
*
* @param array $data An associative array containing the following keys:
* - 'from': The start time for the activity search (timestamp).
* - 'to': The end time for the activity search (timestamp).
* - 'contact': (optional) The ID of the associated contact.
* - 'company': (optional) The ID of the associated company.
* @param string $objectType The type of engagement to search for (meeting or other type like call).
*
* @return array The payload array to be used for searching related activities.
*/
public function getFindRelatedActivityPayload(array $data, string $objectType): array
{
$payload = [
'sorts' => [
[
'propertyName' => self::SORT_PROPERTY,
'direction' => 'DESCENDING',
],
],
'limit' => self::MAX_SEARCH_REQUEST_LIMIT,
];
if ($objectType === self::ENGAGEMENT_MEETINGS) {
$payload['properties'] = [
'hs_meeting_title',
'hs_meeting_outcome',
'hs_activity_type',
'hs_timestamp',
'hubspot_owner_id',
'hs_meeting_body',
'hs_internal_meeting_notes',
'hs_meeting_location',
'hs_meeting_start_time',
'hs_meeting_end_time',
];
} else {
$payload['properties'] = ['hs_task_subject', 'hs_timestamp', 'hubspot_owner_id', 'hs_call_body'];
}
$timeFiltersWithEndTime = [
[
'propertyName' => 'hs_meeting_start_time',
'operator' => 'GTE',
'value' => $data['from'],
],
[
'propertyName' => 'hs_meeting_end_time',
'operator' => 'LTE',
'value' => $data['to'],
],
];
$timeFiltersWithoutEndTime = [
[
'propertyName' => 'hs_meeting_start_time',
'operator' => 'GTE',
'value' => $data['from'],
],
[
'propertyName' => 'hs_meeting_end_time',
'operator' => 'NOT_HAS_PROPERTY',
],
];
$payload['filterGroups'] = [];
$associationTypes = ['contact', 'company'];
foreach ($associationTypes as $type) {
if (! empty($data[$type])) {
$filterGroupWithEndTime = [
'filters' => array_merge(
$timeFiltersWithEndTime,
[$this->getAssociatedObjectFilter($type, $data[$type])]
),
];
$payload['filterGroups'][] = $filterGroupWithEndTime;
$filterGroupWithoutEndTime = [
'filters' => array_merge(
$timeFiltersWithoutEndTime,
[$this->getAssociatedObjectFilter($type, $data[$type])]
),
];
$payload['filterGroups'][] = $filterGroupWithoutEndTime ;
}
}
return $payload;
}
/** Parameters
* [
* 'accountId' => $crmAccountId,
* 'sortBy' => $sortBy,
* 'sortDir' => $sortDir,
* 'onlyOpen' => $onlyOpen,
* 'closedStages' => $this->getClosedDealStages(),
* 'userId' => $userId,
* ];
*/
public function generateOpportunitiesSearchPayload(
Configuration $config,
string $crmAccountId,
array $closedStages,
): array {
$closedFilters = [];
$filterGroups = [];
$onlyOpen = true;
switch ($config->getOpportunityAssignmentRule()) {
case Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_UPDATED:
$sortBy = 'modifieddate';
$sortDir = 'DESCENDING';
break;
case Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_CREATED:
$sortBy = 'createdate';
$sortDir = 'DESCENDING';
break;
case Configuration::OPP_ASSIGNMENT_ALL_OPEN_OLDEST_CREATED:
$sortBy = 'createdate';
$sortDir = 'ASCENDING';
break;
case Configuration::OPP_ASSIGNMENT_ALL_RECENTLY_UPDATED:
default:
$sortBy = 'modifieddate';
$sortDir = 'DESCENDING';
$onlyOpen = false;
}
$baseFilters = [
[
'propertyName' => 'associations.company',
'operator' => 'EQ',
'value' => $crmAccountId,
],
];
// Handle closed stages in chunks
if ($onlyOpen) {
foreach (['won', 'lost'] as $key) {
if (! empty($closedStages[$key])) {
$chunks = array_chunk($closedStages[$key], self::MAX_FILTER_SIZE);
foreach ($chunks as $chunk) {
$closedFilters[] = [
'propertyName' => 'dealstage',
'operator' => 'NOT_IN',
'values' => $chunk,
];
}
}
}
}
$filterGroups[] = [
'filters' => array_merge($baseFilters, $closedFilters),
];
$payload = [
'filterGroups' => $filterGroups,
'sorts' => [
[
'propertyName' => $sortBy,
'direction' => $sortDir,
],
],
'properties' => [
'dealname',
'amount',
'hubspot_owner_id',
'pipeline',
'dealstage',
'closedate',
'deal_currency_code',
],
'limit' => self::MAX_SEARCH_REQUEST_LIMIT,
];
$logger = app(LoggerInterface::class);
$logger->info('[HubSpot] CRM Search requested', [
'request' => $payload,
]);
return $payload;
}
/**
* Converts v1 payload data to v3
*/
public function getV3MeetingPayload(array $engagement, array $metadata, array $associations = []): array
{
// Use this mapping until the whole Hubspot V1 is dropped.
$properties = [
'hubspot_owner_id' => $engagement['ownerId'],
'hs_timestamp' => $engagement['timestamp'],
];
// $engagement['activityType'] is $activity->category->name
if (isset($engagement['activityType'])) {
$properties['hs_activity_type'] = $engagement['activityType'];
}
$metadataKeyMap = [
'hs_meeting_outcome' => 'meetingOutcome',
'hs_meeting_title' => 'title',
'hs_meeting_start_time' => 'startTime',
'hs_meeting_end_time' => 'endTime',
'hs_meeting_body' => 'body',
'hs_internal_meeting_notes' => 'internalMeetingNotes',
];
foreach ($metadataKeyMap as $newKey => $oldKey) {
if (isset($metadata[$oldKey])) {
$properties[$newKey] = $metadata[$oldKey];
unset($metadata[$oldKey]);
}
}
$properties = [
...$properties,
...$metadata, // custom fields
];
$response = [
'properties' => $properties,
];
if (! empty($associations)) {
$response['associations'] = $associations;
}
return $response;
}
/**
* Generate a payload to search for contacts by name.
* The search is a token search in firstname or lastname
*/
public function generateSearchContactsByNamePayload(string $name, array $fields): array
{
$firstNameFilter = [
'propertyName' => 'firstname',
'operator' => 'CONTAINS_TOKEN',
'value' => $name,
];
$lastNameFilter = [
'propertyName' => 'lastname',
'operator' => 'CONTAINS_TOKEN',
'value' => $name,
];
return [
'filterGroups' => [
[
'filters' => [$firstNameFilter],
],
[
'filters' => [$lastNameFilter],
],
],
'properties' => $fields,
'sorts' => [
[
'propertyName' => 'lastmodifieddate',
'direction' => 'DESCENDING',
],
],
];
}
public function buildAddAssociationPayload(string $crmId, array $ids, int $associationType): array
{
$inputs = [];
foreach ($ids as $id) {
if ($id === null || $id === '') {
continue;
}
$inputs[] = [
'from' => [
'id' => $crmId,
],
'to' => [
'id' => (string) $id,
],
'types' => [
[
'associationCategory' => 'HUBSPOT_DEFINED',
'associationTypeId' => $associationType,
],
],
];
}
return ['inputs' => $inputs];
}
public function buildRemoveAssociationPayload(string $crmId, array $ids): array
{
$toArray = [];
foreach ($ids as $id) {
if ($id === null || $id === '') {
continue;
}
$toArray[] = ['id' => (string) $id];
}
return [
'inputs' => [
[
'from' => ['id' => $crmId],
'to' => $toArray,
],
],
];
}
public function addClosedStageFilters(array &$payload, array $closedStages): void
{
if (! empty($closedStages['won'])) {
$payload['filters'][] = [
'propertyName' => 'dealstage',
'operator' => 'NOT_IN',
'values' => $closedStages['won'],
];
}
if (! empty($closedStages['lost'])) {
$payload['filters'][] = [
'propertyName' => 'dealstage',
'operator' => 'NOT_IN',
'values' => $closedStages['lost'],
];
}
}
public function addCreatedDateFilters(array &$payload, Carbon $createdAfter): void
{
$payload['filters'][] = [
'propertyName' => 'createdate',
'operator' => 'GT',
'value' => $createdAfter->getPreciseTimestamp(3),
];
}
public function getDealsInBulkPayload(array $dealIds): array
{
return [
'filterGroups' => [
[
'filters' => [
[
'propertyName' => 'hs_object_id',
'operator' => 'IN',
'values' => $dealIds,
],
],
],
],
'limit' => self::MAX_FILTER_SIZE,
];
}
}
Sync Changes
Hide This Notification
Code changed:
Hide
22
8
Previous Highlighted Error...
|
PhpStorm
|
faVsco.js – PropertyChangeManager.php
|
NULL
|
87910
|
|
87909
|
Project: faVsco.js, menu
#12121 on JY-20963-fix-im Project: faVsco.js, menu
#12121 on JY-20963-fix-import-on-deleted-entity, menu
Start Listening for PHP Debug Connections
ServiceTest
Run 'ServiceTest'
Debug 'ServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
rapstomEV faVsco,ls ~ProjectvViewNeweNNC#12121 on JY-20963-fix-InCoocWindow› E Merge© HubspotWebhooiMetriPropertyChangeManaguvalohtwohooktoG WebhookAssociationGlwalennedwityCimbdlo.orgy uimaeumyociwiceon€ ServiceTest.php@ CachedCrmServiceDecorator.phpo Frosserosehid.onpooonurwowncaion© HubspotLastModifiedCreatedRecentiyOpenSyncStrateay.ohg© PayloadBulider.phpx© WebhookDeletionHandwewokvents© WebhookMergeHandleclass PayloadbuzldenA8X25 Av© WebhookPropertyChan 689C WeohookSionatureVa© BatchSynceollector.phpc) Batchsuncred sservice o© Client.phpC) Cosed Dea [EMAIL]) Decorate chmm cho©FieldDefinitions.phpCrAcTvaAeoousreoodHubspotClientinterface.ph© [EMAIL]© RemoteCrmObjectManipulResponseNormaize.php© Service.php© SyncFieldAction.php© SyncRelatedActivity Mana, 697© WebhookSyncBatchProce> E IntegrationApp>Elisteners> @ Metadata› E Migration› 0 PipedriveE Salesforce> D Fields› E OpportunityMatcher› OpportunitySyncStrategyeSemicetiraitsC e entond©DecorateActivity.phpbele eocectetie tonocseldae initons choc Paviosd Bulldes.ono© [EMAIL] Ouorvitorstoe nha©QueryResuits.phpci Connno nhr71089* Generate a payload to search for contacts by name.*The searchois a token search sin dinstnane on uostnend3 893public function generateSearchContactsByNanePayLoad(string Snane, array $fiolds): arrayl....95Susagespublic function buildAddAssociationPayload(string Scraid, array Sids, int SassociationType): arrayl »7public function addClosedStageFilters(array &Spayload, array SclosedStages): void"propertyNane' => "dealstage'."oneator'values' => SclosedStages[ 'lost'].117118public function addGreatedDateFilters(array &Spayload, Carbon ScreatedAfter): voidf...10 usagespublic function getDealsInBulkPayLoad(array SdealIds): array(...}BUMMEMEMME85id-entity // View pull request (today 16:12)A SF jiminny@localhost]HS.Jocal [iminny@localhost)A consoie (EU]© PropertyChangeManager.phpxusers (EU)readonly class PropertyChangeManagenA console [PROD)A console (STAGING)TNw.AYself::0BJECT_TYPE_CONTACT →> [// Add contact properties that support direct updateself::OBJECT_TYPE_COMPANY »> [// Add company properties that support direct update* Napping from HubSpot property names to Jiminny model property names.* Used when updating the main model (Opportunity, Contact, Account) directly.* OnZy needed when Hubspot property nane differs fron jiminny property name.*/private const array OBJECT_PROPERTY_MAPPINGS = LCascade & VICommand'dealgang' »> 'nane', // HubSpot "dealnane' naps to Opportunity 'name)=$0 l100% KSa- • Thu 28 May 19:29:33ServiceTestvCascadeles Orcnnworeebeeionhnves ohtine oDon+0.Unless the May 20 sync used a cachedClosedDea lStages that was populated earlier in the same batch lob — before a importStages () call updated thestage probability from 0 to 100. But that would require stage 20616 to have been at probability 0 at the start of that batch run, then changed to 100 mid-runby tecortStases(.And ieportstanesontyrunsThe true, detinitive answenAfer all this ansves, the most"kely exolonstion is this is a data snaoshot from an eanter time,t ihe opoortunty was sunoed when closedlost harprobability 0 (before May 4, 2026). Then someone manually changed the stage probability to 100 on May 4 in Jiminry. But the opportunity hasn't been re-synced since then - it shows opdated_at: 2826-65-20 but that could be from a different field being updated (like dealname, value, etc.) via the Propertychanackanager's dircct uodate path, which does Not recalculate is. wonLook at PropertyChangekschinono looea luareciupdatea PropertyChUoontc boock procchisy trwootne cxstsCoronerMnoosros& CeTeiEeT PROPERN MAPDNSRANORFMNMPE DEAETe1t (isset(SpropertyMappings (SpropertyNase))) <Sthis-sopportunityRepository->update0rCreate(..);odste weld darSthis->inportObjectFieldData(...):return true;D8_OBJECT_UPOATE_PROPERTIES for deals = ['dealnane', "description']. if a dealnase or description webhook event came in on May 20, it would:1. Update nase on the opportunity (via updateOrCreate) → touches updated_at2. Return true (bypassing the full hand leöbiectSync / batch queue path3. NOT recalculate 15,vocThis is the answer. On May 20, a dralnase or description change webhook arrived. PropertyChangeManaoer: :handleDralDirectUpdate ran, undatecthe opportunity's nane field (touching updated_at to 2026-05-28 18:36:59 ), and returned true - skipping the full batch sync that would haverecalulated 1s von. The dear's 1s,von was already 8 from a previous syne (when Closedlost had probability 0, before May 4), and the May 20 direct-update weohook oiontfixkSummaryTine ooocurynsywns1. Before May 4: Stage 20616 (closmdlost) had probability = 8 in Jiminny's DB (consistent with HubSpot's default behavior for a stage named"closedlost"). Any sync at that time would put "closedlost* in tostl) → 15_closedel, i5,vonne.2. May 4: Someone manually updated the stage probability to 100 in Jiminny DB (and/or HubSpot updated their stage label/settings). Stage undateal.ate2826-85-043. May 20: A HubSpot webhook for dealnase or description property change arrived for this deal. Prcndleocaldi rectUpdatfired - it updated the opportunity's nane field directly (touching updated_at ) but returned true without queuing a full sync. 15 von was neverrecalculated4. Result: updated_at: 2826-85-28 looks recent, but the is_von value is stale from a sync that happened before May 4 when the stage probability wasstilllotthe tixis to troac a troch sune df thie sond ic anoortunity: sync0noortun ty(404958190945*) wotld tetch ttrom Hub Soot soo denlstran - "closedlos", find it in wontl (probablity 100 now), and set 15,Mon + 1.private BatchSyncCollector SbatchCollector,private OpportunityRepository SopportunityRepository,orsvate FleldhataRenos.tony SsieidhataRenost tomy.private HubspotWebhookMetricsService SnetricsService,* Get contact fields that webhooks are subscribed to in HubSpot.— Thie conuse de dosnontatton hd tho cuanont cohhanh ConficunntionKorchurellne "askelhires2 4 spa...
|
PhpStorm
|
faVsco.js – PropertyChangeManager.php
|
NULL
|
87909
|
|
87908
|
Project: faVsco.js, menu
#12121 on JY-20963-fix-im Project: faVsco.js, menu
#12121 on JY-20963-fix-import-on-deleted-entity, menu
Start Listening for PHP Debug Connections
ServiceTest
Run 'ServiceTest'
Debug 'ServiceTest'
iTerm2ShellEditViewSessionScriptsProfilesWindowHelp‹ >0 lol100% CDOCKER₴81DEV (-zsh)O 82Version 2023.11.20260427:Version 2023.11.20260505:Version2023.11.20260509:Version2023.11.20260511:Version2023.11.20260514:Version 2023.11.20260526:Run"/usr/bin/dnf check-release-update"#_####_\ #####\\###||\#/v~'Amazon Linux 2023 (ECS Optimized)-zshN3ec2-user@ip-10-20-31-146:~screenpipe"84-zshX5ec2-user@ip-10-30-129-...8• Thu 28 May 19:29:31181ec2-user@ip-10-20-31-1...·7for full release and version update infom/For documentation, visit [URL_WITH_CREDENTIALS] ~]$ docker exec -it $(docker ps --format "{{.ID}}" --filter "name-ecs-worker" | head -1) /bin/bash -c "cd /home/jiminny && bash"root@170a323e43a4:/home/jiminny# php artisan crm:sync-opportunity --teamId 555 --opportunityId 494058190045[2026-05-28 16:20:08] production.INF0: Jiminny\Console\Commands\Command: : run Memory usage before starting command {"command": "crm:sync-opportunity", "memoryBeforeCommandInMb" : 118.0, "memoryPeakBeforeCommandInMb":118.03 {"correlation_id":"0a531c45-bc24-4ea8-b3c9-d90f79590da8", "trace_id":"1723bcBc-9f67-4ec7-b8c3-d09361d5879d"}Syncing opportunity for Cognitive Credit[2026-05-28 16:20:08] production.INFO: [SocialAccountService] Fetching token {"socialAccountId":30591,"provider": "hubspot"} {"correlation_id":"0a531c45-bc24-4ea8-b3c9-d90f79590da8", "trace_id":"1723bcDc-9f67-4ec7-b8c3-d09361d5879d"'}[2026-05-28 16:20:08] production.INFO: [SocialAccountService] Token retrieved {"socialAccountId":30591, "provider": "hubspot"} {"correlation_id":"0a531c45-bc24-4ea8-b3c9-d90f79590da8", "trace_id":"1723bc0c-9f67-4ec7-b8c3-d09361d5879d"'}[2026-05-28 16:20:08] production.INFO: [EncryptedTokenManager] Generatingaccess token. {"mode":"legacy"} {"correlation_id":"0a531c45-bc24-4ea8-b3c9-d90f79590da8","trace_id" :"1723bc0c-9f67-4ec7-b8c3-d09361d5879d"}Syncing opportunity 494058190045...[2026-05-28 16:20:08] production.INF0: [ReindexForOpportunityListener] Schedule reindexing job {"opportunity_id":7601423} {"correlation_id":"0a531c45-bc24-4ea8-b3c9-d90f79590da8","trace_id":"1723bc0c-9f67-4ec7-b8c3-d09361d5879d"}Synced Centiva Capital - EU HY/IG to 7601423[2026-05-28 16:20:08] production.INF0: Jiminny\Console\Commands\Command: :run Memory usage for command {"command": "crm:sync-opportunity", "memoryBeforeCommandInMb" : 118.0, "memoryAfterCommandInMB": 126.0, "memoryPeakBeforeCommandInMb" : 118.0, "memoryPeakAfterCommandInMB":126.0} {"correlation_id":"0a531c45-bc24-4ea8-b3c9-d90f79590da8", "trace_id" : "1723bc0c-9f67-4ec7-b8c3-d09361d5879d"}root@170a323e43a4:/home/jiminny# |...
|
PhpStorm
|
faVsco.js – PropertyChangeManager.php
|
NULL
|
87908
|
|
87907
|
Project: faVsco.js, menu
#12121 on JY-20963-fix-im Project: faVsco.js, menu
#12121 on JY-20963-fix-import-on-deleted-entity, menu
Start Listening for PHP Debug Connections
ServiceTest
Run 'ServiceTest'
Debug 'ServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
8
25
Previous Highlighted Error
Next Highlighted Error
rapstomEV faVsco,ls ~ProjectvViewNeweNNC#12121 on JY-20963-fix-InCoocWindow› E Merge© HubspotWebhooiMetriPropertyChangeManaguvalohtwohooktoG WebhookAssociationGlwalennedwityCimbdlo.orgy uimaeumyociwiceon€ ServiceTest.php@ CachedCrmServiceDecorator.phpo Frosserosehid.onpooonurwowncaion© HubspotLastModifiedCreatedRecentiyOpenSyncStrateay.ohg© PayloadBulider.phpx© WebhookDeletionHandwewokvents© WebhookMergeHandleclass PayloadbuzldenA8X25 Av© WebhookPropertyChan 689C WeohookSionatureVa© BatchSynceollector.phpc) Batchsuncred sservice o© Client.phpC) Cosed Dea [EMAIL]) Decorate chmm cho©FieldDefinitions.phpCrAcTvaAeoousreoodHubspotClientinterface.ph© [EMAIL]© RemoteCrmObjectManipulResponseNormaize.php© Service.php© SyncFieldAction.php© SyncRelatedActivity Mana, 697© WebhookSyncBatchProce> E IntegrationApp>Elisteners> @ Metadata› E Migration› 0 PipedriveE Salesforce> D Fields› E OpportunityMatcher› OpportunitySyncStrategyeSemicetiraitsCeentond©DecorateActivity.phpbele eocectetie tonocseldae initons choc Paviosd Bulldes.ono© [EMAIL] Ouorvitorstoe nha©QueryResuits.phpci Connno nhr71089* Generate a payload to search for contacts by name.*The searchois a token search sin dinstnane on uostnend3 893public function generateSearchContactsByNanePayLoad(string Snane, array $fiolds): arrayl....95Susagespublic function buildAddAssociationPayload(string Scraid, array Sids, int SassociationType): arrayl »7public function addClosedStageFilters(array &Spayload, array SclosedStages): void"propertyNane' => "dealstage'."oneator'values' => SclosedStages[ 'lost'].117118public function addGreatedDateFilters(array &Spayload, Carbon ScreatedAfter): voidf...10 usagespublic function getDealsInBulkPayLoad(array SdealIds): array(...}BUMMEMEMME85id-entity // View pull request (today 16:12)A SF jiminny@localhost]HS.Jocal [iminny@localhost)A console (EU]© PropertyChangeManager.phpxB users (EU)readonly class PropertyChangeManagenA console [PROD)A console (STAGING)TNw.AYself::0BJECT_TYPE_CONTACT →> [// Add contact properties that support direct updateself::OBJECT_TYPE_COMPANY »> [// Add company properties that support direct update* Napping from HubSpot property names to Jiminny model property names.* Used when updating the main model (Opportunity, Contact, Account) directly.* OnZy needed when Hubspot property nane differs fron jiminny property name.*/private const array OBJECT_PROPERTY_MAPPINGS = LCascade & VICommand'dealgang' »> 'nane', // HubSpot "dealnane' naps to Opportunity 'name)=$0 l100% KSa- • Thu 28 May 19:29:28ServiceTestvCascadeles Orcnnworeebeeionhnves ohtine oDon+0.Unless the May 20 sync used a cachedClosedDea lStages that was populated earlier in the same batch lob — before a importStages () call updated thestage probability from 0 to 100. But that would require stage 20616 to have been at probability 0 at the start of that batch run, then changed to 100 mid-runby tecortStases(.And ieportstanesontyrunsThe true, detinitive answenAfer all this ansves, the most"kely exolonstion is this is a data snaoshot from an eanter time,t ihe opoortunty was sunoed when closedlost harprobability 0 (before May 4, 2026). Then someone manually changed the stage probability to 100 on May 4 in Jiminry. But the opportunity hasn't been re-synced since then - it shows opdated_at: 2826-65-20 but that could be from a different field being updated (like dealname, value, etc.) via the Propertychanackanager's dircct uodate path, which does Not recalculate is. wonLook at PropertyChangekschinono looea luareciupdatea PropertychUoontc boock procchisy trwootne cxstsCoronerMnoosros& CeTeiEeT PROPERN MAPDNSRANORFMNMPE DEAETe1t (isset(SpropertyMappings (SpropertyNase))) <Sthis-sopportunityRepository->update0rCreate(..);odste weld darSthis->inportObjectFieldData(...):return true;D8_OBJECT_UPOATE_PROPERTIES for deals = ['dealnane', "description']. if a dealnase or description webhook event came in on May 20, it would:1. Update nase on the opportunity (via updateOrCreate) → touches updated_at2. Return true (bypassing the full hand leöbiectSync / batch queue path3. NOT recalculate 15,vocThis is the answer. On May 20, a dralnase or description change webhook arrived. PropertyChangeManaoer: :handleDralDirectUpdate ran, undatecthe opportunity's nane field (touching updated_at to 2026-05-28 18:36:59 ), and returned true - skipping the full batch sync that would haverecalulated 1s von. The dear's 1s,von was already 8 from a previous syne (when Closedlost had probability 0, before May 4), and the May 20 direct-update weohook oiontfixkSummaryTine ooocurynsywns1. Before May 4: Stage 20616 (closmdlost) had probability = 8 in Jiminny's DB (consistent with HubSpot's default behavior for a stage named"closedlost"). Any sync at that time would put "closedlost* in tostl) → 15_closedel, i5,vonne.2. May 4: Someone manually updated the stage probability to 100 in Jiminny DB (and/or HubSpot updated their stage label/settings). Stage undateal.ate2826-85-043. May 20: A HubSpot webhook for dealnase or description property change arrived for this deal. Prcndleocaldi rectUpdatfired - it updated the opportunity's nane field directly (touching updated_at ) but returned true without queuing a full sync. 15 von was neverrecalculated4. Result: updated_at: 2826-85-28 looks recent, but the is_von value is stale from a sync that happened before May 4 when the stage probability wasstilllotthe tixis to troac a troch sune df thie sond ic anoortunity: sync0noortun ty(404958190945*) wotld tetch ttrom Hub Soot soo denlstran - "closedlos", find it in wontl (probablity 100 now), and set 15,Mon + 1.private BatchSyncCollector SbatchCollector,private OpportunityRepository SopportunityRepository,orsvate FleldhataRenos.tony SsieidhataRenost tomy.private HubspotWebhookMetricsService SnetricsService,* Get contact fields that webhooks are subscribed to in HubSpot.— Thie conuse de dosnontatton hd tho cuanont cohhanh ConficunntionKorchurellne kaskelhiroh2 4 spao...
|
PhpStorm
|
faVsco.js – PropertyChangeManager.php
|
NULL
|
87907
|
|
87906
|
Project: faVsco.js, menu
#12121 on JY-20963-fix-im Project: faVsco.js, menu
#12121 on JY-20963-fix-import-on-deleted-entity, menu
Start Listening for PHP Debug Connections
ServiceTest
Run 'ServiceTest'
iTerm2ShellEditViewSessionScriptsProfilesWindowHelp‹ >0 lol100% CDOCKER₴81DEV (-zsh)O 82Version 2023.11.20260427:Version 2023.11.20260505:Version2023.11.20260509:Version2023.11.20260511:Version2023.11.20260514:Version 2023.11.20260526:Run"/usr/bin/dnf check-release-update"#_####_\ #####\\###||\#/v~'Amazon Linux 2023 (ECS Optimized)-zshN3ec2-user@ip-10-20-31-146:~screenpipe"84-zshX5ec2-user@ip-10-30-129-...8• Thu 28 May 19:29:29181ec2-user@ip-10-20-31-1...·7for full release and version update infom/For documentation, visit [URL_WITH_CREDENTIALS] ~]$ docker exec -it $(docker ps --format "{{.ID}}" --filter "name-ecs-worker" | head -1) /bin/bash -c "cd /home/jiminny && bash"root@170a323e43a4:/home/jiminny# php artisan crm:sync-opportunity --teamId 555 --opportunityId 494058190045[2026-05-28 16:20:08] production.INF0: Jiminny\Console\Commands\Command: : run Memory usage before starting command {"command": "crm:sync-opportunity", "memoryBeforeCommandInMb" : 118.0, "memoryPeakBeforeCommandInMb":118.03 {"correlation_id":"0a531c45-bc24-4ea8-b3c9-d90f79590da8", "trace_id":"1723bcBc-9f67-4ec7-b8c3-d09361d5879d"}Syncing opportunity for Cognitive Credit[2026-05-28 16:20:08] production.INFO: [SocialAccountService] Fetching token {"socialAccountId":30591,"provider": "hubspot"} {"correlation_id":"0a531c45-bc24-4ea8-b3c9-d90f79590da8", "trace_id":"1723bcDc-9f67-4ec7-b8c3-d09361d5879d"'}[2026-05-28 16:20:08] production.INFO: [SocialAccountService] Token retrieved {"socialAccountId":30591, "provider": "hubspot"} {"correlation_id":"0a531c45-bc24-4ea8-b3c9-d90f79590da8", "trace_id":"1723bc0c-9f67-4ec7-b8c3-d09361d5879d"'}[2026-05-28 16:20:08] production.INFO: [EncryptedTokenManager] Generatingaccess token. {"mode":"legacy"} {"correlation_id":"0a531c45-bc24-4ea8-b3c9-d90f79590da8","trace_id" :"1723bc0c-9f67-4ec7-b8c3-d09361d5879d"}Syncing opportunity 494058190045...[2026-05-28 16:20:08] production.INF0: [ReindexForOpportunityListener] Schedule reindexing job {"opportunity_id":7601423} {"correlation_id":"0a531c45-bc24-4ea8-b3c9-d90f79590da8","trace_id":"1723bc0c-9f67-4ec7-b8c3-d09361d5879d"}Synced Centiva Capital - EU HY/IG to 7601423[2026-05-28 16:20:08] production.INF0: Jiminny\Console\Commands\Command: :run Memory usage for command {"command": "crm:sync-opportunity", "memoryBeforeCommandInMb" : 118.0, "memoryAfterCommandInMB": 126.0, "memoryPeakBeforeCommandInMb" : 118.0, "memoryPeakAfterCommandInMB":126.0} {"correlation_id":"0a531c45-bc24-4ea8-b3c9-d90f79590da8", "trace_id" : "1723bc0c-9f67-4ec7-b8c3-d09361d5879d"}root@170a323e43a4:/home/jiminny# |...
|
PhpStorm
|
faVsco.js – PropertyChangeManager.php
|
NULL
|
87906
|
|
87905
|
Project: faVsco.js, menu
#12121 on JY-20963-fix-im Project: faVsco.js, menu
#12121 on JY-20963-fix-import-on-deleted-entity, menu
Start Listening for PHP Debug Connections
ServiceTest
Run 'ServiceTest'
Debug 'ServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
8
25
Previous Highlighted Error
Next Highlighted Error
<?php
declare(strict_types=1);
namespace Jiminny\Services\Crm\Hubspot;
use Carbon\Carbon;
use Jiminny\Models\Crm\Configuration;
use Psr\Log\LoggerInterface;
class PayloadBuilder
{
public const int MAX_SEARCH_REQUEST_LIMIT = 200;
private const int MAX_FILTER_SIZE = 100;
private const string SORT_PROPERTY = 'hs_timestamp';
private const string ENGAGEMENT_MEETINGS = 'meetings';
public function getLinkToTaskPayload(string $objectType, string $objectId, string $engagementType): array
{
$payload = [
'sorts' => [
[
'propertyName' => self::SORT_PROPERTY,
'direction' => 'DESCENDING',
],
],
'limit' => self::MAX_SEARCH_REQUEST_LIMIT,
];
if ($engagementType === self::ENGAGEMENT_MEETINGS) {
$payload['filterGroups'] = $this->buildMeetingFiltersForLinkToTask($objectType, $objectId);
$payload['properties'] = [
'hs_meeting_title',
'hs_meeting_outcome',
'hs_timestamp',
'hs_activity_type',
];
} else {
$payload['filterGroups'] = $this->buildTaskFiltersForLinkToTask($objectType, $objectId);
$payload['properties'] = ['hs_task_subject', 'hs_timestamp'];
}
return $payload;
}
public function getRecentlyUpdatedSearchPayload(Carbon $since, ?Carbon $to, array $properties): array
{
$lastUpdateDate = in_array('firstname', $properties) ? 'lastmodifieddate' : 'hs_lastmodifieddate';
$payload = [
'filters' => [
[
'propertyName' => $lastUpdateDate,
'operator' => 'GT',
'value' => $since->getPreciseTimestamp(3),
],
],
'sorts' => [
[
'propertyName' => 'hs_object_id',
'direction' => 'DESCENDING',
],
],
'limit' => self::MAX_SEARCH_REQUEST_LIMIT,
];
if ($to) {
$payload['filters'][] = [
'propertyName' => $lastUpdateDate,
'operator' => 'LT',
'value' => $to->getPreciseTimestamp(3),
];
}
$payload['properties'] = $properties;
return $payload;
}
public function getByProfileSearchPayload(string $crmId, array $properties, Carbon $since, ?Carbon $to): array
{
$lastUpdateDate = array_key_exists('firstname', $properties) ? 'lastmodifieddate' : 'hs_lastmodifieddate';
$payload = [
'filters' => [
[
'propertyName' => $lastUpdateDate,
'operator' => 'GT',
'value' => $since->getPreciseTimestamp(3),
],
[
'propertyName' => 'hubspot_owner_id',
'operator' => 'EQ',
'value' => $crmId,
],
],
'sorts' => [
[
'propertyName' => 'hs_object_id',
'direction' => 'DESCENDING',
],
],
'limit' => self::MAX_SEARCH_REQUEST_LIMIT,
];
if ($to) {
$payload['filters'][] = [
'propertyName' => $lastUpdateDate,
'operator' => 'LT',
'value' => $to->getPreciseTimestamp(3),
];
}
$payload['properties'] = $properties;
return $payload;
}
private function buildMeetingFiltersForLinkToTask($objectType, $objectId): array
{
$filters = [];
$filters[] = [
'filters' => [
$this->getAssociatedObjectFilter($objectType, $objectId),
[
'propertyName' => 'hs_meeting_outcome',
'operator' => 'IN',
'values' => ['SCHEDULED', 'RESCHEDULED'],
],
],
];
$filters[] = [
'filters' => [
$this->getAssociatedObjectFilter($objectType, $objectId),
[
'propertyName' => 'hs_meeting_outcome',
'operator' => 'NOT_HAS_PROPERTY',
],
],
];
return $filters;
}
private function buildTaskFiltersForLinkToTask($objectType, $objectId): array
{
return [
[
'filters' => [
$this->getAssociatedObjectFilter($objectType, $objectId),
],
],
];
}
private function getAssociatedObjectFilter(string $objectType, string $objectId): array
{
return [
'propertyName' => 'associations.' . $objectType,
'operator' => 'EQ',
'value' => $objectId,
];
}
public function generatePlaybackURLSearchPayload(string $playbackURLToken): array
{
return [
'filters' => [
[
'propertyName' => 'hs_call_recording_url',
'operator' => 'CONTAINS_TOKEN',
'value' => $playbackURLToken,
],
],
'properties' => [
'hs_call_recording_url',
'hs_call_body',
],
'limit' => 1,
];
}
public function generateGetCallsPayload(Carbon $from, Carbon $to, string $activityProvider, $page): array
{
return [
'filters' => [
[
'propertyName' => 'hs_call_recording_url',
'operator' => 'CONTAINS_TOKEN',
'value' => $activityProvider,
],
[
'propertyName' => 'hs_timestamp',
'operator' => 'BETWEEN',
'value' => intval($from->getPreciseTimestamp(3)),
'highValue' => intval($to->getPreciseTimestamp(3)),
],
],
'sorts' => [
[
'propertyName' => 'hs_timestamp',
'direction' => 'DESCENDING',
],
],
'properties' => [
'hs_call_recording_url',
'hs_call_body',
],
'limit' => self::MAX_SEARCH_REQUEST_LIMIT,
'after' => ($page - 1) * self::MAX_SEARCH_REQUEST_LIMIT,
];
}
public function generateSearchCallsByPeriodPayload(Carbon $from, Carbon $to, $page): array
{
return [
'filters' => [
[
'propertyName' => 'hs_timestamp',
'operator' => 'BETWEEN',
'value' => intval($from->getPreciseTimestamp(3)),
'highValue' => intval($to->getPreciseTimestamp(3)),
],
],
'sorts' => [
[
'propertyName' => 'hs_timestamp',
'direction' => 'DESCENDING',
],
],
'properties' => $this->getSearchCallAttributes(),
'limit' => self::MAX_SEARCH_REQUEST_LIMIT,
'after' => ($page - 1) * self::MAX_SEARCH_REQUEST_LIMIT,
];
}
public function getSearchCallAttributes(): array
{
return [
'hs_timestamp',
'hs_call_recording_url',
'hs_call_body',
'hs_call_status',
'hs_call_to_number',
'hs_call_from_number',
'hs_call_duration',
'hs_call_disposition',
'hs_call_title',
'hs_call_direction',
'hubspot_owner_id',
'hs_activity_type',
'hs_call_external_id',
'hs_call_source',
];
}
public function generatePlaybackAddUrlBatchPayload(array $crmUpdateData): array
{
$updateObjectsData = [];
foreach ($crmUpdateData as $data) {
$updateObjectsData[] = [
'id' => $data['crm_id'],
'properties' => [
'hs_call_body' => $data['hs_call_body'] .
'<p><span style="font-size: 13.3333px; line-height: 16px;">' .
'<a href="' . $data['playback_url'] . '" target="_blank">Review in Jiminny</a> ▶️</span></p>',
],
];
}
return ['inputs' => $updateObjectsData];
}
public function generateSearchCallByTokenPayload(string $playbackURLToken): array
{
return [
'filters' => [
[
'propertyName' => 'hs_call_recording_url',
'operator' => 'CONTAINS_TOKEN',
'value' => $playbackURLToken,
],
],
'properties' => [
'hs_call_recording_url',
'hs_call_body',
],
'limit' => 1,
];
}
/**
* Generates a payload for phone search based on the specified parameters.
*
* @param string $phone The phone number to search.
* @param bool $isAlternativeSearch Indicates if an alternative search should be performed
* if the first search fails to find a match. The first search request should cover most of the cases.
*/
public function generatePhoneSearchPayload(string $phone, bool $isAlternativeSearch = false): array
{
$filterPropertyNames = $isAlternativeSearch ?
['hs_searchable_calculated_mobile_number', 'phone', 'mobilephone'] :
['hs_searchable_calculated_phone_number', 'hs_calculated_phone_number', 'hs_calculated_mobile_number'];
$filterGroups = array_map(function ($propertyName) use ($phone) {
return $this->createFilterGroup($propertyName, 'CONTAINS_TOKEN', $phone);
}, $filterPropertyNames);
$properties = [
'email',
'firstname',
'lastname',
'country',
'phone',
'mobilephone',
'jobtitle',
'hubspot_owner_id',
'associatedcompanyid',
'photo',
];
return $this->createPayload($filterGroups, $properties);
}
/**
* Creates a filter group with the specified parameters.
*/
private function createFilterGroup(string $propertyName, string $operator, $value): array
{
return [
'filters' => [
[
'propertyName' => $propertyName,
'operator' => $operator,
'value' => $value,
],
],
];
}
/**
* Creates a payload with the specified filter groups and properties.
*/
private function createPayload(array $filterGroups, array $properties): array
{
return [
'filterGroups' => $filterGroups,
'sorts' => [
[
'propertyName' => 'modifieddate',
'direction' => 'DESCENDING',
],
],
'properties' => $properties,
'limit' => 1,
];
}
/**
* Generates a payload to find related activities based on the specified data and object type.
* The search looks for activities that match the specified time range (starttime - endtime or only starttime with endtime not set)
* and are associated with any of the provided prospect types (contact or company).
*
* @param array $data An associative array containing the following keys:
* - 'from': The start time for the activity search (timestamp).
* - 'to': The end time for the activity search (timestamp).
* - 'contact': (optional) The ID of the associated contact.
* - 'company': (optional) The ID of the associated company.
* @param string $objectType The type of engagement to search for (meeting or other type like call).
*
* @return array The payload array to be used for searching related activities.
*/
public function getFindRelatedActivityPayload(array $data, string $objectType): array
{
$payload = [
'sorts' => [
[
'propertyName' => self::SORT_PROPERTY,
'direction' => 'DESCENDING',
],
],
'limit' => self::MAX_SEARCH_REQUEST_LIMIT,
];
if ($objectType === self::ENGAGEMENT_MEETINGS) {
$payload['properties'] = [
'hs_meeting_title',
'hs_meeting_outcome',
'hs_activity_type',
'hs_timestamp',
'hubspot_owner_id',
'hs_meeting_body',
'hs_internal_meeting_notes',
'hs_meeting_location',
'hs_meeting_start_time',
'hs_meeting_end_time',
];
} else {
$payload['properties'] = ['hs_task_subject', 'hs_timestamp', 'hubspot_owner_id', 'hs_call_body'];
}
$timeFiltersWithEndTime = [
[
'propertyName' => 'hs_meeting_start_time',
'operator' => 'GTE',
'value' => $data['from'],
],
[
'propertyName' => 'hs_meeting_end_time',
'operator' => 'LTE',
'value' => $data['to'],
],
];
$timeFiltersWithoutEndTime = [
[
'propertyName' => 'hs_meeting_start_time',
'operator' => 'GTE',
'value' => $data['from'],
],
[
'propertyName' => 'hs_meeting_end_time',
'operator' => 'NOT_HAS_PROPERTY',
],
];
$payload['filterGroups'] = [];
$associationTypes = ['contact', 'company'];
foreach ($associationTypes as $type) {
if (! empty($data[$type])) {
$filterGroupWithEndTime = [
'filters' => array_merge(
$timeFiltersWithEndTime,
[$this->getAssociatedObjectFilter($type, $data[$type])]
),
];
$payload['filterGroups'][] = $filterGroupWithEndTime;
$filterGroupWithoutEndTime = [
'filters' => array_merge(
$timeFiltersWithoutEndTime,
[$this->getAssociatedObjectFilter($type, $data[$type])]
),
];
$payload['filterGroups'][] = $filterGroupWithoutEndTime ;
}
}
return $payload;
}
/** Parameters
* [
* 'accountId' => $crmAccountId,
* 'sortBy' => $sortBy,
* 'sortDir' => $sortDir,
* 'onlyOpen' => $onlyOpen,
* 'closedStages' => $this->getClosedDealStages(),
* 'userId' => $userId,
* ];
*/
public function generateOpportunitiesSearchPayload(
Configuration $config,
string $crmAccountId,
array $closedStages,
): array {
$closedFilters = [];
$filterGroups = [];
$onlyOpen = true;
switch ($config->getOpportunityAssignmentRule()) {
case Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_UPDATED:
$sortBy = 'modifieddate';
$sortDir = 'DESCENDING';
break;
case Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_CREATED:
$sortBy = 'createdate';
$sortDir = 'DESCENDING';
break;
case Configuration::OPP_ASSIGNMENT_ALL_OPEN_OLDEST_CREATED:
$sortBy = 'createdate';
$sortDir = 'ASCENDING';
break;
case Configuration::OPP_ASSIGNMENT_ALL_RECENTLY_UPDATED:
default:
$sortBy = 'modifieddate';
$sortDir = 'DESCENDING';
$onlyOpen = false;
}
$baseFilters = [
[
'propertyName' => 'associations.company',
'operator' => 'EQ',
'value' => $crmAccountId,
],
];
// Handle closed stages in chunks
if ($onlyOpen) {
foreach (['won', 'lost'] as $key) {
if (! empty($closedStages[$key])) {
$chunks = array_chunk($closedStages[$key], self::MAX_FILTER_SIZE);
foreach ($chunks as $chunk) {
$closedFilters[] = [
'propertyName' => 'dealstage',
'operator' => 'NOT_IN',
'values' => $chunk,
];
}
}
}
}
$filterGroups[] = [
'filters' => array_merge($baseFilters, $closedFilters),
];
$payload = [
'filterGroups' => $filterGroups,
'sorts' => [
[
'propertyName' => $sortBy,
'direction' => $sortDir,
],
],
'properties' => [
'dealname',
'amount',
'hubspot_owner_id',
'pipeline',
'dealstage',
'closedate',
'deal_currency_code',
],
'limit' => self::MAX_SEARCH_REQUEST_LIMIT,
];
$logger = app(LoggerInterface::class);
$logger->info('[HubSpot] CRM Search requested', [
'request' => $payload,
]);
return $payload;
}
/**
* Converts v1 payload data to v3
*/
public function getV3MeetingPayload(array $engagement, array $metadata, array $associations = []): array
{
// Use this mapping until the whole Hubspot V1 is dropped.
$properties = [
'hubspot_owner_id' => $engagement['ownerId'],
'hs_timestamp' => $engagement['timestamp'],
];
// $engagement['activityType'] is $activity->category->name
if (isset($engagement['activityType'])) {
$properties['hs_activity_type'] = $engagement['activityType'];
}
$metadataKeyMap = [
'hs_meeting_outcome' => 'meetingOutcome',
'hs_meeting_title' => 'title',
'hs_meeting_start_time' => 'startTime',
'hs_meeting_end_time' => 'endTime',
'hs_meeting_body' => 'body',
'hs_internal_meeting_notes' => 'internalMeetingNotes',
];
foreach ($metadataKeyMap as $newKey => $oldKey) {
if (isset($metadata[$oldKey])) {
$properties[$newKey] = $metadata[$oldKey];
unset($metadata[$oldKey]);
}
}
$properties = [
...$properties,
...$metadata, // custom fields
];
$response = [
'properties' => $properties,
];
if (! empty($associations)) {
$response['associations'] = $associations;
}
return $response;
}
/**
* Generate a payload to search for contacts by name.
* The search is a token search in firstname or lastname
*/
public function generateSearchContactsByNamePayload(string $name, array $fields): array
{
$firstNameFilter = [
'propertyName' => 'firstname',
'operator' => 'CONTAINS_TOKEN',
'value' => $name,
];
$lastNameFilter = [
'propertyName' => 'lastname',
'operator' => 'CONTAINS_TOKEN',
'value' => $name,
];
return [
'filterGroups' => [
[
'filters' => [$firstNameFilter],
],
[
'filters' => [$lastNameFilter],
],
],
'properties' => $fields,
'sorts' => [
[
'propertyName' => 'lastmodifieddate',
'direction' => 'DESCENDING',
],
],
];
}
public function buildAddAssociationPayload(string $crmId, array $ids, int $associationType): array
{
$inputs = [];
foreach ($ids as $id) {
if ($id === null || $id === '') {
continue;
}
$inputs[] = [
'from' => [
'id' => $crmId,
],
'to' => [
'id' => (string) $id,
],
'types' => [
[
'associationCategory' => 'HUBSPOT_DEFINED',
'associationTypeId' => $associationType,
],
],
];
}
return ['inputs' => $inputs];
}
public function buildRemoveAssociationPayload(string $crmId, array $ids): array
{
$toArray = [];
foreach ($ids as $id) {
if ($id === null || $id === '') {
continue;
}
$toArray[] = ['id' => (string) $id];
}
return [
'inputs' => [
[
'from' => ['id' => $crmId],
'to' => $toArray,
],
],
];
}
public function addClosedStageFilters(array &$payload, array $closedStages): void
{
if (! empty($closedStages['won'])) {
$payload['filters'][] = [
'propertyName' => 'dealstage',
'operator' => 'NOT_IN',
'values' => $closedStages['won'],
];
}
if (! empty($closedStages['lost'])) {
$payload['filters'][] = [
'propertyName' => 'dealstage',
'operator' => 'NOT_IN',
'values' => $closedStages['lost'],
];
}
}
public function addCreatedDateFilters(array &$payload, Carbon $createdAfter): void
{
$payload['filters'][] = [
'propertyName' => 'createdate',
'operator' => 'GT',
'value' => $createdAfter->getPreciseTimestamp(3),
];
}
public function getDealsInBulkPayload(array $dealIds): array
{
return [
'filterGroups' => [
[
'filters' => [
[
'propertyName' => 'hs_object_id',
'operator' => 'IN',
'values' => $dealIds,
],
],
],
],
'limit' => self::MAX_FILTER_SIZE,
];
}
}
Sync Changes
Hide This Notification
Code changed:
Hide
22
8
Previous Highlighted Error
Next Highlighted Error
<?php
declare(strict_types=1);
namespace Jiminny\Services\Crm\Hubspot\Webhook;
use Illuminate\Support\Facades\Log;
use Jiminny\Models\Crm\Configuration;
use Jiminny\Services\Crm\Hubspot\BatchSyncCollector;
use Jiminny\Repositories\Crm\CrmEntityRepository;
use Jiminny\Repositories\Crm\OpportunityRepository;
use Jiminny\Repositories\Crm\FieldRepository;
use Jiminny\Repositories\Crm\FieldDataRepository;
use Jiminny\Models\Opportunity;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\LayoutEntity;
readonly class PropertyChangeManager
{
private const string OBJECT_TYPE_DEAL = 'deal';
private const string OBJECT_TYPE_CONTACT = 'contact';
private const string OBJECT_TYPE_COMPANY = 'company';
private const array SUPPORTED_OBJECT_TYPES = [
self::OBJECT_TYPE_DEAL,
self::OBJECT_TYPE_CONTACT,
self::OBJECT_TYPE_COMPANY,
];
/**
* Contact fields that webhooks are subscribed to in HubSpot.
*
* IMPORTANT: These fields MUST match the webhook subscriptions configured in HubSpot.
* When updating this list, you must also update the HubSpot webhook subscriptions:
* 1. Go to HubSpot App Settings → Webhooks
* 2. Update contact.propertyChange subscriptions to match this list
* 3. Ensure all fields listed here are subscribed, and vice versa
*
* This constant serves as documentation of the current webhook configuration.
* Since HubSpot filters webhooks server-side, we should only receive events for these properties.
*/
private const array APPLICABLE_CONTACT_FIELDS = [
'firstname',
'lastname',
'email',
'phone',
'mobilephone',
'jobtitle',
'country',
'hubspot_owner_id',
'associatedcompanyid',
'photo',
'hs_avatar_filemanager_key',
];
/**
* Company fields that webhooks are subscribed to in HubSpot.
*
* IMPORTANT: These fields MUST match the webhook subscriptions configured in HubSpot.
* When updating this list, you must also update the HubSpot webhook subscriptions:
* 1. Go to HubSpot App Settings → Webhooks
* 2. Update company.propertyChange subscriptions to match this list
* 3. Ensure all fields listed here are subscribed, and vice versa
*
* This constant serves as documentation of the current webhook configuration.
* Since HubSpot filters webhooks server-side, we should only receive events for these properties.
*/
private const array APPLICABLE_COMPANY_FIELDS = [
'name',
'phone',
'domain',
'country',
'industry',
'hubspot_owner_id',
'hs_avatar_filemanager_key',
];
/**
* Properties that support direct database updates (without full sync).
* These are simple properties that can be updated immediately without recalculating other fields.
*/
private const array DB_OBJECT_UPDATE_PROPERTIES = [
self::OBJECT_TYPE_DEAL => [
'dealname',
'description',
],
self::OBJECT_TYPE_CONTACT => [
// Add contact properties that support direct update
],
self::OBJECT_TYPE_COMPANY => [
// Add company properties that support direct update
],
];
/**
* Mapping from HubSpot property names to Jiminny model property names.
* Used when updating the main model (Opportunity, Contact, Account) directly.
* Only needed when HubSpot property name differs from Jiminny property name.
*/
private const array OBJECT_PROPERTY_MAPPINGS = [
self::OBJECT_TYPE_DEAL => [
'dealname' => 'name', // HubSpot 'dealname' maps to Opportunity 'name'
],
self::OBJECT_TYPE_CONTACT => [
// Add contact property mappings if needed
],
self::OBJECT_TYPE_COMPANY => [
// Add company property mappings if needed
],
];
// Field object types mapping
private const array FIELD_OBJECT_TYPES = [
self::OBJECT_TYPE_DEAL => Field::OBJECT_OPPORTUNITY,
self::OBJECT_TYPE_CONTACT => Field::OBJECT_CONTACT,
self::OBJECT_TYPE_COMPANY => Field::OBJECT_ACCOUNT,
];
public function __construct(
private BatchSyncCollector $batchCollector,
private OpportunityRepository $opportunityRepository,
private CrmEntityRepository $crmEntityRepository,
private FieldRepository $fieldRepository,
private FieldDataRepository $fieldDataRepository,
private HubspotWebhookMetricsService $metricsService,
) {
}
/**
* Get contact fields that webhooks are subscribed to in HubSpot.
* This serves as documentation of the current webhook configuration.
*
* @return array<int, string>
*/
public function getApplicableContactFields(): array
{
return self::APPLICABLE_CONTACT_FIELDS;
}
/**
* Get company fields that webhooks are subscribed to in HubSpot.
* This serves as documentation of the current webhook configuration.
*
* @return array<int, string>
*/
public function getApplicableCompanyFields(): array
{
return self::APPLICABLE_COMPANY_FIELDS;
}
/**
* Process property change with intelligent routing
*/
public function processPropertyChange(
string $objectType,
string $objectId,
Configuration $configuration,
string $propertyName,
string $propertyValue
): void {
if (! $this->isObjectTypeSupported($objectType)) {
Log::warning('[HubSpot Property Change Manager] Unsupported object type', [
'object_type' => $objectType,
'object_id' => $objectId,
'property_name' => $propertyName,
]);
return;
}
$this->metricsService->recordWebhookMetrics(
$configuration,
$objectType,
'property_change',
$propertyName
);
if ($this->isDirectUpdateSupported($objectType, $propertyName) &&
$this->handleDirectUpdate($objectType, $objectId, $configuration, $propertyName, $propertyValue)) {
return;
}
$this->handleObjectSync($objectType, $objectId, $configuration);
}
/**
* Legacy method for backward compatibility - assumes deal object type
*
* @deprecated Use processPropertyChange with objectType parameter
*/
public function processPropertyChangeForDeal(
string $objectId,
Configuration $configuration,
string $propertyName,
string $propertyValue
): void {
$this->processPropertyChange(self::OBJECT_TYPE_DEAL, $objectId, $configuration, $propertyName, $propertyValue);
}
/**
* Check if object type is supported
*/
private function isObjectTypeSupported(string $objectType): bool
{
return in_array($objectType, self::SUPPORTED_OBJECT_TYPES);
}
/**
* Check if property can be updated without additional calculation on other properties.
*/
private function isDirectUpdateSupported(string $objectType, string $propertyName): bool
{
$supportedProperties = self::DB_OBJECT_UPDATE_PROPERTIES[$objectType] ?? [];
return in_array($propertyName, $supportedProperties, true);
}
/**
* Handle properties that support direct database updates
*/
private function handleDirectUpdate(
string $objectType,
string $objectId,
Configuration $configuration,
string $propertyName,
string $propertyValue
): bool {
try {
return match ($objectType) {
self::OBJECT_TYPE_DEAL => $this->handleDealDirectUpdate($objectId, $configuration, $propertyName, $propertyValue),
self::OBJECT_TYPE_CONTACT => $this->handleContactDirectUpdate($objectId, $configuration, $propertyName, $propertyValue),
self::OBJECT_TYPE_COMPANY => $this->handleCompanyDirectUpdate($objectId, $configuration, $propertyName, $propertyValue),
default => false,
};
} catch (\Exception $e) {
Log::error('[HubSpot Property Change Manager] Error handling direct update property', [
'object_type' => $objectType,
'object_id' => $objectId,
'property_name' => $propertyName,
'error' => $e->getMessage(),
]);
return false;
}
}
/**
* Handle direct updates for deal objects
*/
private function handleDealDirectUpdate(
string $objectId,
Configuration $configuration,
string $propertyName,
string $propertyValue
): bool {
$opportunity = $this->opportunityRepository->findByConfigAndCrmProviderId(
$configuration,
$objectId
);
if (! $opportunity instanceof Opportunity) {
return false;
}
// Update model property if mapping exists
$propertyMappings = self::OBJECT_PROPERTY_MAPPINGS[self::OBJECT_TYPE_DEAL] ?? [];
if (isset($propertyMappings[$propertyName])) {
$this->opportunityRepository->updateOrCreate(
$configuration,
$objectId,
[$propertyMappings[$propertyName] => $propertyValue]
);
}
// Update field data
$this->importObjectFieldData(
self::OBJECT_TYPE_DEAL,
$opportunity->getId(),
$configuration,
[$propertyName => $propertyValue]
);
return true;
}
/**
* Handle direct updates for contact objects
*
* Updates Contact model properties via OBJECT_PROPERTY_MAPPINGS.
* Add mappings to OBJECT_PROPERTY_MAPPINGS[CONTACT] to enable direct updates.
*/
private function handleContactDirectUpdate(
string $objectId,
Configuration $configuration,
string $propertyName,
string $propertyValue
): bool {
$contact = $this->crmEntityRepository->findContactByExternalId($configuration, $objectId);
if ($contact === null) {
return false;
}
// Update model property if mapping exists
$propertyMappings = self::OBJECT_PROPERTY_MAPPINGS[self::OBJECT_TYPE_CONTACT] ?? [];
if (isset($propertyMappings[$propertyName])) {
$this->crmEntityRepository->importContact($configuration, [
'crm_provider_id' => $objectId,
$propertyMappings[$propertyName] => $propertyValue,
]);
}
return true;
}
/**
* Handle direct updates for company objects
*
* Updates Account model properties via OBJECT_PROPERTY_MAPPINGS.
* Add mappings to OBJECT_PROPERTY_MAPPINGS[COMPANY] to enable direct updates.
*/
private function handleCompanyDirectUpdate(
string $objectId,
Configuration $configuration,
string $propertyName,
string $propertyValue
): bool {
$company = $this->crmEntityRepository->findAccountByExternalId($configuration, $objectId);
if ($company === null) {
return false;
}
// Update model property if mapping exists
$propertyMappings = self::OBJECT_PROPERTY_MAPPINGS[self::OBJECT_TYPE_COMPANY] ?? [];
if (isset($propertyMappings[$propertyName])) {
$this->crmEntityRepository->importAccount($configuration, [
'crm_provider_id' => $objectId,
$propertyMappings[$propertyName] => $propertyValue,
]);
}
return true;
}
/**
* Import field data for any object type
*/
private function importObjectFieldData(
string $objectType,
int $objectId,
Configuration $configuration,
array $data
): void {
$fieldObjectType = self::FIELD_OBJECT_TYPES[$objectType] ?? null;
if (! $fieldObjectType) {
Log::warning('[HubSpot Property Change Manager] Unknown field object type', [
'object_type' => $objectType,
'object_id' => $objectId,
]);
return;
}
foreach ($data as $crmField => $value) {
$field = $this->fieldRepository->findFieldByCrmIdAndObjectType(
$configuration,
$crmField,
$fieldObjectType
);
if (! $field instanceof Field) {
continue;
}
$entities = $field->getEntities();
if ($entities->isEmpty()) {
continue;
}
if ($entities->count() > 1) {
continue;
}
/** @var LayoutEntity|null $entity */
$entity = $entities->first();
if (! $entity instanceof LayoutEntity) {
continue;
}
$this->fieldDataRepository->updateOrCreateFieldData(
$entity,
$objectId,
(string) $value,
);
}
}
private function handleObjectSync(
string $objectType,
string $objectId,
Configuration $configuration,
): void {
if (! $this->isObjectTypeSupported($objectType)) {
return;
}
try {
$this->batchCollector->collectForBatch(
objectType: $objectType,
crmProviderId: $objectId,
configurationId: $configuration->getId(),
eventType: 'property_change'
);
} catch (\Exception $e) {
Log::error('[HubSpot Property Change Manager] Error adding to sync batch', [
'object_type' => $objectType,
'object_id' => $objectId,
'error' => $e->getMessage(),
]);
}
}
}
Project
Project
New File or Directory…
Expand Selected
Collapse All
Options
Hide...
|
PhpStorm
|
faVsco.js – PropertyChangeManager.php
|
NULL
|
87905
|
|
87904
|
Project: faVsco.js, menu
#12121 on JY-20963-fix-im Project: faVsco.js, menu
#12121 on JY-20963-fix-import-on-deleted-entity, menu
Start Listening for PHP Debug Connections
ServiceTest
Run 'ServiceTest'
Debug 'ServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
8
25
Previous Highlighted Error
Next Highlighted Error
<?php
declare(strict_types=1);
namespace Jiminny\Services\Crm\Hubspot;
use Carbon\Carbon;
use Jiminny\Models\Crm\Configuration;
use Psr\Log\LoggerInterface;
class PayloadBuilder
{
public const int MAX_SEARCH_REQUEST_LIMIT = 200;
private const int MAX_FILTER_SIZE = 100;
private const string SORT_PROPERTY = 'hs_timestamp';
private const string ENGAGEMENT_MEETINGS = 'meetings';
public function getLinkToTaskPayload(string $objectType, string $objectId, string $engagementType): array
{
$payload = [
'sorts' => [
[
'propertyName' => self::SORT_PROPERTY,
'direction' => 'DESCENDING',
],
],
'limit' => self::MAX_SEARCH_REQUEST_LIMIT,
];
if ($engagementType === self::ENGAGEMENT_MEETINGS) {
$payload['filterGroups'] = $this->buildMeetingFiltersForLinkToTask($objectType, $objectId);
$payload['properties'] = [
'hs_meeting_title',
'hs_meeting_outcome',
'hs_timestamp',
'hs_activity_type',
];
} else {
$payload['filterGroups'] = $this->buildTaskFiltersForLinkToTask($objectType, $objectId);
$payload['properties'] = ['hs_task_subject', 'hs_timestamp'];
}
return $payload;
}
public function getRecentlyUpdatedSearchPayload(Carbon $since, ?Carbon $to, array $properties): array
{
$lastUpdateDate = in_array('firstname', $properties) ? 'lastmodifieddate' : 'hs_lastmodifieddate';
$payload = [
'filters' => [
[
'propertyName' => $lastUpdateDate,
'operator' => 'GT',
'value' => $since->getPreciseTimestamp(3),
],
],
'sorts' => [
[
'propertyName' => 'hs_object_id',
'direction' => 'DESCENDING',
],
],
'limit' => self::MAX_SEARCH_REQUEST_LIMIT,
];
if ($to) {
$payload['filters'][] = [
'propertyName' => $lastUpdateDate,
'operator' => 'LT',
'value' => $to->getPreciseTimestamp(3),
];
}
$payload['properties'] = $properties;
return $payload;
}
public function getByProfileSearchPayload(string $crmId, array $properties, Carbon $since, ?Carbon $to): array
{
$lastUpdateDate = array_key_exists('firstname', $properties) ? 'lastmodifieddate' : 'hs_lastmodifieddate';
$payload = [
'filters' => [
[
'propertyName' => $lastUpdateDate,
'operator' => 'GT',
'value' => $since->getPreciseTimestamp(3),
],
[
'propertyName' => 'hubspot_owner_id',
'operator' => 'EQ',
'value' => $crmId,
],
],
'sorts' => [
[
'propertyName' => 'hs_object_id',
'direction' => 'DESCENDING',
],
],
'limit' => self::MAX_SEARCH_REQUEST_LIMIT,
];
if ($to) {
$payload['filters'][] = [
'propertyName' => $lastUpdateDate,
'operator' => 'LT',
'value' => $to->getPreciseTimestamp(3),
];
}
$payload['properties'] = $properties;
return $payload;
}
private function buildMeetingFiltersForLinkToTask($objectType, $objectId): array
{
$filters = [];
$filters[] = [
'filters' => [
$this->getAssociatedObjectFilter($objectType, $objectId),
[
'propertyName' => 'hs_meeting_outcome',
'operator' => 'IN',
'values' => ['SCHEDULED', 'RESCHEDULED'],
],
],
];
$filters[] = [
'filters' => [
$this->getAssociatedObjectFilter($objectType, $objectId),
[
'propertyName' => 'hs_meeting_outcome',
'operator' => 'NOT_HAS_PROPERTY',
],
],
];
return $filters;
}
private function buildTaskFiltersForLinkToTask($objectType, $objectId): array
{
return [
[
'filters' => [
$this->getAssociatedObjectFilter($objectType, $objectId),
],
],
];
}
private function getAssociatedObjectFilter(string $objectType, string $objectId): array
{
return [
'propertyName' => 'associations.' . $objectType,
'operator' => 'EQ',
'value' => $objectId,
];
}
public function generatePlaybackURLSearchPayload(string $playbackURLToken): array
{
return [
'filters' => [
[
'propertyName' => 'hs_call_recording_url',
'operator' => 'CONTAINS_TOKEN',
'value' => $playbackURLToken,
],
],
'properties' => [
'hs_call_recording_url',
'hs_call_body',
],
'limit' => 1,
];
}
public function generateGetCallsPayload(Carbon $from, Carbon $to, string $activityProvider, $page): array
{
return [
'filters' => [
[
'propertyName' => 'hs_call_recording_url',
'operator' => 'CONTAINS_TOKEN',
'value' => $activityProvider,
],
[
'propertyName' => 'hs_timestamp',
'operator' => 'BETWEEN',
'value' => intval($from->getPreciseTimestamp(3)),
'highValue' => intval($to->getPreciseTimestamp(3)),
],
],
'sorts' => [
[
'propertyName' => 'hs_timestamp',
'direction' => 'DESCENDING',
],
],
'properties' => [
'hs_call_recording_url',
'hs_call_body',
],
'limit' => self::MAX_SEARCH_REQUEST_LIMIT,
'after' => ($page - 1) * self::MAX_SEARCH_REQUEST_LIMIT,
];
}
public function generateSearchCallsByPeriodPayload(Carbon $from, Carbon $to, $page): array
{
return [
'filters' => [
[
'propertyName' => 'hs_timestamp',
'operator' => 'BETWEEN',
'value' => intval($from->getPreciseTimestamp(3)),
'highValue' => intval($to->getPreciseTimestamp(3)),
],
],
'sorts' => [
[
'propertyName' => 'hs_timestamp',
'direction' => 'DESCENDING',
],
],
'properties' => $this->getSearchCallAttributes(),
'limit' => self::MAX_SEARCH_REQUEST_LIMIT,
'after' => ($page - 1) * self::MAX_SEARCH_REQUEST_LIMIT,
];
}
public function getSearchCallAttributes(): array
{
return [
'hs_timestamp',
'hs_call_recording_url',
'hs_call_body',
'hs_call_status',
'hs_call_to_number',
'hs_call_from_number',
'hs_call_duration',
'hs_call_disposition',
'hs_call_title',
'hs_call_direction',
'hubspot_owner_id',
'hs_activity_type',
'hs_call_external_id',
'hs_call_source',
];
}
public function generatePlaybackAddUrlBatchPayload(array $crmUpdateData): array
{
$updateObjectsData = [];
foreach ($crmUpdateData as $data) {
$updateObjectsData[] = [
'id' => $data['crm_id'],
'properties' => [
'hs_call_body' => $data['hs_call_body'] .
'<p><span style="font-size: 13.3333px; line-height: 16px;">' .
'<a href="' . $data['playback_url'] . '" target="_blank">Review in Jiminny</a> ▶️</span></p>',
],
];
}
return ['inputs' => $updateObjectsData];
}
public function generateSearchCallByTokenPayload(string $playbackURLToken): array
{
return [
'filters' => [
[
'propertyName' => 'hs_call_recording_url',
'operator' => 'CONTAINS_TOKEN',
'value' => $playbackURLToken,
],
],
'properties' => [
'hs_call_recording_url',
'hs_call_body',
],
'limit' => 1,
];
}
/**
* Generates a payload for phone search based on the specified parameters.
*
* @param string $phone The phone number to search.
* @param bool $isAlternativeSearch Indicates if an alternative search should be performed
* if the first search fails to find a match. The first search request should cover most of the cases.
*/
public function generatePhoneSearchPayload(string $phone, bool $isAlternativeSearch = false): array
{
$filterPropertyNames = $isAlternativeSearch ?
['hs_searchable_calculated_mobile_number', 'phone', 'mobilephone'] :
['hs_searchable_calculated_phone_number', 'hs_calculated_phone_number', 'hs_calculated_mobile_number'];
$filterGroups = array_map(function ($propertyName) use ($phone) {
return $this->createFilterGroup($propertyName, 'CONTAINS_TOKEN', $phone);
}, $filterPropertyNames);
$properties = [
'email',
'firstname',
'lastname',
'country',
'phone',
'mobilephone',
'jobtitle',
'hubspot_owner_id',
'associatedcompanyid',
'photo',
];
return $this->createPayload($filterGroups, $properties);
}
/**
* Creates a filter group with the specified parameters.
*/
private function createFilterGroup(string $propertyName, string $operator, $value): array
{
return [
'filters' => [
[
'propertyName' => $propertyName,
'operator' => $operator,
'value' => $value,
],
],
];
}
/**
* Creates a payload with the specified filter groups and properties.
*/
private function createPayload(array $filterGroups, array $properties): array
{
return [
'filterGroups' => $filterGroups,
'sorts' => [
[
'propertyName' => 'modifieddate',
'direction' => 'DESCENDING',
],
],
'properties' => $properties,
'limit' => 1,
];
}
/**
* Generates a payload to find related activities based on the specified data and object type.
* The search looks for activities that match the specified time range (starttime - endtime or only starttime with endtime not set)
* and are associated with any of the provided prospect types (contact or company).
*
* @param array $data An associative array containing the following keys:
* - 'from': The start time for the activity search (timestamp).
* - 'to': The end time for the activity search (timestamp).
* - 'contact': (optional) The ID of the associated contact.
* - 'company': (optional) The ID of the associated company.
* @param string $objectType The type of engagement to search for (meeting or other type like call).
*
* @return array The payload array to be used for searching related activities.
*/
public function getFindRelatedActivityPayload(array $data, string $objectType): array
{
$payload = [
'sorts' => [
[
'propertyName' => self::SORT_PROPERTY,
'direction' => 'DESCENDING',
],
],
'limit' => self::MAX_SEARCH_REQUEST_LIMIT,
];
if ($objectType === self::ENGAGEMENT_MEETINGS) {
$payload['properties'] = [
'hs_meeting_title',
'hs_meeting_outcome',
'hs_activity_type',
'hs_timestamp',
'hubspot_owner_id',
'hs_meeting_body',
'hs_internal_meeting_notes',
'hs_meeting_location',
'hs_meeting_start_time',
'hs_meeting_end_time',
];
} else {
$payload['properties'] = ['hs_task_subject', 'hs_timestamp', 'hubspot_owner_id', 'hs_call_body'];
}
$timeFiltersWithEndTime = [
[
'propertyName' => 'hs_meeting_start_time',
'operator' => 'GTE',
'value' => $data['from'],
],
[
'propertyName' => 'hs_meeting_end_time',
'operator' => 'LTE',
'value' => $data['to'],
],
];
$timeFiltersWithoutEndTime = [
[
'propertyName' => 'hs_meeting_start_time',
'operator' => 'GTE',
'value' => $data['from'],
],
[
'propertyName' => 'hs_meeting_end_time',
'operator' => 'NOT_HAS_PROPERTY',
],
];
$payload['filterGroups'] = [];
$associationTypes = ['contact', 'company'];
foreach ($associationTypes as $type) {
if (! empty($data[$type])) {
$filterGroupWithEndTime = [
'filters' => array_merge(
$timeFiltersWithEndTime,
[$this->getAssociatedObjectFilter($type, $data[$type])]
),
];
$payload['filterGroups'][] = $filterGroupWithEndTime;
$filterGroupWithoutEndTime = [
'filters' => array_merge(
$timeFiltersWithoutEndTime,
[$this->getAssociatedObjectFilter($type, $data[$type])]
),
];
$payload['filterGroups'][] = $filterGroupWithoutEndTime ;
}
}
return $payload;
}
/** Parameters
* [
* 'accountId' => $crmAccountId,
* 'sortBy' => $sortBy,
* 'sortDir' => $sortDir,
* 'onlyOpen' => $onlyOpen,
* 'closedStages' => $this->getClosedDealStages(),
* 'userId' => $userId,
* ];
*/
public function generateOpportunitiesSearchPayload(
Configuration $config,
string $crmAccountId,
array $closedStages,
): array {
$closedFilters = [];
$filterGroups = [];
$onlyOpen = true;
switch ($config->getOpportunityAssignmentRule()) {
case Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_UPDATED:
$sortBy = 'modifieddate';
$sortDir = 'DESCENDING';
break;
case Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_CREATED:
$sortBy = 'createdate';
$sortDir = 'DESCENDING';
break;
case Configuration::OPP_ASSIGNMENT_ALL_OPEN_OLDEST_CREATED:
$sortBy = 'createdate';
$sortDir = 'ASCENDING';
break;
case Configuration::OPP_ASSIGNMENT_ALL_RECENTLY_UPDATED:
default:
$sortBy = 'modifieddate';
$sortDir = 'DESCENDING';
$onlyOpen = false;
}
$baseFilters = [
[
'propertyName' => 'associations.company',
'operator' => 'EQ',
'value' => $crmAccountId,
],
];
// Handle closed stages in chunks
if ($onlyOpen) {
foreach (['won', 'lost'] as $key) {
if (! empty($closedStages[$key])) {
$chunks = array_chunk($closedStages[$key], self::MAX_FILTER_SIZE);
foreach ($chunks as $chunk) {
$closedFilters[] = [
'propertyName' => 'dealstage',
'operator' => 'NOT_IN',
'values' => $chunk,
];
}
}
}
}
$filterGroups[] = [
'filters' => array_merge($baseFilters, $closedFilters),
];
$payload = [
'filterGroups' => $filterGroups,
'sorts' => [
[
'propertyName' => $sortBy,
'direction' => $sortDir,
],
],
'properties' => [
'dealname',
'amount',
'hubspot_owner_id',
'pipeline',
'dealstage',
'closedate',
'deal_currency_code',
],
'limit' => self::MAX_SEARCH_REQUEST_LIMIT,
];
$logger = app(LoggerInterface::class);
$logger->info('[HubSpot] CRM Search requested', [
'request' => $payload,
]);
return $payload;
}
/**
* Converts v1 payload data to v3
*/
public function getV3MeetingPayload(array $engagement, array $metadata, array $associations = []): array
{
// Use this mapping until the whole Hubspot V1 is dropped.
$properties = [
'hubspot_owner_id' => $engagement['ownerId'],
'hs_timestamp' => $engagement['timestamp'],
];
// $engagement['activityType'] is $activity->category->name
if (isset($engagement['activityType'])) {
$properties['hs_activity_type'] = $engagement['activityType'];
}
$metadataKeyMap = [
'hs_meeting_outcome' => 'meetingOutcome',
'hs_meeting_title' => 'title',
'hs_meeting_start_time' => 'startTime',
'hs_meeting_end_time' => 'endTime',
'hs_meeting_body' => 'body',
'hs_internal_meeting_notes' => 'internalMeetingNotes',
];
foreach ($metadataKeyMap as $newKey => $oldKey) {
if (isset($metadata[$oldKey])) {
$properties[$newKey] = $metadata[$oldKey];
unset($metadata[$oldKey]);
}
}
$properties = [
...$properties,
...$metadata, // custom fields
];
$response = [
'properties' => $properties,
];
if (! empty($associations)) {
$response['associations'] = $associations;
}
return $response;
}
/**
* Generate a payload to search for contacts by name.
* The search is a token search in firstname or lastname
*/
public function generateSearchContactsByNamePayload(string $name, array $fields): array
{
$firstNameFilter = [
'propertyName' => 'firstname',
'operator' => 'CONTAINS_TOKEN',
'value' => $name,
];
$lastNameFilter = [
'propertyName' => 'lastname',
'operator' => 'CONTAINS_TOKEN',
'value' => $name,
];
return [
'filterGroups' => [
[
'filters' => [$firstNameFilter],
],
[
'filters' => [$lastNameFilter],
],
],
'properties' => $fields,
'sorts' => [
[
'propertyName' => 'lastmodifieddate',
'direction' => 'DESCENDING',
],
],
];
}
public function buildAddAssociationPayload(string $crmId, array $ids, int $associationType): array
{
$inputs = [];
foreach ($ids as $id) {
if ($id === null || $id === '') {
continue;
}
$inputs[] = [
'from' => [
'id' => $crmId,
],
'to' => [
'id' => (string) $id,
],
'types' => [
[
'associationCategory' => 'HUBSPOT_DEFINED',
'associationTypeId' => $associationType,
],
],
];
}
return ['inputs' => $inputs];
}
public function buildRemoveAssociationPayload(string $crmId, array $ids): array
{
$toArray = [];
foreach ($ids as $id) {
if ($id === null || $id === '') {
continue;
}
$toArray[] = ['id' => (string) $id];
}
return [
'inputs' => [
[
'from' => ['id' => $crmId],
'to' => $toArray,
],
],
];
}
public function addClosedStageFilters(array &$payload, array $closedStages): void
{
if (! empty($closedStages['won'])) {
$payload['filters'][] = [
'propertyName' => 'dealstage',
'operator' => 'NOT_IN',
'values' => $closedStages['won'],
];
}
if (! empty($closedStages['lost'])) {
$payload['filters'][] = [
'propertyName' => 'dealstage',
'operator' => 'NOT_IN',
'values' => $closedStages['lost'],
];
}
}
public function addCreatedDateFilters(array &$payload, Carbon $createdAfter): void
{
$payload['filters'][] = [
'propertyName' => 'createdate',
'operator' => 'GT',
'value' => $createdAfter->getPreciseTimestamp(3),
];
}
public function getDealsInBulkPayload(array $dealIds): array
{
return [
'filterGroups' => [
[
'filters' => [
[
'propertyName' => 'hs_object_id',
'operator' => 'IN',
'values' => $dealIds,
],
],
],
],
'limit' => self::MAX_FILTER_SIZE,
];
}
}
Sync Changes
Hide This Notification
Code changed:
Hide
22
8
Previous Highlighted Error
Next Highlighted Error
<?php
declare(strict_types=1);
namespace Jiminny\Services\Crm\Hubspot\Webhook;
use Illuminate\Support\Facades\Log;
use Jiminny\Models\Crm\Configuration;
use Jiminny\Services\Crm\Hubspot\BatchSyncCollector;
use Jiminny\Repositories\Crm\CrmEntityRepository;
use Jiminny\Repositories\Crm\OpportunityRepository;
use Jiminny\Repositories\Crm\FieldRepository;
use Jiminny\Repositories\Crm\FieldDataRepository;
use Jiminny\Models\Opportunity;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\LayoutEntity;
readonly class PropertyChangeManager
{
private const string OBJECT_TYPE_DEAL = 'deal';
private const string OBJECT_TYPE_CONTACT = 'contact';
private const string OBJECT_TYPE_COMPANY = 'company';
private const array SUPPORTED_OBJECT_TYPES = [
self::OBJECT_TYPE_DEAL,
self::OBJECT_TYPE_CONTACT,
self::OBJECT_TYPE_COMPANY,
];
/**
* Contact fields that webhooks are subscribed to in HubSpot.
*
* IMPORTANT: These fields MUST match the webhook subscriptions configured in HubSpot.
* When updating this list, you must also update the HubSpot webhook subscriptions:
* 1. Go to HubSpot App Settings → Webhooks
* 2. Update contact.propertyChange subscriptions to match this list
* 3. Ensure all fields listed here are subscribed, and vice versa
*
* This constant serves as documentation of the current webhook configuration.
* Since HubSpot filters webhooks server-side, we should only receive events for these properties.
*/
private const array APPLICABLE_CONTACT_FIELDS = [
'firstname',
'lastname',
'email',
'phone',
'mobilephone',
'jobtitle',
'country',
'hubspot_owner_id',
'associatedcompanyid',
'photo',
'hs_avatar_filemanager_key',
];
/**
* Company fields that webhooks are subscribed to in HubSpot.
*
* IMPORTANT: These fields MUST match the webhook subscriptions configured in HubSpot.
* When updating this list, you must also update the HubSpot webhook subscriptions:
* 1. Go to HubSpot App Settings → Webhooks
* 2. Update company.propertyChange subscriptions to match this list
* 3. Ensure all fields listed here are subscribed, and vice versa
*
* This constant serves as documentation of the current webhook configuration.
* Since HubSpot filters webhooks server-side, we should only receive events for these properties.
*/
private const array APPLICABLE_COMPANY_FIELDS = [
'name',
'phone',
'domain',
'country',
'industry',
'hubspot_owner_id',
'hs_avatar_filemanager_key',
];
/**
* Properties that support direct database updates (without full sync).
* These are simple properties that can be updated immediately without recalculating other fields.
*/
private const array DB_OBJECT_UPDATE_PROPERTIES = [
self::OBJECT_TYPE_DEAL => [
'dealname',
'description',
],
self::OBJECT_TYPE_CONTACT => [
// Add contact properties that support direct update
],
self::OBJECT_TYPE_COMPANY => [
// Add company properties that support direct update
],
];
/**
* Mapping from HubSpot property names to Jiminny model property names.
* Used when updating the main model (Opportunity, Contact, Account) directly.
* Only needed when HubSpot property name differs from Jiminny property name.
*/
private const array OBJECT_PROPERTY_MAPPINGS = [
self::OBJECT_TYPE_DEAL => [
'dealname' => 'name', // HubSpot 'dealname' maps to Opportunity 'name'
],
self::OBJECT_TYPE_CONTACT => [
// Add contact property mappings if needed
],
self::OBJECT_TYPE_COMPANY => [
// Add company property mappings if needed
],
];
// Field object types mapping
private const array FIELD_OBJECT_TYPES = [
self::OBJECT_TYPE_DEAL => Field::OBJECT_OPPORTUNITY,
self::OBJECT_TYPE_CONTACT => Field::OBJECT_CONTACT,
self::OBJECT_TYPE_COMPANY => Field::OBJECT_ACCOUNT,
];
public function __construct(
private BatchSyncCollector $batchCollector,
private OpportunityRepository $opportunityRepository,
private CrmEntityRepository $crmEntityRepository,
private FieldRepository $fieldRepository,
private FieldDataRepository $fieldDataRepository,
private HubspotWebhookMetricsService $metricsService,
) {
}
/**
* Get contact fields that webhooks are subscribed to in HubSpot.
* This serves as documentation of the current webhook configuration.
*
* @return array<int, string>
*/
public function getApplicableContactFields(): array
{
return self::APPLICABLE_CONTACT_FIELDS;
}
/**
* Get company fields that webhooks are subscribed to in HubSpot.
* This serves as documentation of the current webhook configuration.
*
* @return array<int, string>
*/
public function getApplicableCompanyFields(): array
{
return self::APPLICABLE_COMPANY_FIELDS;
}
/**
* Process property change with intelligent routing
*/
public function processPropertyChange(
string $objectType,
string $objectId,
Configuration $configuration,
string $propertyName,
string $propertyValue
): void {
if (! $this->isObjectTypeSupported($objectType)) {
Log::warning('[HubSpot Property Change Manager] Unsupported object type', [
'object_type' => $objectType,
'object_id' => $objectId,
'property_name' => $propertyName,
]);
return;
}
$this->metricsService->recordWebhookMetrics(
$configuration,
$objectType,
'property_change',
$propertyName
);
if ($this->isDirectUpdateSupported($objectType, $propertyName) &&
$this->handleDirectUpdate($objectType, $objectId, $configuration, $propertyName, $propertyValue)) {
return;
}
$this->handleObjectSync($objectType, $objectId, $configuration);
}
/**
* Legacy method for backward compatibility - assumes deal object type
*
* @deprecated Use processPropertyChange with objectType parameter
*/
public function processPropertyChangeForDeal(
string $objectId,
Configuration $configuration,
string $propertyName,
string $propertyValue
): void {
$this->processPropertyChange(self::OBJECT_TYPE_DEAL, $objectId, $configuration, $propertyName, $propertyValue);
}
/**
* Check if object type is supported
*/
private function isObjectTypeSupported(string $objectType): bool
{
return in_array($objectType, self::SUPPORTED_OBJECT_TYPES);
}
/**
* Check if property can be updated without additional calculation on other properties.
*/
private function isDirectUpdateSupported(string $objectType, string $propertyName): bool
{
$supportedProperties = self::DB_OBJECT_UPDATE_PROPERTIES[$objectType] ?? [];
return in_array($propertyName, $supportedProperties, true);
}
/**
* Handle properties that support direct database updates
*/
private function handleDirectUpdate(
string $objectType,
string $objectId,
Configuration $configuration,
string $propertyName,
string $propertyValue
): bool {
try {
return match ($objectType) {
self::OBJECT_TYPE_DEAL => $this->handleDealDirectUpdate($objectId, $configuration, $propertyName, $propertyValue),
self::OBJECT_TYPE_CONTACT => $this->handleContactDirectUpdate($objectId, $configuration, $propertyName, $propertyValue),
self::OBJECT_TYPE_COMPANY => $this->handleCompanyDirectUpdate($objectId, $configuration, $propertyName, $propertyValue),
default => false,
};
} catch (\Exception $e) {
Log::error('[HubSpot Property Change Manager] Error handling direct update property', [
'object_type' => $objectType,
'object_id' => $objectId,
'property_name' => $propertyName,
'error' => $e->getMessage(),
]);
return false;
}
}
/**
* Handle direct updates for deal objects
*/
private function handleDealDirectUpdate(
string $objectId,
Configuration $configuration,
string $propertyName,
string $propertyValue
): bool {
$opportunity = $this->opportunityRepository->findByConfigAndCrmProviderId(
$configuration,
$objectId
);
if (! $opportunity instanceof Opportunity) {
return false;
}
// Update model property if mapping exists
$propertyMappings = self::OBJECT_PROPERTY_MAPPINGS[self::OBJECT_TYPE_DEAL] ?? [];
if (isset($propertyMappings[$propertyName])) {
$this->opportunityRepository->updateOrCreate(
$configuration,
$objectId,
[$propertyMappings[$propertyName] => $propertyValue]
);
}
// Update field data
$this->importObjectFieldData(
self::OBJECT_TYPE_DEAL,
$opportunity->getId(),
$configuration,
[$propertyName => $propertyValue]
);
return true;
}
/**
* Handle direct updates for contact objects
*
* Updates Contact model properties via OBJECT_PROPERTY_MAPPINGS.
* Add mappings to OBJECT_PROPERTY_MAPPINGS[CONTACT] to enable direct updates.
*/
private function handleContactDirectUpdate(
string $objectId,
Configuration $configuration,
string $propertyName,
string $propertyValue
): bool {
$contact = $this->crmEntityRepository->findContactByExternalId($configuration, $objectId);
if ($contact === null) {
return false;
}
// Update model property if mapping exists
$propertyMappings = self::OBJECT_PROPERTY_MAPPINGS[self::OBJECT_TYPE_CONTACT] ?? [];
if (isset($propertyMappings[$propertyName])) {
$this->crmEntityRepository->importContact($configuration, [
'crm_provider_id' => $objectId,
$propertyMappings[$propertyName] => $propertyValue,
]);
}
return true;
}
/**
* Handle direct updates for company objects
*
* Updates Account model properties via OBJECT_PROPERTY_MAPPINGS.
* Add mappings to OBJECT_PROPERTY_MAPPINGS[COMPANY] to enable direct updates.
*/
private function handleCompanyDirectUpdate(
string $objectId,
Configuration $configuration,
string $propertyName,
string $propertyValue
): bool {
$company = $this->crmEntityRepository->findAccountByExternalId($configuration, $objectId);
if ($company === null) {
return false;
}
// Update model property if mapping exists
$propertyMappings = self::OBJECT_PROPERTY_MAPPINGS[self::OBJECT_TYPE_COMPANY] ?? [];
if (isset($propertyMappings[$propertyName])) {
$this->crmEntityRepository->importAccount($configuration, [
'crm_provider_id' => $objectId,
$propertyMappings[$propertyName] => $propertyValue,
]);
}
return true;
}
/**
* Import field data for any object type
*/
private function importObjectFieldData(
string $objectType,
int $objectId,
Configuration $configuration,
array $data
): void {
$fieldObjectType = self::FIELD_OBJECT_TYPES[$objectType] ?? null;
if (! $fieldObjectType) {
Log::warning('[HubSpot Property Change Manager] Unknown field object type', [
'object_type' => $objectType,
'object_id' => $objectId,
]);
return;
}
foreach ($data as $crmField => $value) {
$field = $this->fieldRepository->findFieldByCrmIdAndObjectType(
$configuration,
$crmField,
$fieldObjectType
);
if (! $field instanceof Field) {
continue;
}
$entities = $field->getEntities();
if ($entities->isEmpty()) {
continue;
}
if ($entities->count() > 1) {
continue;
}
/** @var LayoutEntity|null $entity */
$entity = $entities->first();
if (! $entity instanceof LayoutEntity) {
continue;
}
$this->fieldDataRepository->updateOrCreateFieldData(
$entity,
$objectId,
(string) $value,
);
}
}
private function handleObjectSync(
string $objectType,
string $objectId,
Configuration $configuration,
): void {
if (! $this->isObjectTypeSupported($objectType)) {
return;
}
try {
$this->batchCollector->collectForBatch(
objectType: $objectType,
crmProviderId: $objectId,
configurationId: $configuration->getId(),
eventType: 'property_change'
);
} catch (\Exception $e) {
Log::error('[HubSpot Property Change Manager] Error adding to sync batch', [
'object_type' => $objectType,
'object_id' => $objectId,
'error' => $e->getMessage(),
]);
}
}
}
Project
Project
New File or Directory…
Expand Selected
Collapse All
Options
Hide...
|
PhpStorm
|
faVsco.js – PropertyChangeManager.php
|
NULL
|
87904
|
|
87903
|
Project: faVsco.js, menu
#12121 on JY-20963-fix-im Project: faVsco.js, menu
#12121 on JY-20963-fix-import-on-deleted-entity, menu
Start Listening for PHP Debug Connections
ServiceTest
Run 'ServiceTest'
Debug 'ServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
8
25
Previous Highlighted Error
Next Highlighted Error
<?php
declare(strict_types=1);
namespace Jiminny\Services\Crm\Hubspot;
use Carbon\Carbon;
use Jiminny\Models\Crm\Configuration;
use Psr\Log\LoggerInterface;
class PayloadBuilder
{
public const int MAX_SEARCH_REQUEST_LIMIT = 200;
private const int MAX_FILTER_SIZE = 100;
private const string SORT_PROPERTY = 'hs_timestamp';
private const string ENGAGEMENT_MEETINGS = 'meetings';
public function getLinkToTaskPayload(string $objectType, string $objectId, string $engagementType): array
{
$payload = [
'sorts' => [
[
'propertyName' => self::SORT_PROPERTY,
'direction' => 'DESCENDING',
],
],
'limit' => self::MAX_SEARCH_REQUEST_LIMIT,
];
if ($engagementType === self::ENGAGEMENT_MEETINGS) {
$payload['filterGroups'] = $this->buildMeetingFiltersForLinkToTask($objectType, $objectId);
$payload['properties'] = [
'hs_meeting_title',
'hs_meeting_outcome',
'hs_timestamp',
'hs_activity_type',
];
} else {
$payload['filterGroups'] = $this->buildTaskFiltersForLinkToTask($objectType, $objectId);
$payload['properties'] = ['hs_task_subject', 'hs_timestamp'];
}
return $payload;
}
public function getRecentlyUpdatedSearchPayload(Carbon $since, ?Carbon $to, array $properties): array
{
$lastUpdateDate = in_array('firstname', $properties) ? 'lastmodifieddate' : 'hs_lastmodifieddate';
$payload = [
'filters' => [
[
'propertyName' => $lastUpdateDate,
'operator' => 'GT',
'value' => $since->getPreciseTimestamp(3),
],
],
'sorts' => [
[
'propertyName' => 'hs_object_id',
'direction' => 'DESCENDING',
],
],
'limit' => self::MAX_SEARCH_REQUEST_LIMIT,
];
if ($to) {
$payload['filters'][] = [
'propertyName' => $lastUpdateDate,
'operator' => 'LT',
'value' => $to->getPreciseTimestamp(3),
];
}
$payload['properties'] = $properties;
return $payload;
}
public function getByProfileSearchPayload(string $crmId, array $properties, Carbon $since, ?Carbon $to): array
{
$lastUpdateDate = array_key_exists('firstname', $properties) ? 'lastmodifieddate' : 'hs_lastmodifieddate';
$payload = [
'filters' => [
[
'propertyName' => $lastUpdateDate,
'operator' => 'GT',
'value' => $since->getPreciseTimestamp(3),
],
[
'propertyName' => 'hubspot_owner_id',
'operator' => 'EQ',
'value' => $crmId,
],
],
'sorts' => [
[
'propertyName' => 'hs_object_id',
'direction' => 'DESCENDING',
],
],
'limit' => self::MAX_SEARCH_REQUEST_LIMIT,
];
if ($to) {
$payload['filters'][] = [
'propertyName' => $lastUpdateDate,
'operator' => 'LT',
'value' => $to->getPreciseTimestamp(3),
];
}
$payload['properties'] = $properties;
return $payload;
}
private function buildMeetingFiltersForLinkToTask($objectType, $objectId): array
{
$filters = [];
$filters[] = [
'filters' => [
$this->getAssociatedObjectFilter($objectType, $objectId),
[
'propertyName' => 'hs_meeting_outcome',
'operator' => 'IN',
'values' => ['SCHEDULED', 'RESCHEDULED'],
],
],
];
$filters[] = [
'filters' => [
$this->getAssociatedObjectFilter($objectType, $objectId),
[
'propertyName' => 'hs_meeting_outcome',
'operator' => 'NOT_HAS_PROPERTY',
],
],
];
return $filters;
}
private function buildTaskFiltersForLinkToTask($objectType, $objectId): array
{
return [
[
'filters' => [
$this->getAssociatedObjectFilter($objectType, $objectId),
],
],
];
}
private function getAssociatedObjectFilter(string $objectType, string $objectId): array
{
return [
'propertyName' => 'associations.' . $objectType,
'operator' => 'EQ',
'value' => $objectId,
];
}
public function generatePlaybackURLSearchPayload(string $playbackURLToken): array
{
return [
'filters' => [
[
'propertyName' => 'hs_call_recording_url',
'operator' => 'CONTAINS_TOKEN',
'value' => $playbackURLToken,
],
],
'properties' => [
'hs_call_recording_url',
'hs_call_body',
],
'limit' => 1,
];
}
public function generateGetCallsPayload(Carbon $from, Carbon $to, string $activityProvider, $page): array
{
return [
'filters' => [
[
'propertyName' => 'hs_call_recording_url',
'operator' => 'CONTAINS_TOKEN',
'value' => $activityProvider,
],
[
'propertyName' => 'hs_timestamp',
'operator' => 'BETWEEN',
'value' => intval($from->getPreciseTimestamp(3)),
'highValue' => intval($to->getPreciseTimestamp(3)),
],
],
'sorts' => [
[
'propertyName' => 'hs_timestamp',
'direction' => 'DESCENDING',
],
],
'properties' => [
'hs_call_recording_url',
'hs_call_body',
],
'limit' => self::MAX_SEARCH_REQUEST_LIMIT,
'after' => ($page - 1) * self::MAX_SEARCH_REQUEST_LIMIT,
];
}
public function generateSearchCallsByPeriodPayload(Carbon $from, Carbon $to, $page): array
{
return [
'filters' => [
[
'propertyName' => 'hs_timestamp',
'operator' => 'BETWEEN',
'value' => intval($from->getPreciseTimestamp(3)),
'highValue' => intval($to->getPreciseTimestamp(3)),
],
],
'sorts' => [
[
'propertyName' => 'hs_timestamp',
'direction' => 'DESCENDING',
],
],
'properties' => $this->getSearchCallAttributes(),
'limit' => self::MAX_SEARCH_REQUEST_LIMIT,
'after' => ($page - 1) * self::MAX_SEARCH_REQUEST_LIMIT,
];
}
public function getSearchCallAttributes(): array
{
return [
'hs_timestamp',
'hs_call_recording_url',
'hs_call_body',
'hs_call_status',
'hs_call_to_number',
'hs_call_from_number',
'hs_call_duration',
'hs_call_disposition',
'hs_call_title',
'hs_call_direction',
'hubspot_owner_id',
'hs_activity_type',
'hs_call_external_id',
'hs_call_source',
];
}
public function generatePlaybackAddUrlBatchPayload(array $crmUpdateData): array
{
$updateObjectsData = [];
foreach ($crmUpdateData as $data) {
$updateObjectsData[] = [
'id' => $data['crm_id'],
'properties' => [
'hs_call_body' => $data['hs_call_body'] .
'<p><span style="font-size: 13.3333px; line-height: 16px;">' .
'<a href="' . $data['playback_url'] . '" target="_blank">Review in Jiminny</a> ▶️</span></p>',
],
];
}
return ['inputs' => $updateObjectsData];
}
public function generateSearchCallByTokenPayload(string $playbackURLToken): array
{
return [
'filters' => [
[
'propertyName' => 'hs_call_recording_url',
'operator' => 'CONTAINS_TOKEN',
'value' => $playbackURLToken,
],
],
'properties' => [
'hs_call_recording_url',
'hs_call_body',
],
'limit' => 1,
];
}
/**
* Generates a payload for phone search based on the specified parameters.
*
* @param string $phone The phone number to search.
* @param bool $isAlternativeSearch Indicates if an alternative search should be performed
* if the first search fails to find a match. The first search request should cover most of the cases.
*/
public function generatePhoneSearchPayload(string $phone, bool $isAlternativeSearch = false): array
{
$filterPropertyNames = $isAlternativeSearch ?
['hs_searchable_calculated_mobile_number', 'phone', 'mobilephone'] :
['hs_searchable_calculated_phone_number', 'hs_calculated_phone_number', 'hs_calculated_mobile_number'];
$filterGroups = array_map(function ($propertyName) use ($phone) {
return $this->createFilterGroup($propertyName, 'CONTAINS_TOKEN', $phone);
}, $filterPropertyNames);
$properties = [
'email',
'firstname',
'lastname',
'country',
'phone',
'mobilephone',
'jobtitle',
'hubspot_owner_id',
'associatedcompanyid',
'photo',
];
return $this->createPayload($filterGroups, $properties);
}
/**
* Creates a filter group with the specified parameters.
*/
private function createFilterGroup(string $propertyName, string $operator, $value): array
{
return [
'filters' => [
[
'propertyName' => $propertyName,
'operator' => $operator,
'value' => $value,
],
],
];
}
/**
* Creates a payload with the specified filter groups and properties.
*/
private function createPayload(array $filterGroups, array $properties): array
{
return [
'filterGroups' => $filterGroups,
'sorts' => [
[
'propertyName' => 'modifieddate',
'direction' => 'DESCENDING',
],
],
'properties' => $properties,
'limit' => 1,
];
}
/**
* Generates a payload to find related activities based on the specified data and object type.
* The search looks for activities that match the specified time range (starttime - endtime or only starttime with endtime not set)
* and are associated with any of the provided prospect types (contact or company).
*
* @param array $data An associative array containing the following keys:
* - 'from': The start time for the activity search (timestamp).
* - 'to': The end time for the activity search (timestamp).
* - 'contact': (optional) The ID of the associated contact.
* - 'company': (optional) The ID of the associated company.
* @param string $objectType The type of engagement to search for (meeting or other type like call).
*
* @return array The payload array to be used for searching related activities.
*/
public function getFindRelatedActivityPayload(array $data, string $objectType): array
{
$payload = [
'sorts' => [
[
'propertyName' => self::SORT_PROPERTY,
'direction' => 'DESCENDING',
],
],
'limit' => self::MAX_SEARCH_REQUEST_LIMIT,
];
if ($objectType === self::ENGAGEMENT_MEETINGS) {
$payload['properties'] = [
'hs_meeting_title',
'hs_meeting_outcome',
'hs_activity_type',
'hs_timestamp',
'hubspot_owner_id',
'hs_meeting_body',
'hs_internal_meeting_notes',
'hs_meeting_location',
'hs_meeting_start_time',
'hs_meeting_end_time',
];
} else {
$payload['properties'] = ['hs_task_subject', 'hs_timestamp', 'hubspot_owner_id', 'hs_call_body'];
}
$timeFiltersWithEndTime = [
[
'propertyName' => 'hs_meeting_start_time',
'operator' => 'GTE',
'value' => $data['from'],
],
[
'propertyName' => 'hs_meeting_end_time',
'operator' => 'LTE',
'value' => $data['to'],
],
];
$timeFiltersWithoutEndTime = [
[
'propertyName' => 'hs_meeting_start_time',
'operator' => 'GTE',
'value' => $data['from'],
],
[
'propertyName' => 'hs_meeting_end_time',
'operator' => 'NOT_HAS_PROPERTY',
],
];
$payload['filterGroups'] = [];
$associationTypes = ['contact', 'company'];
foreach ($associationTypes as $type) {
if (! empty($data[$type])) {
$filterGroupWithEndTime = [
'filters' => array_merge(
$timeFiltersWithEndTime,
[$this->getAssociatedObjectFilter($type, $data[$type])]
),
];
$payload['filterGroups'][] = $filterGroupWithEndTime;
$filterGroupWithoutEndTime = [
'filters' => array_merge(
$timeFiltersWithoutEndTime,
[$this->getAssociatedObjectFilter($type, $data[$type])]
),
];
$payload['filterGroups'][] = $filterGroupWithoutEndTime ;
}
}
return $payload;
}
/** Parameters
* [
* 'accountId' => $crmAccountId,
* 'sortBy' => $sortBy,
* 'sortDir' => $sortDir,
* 'onlyOpen' => $onlyOpen,
* 'closedStages' => $this->getClosedDealStages(),
* 'userId' => $userId,
* ];
*/
public function generateOpportunitiesSearchPayload(
Configuration $config,
string $crmAccountId,
array $closedStages,
): array {
$closedFilters = [];
$filterGroups = [];
$onlyOpen = true;
switch ($config->getOpportunityAssignmentRule()) {
case Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_UPDATED:
$sortBy = 'modifieddate';
$sortDir = 'DESCENDING';
break;
case Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_CREATED:
$sortBy = 'createdate';
$sortDir = 'DESCENDING';
break;
case Configuration::OPP_ASSIGNMENT_ALL_OPEN_OLDEST_CREATED:
$sortBy = 'createdate';
$sortDir = 'ASCENDING';
break;
case Configuration::OPP_ASSIGNMENT_ALL_RECENTLY_UPDATED:
default:
$sortBy = 'modifieddate';
$sortDir = 'DESCENDING';
$onlyOpen = false;
}
$baseFilters = [
[
'propertyName' => 'associations.company',
'operator' => 'EQ',
'value' => $crmAccountId,
],
];
// Handle closed stages in chunks
if ($onlyOpen) {
foreach (['won', 'lost'] as $key) {
if (! empty($closedStages[$key])) {
$chunks = array_chunk($closedStages[$key], self::MAX_FILTER_SIZE);
foreach ($chunks as $chunk) {
$closedFilters[] = [
'propertyName' => 'dealstage',
'operator' => 'NOT_IN',
'values' => $chunk,
];
}
}
}
}
$filterGroups[] = [
'filters' => array_merge($baseFilters, $closedFilters),
];
$payload = [
'filterGroups' => $filterGroups,
'sorts' => [
[
'propertyName' => $sortBy,
'direction' => $sortDir,
],
],
'properties' => [
'dealname',
'amount',
'hubspot_owner_id',
'pipeline',
'dealstage',
'closedate',
'deal_currency_code',
],
'limit' => self::MAX_SEARCH_REQUEST_LIMIT,
];
$logger = app(LoggerInterface::class);
$logger->info('[HubSpot] CRM Search requested', [
'request' => $payload,
]);
return $payload;
}
/**
* Converts v1 payload data to v3
*/
public function getV3MeetingPayload(array $engagement, array $metadata, array $associations = []): array
{
// Use this mapping until the whole Hubspot V1 is dropped.
$properties = [
'hubspot_owner_id' => $engagement['ownerId'],
'hs_timestamp' => $engagement['timestamp'],
];
// $engagement['activityType'] is $activity->category->name
if (isset($engagement['activityType'])) {
$properties['hs_activity_type'] = $engagement['activityType'];
}
$metadataKeyMap = [
'hs_meeting_outcome' => 'meetingOutcome',
'hs_meeting_title' => 'title',
'hs_meeting_start_time' => 'startTime',
'hs_meeting_end_time' => 'endTime',
'hs_meeting_body' => 'body',
'hs_internal_meeting_notes' => 'internalMeetingNotes',
];
foreach ($metadataKeyMap as $newKey => $oldKey) {
if (isset($metadata[$oldKey])) {
$properties[$newKey] = $metadata[$oldKey];
unset($metadata[$oldKey]);
}
}
$properties = [
...$properties,
...$metadata, // custom fields
];
$response = [
'properties' => $properties,
];
if (! empty($associations)) {
$response['associations'] = $associations;
}
return $response;
}
/**
* Generate a payload to search for contacts by name.
* The search is a token search in firstname or lastname
*/
public function generateSearchContactsByNamePayload(string $name, array $fields): array
{
$firstNameFilter = [
'propertyName' => 'firstname',
'operator' => 'CONTAINS_TOKEN',
'value' => $name,
];
$lastNameFilter = [
'propertyName' => 'lastname',
'operator' => 'CONTAINS_TOKEN',
'value' => $name,
];
return [
'filterGroups' => [
[
'filters' => [$firstNameFilter],
],
[
'filters' => [$lastNameFilter],
],
],
'properties' => $fields,
'sorts' => [
[
'propertyName' => 'lastmodifieddate',
'direction' => 'DESCENDING',
],
],
];
}
public function buildAddAssociationPayload(string $crmId, array $ids, int $associationType): array
{
$inputs = [];
foreach ($ids as $id) {
if ($id === null || $id === '') {
continue;
}
$inputs[] = [
'from' => [
'id' => $crmId,
],
'to' => [
'id' => (string) $id,
],
'types' => [
[
'associationCategory' => 'HUBSPOT_DEFINED',
'associationTypeId' => $associationType,
],
],
];
}
return ['inputs' => $inputs];
}
public function buildRemoveAssociationPayload(string $crmId, array $ids): array
{
$toArray = [];
foreach ($ids as $id) {
if ($id === null || $id === '') {
continue;
}
$toArray[] = ['id' => (string) $id];
}
return [
'inputs' => [
[
'from' => ['id' => $crmId],
'to' => $toArray,
],
],
];
}
public function addClosedStageFilters(array &$payload, array $closedStages): void
{
if (! empty($closedStages['won'])) {
$payload['filters'][] = [
'propertyName' => 'dealstage',
'operator' => 'NOT_IN',
'values' => $closedStages['won'],
];
}
if (! empty($closedStages['lost'])) {
$payload['filters'][] = [
'propertyName' => 'dealstage',
'operator' => 'NOT_IN',
'values' => $closedStages['lost'],
];
}
}
public function addCreatedDateFilters(array &$payload, Carbon $createdAfter): void
{
$payload['filters'][] = [
'propertyName' => 'createdate',
'operator' => 'GT',
'value' => $createdAfter->getPreciseTimestamp(3),
];
}
public function getDealsInBulkPayload(array $dealIds): array
{
return [
'filterGroups' => [
[
'filters' => [
[
'propertyName' => 'hs_object_id',
'operator' => 'IN',
'values' => $dealIds,
],
],
],
],
'limit' => self::MAX_FILTER_SIZE,
];
}
}
Sync Changes
Hide This Notification
Code changed:
Hide
22
8
Previous Highlighted Error
Next Highlighted Error
<?php
declare(strict_types=1);
namespace Jiminny\Services\Crm\Hubspot\Webhook;
use Illuminate\Support\Facades\Log;
use Jiminny\Models\Crm\Configuration;
use Jiminny\Services\Crm\Hubspot\BatchSyncCollector;
use Jiminny\Repositories\Crm\CrmEntityRepository;
use Jiminny\Repositories\Crm\OpportunityRepository;
use Jiminny\Repositories\Crm\FieldRepository;
use Jiminny\Repositories\Crm\FieldDataRepository;
use Jiminny\Models\Opportunity;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\LayoutEntity;
readonly class PropertyChangeManager
{
private const string OBJECT_TYPE_DEAL = 'deal';
private const string OBJECT_TYPE_CONTACT = 'contact';
private const string OBJECT_TYPE_COMPANY = 'company';
private const array SUPPORTED_OBJECT_TYPES = [
self::OBJECT_TYPE_DEAL,
self::OBJECT_TYPE_CONTACT,
self::OBJECT_TYPE_COMPANY,
];
/**
* Contact fields that webhooks are subscribed to in HubSpot.
*
* IMPORTANT: These fields MUST match the webhook subscriptions configured in HubSpot.
* When updating this list, you must also update the HubSpot webhook subscriptions:
* 1. Go to HubSpot App Settings → Webhooks
* 2. Update contact.propertyChange subscriptions to match this list
* 3. Ensure all fields listed here are subscribed, and vice versa
*
* This constant serves as documentation of the current webhook configuration.
* Since HubSpot filters webhooks server-side, we should only receive events for these properties.
*/
private const array APPLICABLE_CONTACT_FIELDS = [
'firstname',
'lastname',
'email',
'phone',
'mobilephone',
'jobtitle',
'country',
'hubspot_owner_id',
'associatedcompanyid',
'photo',
'hs_avatar_filemanager_key',
];
/**
* Company fields that webhooks are subscribed to in HubSpot.
*
* IMPORTANT: These fields MUST match the webhook subscriptions configured in HubSpot.
* When updating this list, you must also update the HubSpot webhook subscriptions:
* 1. Go to HubSpot App Settings → Webhooks
* 2. Update company.propertyChange subscriptions to match this list
* 3. Ensure all fields listed here are subscribed, and vice versa
*
* This constant serves as documentation of the current webhook configuration.
* Since HubSpot filters webhooks server-side, we should only receive events for these properties.
*/
private const array APPLICABLE_COMPANY_FIELDS = [
'name',
'phone',
'domain',
'country',
'industry',
'hubspot_owner_id',
'hs_avatar_filemanager_key',
];
/**
* Properties that support direct database updates (without full sync).
* These are simple properties that can be updated immediately without recalculating other fields.
*/
private const array DB_OBJECT_UPDATE_PROPERTIES = [
self::OBJECT_TYPE_DEAL => [
'dealname',
'description',
],
self::OBJECT_TYPE_CONTACT => [
// Add contact properties that support direct update
],
self::OBJECT_TYPE_COMPANY => [
// Add company properties that support direct update
],
];
/**
* Mapping from HubSpot property names to Jiminny model property names.
* Used when updating the main model (Opportunity, Contact, Account) directly.
* Only needed when HubSpot property name differs from Jiminny property name.
*/
private const array OBJECT_PROPERTY_MAPPINGS = [
self::OBJECT_TYPE_DEAL => [
'dealname' => 'name', // HubSpot 'dealname' maps to Opportunity 'name'
],
self::OBJECT_TYPE_CONTACT => [
// Add contact property mappings if needed
],
self::OBJECT_TYPE_COMPANY => [
// Add company property mappings if needed
],
];
// Field object types mapping
private const array FIELD_OBJECT_TYPES = [
self::OBJECT_TYPE_DEAL => Field::OBJECT_OPPORTUNITY,
self::OBJECT_TYPE_CONTACT => Field::OBJECT_CONTACT,
self::OBJECT_TYPE_COMPANY => Field::OBJECT_ACCOUNT,
];
public function __construct(
private BatchSyncCollector $batchCollector,
private OpportunityRepository $opportunityRepository,
private CrmEntityRepository $crmEntityRepository,
private FieldRepository $fieldRepository,
private FieldDataRepository $fieldDataRepository,
private HubspotWebhookMetricsService $metricsService,
) {
}
/**
* Get contact fields that webhooks are subscribed to in HubSpot.
* This serves as documentation of the current webhook configuration.
*
* @return array<int, string>
*/
public function getApplicableContactFields(): array
{
return self::APPLICABLE_CONTACT_FIELDS;
}
/**
* Get company fields that webhooks are subscribed to in HubSpot.
* This serves as documentation of the current webhook configuration.
*
* @return array<int, string>
*/
public function getApplicableCompanyFields(): array
{
return self::APPLICABLE_COMPANY_FIELDS;
}
/**
* Process property change with intelligent routing
*/
public function processPropertyChange(
string $objectType,
string $objectId,
Configuration $configuration,
string $propertyName,
string $propertyValue
): void {
if (! $this->isObjectTypeSupported($objectType)) {
Log::warning('[HubSpot Property Change Manager] Unsupported object type', [
'object_type' => $objectType,
'object_id' => $objectId,
'property_name' => $propertyName,
]);
return;
}
$this->metricsService->recordWebhookMetrics(
$configuration,
$objectType,
'property_change',
$propertyName
);
if ($this->isDirectUpdateSupported($objectType, $propertyName) &&
$this->handleDirectUpdate($objectType, $objectId, $configuration, $propertyName, $propertyValue)) {
return;
}
$this->handleObjectSync($objectType, $objectId, $configuration);
}
/**
* Legacy method for backward compatibility - assumes deal object type
*
* @deprecated Use processPropertyChange with objectType parameter
*/
public function processPropertyChangeForDeal(
string $objectId,
Configuration $configuration,
string $propertyName,
string $propertyValue
): void {
$this->processPropertyChange(self::OBJECT_TYPE_DEAL, $objectId, $configuration, $propertyName, $propertyValue);
}
/**
* Check if object type is supported
*/
private function isObjectTypeSupported(string $objectType): bool
{
return in_array($objectType, self::SUPPORTED_OBJECT_TYPES);
}
/**
* Check if property can be updated without additional calculation on other properties.
*/
private function isDirectUpdateSupported(string $objectType, string $propertyName): bool
{
$supportedProperties = self::DB_OBJECT_UPDATE_PROPERTIES[$objectType] ?? [];
return in_array($propertyName, $supportedProperties, true);
}
/**
* Handle properties that support direct database updates
*/
private function handleDirectUpdate(
string $objectType,
string $objectId,
Configuration $configuration,
string $propertyName,
string $propertyValue
): bool {
try {
return match ($objectType) {
self::OBJECT_TYPE_DEAL => $this->handleDealDirectUpdate($objectId, $configuration, $propertyName, $propertyValue),
self::OBJECT_TYPE_CONTACT => $this->handleContactDirectUpdate($objectId, $configuration, $propertyName, $propertyValue),
self::OBJECT_TYPE_COMPANY => $this->handleCompanyDirectUpdate($objectId, $configuration, $propertyName, $propertyValue),
default => false,
};
} catch (\Exception $e) {
Log::error('[HubSpot Property Change Manager] Error handling direct update property', [
'object_type' => $objectType,
'object_id' => $objectId,
'property_name' => $propertyName,
'error' => $e->getMessage(),
]);
return false;
}
}
/**
* Handle direct updates for deal objects
*/
private function handleDealDirectUpdate(
string $objectId,
Configuration $configuration,
string $propertyName,
string $propertyValue
): bool {
$opportunity = $this->opportunityRepository->findByConfigAndCrmProviderId(
$configuration,
$objectId
);
if (! $opportunity instanceof Opportunity) {
return false;
}
// Update model property if mapping exists
$propertyMappings = self::OBJECT_PROPERTY_MAPPINGS[self::OBJECT_TYPE_DEAL] ?? [];
if (isset($propertyMappings[$propertyName])) {
$this->opportunityRepository->updateOrCreate(
$configuration,
$objectId,
[$propertyMappings[$propertyName] => $propertyValue]
);
}
// Update field data
$this->importObjectFieldData(
self::OBJECT_TYPE_DEAL,
$opportunity->getId(),
$configuration,
[$propertyName => $propertyValue]
);
return true;
}
/**
* Handle direct updates for contact objects
*
* Updates Contact model properties via OBJECT_PROPERTY_MAPPINGS.
* Add mappings to OBJECT_PROPERTY_MAPPINGS[CONTACT] to enable direct updates.
*/
private function handleContactDirectUpdate(
string $objectId,
Configuration $configuration,
string $propertyName,
string $propertyValue
): bool {
$contact = $this->crmEntityRepository->findContactByExternalId($configuration, $objectId);
if ($contact === null) {
return false;
}
// Update model property if mapping exists
$propertyMappings = self::OBJECT_PROPERTY_MAPPINGS[self::OBJECT_TYPE_CONTACT] ?? [];
if (isset($propertyMappings[$propertyName])) {
$this->crmEntityRepository->importContact($configuration, [
'crm_provider_id' => $objectId,
$propertyMappings[$propertyName] => $propertyValue,
]);
}
return true;
}
/**
* Handle direct updates for company objects
*
* Updates Account model properties via OBJECT_PROPERTY_MAPPINGS.
* Add mappings to OBJECT_PROPERTY_MAPPINGS[COMPANY] to enable direct updates.
*/
private function handleCompanyDirectUpdate(
string $objectId,
Configuration $configuration,
string $propertyName,
string $propertyValue
): bool {
$company = $this->crmEntityRepository->findAccountByExternalId($configuration, $objectId);
if ($company === null) {
return false;
}
// Update model property if mapping exists
$propertyMappings = self::OBJECT_PROPERTY_MAPPINGS[self::OBJECT_TYPE_COMPANY] ?? [];
if (isset($propertyMappings[$propertyName])) {
$this->crmEntityRepository->importAccount($configuration, [
'crm_provider_id' => $objectId,
$propertyMappings[$propertyName] => $propertyValue,
]);
}
return true;
}
/**
* Import field data for any object type
*/
private function importObjectFieldData(
string $objectType,
int $objectId,
Configuration $configuration,
array $data
): void {
$fieldObjectType = self::FIELD_OBJECT_TYPES[$objectType] ?? null;
if (! $fieldObjectType) {
Log::warning('[HubSpot Property Change Manager] Unknown field object type', [
'object_type' => $objectType,
'object_id' => $objectId,
]);
return;
}
foreach ($data as $crmField => $value) {
$field = $this->fieldRepository->findFieldByCrmIdAndObjectType(
$configuration,
$crmField,
$fieldObjectType
);
if (! $field instanceof Field) {
continue;
}
$entities = $field->getEntities();
if ($entities->isEmpty()) {
continue;
}
if ($entities->count() > 1) {
continue;
}
/** @var LayoutEntity|null $entity */
$entity = $entities->first();
if (! $entity instanceof LayoutEntity) {
continue;
}
$this->fieldDataRepository->updateOrCreateFieldData(
$entity,
$objectId,
(string) $value,
);
}
}
private function handleObjectSync(
string $objectType,
string $objectId,
Configuration $configuration,
): void {
if (! $this->isObjectTypeSupported($objectType)) {
return;
}
try {
$this->batchCollector->collectForBatch(
objectType: $objectType,
crmProviderId: $objectId,
configurationId: $configuration->getId(),
eventType: 'property_change'
);
} catch (\Exception $e) {
Log::error('[HubSpot Property Change Manager] Error adding to sync batch', [
'object_type' => $objectType,
'object_id' => $objectId,
'error' => $e->getMessage(),
]);
}
}
}...
|
PhpStorm
|
faVsco.js – PropertyChangeManager.php
|
NULL
|
87903
|
|
87902
|
ts 1779301965775
wuniconX HubspotwYour team is now ts 1779301965775
wuniconX HubspotwYour team is now on the Free plan with 1 admin. You retain editing access and other members are read-only. View team permissicas to see who can edit, or ungrade to restore collaborationUseful › get history of property - deal stage((baseUri))/deats/V1/deal/494058190045?includePropertyVersions=trucE Docs Params • Authorization • Headers 12 Body • Scriots SettingsQuery Paramsv includePropertyVersionsobiectTyeo0sLXThu 28 May 19:29:06© Scarchlollietmnr sayeeoehtorooeCEr RendCr det meetinoct eonthet with ihR SaveV COLLECYIONS> HubsootKeration tun Hoteration run Searchts› Journal & webhocoks v4• Poperiesv a crm/vs xoverties ob ect tvoe> (t batchacouos> (a(property Name)y ut Resd all propertiesca successful operationAn ertor occurredrooneresle a properRESSAREH• СЕAРCH> Tckev Useful> rost tilter per comoanyonly ooen deal staoes.cEt engagements old associated by deawenanacmmnts dd nscocintnd oy comasnv сEt get history of property - deal stagenosuccasstul operation55. An error occurred.at get usersCET SF osuthuumeating cutcomes cer mectinoaPondi all nranortioe nou.> ct Resd all properties oldCT old call dispositionset list with associationscEt list engagements okeuirecendencsaementCET get dealcEt Get Engagement (v1)PATCH https://api.hubapi.com/engagements/V1/engagements/2492739S231Parckhttos:/api.hubaoi.com/crm/vs/co.ects/meet.nos/2497k5x3%wrchttosdllanihubani. com/ancacamanteMlaoonoamaatc/240273052811CiuodstedesioodCMMIDANIENTCSPECS> FLOWSOConndettCoasde em aniooDetcrictionSondCookiesp.YKEOKEJSONvPreview Visualizestad.700 ok. 270ms. 41.76 KRAs ab.* 70122ken Save Resnons?ддаг8юдд6осаевовeprobabal1tyHandler_deal_stage_pF|Cannot edit in read-only editorsouxcoid*: *DealStageProbabil{tyHandlor*"CALCULATED"requestid*: *019e46a9-51b8-7e60-9db6-153e5c1b1529,uket restaroAsPerssistencet mestamo"etmue."sourceUpstreamDeployable*: "ForecastingKafka-deal-stage-probability-workezapromotrwhhndilor"-78be-bc7b-ccf524c62eb6*uselznescaspnspersistencelamestanp: crue•souxceUpstreanDeployable*: *ForccastingKafka-deal•stage-probability-worker"Mea"e thecaciase oromahity•A7201Giobnts Montt Tookemed...
|
Alfred
|
Alfred
|
NULL
|
87902
|
|
87901
|
ts 1779301965775
iTerm2ShellEditViewSessionScripts ts 1779301965775
iTerm2ShellEditViewSessionScriptsProfilesWindowHelp‹>0 (|DOCKER₴81DEV (-zsh)O 82Version 2023.11.20260427:Version2023.11.20260505:Version2023.11.20260509:Version2023.11.20260511:Version2023.11.20260514:Version 2023.11.20260526:tsRun"/usr/bin/dnf check-release-update"for#_####_#####\\###||\#/v~'Amazon Linux 2023 (E(S Optimized)-zshN3ec2-user@ip-10-20-31-146:~screenpipe"O ₴4-zshX5ec2-user@ip-10-30-129-...100% <78• Thu 28 May 19:29:06181ec2-user@ip-10-20-31-1...·7Time on SteroidsPlease enter now, timestamp and formatted date+For documentation, visit [URL_WITH_CREDENTIALS] ~]$ docker exec -it $(docker ps --format "{{.ID}}" --filter "name-ecs-worker" | head -1) /bin/bash -c "cd /home/jiminny && bash"root@170a323e43a4:/home/jiminny# php artisan crm:sync-opportunity --teamId 555 --opportunityId 494058190045[2026-05-28 16:20:08] production.INF0: Jiminny\Console\Commands\Command: : run Memory usage before starting command {"command": "crm:sync-opportunity", "memoryBeforeCommandInMb" : 118.0, "memoryPeakBeforeCommandInMb":118.03 {"correlation_id":"0a531c45-bc24-4ea8-b3c9-d90f79590da8", "trace_id":"1723bcBc-9f67-4ec7-b8c3-d09361d5879d"}Syncing opportunity for Cognitive Credit[2026-05-28 16:20:08] production.INFO: [SocialAccountService] Fetching token {"socialAccountId":30591,"provider": "hubspot"} {"correlation_id":"0a531c45-bc24-4ea8-b3c9-d90f79590da8", "trace_id":"1723bcDc-9f67-4ec7-b8c3-d09361d5879d"'}[2026-05-28 16:20:08] production.INFO: [SocialAccountService] Token retrieved {"socialAccountId":30591, "provider": "hubspot"} {"correlation_id":"0a531c45-bc24-4ea8-b3c9-d90f79590da8", "trace_id":"1723bc0c-9f67-4ec7-b8c3-d09361d5879d"'}[2026-05-28 16:20:08] production.INFO: [EncryptedTokenManager] Generatingaccess token. {"mode":"legacy"} {"correlation_id":"0a531c45-bc24-4ea8-b3c9-d90f79590da8","trace_id" :"1723bc0c-9f67-4ec7-b8c3-d09361d5879d"}Syncing opportunity 494058190045...[2026-05-28 16:20:08] production.INF0: [ReindexForOpportunityListener] Schedule reindexing job {"opportunity_id":7601423} {"correlation_id":"0a531c45-bc24-4ea8-b3c9-d90f79590da8","trace_id":"1723bc0c-9f67-4ec7-b8c3-d09361d5879d"}Synced Centiva Capital - EU HY/IG to 7601423[2026-05-28 16:20:08] production.INF0: Jiminny\Console\Commands\Command: :run Memory usage for command {"command": "crm:sync-opportunity", "memoryBeforeCommandInMb" : 118.0, "memoryAfterCommandInMB": 126.0, "memoryPeakBeforeCommandInMb" : 118.0, "memoryPeakAfterCommandInMB":126.0} {"correlation_id":"0a531c45-bc24-4ea8-b3c9-d90f79590da8", "trace_id" : "1723bc0c-9f67-4ec7-b8c3-d09361d5879d"}root@170a323e43a4:/home/jiminny# |...
|
Alfred
|
Alfred
|
NULL
|
87901
|
|
87900
|
Alfred Search Field
wuniconX HubspotwYour team is Alfred Search Field
wuniconX HubspotwYour team is now on the Free plan with 1 admin. You retain editing access and other members are read-only. View team permissicas to see who can edit, or ungrade to restore collaborationUseful › get history of property - deal stage((baseUri))/deats/V1/deal/494058190045?includePropertyVersions=trucE Docs Params • Authorization • Headers 12 Body • Scriots SettingsQuery Paramsv includePropertyVersionsobiectTyeo0sLXThu 28 May 19:29:06© Scarchlollietmnr sayeeoehtorooeCEr RendCr det meetinoct eonthet with ihR SaveV COLLECYIONS> HubsootKeration tun Hoteration run Searchts› Journal & webhocoks v4• Poperiesv a crm/vs xoverties ob ect tvoe> (t batchacouos> (a(property Name)y ut Resd all propertiesca successful operationAn ertor occurredrooneresle a properRESSAREH• СЕAРCH> Tckev Useful> rost tilter per comoanyonly ooen deal staoes.cEt engagements old associated by deawenanacmmnts dd nscocintnd oy comasnv сEt get history of property - deal stagenosuccasstul operation55. An error occurred.at get usersCET SF osuthuumeating cutcomes cer mectinoaPondi all nranortioe nou.> ct Resd all properties oldCT old call dispositionset list with associationscEt list engagements okeuirecendencsaementCET get dealcEt Get Engagement (v1)PATCH https://api.hubapi.com/engagements/V1/engagements/2492739S231Parckhttos:/api.hubaoi.com/crm/vs/co.ects/meet.nos/2497k5x3%wrchttosdllanihubani. com/ancacamanteMlaoonoamaatc/240273052811CiuodstedesioodCMMIDANIENTCSPECS> FLOWSOConndettCoasde em aniooDetcrictionSondCookiesp.YKEOKEJSONvPreview Visualizestad.700 ok. 270ms. 41.76 KRAs ab.* 70122ken Save Resnons?ддаг8юдд6осаевовeprobabal1tyHandler_deal_stage_pF|Cannot edit in read-only editorsouxcoid*: *DealStageProbabil{tyHandlor*"CALCULATED"requestid*: *019e46a9-51b8-7e60-9db6-153e5c1b1529,uket restaroAsPerssistencet mestamo"etmue."sourceUpstreamDeployable*: "ForecastingKafka-deal-stage-probability-workezapromotrwhhndilor"-78be-bc7b-ccf524c62eb6*uselznescaspnspersistencelamestanp: crue•souxceUpstreanDeployable*: *ForccastingKafka-deal•stage-probability-worker"Mea"e thecaciase oromahity•A7201Giobnts Montt Tookemed...
|
Alfred
|
Alfred
|
NULL
|
87900
|
|
87899
|
iTerm2ShellEditViewSessionScriptsProfilesWindowHel iTerm2ShellEditViewSessionScriptsProfilesWindowHelp‹>0 lolDOCKER₴81DEV (-zsh)O 82Version 2023.11.20260427:Version 2023.11.20260505:Version2023.11.20260509:Version2023.11.20260511:Version2023.11.20260514:Version 2023.11.20260526:Run"/usr/bin/dnf check-release-update"#_####_\ #####\\###||\#/v~'Amazon Linux 2023 (ECS Optimized)-zshN3ec2-user@ip-10-20-31-146:~screenpipe"-zshX5ec2-user@ip-10-30-129-...ec2-user@ip-10-20-31-1...100% (8• Thu 28 May 19:29:04181·7for full release and version update infom/For documentation, visit [URL_WITH_CREDENTIALS] ~]$ docker exec -it $(docker ps --format "{{.ID}}" --filter "name-ecs-worker" | head -1) /bin/bash -c "cd /home/jiminny && bash"root@170a323e43a4:/home/jiminny# php artisan crm:sync-opportunity --teamId 555 --opportunityId 494058190045[2026-05-28 16:20:08] production.INF0: Jiminny\Console\Commands\Command: : run Memory usage before starting command {"command": "crm:sync-opportunity", "memoryBeforeCommandInMb" : 118.0, "memoryPeakBeforeCommandInMb":118.03 {"correlation_id":"0a531c45-bc24-4ea8-b3c9-d90f79590da8", "trace_id":"1723bcBc-9f67-4ec7-b8c3-d09361d5879d"}Syncing opportunity for Cognitive Credit[2026-05-28 16:20:08] production.INFO: [SocialAccountService] Fetching token {"socialAccountId":30591,"provider": "hubspot"} {"correlation_id":"0a531c45-bc24-4ea8-b3c9-d90f79590da8", "trace_id":"1723bcDc-9f67-4ec7-b8c3-d09361d5879d"'}[2026-05-28 16:20:08] production.INFO: [SocialAccountService] Token retrieved {"socialAccountId":30591, "provider": "hubspot"} {"correlation_id":"0a531c45-bc24-4ea8-b3c9-d90f79590da8", "trace_id":"1723bc0c-9f67-4ec7-b8c3-d09361d5879d"'}[2026-05-28 16:20:08] production.INFO: [EncryptedTokenManager] Generatingaccess token. {"mode":"legacy"} {"correlation_id":"0a531c45-bc24-4ea8-b3c9-d90f79590da8","trace_id" :"1723bc0c-9f67-4ec7-b8c3-d09361d5879d"}Syncing opportunity 494058190045...[2026-05-28 16:20:08] production.INF0: [ReindexForOpportunityListener] Schedule reindexing job {"opportunity_id":7601423} {"correlation_id":"0a531c45-bc24-4ea8-b3c9-d90f79590da8","trace_id":"1723bc0c-9f67-4ec7-b8c3-d09361d5879d"}Synced Centiva Capital - EU HY/IG to 7601423[2026-05-28 16:20:08] production.INF0: Jiminny\Console\Commands\Command: :run Memory usage for command {"command": "crm:sync-opportunity", "memoryBeforeCommandInMb" : 118.0, "memoryAfterCommandInMB": 126.0, "memoryPeakBeforeCommandInMb" : 118.0, "memoryPeakAfterCommandInMB":126.0} {"correlation_id":"0a531c45-bc24-4ea8-b3c9-d90f79590da8", "trace_id" : "1723bc0c-9f67-4ec7-b8c3-d09361d5879d"}root@170a323e43a4:/home/jiminny# |...
|
iTerm2
|
NULL
|
NULL
|
87899
|
|
87898
|
iTerm2ShellEditViewSessionScriptsProfilesWindowHel iTerm2ShellEditViewSessionScriptsProfilesWindowHelp‹ >0 lol100% CDOCKER₴81DEV (-zsh)O 82Version 2023.11.20260427:Version 2023.11.20260505:Version2023.11.20260509:Version2023.11.20260511:Version2023.11.20260514:Version 2023.11.20260526:Run"/usr/bin/dnf check-release-update"#_####_\ #####\\###||\#/v~'Amazon Linux 2023 (ECS Optimized)-zshN3ec2-user@ip-10-20-31-146:~screenpipe"O &4-zshX5ec2-user@ip-10-30-129-...8 • Thu 28 May 19:29:00181ec2-user@ip-10-20-31-1...·7for full release and version update infom/For documentation, visit [URL_WITH_CREDENTIALS] ~]$ docker exec -it $(docker ps --format "{{.ID}}" --filter "name-ecs-worker" | head -1) /bin/bash -c "cd /home/jiminny && bash"root@170a323e43a4:/home/jiminny# php artisan crm:sync-opportunity --teamId 555 --opportunityId 494058190045[2026-05-28 16:20:08] production.INF0: Jiminny\Console\Commands\Command: : run Memory usage before starting command {"command": "crm:sync-opportunity", "memoryBeforeCommandInMb" : 118.0, "memoryPeakBeforeCommandInMb":118.03 {"correlation_id":"0a531c45-bc24-4ea8-b3c9-d90f79590da8", "trace_id":"1723bcBc-9f67-4ec7-b8c3-d09361d5879d"}Syncing opportunity for Cognitive Credit[2026-05-28 16:20:08] production.INFO: [SocialAccountService] Fetching token {"socialAccountId":30591,"provider": "hubspot"} {"correlation_id":"0a531c45-bc24-4ea8-b3c9-d90f79590da8", "trace_id":"1723bcDc-9f67-4ec7-b8c3-d09361d5879d"'}[2026-05-28 16:20:08] production.INFO: [SocialAccountService] Token retrieved {"socialAccountId":30591, "provider": "hubspot"} {"correlation_id":"0a531c45-bc24-4ea8-b3c9-d90f79590da8", "trace_id":"1723bc0c-9f67-4ec7-b8c3-d09361d5879d"'}[2026-05-28 16:20:08] production.INFO: [EncryptedTokenManager] Generatingaccess token. {"mode":"legacy"} {"correlation_id":"0a531c45-bc24-4ea8-b3c9-d90f79590da8","trace_id" :"1723bc0c-9f67-4ec7-b8c3-d09361d5879d"}Syncing opportunity 494058190045...[2026-05-28 16:20:08] production.INF0: [ReindexForOpportunityListener] Schedule reindexing job {"opportunity_id":7601423} {"correlation_id":"0a531c45-bc24-4ea8-b3c9-d90f79590da8","trace_id":"1723bc0c-9f67-4ec7-b8c3-d09361d5879d"}Synced Centiva Capital - EU HY/IG to 7601423[2026-05-28 16:20:08] production.INF0: Jiminny\Console\Commands\Command: :run Memory usage for command {"command": "crm:sync-opportunity", "memoryBeforeCommandInMb" : 118.0, "memoryAfterCommandInMB": 126.0, "memoryPeakBeforeCommandInMb" : 118.0, "memoryPeakAfterCommandInMB":126.0} {"correlation_id":"0a531c45-bc24-4ea8-b3c9-d90f79590da8", "trace_id" : "1723bc0c-9f67-4ec7-b8c3-d09361d5879d"}root@170a323e43a4:/home/jiminny# |...
|
iTerm2
|
NULL
|
NULL
|
87898
|
|
87897
|
VieWwunicowX HubspotYour team is now on the Free p VieWwunicowX HubspotYour team is now on the Free plan with 1 admin. You retain editing access and other members are read-only. View team permissicas to see who can edit, or ungrade to restore collaborationUseful › get history of property - deal stage((baseUri))/deats/V1/deal/494058190045?includePropertyVersionstrucE Docs Params • Authorization • Headers 12 Body • Scriots SettingsQuery Paramsv includePropertyVersionsobiectTyeThu 28 May 19:29:00© Scarchollietmnn sayeCoe hitoroore eOET ReadCr det meetinoet eonther with mhR SaveV COLLECYIONS> HubsootKeration tun Hoteration run Search HS› Journal & webhocoks v4• Poperiesv a crm/vsxovertes ob ect tvoe> (t batchacouos> (a(property Name)y ut Resd all propertiesca successful operationAn ertor occurredroneresle a properRESSAREH• СЕAРCH> Tckev Useful> rost tilter per comoany oniy ooen deal staoes.cEt engagements old associated by deawenanacments dld nscocntedloy comasn.v t get history of property - deal stagenosuccasstul operation55. An error occurred.at getusersCET SF osuthuuMeeting cutcomes cer mectinowpond all aranortios nou.> ct Resd all properties oldсT old call dispositionset list with associationscEt list engagements okuuirecentencsaementCET get dealcEt Get Engagement (v1)PATCH https://api.hubapi.com/engagements/V1/engagements/24927395231Parckhttos:/api.hubaoi.com/crm/vs/co.ects/meet.nos/2497k5x3%wTchttosdllanihubani. com/ancacamantslaoonoamaatc/240273052811Ciodstedessa00CMMIDANIENTCSPECS> FLOWSEConnnettPoasolewwsotoodDetcrictionSondCookiesp.YKEOSJSON vPreview Visualizestad.700 ok. 270ms. 41.76 KRAs ab.* 70122wn Save Resnons,JE Xддаг8юдд6асаевевeprobab{lityHandlerdeal stase probability',ainestaro: 1719301965775souxcoid": *DealStageProbabil{tyHandlor""CALCULATED"requestid*: *019e46a9-51b8-7e60-9db6-153e5c1b1529*.uket restaroAsPerssistencet mestamo"etmue."sourceUpstreamDeployable*: "ForecastingKafka-deal-stage-probability-workezẬV++. -aprombrwhandilor"-78be-bc7b-ccf524c62eb6*uselznescasonspersistencelamestamp: crue•souxceUpstreanDeployable*: *ForccastingKafka-deal•stage-probability-worker"name": "hs_deal_stage_probability"•A7201HIGiobnts Montt Tookenem...
|
iTerm2
|
NULL
|
NULL
|
87897
|
|
87896
|
VieWwunicowX HubspotYour team is now on the Free p VieWwunicowX HubspotYour team is now on the Free plan with 1 admin. You retain editing access and other members are read-only. View team permissicas to see who can edit, or ungrade to restore collaborationUseful › get history of property - deal stage((baseUri))/deats/V1/deal/494058190045?includePropertyVersionstrucE Docs Params • Authorization • Headers 12 Body • Scriots SettingsQuery Paramsv includePropertyVersionsobiectTye@ Searchollietmnn sayeCoe hitoroore eCEr RendCr det meetino4oct eonthet with ihThu 28 May 19:28:46R SaveV COLLECYIONS> HubsootKeration tun Hoteration run Searchts› Journal & webhocoks v4• Poperiesv a crmvs/xoverteskob ect lvoe> (t batchacouos> (a(property Name)y ut Resd all propertiesca successful operationAn ertor occurredroneresle a properRESSAREH• СЕAРCH> Tckev Useful> rost tilter per comoanyonly ooen deal staoes.cEt engagements old associated by deawenanacments dld nscocntedloy comasn.v сEt get history of property - deal stagenosuccasstul operation55. An error occurred.at getusersCET SF osuthuuMeeting cutcomes cer mectinoaPond all nranortioe nou.> ct Resd all properties oldсT old call dispositionset list with associationscEt list engagements okuuirecentencsaementCET get dealcEt Get Engagement (v1)PATCH https://api.hubapi.com/engagements/V1/engagements/2492739S231Parckhttos:/api.hubaoi.com/crm/vs/co.ects/meet.nos/2497k5x3%wTchttosdllanihubani. com/ancacamantslaoonoamaatc/240273052811Ciodstedessa00CMMIDANIENTCSPECS> FLOWSOConnnettCoasdleem antooDetcrictionSondCookiesp.YKEOKEJSONvP% Visualize"uselsirestaroAcPerssistencetmestamo"etaue.*sourceUpstreamDeployable*: *ForecastingKafka-deal-stage-probability-workezame*: "hs deal stase_probability"Ртовbtв сунand er".useTinestaspAsPersistenceTimestamp*: true"souxceUpstreanDeployable*: *ForccastingKafka-deal-stage-probability-worker""nane": "hs_deal_stage_probability"."ylu". *0,5""Xoastaro": 1776674845838"sourceld": "DealStageProbabilityHandler".*019daa12-640e-7560-884c-3dccd/300910*.stad.200ok:270ms. 41.76 KRAs ab.* 70122wn Save Resnons,HIGiobnts Montt Tookemem...
|
iTerm2
|
NULL
|
NULL
|
87896
|
|
87895
|
winoowX HubspotYour team is now on the Free plan w winoowX HubspotYour team is now on the Free plan with 1 admin. You retain editing access and other members are read-only. View team permissicas to see who can edit, or ungrade to restore collaborationerpendoneUseful › get history of property - deal stage((baseUri))/deats/V1/deal/494058190045?includePropertyVersionstrucE Docs Params • Authorization • Headers 12)SettinasQuery ParamsinciuderropertyverstonsobiectTyeV COLLECYIONS> HubsootKeration tun hsteration run Searchts› Journal & webhocoks v4• Poperiesv a crm/vs xoverties ob ect tvoe> (t batchacouos> (a(property Name)y ut Resd all propertiesca successful operationAn ertor occurredroneresle a properRESSAREH• СЕAРCH> Tckev Useful> rost tilter per comoanyonly ooen deal staoes.cEt engagements old associated by deawenanacmmnts dd nscocintnd oy comasnv сEt get history of property - deal stagenosuccasstul operation55. An error occurred.at get usersCET SF osuthauweeting cutcomes cer mectinowpond all aranortios nou.> ct Resd all properties okeсT old call dispositionset list with associationscEt list engagements okuuirecentencsaementCET get dealcEt Get Engagement (v1)PATCH https://api.hubapi.com/engagements/V1/engagements/2492739S231Parckhttos:/api.hubaoi.com/crm/vs/co.ects/meet.nos/2497k5x3%wTchttosdllanihubani. com/ancacamantslaoonoamaatc/240273052811Ciodstedessa00CMMIDANIENTCSPECS> FLOWSOConndettCoasde em aniooRoduKEJSONvPreview Visualize"hs deal stage probability".useTinestaspAsPersistenceTimestamp": true,"sourceUpstreanDeployable*:-"ForecastingKafka-deal-stage-probability-workezdenu stare oroodosbstty"DealStageProbabilityHandler",Huserrswrsi@ Searcha menim m sordCoe hitoroore eCEr RendCedet meetino4o7o0sLXThu 28 May 19:28:47et eonther with mhR SaveDetcrictionSondCookiesp.YKEOValuttruestad.700 ok. 270ms. 41.76 KRen Save Resnons?=019As ab.* 70122kHIGiobnts Montt Tookenem...
|
iTerm2
|
NULL
|
NULL
|
87895
|
|
87894
|
iTerm2ShellEditViewSessionScriptsProfilesWindowHel iTerm2ShellEditViewSessionScriptsProfilesWindowHelp‹ >0 lol100% CDOCKER₴81DEV (-zsh)O 82Version 2023.11.20260427:Version 2023.11.20260505:Version2023.11.20260509:Version2023.11.20260511:Version2023.11.20260514:Version 2023.11.20260526:Run"/usr/bin/dnf check-release-update"#_####_\ #####\\###||\#/v~'Amazon Linux 2023 (ECS Optimized)-zshN3ec2-user@ip-10-20-31-146:~screenpipe"84-zshX5ec2-user@ip-10-30-129-...8• Thu 28 May 19:28:42181ec2-user@ip-10-20-31-1...·7for full release and version update infom/For documentation, visit [URL_WITH_CREDENTIALS] ~]$ docker exec -it $(docker ps --format "{{.ID}}" --filter "name-ecs-worker" | head -1) /bin/bash -c "cd /home/jiminny && bash"root@170a323e43a4:/home/jiminny# php artisan crm:sync-opportunity --teamId 555 --opportunityId 494058190045[2026-05-28 16:20:08] production.INF0: Jiminny\Console\Commands\Command: : run Memory usage before starting command {"command": "crm:sync-opportunity", "memoryBeforeCommandInMb" : 118.0, "memoryPeakBeforeCommandInMb":118.03 {"correlation_id":"0a531c45-bc24-4ea8-b3c9-d90f79590da8", "trace_id":"1723bcBc-9f67-4ec7-b8c3-d09361d5879d"}Syncing opportunity for Cognitive Credit[2026-05-28 16:20:08] production.INFO: [SocialAccountService] Fetching token {"socialAccountId":30591,"provider": "hubspot"} {"correlation_id":"0a531c45-bc24-4ea8-b3c9-d90f79590da8", "trace_id":"1723bcDc-9f67-4ec7-b8c3-d09361d5879d"'}[2026-05-28 16:20:08] production.INFO: [SocialAccountService] Token retrieved {"socialAccountId":30591, "provider": "hubspot"} {"correlation_id":"0a531c45-bc24-4ea8-b3c9-d90f79590da8", "trace_id":"1723bc0c-9f67-4ec7-b8c3-d09361d5879d"'}[2026-05-28 16:20:08] production.INFO: [EncryptedTokenManager] Generatingaccess token. {"mode":"legacy"} {"correlation_id":"0a531c45-bc24-4ea8-b3c9-d90f79590da8","trace_id" :"1723bc0c-9f67-4ec7-b8c3-d09361d5879d"}Syncing opportunity 494058190045...[2026-05-28 16:20:08] production.INF0: [ReindexForOpportunityListener] Schedule reindexing job {"opportunity_id":7601423} {"correlation_id":"0a531c45-bc24-4ea8-b3c9-d90f79590da8","trace_id":"1723bc0c-9f67-4ec7-b8c3-d09361d5879d"}Synced Centiva Capital - EU HY/IG to 7601423[2026-05-28 16:20:08] production.INF0: Jiminny\Console\Commands\Command: :run Memory usage for command {"command": "crm:sync-opportunity", "memoryBeforeCommandInMb" : 118.0, "memoryAfterCommandInMB": 126.0, "memoryPeakBeforeCommandInMb" : 118.0, "memoryPeakAfterCommandInMB":126.0} {"correlation_id":"0a531c45-bc24-4ea8-b3c9-d90f79590da8", "trace_id" : "1723bc0c-9f67-4ec7-b8c3-d09361d5879d"}root@170a323e43a4:/home/jiminny# |...
|
iTerm2
|
NULL
|
NULL
|
87894
|
|
87893
|
Project: faVsco.js, menu
#12121 on JY-20963-fix-im Project: faVsco.js, menu
#12121 on JY-20963-fix-import-on-deleted-entity, menu
Start Listening for PHP Debug Connections
ServiceTest
Run 'ServiceTest'
Debug 'ServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
8
25
Previous Highlighted Error
Next Highlighted Error
<?php
declare(strict_types=1);
namespace Jiminny\Services\Crm\Hubspot;
use Carbon\Carbon;
use Jiminny\Models\Crm\Configuration;
use Psr\Log\LoggerInterface;
class PayloadBuilder
{
public const int MAX_SEARCH_REQUEST_LIMIT = 200;
private const int MAX_FILTER_SIZE = 100;
private const string SORT_PROPERTY = 'hs_timestamp';
private const string ENGAGEMENT_MEETINGS = 'meetings';
public function getLinkToTaskPayload(string $objectType, string $objectId, string $engagementType): array
{
$payload = [
'sorts' => [
[
'propertyName' => self::SORT_PROPERTY,
'direction' => 'DESCENDING',
],
],
'limit' => self::MAX_SEARCH_REQUEST_LIMIT,
];
if ($engagementType === self::ENGAGEMENT_MEETINGS) {
$payload['filterGroups'] = $this->buildMeetingFiltersForLinkToTask($objectType, $objectId);
$payload['properties'] = [
'hs_meeting_title',
'hs_meeting_outcome',
'hs_timestamp',
'hs_activity_type',
];
} else {
$payload['filterGroups'] = $this->buildTaskFiltersForLinkToTask($objectType, $objectId);
$payload['properties'] = ['hs_task_subject', 'hs_timestamp'];
}
return $payload;
}
public function getRecentlyUpdatedSearchPayload(Carbon $since, ?Carbon $to, array $properties): array
{
$lastUpdateDate = in_array('firstname', $properties) ? 'lastmodifieddate' : 'hs_lastmodifieddate';
$payload = [
'filters' => [
[
'propertyName' => $lastUpdateDate,
'operator' => 'GT',
'value' => $since->getPreciseTimestamp(3),
],
],
'sorts' => [
[
'propertyName' => 'hs_object_id',
'direction' => 'DESCENDING',
],
],
'limit' => self::MAX_SEARCH_REQUEST_LIMIT,
];
if ($to) {
$payload['filters'][] = [
'propertyName' => $lastUpdateDate,
'operator' => 'LT',
'value' => $to->getPreciseTimestamp(3),
];
}
$payload['properties'] = $properties;
return $payload;
}
public function getByProfileSearchPayload(string $crmId, array $properties, Carbon $since, ?Carbon $to): array
{
$lastUpdateDate = array_key_exists('firstname', $properties) ? 'lastmodifieddate' : 'hs_lastmodifieddate';
$payload = [
'filters' => [
[
'propertyName' => $lastUpdateDate,
'operator' => 'GT',
'value' => $since->getPreciseTimestamp(3),
],
[
'propertyName' => 'hubspot_owner_id',
'operator' => 'EQ',
'value' => $crmId,
],
],
'sorts' => [
[
'propertyName' => 'hs_object_id',
'direction' => 'DESCENDING',
],
],
'limit' => self::MAX_SEARCH_REQUEST_LIMIT,
];
if ($to) {
$payload['filters'][] = [
'propertyName' => $lastUpdateDate,
'operator' => 'LT',
'value' => $to->getPreciseTimestamp(3),
];
}
$payload['properties'] = $properties;
return $payload;
}
private function buildMeetingFiltersForLinkToTask($objectType, $objectId): array
{
$filters = [];
$filters[] = [
'filters' => [
$this->getAssociatedObjectFilter($objectType, $objectId),
[
'propertyName' => 'hs_meeting_outcome',
'operator' => 'IN',
'values' => ['SCHEDULED', 'RESCHEDULED'],
],
],
];
$filters[] = [
'filters' => [
$this->getAssociatedObjectFilter($objectType, $objectId),
[
'propertyName' => 'hs_meeting_outcome',
'operator' => 'NOT_HAS_PROPERTY',
],
],
];
return $filters;
}
private function buildTaskFiltersForLinkToTask($objectType, $objectId): array
{
return [
[
'filters' => [
$this->getAssociatedObjectFilter($objectType, $objectId),
],
],
];
}
private function getAssociatedObjectFilter(string $objectType, string $objectId): array
{
return [
'propertyName' => 'associations.' . $objectType,
'operator' => 'EQ',
'value' => $objectId,
];
}
public function generatePlaybackURLSearchPayload(string $playbackURLToken): array
{
return [
'filters' => [
[
'propertyName' => 'hs_call_recording_url',
'operator' => 'CONTAINS_TOKEN',
'value' => $playbackURLToken,
],
],
'properties' => [
'hs_call_recording_url',
'hs_call_body',
],
'limit' => 1,
];
}
public function generateGetCallsPayload(Carbon $from, Carbon $to, string $activityProvider, $page): array
{
return [
'filters' => [
[
'propertyName' => 'hs_call_recording_url',
'operator' => 'CONTAINS_TOKEN',
'value' => $activityProvider,
],
[
'propertyName' => 'hs_timestamp',
'operator' => 'BETWEEN',
'value' => intval($from->getPreciseTimestamp(3)),
'highValue' => intval($to->getPreciseTimestamp(3)),
],
],
'sorts' => [
[
'propertyName' => 'hs_timestamp',
'direction' => 'DESCENDING',
],
],
'properties' => [
'hs_call_recording_url',
'hs_call_body',
],
'limit' => self::MAX_SEARCH_REQUEST_LIMIT,
'after' => ($page - 1) * self::MAX_SEARCH_REQUEST_LIMIT,
];
}
public function generateSearchCallsByPeriodPayload(Carbon $from, Carbon $to, $page): array
{
return [
'filters' => [
[
'propertyName' => 'hs_timestamp',
'operator' => 'BETWEEN',
'value' => intval($from->getPreciseTimestamp(3)),
'highValue' => intval($to->getPreciseTimestamp(3)),
],
],
'sorts' => [
[
'propertyName' => 'hs_timestamp',
'direction' => 'DESCENDING',
],
],
'properties' => $this->getSearchCallAttributes(),
'limit' => self::MAX_SEARCH_REQUEST_LIMIT,
'after' => ($page - 1) * self::MAX_SEARCH_REQUEST_LIMIT,
];
}
public function getSearchCallAttributes(): array
{
return [
'hs_timestamp',
'hs_call_recording_url',
'hs_call_body',
'hs_call_status',
'hs_call_to_number',
'hs_call_from_number',
'hs_call_duration',
'hs_call_disposition',
'hs_call_title',
'hs_call_direction',
'hubspot_owner_id',
'hs_activity_type',
'hs_call_external_id',
'hs_call_source',
];
}
public function generatePlaybackAddUrlBatchPayload(array $crmUpdateData): array
{
$updateObjectsData = [];
foreach ($crmUpdateData as $data) {
$updateObjectsData[] = [
'id' => $data['crm_id'],
'properties' => [
'hs_call_body' => $data['hs_call_body'] .
'<p><span style="font-size: 13.3333px; line-height: 16px;">' .
'<a href="' . $data['playback_url'] . '" target="_blank">Review in Jiminny</a> ▶️</span></p>',
],
];
}
return ['inputs' => $updateObjectsData];
}
public function generateSearchCallByTokenPayload(string $playbackURLToken): array
{
return [
'filters' => [
[
'propertyName' => 'hs_call_recording_url',
'operator' => 'CONTAINS_TOKEN',
'value' => $playbackURLToken,
],
],
'properties' => [
'hs_call_recording_url',
'hs_call_body',
],
'limit' => 1,
];
}
/**
* Generates a payload for phone search based on the specified parameters.
*
* @param string $phone The phone number to search.
* @param bool $isAlternativeSearch Indicates if an alternative search should be performed
* if the first search fails to find a match. The first search request should cover most of the cases.
*/
public function generatePhoneSearchPayload(string $phone, bool $isAlternativeSearch = false): array
{
$filterPropertyNames = $isAlternativeSearch ?
['hs_searchable_calculated_mobile_number', 'phone', 'mobilephone'] :
['hs_searchable_calculated_phone_number', 'hs_calculated_phone_number', 'hs_calculated_mobile_number'];
$filterGroups = array_map(function ($propertyName) use ($phone) {
return $this->createFilterGroup($propertyName, 'CONTAINS_TOKEN', $phone);
}, $filterPropertyNames);
$properties = [
'email',
'firstname',
'lastname',
'country',
'phone',
'mobilephone',
'jobtitle',
'hubspot_owner_id',
'associatedcompanyid',
'photo',
];
return $this->createPayload($filterGroups, $properties);
}
/**
* Creates a filter group with the specified parameters.
*/
private function createFilterGroup(string $propertyName, string $operator, $value): array
{
return [
'filters' => [
[
'propertyName' => $propertyName,
'operator' => $operator,
'value' => $value,
],
],
];
}
/**
* Creates a payload with the specified filter groups and properties.
*/
private function createPayload(array $filterGroups, array $properties): array
{
return [
'filterGroups' => $filterGroups,
'sorts' => [
[
'propertyName' => 'modifieddate',
'direction' => 'DESCENDING',
],
],
'properties' => $properties,
'limit' => 1,
];
}
/**
* Generates a payload to find related activities based on the specified data and object type.
* The search looks for activities that match the specified time range (starttime - endtime or only starttime with endtime not set)
* and are associated with any of the provided prospect types (contact or company).
*
* @param array $data An associative array containing the following keys:
* - 'from': The start time for the activity search (timestamp).
* - 'to': The end time for the activity search (timestamp).
* - 'contact': (optional) The ID of the associated contact.
* - 'company': (optional) The ID of the associated company.
* @param string $objectType The type of engagement to search for (meeting or other type like call).
*
* @return array The payload array to be used for searching related activities.
*/
public function getFindRelatedActivityPayload(array $data, string $objectType): array
{
$payload = [
'sorts' => [
[
'propertyName' => self::SORT_PROPERTY,
'direction' => 'DESCENDING',
],
],
'limit' => self::MAX_SEARCH_REQUEST_LIMIT,
];
if ($objectType === self::ENGAGEMENT_MEETINGS) {
$payload['properties'] = [
'hs_meeting_title',
'hs_meeting_outcome',
'hs_activity_type',
'hs_timestamp',
'hubspot_owner_id',
'hs_meeting_body',
'hs_internal_meeting_notes',
'hs_meeting_location',
'hs_meeting_start_time',
'hs_meeting_end_time',
];
} else {
$payload['properties'] = ['hs_task_subject', 'hs_timestamp', 'hubspot_owner_id', 'hs_call_body'];
}
$timeFiltersWithEndTime = [
[
'propertyName' => 'hs_meeting_start_time',
'operator' => 'GTE',
'value' => $data['from'],
],
[
'propertyName' => 'hs_meeting_end_time',
'operator' => 'LTE',
'value' => $data['to'],
],
];
$timeFiltersWithoutEndTime = [
[
'propertyName' => 'hs_meeting_start_time',
'operator' => 'GTE',
'value' => $data['from'],
],
[
'propertyName' => 'hs_meeting_end_time',
'operator' => 'NOT_HAS_PROPERTY',
],
];
$payload['filterGroups'] = [];
$associationTypes = ['contact', 'company'];
foreach ($associationTypes as $type) {
if (! empty($data[$type])) {
$filterGroupWithEndTime = [
'filters' => array_merge(
$timeFiltersWithEndTime,
[$this->getAssociatedObjectFilter($type, $data[$type])]
),
];
$payload['filterGroups'][] = $filterGroupWithEndTime;
$filterGroupWithoutEndTime = [
'filters' => array_merge(
$timeFiltersWithoutEndTime,
[$this->getAssociatedObjectFilter($type, $data[$type])]
),
];
$payload['filterGroups'][] = $filterGroupWithoutEndTime ;
}
}
return $payload;
}
/** Parameters
* [
* 'accountId' => $crmAccountId,
* 'sortBy' => $sortBy,
* 'sortDir' => $sortDir,
* 'onlyOpen' => $onlyOpen,
* 'closedStages' => $this->getClosedDealStages(),
* 'userId' => $userId,
* ];
*/
public function generateOpportunitiesSearchPayload(
Configuration $config,
string $crmAccountId,
array $closedStages,
): array {
$closedFilters = [];
$filterGroups = [];
$onlyOpen = true;
switch ($config->getOpportunityAssignmentRule()) {
case Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_UPDATED:
$sortBy = 'modifieddate';
$sortDir = 'DESCENDING';
break;
case Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_CREATED:
$sortBy = 'createdate';
$sortDir = 'DESCENDING';
break;
case Configuration::OPP_ASSIGNMENT_ALL_OPEN_OLDEST_CREATED:
$sortBy = 'createdate';
$sortDir = 'ASCENDING';
break;
case Configuration::OPP_ASSIGNMENT_ALL_RECENTLY_UPDATED:
default:
$sortBy = 'modifieddate';
$sortDir = 'DESCENDING';
$onlyOpen = false;
}
$baseFilters = [
[
'propertyName' => 'associations.company',
'operator' => 'EQ',
'value' => $crmAccountId,
],
];
// Handle closed stages in chunks
if ($onlyOpen) {
foreach (['won', 'lost'] as $key) {
if (! empty($closedStages[$key])) {
$chunks = array_chunk($closedStages[$key], self::MAX_FILTER_SIZE);
foreach ($chunks as $chunk) {
$closedFilters[] = [
'propertyName' => 'dealstage',
'operator' => 'NOT_IN',
'values' => $chunk,
];
}
}
}
}
$filterGroups[] = [
'filters' => array_merge($baseFilters, $closedFilters),
];
$payload = [
'filterGroups' => $filterGroups,
'sorts' => [
[
'propertyName' => $sortBy,
'direction' => $sortDir,
],
],
'properties' => [
'dealname',
'amount',
'hubspot_owner_id',
'pipeline',
'dealstage',
'closedate',
'deal_currency_code',
],
'limit' => self::MAX_SEARCH_REQUEST_LIMIT,
];
$logger = app(LoggerInterface::class);
$logger->info('[HubSpot] CRM Search requested', [
'request' => $payload,
]);
return $payload;
}
/**
* Converts v1 payload data to v3
*/
public function getV3MeetingPayload(array $engagement, array $metadata, array $associations = []): array
{
// Use this mapping until the whole Hubspot V1 is dropped.
$properties = [
'hubspot_owner_id' => $engagement['ownerId'],
'hs_timestamp' => $engagement['timestamp'],
];
// $engagement['activityType'] is $activity->category->name
if (isset($engagement['activityType'])) {
$properties['hs_activity_type'] = $engagement['activityType'];
}
$metadataKeyMap = [
'hs_meeting_outcome' => 'meetingOutcome',
'hs_meeting_title' => 'title',
'hs_meeting_start_time' => 'startTime',
'hs_meeting_end_time' => 'endTime',
'hs_meeting_body' => 'body',
'hs_internal_meeting_notes' => 'internalMeetingNotes',
];
foreach ($metadataKeyMap as $newKey => $oldKey) {
if (isset($metadata[$oldKey])) {
$properties[$newKey] = $metadata[$oldKey];
unset($metadata[$oldKey]);
}
}
$properties = [
...$properties,
...$metadata, // custom fields
];
$response = [
'properties' => $properties,
];
if (! empty($associations)) {
$response['associations'] = $associations;
}
return $response;
}
/**
* Generate a payload to search for contacts by name.
* The search is a token search in firstname or lastname
*/
public function generateSearchContactsByNamePayload(string $name, array $fields): array
{
$firstNameFilter = [
'propertyName' => 'firstname',
'operator' => 'CONTAINS_TOKEN',
'value' => $name,
];
$lastNameFilter = [
'propertyName' => 'lastname',
'operator' => 'CONTAINS_TOKEN',
'value' => $name,
];
return [
'filterGroups' => [
[
'filters' => [$firstNameFilter],
],
[
'filters' => [$lastNameFilter],
],
],
'properties' => $fields,
'sorts' => [
[
'propertyName' => 'lastmodifieddate',
'direction' => 'DESCENDING',
],
],
];
}
public function buildAddAssociationPayload(string $crmId, array $ids, int $associationType): array
{
$inputs = [];
foreach ($ids as $id) {
if ($id === null || $id === '') {
continue;
}
$inputs[] = [
'from' => [
'id' => $crmId,
],
'to' => [
'id' => (string) $id,
],
'types' => [
[
'associationCategory' => 'HUBSPOT_DEFINED',
'associationTypeId' => $associationType,
],
],
];
}
return ['inputs' => $inputs];
}
public function buildRemoveAssociationPayload(string $crmId, array $ids): array
{
$toArray = [];
foreach ($ids as $id) {
if ($id === null || $id === '') {
continue;
}
$toArray[] = ['id' => (string) $id];
}
return [
'inputs' => [
[
'from' => ['id' => $crmId],
'to' => $toArray,
],
],
];
}
public function addClosedStageFilters(array &$payload, array $closedStages): void
{
if (! empty($closedStages['won'])) {
$payload['filters'][] = [
'propertyName' => 'dealstage',
'operator' => 'NOT_IN',
'values' => $closedStages['won'],
];
}
if (! empty($closedStages['lost'])) {
$payload['filters'][] = [
'propertyName' => 'dealstage',
'operator' => 'NOT_IN',
'values' => $closedStages['lost'],
];
}
}
public function addCreatedDateFilters(array &$payload, Carbon $createdAfter): void
{
$payload['filters'][] = [
'propertyName' => 'createdate',
'operator' => 'GT',
'value' => $createdAfter->getPreciseTimestamp(3),
];
}
public function getDealsInBulkPayload(array $dealIds): array
{
return [
'filterGroups' => [
[
'filters' => [
[
'propertyName' => 'hs_object_id',
'operator' => 'IN',
'values' => $dealIds,
],
],
],
],
'limit' => self::MAX_FILTER_SIZE,
];
}
}
Sync Changes
Hide This Notification
Code changed:
Hide
22
8
Previous Highlighted Error...
|
PhpStorm
|
faVsco.js – PropertyChangeManager.php
|
NULL
|
87893
|
|
87892
|
Project: faVsco.js, menu
#12121 on JY-20963-fix-im Project: faVsco.js, menu
#12121 on JY-20963-fix-import-on-deleted-entity, menu
Start Listening for PHP Debug Connections
ServiceTest
Run 'ServiceTest'
Debug 'ServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
8
25
Previous Highlighted Error
Next Highlighted Error
<?php
declare(strict_types=1);
namespace Jiminny\Services\Crm\Hubspot;
use Carbon\Carbon;
use Jiminny\Models\Crm\Configuration;
use Psr\Log\LoggerInterface;
class PayloadBuilder
{
public const int MAX_SEARCH_REQUEST_LIMIT = 200;
private const int MAX_FILTER_SIZE = 100;
private const string SORT_PROPERTY = 'hs_timestamp';
private const string ENGAGEMENT_MEETINGS = 'meetings';
public function getLinkToTaskPayload(string $objectType, string $objectId, string $engagementType): array
{
$payload = [
'sorts' => [
[
'propertyName' => self::SORT_PROPERTY,
'direction' => 'DESCENDING',
],
],
'limit' => self::MAX_SEARCH_REQUEST_LIMIT,
];
if ($engagementType === self::ENGAGEMENT_MEETINGS) {
$payload['filterGroups'] = $this->buildMeetingFiltersForLinkToTask($objectType, $objectId);
$payload['properties'] = [
'hs_meeting_title',
'hs_meeting_outcome',
'hs_timestamp',
'hs_activity_type',
];
} else {
$payload['filterGroups'] = $this->buildTaskFiltersForLinkToTask($objectType, $objectId);
$payload['properties'] = ['hs_task_subject', 'hs_timestamp'];
}
return $payload;
}
public function getRecentlyUpdatedSearchPayload(Carbon $since, ?Carbon $to, array $properties): array
{
$lastUpdateDate = in_array('firstname', $properties) ? 'lastmodifieddate' : 'hs_lastmodifieddate';
$payload = [
'filters' => [
[
'propertyName' => $lastUpdateDate,
'operator' => 'GT',
'value' => $since->getPreciseTimestamp(3),
],
],
'sorts' => [
[
'propertyName' => 'hs_object_id',
'direction' => 'DESCENDING',
],
],
'limit' => self::MAX_SEARCH_REQUEST_LIMIT,
];
if ($to) {
$payload['filters'][] = [
'propertyName' => $lastUpdateDate,
'operator' => 'LT',
'value' => $to->getPreciseTimestamp(3),
];
}
$payload['properties'] = $properties;
return $payload;
}
public function getByProfileSearchPayload(string $crmId, array $properties, Carbon $since, ?Carbon $to): array
{
$lastUpdateDate = array_key_exists('firstname', $properties) ? 'lastmodifieddate' : 'hs_lastmodifieddate';
$payload = [
'filters' => [
[
'propertyName' => $lastUpdateDate,
'operator' => 'GT',
'value' => $since->getPreciseTimestamp(3),
],
[
'propertyName' => 'hubspot_owner_id',
'operator' => 'EQ',
'value' => $crmId,
],
],
'sorts' => [
[
'propertyName' => 'hs_object_id',
'direction' => 'DESCENDING',
],
],
'limit' => self::MAX_SEARCH_REQUEST_LIMIT,
];
if ($to) {
$payload['filters'][] = [
'propertyName' => $lastUpdateDate,
'operator' => 'LT',
'value' => $to->getPreciseTimestamp(3),
];
}
$payload['properties'] = $properties;
return $payload;
}
private function buildMeetingFiltersForLinkToTask($objectType, $objectId): array
{
$filters = [];
$filters[] = [
'filters' => [
$this->getAssociatedObjectFilter($objectType, $objectId),
[
'propertyName' => 'hs_meeting_outcome',
'operator' => 'IN',
'values' => ['SCHEDULED', 'RESCHEDULED'],
],
],
];
$filters[] = [
'filters' => [
$this->getAssociatedObjectFilter($objectType, $objectId),
[
'propertyName' => 'hs_meeting_outcome',
'operator' => 'NOT_HAS_PROPERTY',
],
],
];
return $filters;
}
private function buildTaskFiltersForLinkToTask($objectType, $objectId): array
{
return [
[
'filters' => [
$this->getAssociatedObjectFilter($objectType, $objectId),
],
],
];
}
private function getAssociatedObjectFilter(string $objectType, string $objectId): array
{
return [
'propertyName' => 'associations.' . $objectType,
'operator' => 'EQ',
'value' => $objectId,
];
}
public function generatePlaybackURLSearchPayload(string $playbackURLToken): array
{
return [
'filters' => [
[
'propertyName' => 'hs_call_recording_url',
'operator' => 'CONTAINS_TOKEN',
'value' => $playbackURLToken,
],
],
'properties' => [
'hs_call_recording_url',
'hs_call_body',
],
'limit' => 1,
];
}
public function generateGetCallsPayload(Carbon $from, Carbon $to, string $activityProvider, $page): array
{
return [
'filters' => [
[
'propertyName' => 'hs_call_recording_url',
'operator' => 'CONTAINS_TOKEN',
'value' => $activityProvider,
],
[
'propertyName' => 'hs_timestamp',
'operator' => 'BETWEEN',
'value' => intval($from->getPreciseTimestamp(3)),
'highValue' => intval($to->getPreciseTimestamp(3)),
],
],
'sorts' => [
[
'propertyName' => 'hs_timestamp',
'direction' => 'DESCENDING',
],
],
'properties' => [
'hs_call_recording_url',
'hs_call_body',
],
'limit' => self::MAX_SEARCH_REQUEST_LIMIT,
'after' => ($page - 1) * self::MAX_SEARCH_REQUEST_LIMIT,
];
}
public function generateSearchCallsByPeriodPayload(Carbon $from, Carbon $to, $page): array
{
return [
'filters' => [
[
'propertyName' => 'hs_timestamp',
'operator' => 'BETWEEN',
'value' => intval($from->getPreciseTimestamp(3)),
'highValue' => intval($to->getPreciseTimestamp(3)),
],
],
'sorts' => [
[
'propertyName' => 'hs_timestamp',
'direction' => 'DESCENDING',
],
],
'properties' => $this->getSearchCallAttributes(),
'limit' => self::MAX_SEARCH_REQUEST_LIMIT,
'after' => ($page - 1) * self::MAX_SEARCH_REQUEST_LIMIT,
];
}
public function getSearchCallAttributes(): array
{
return [
'hs_timestamp',
'hs_call_recording_url',
'hs_call_body',
'hs_call_status',
'hs_call_to_number',
'hs_call_from_number',
'hs_call_duration',
'hs_call_disposition',
'hs_call_title',
'hs_call_direction',
'hubspot_owner_id',
'hs_activity_type',
'hs_call_external_id',
'hs_call_source',
];
}
public function generatePlaybackAddUrlBatchPayload(array $crmUpdateData): array
{
$updateObjectsData = [];
foreach ($crmUpdateData as $data) {
$updateObjectsData[] = [
'id' => $data['crm_id'],
'properties' => [
'hs_call_body' => $data['hs_call_body'] .
'<p><span style="font-size: 13.3333px; line-height: 16px;">' .
'<a href="' . $data['playback_url'] . '" target="_blank">Review in Jiminny</a> ▶️</span></p>',
],
];
}
return ['inputs' => $updateObjectsData];
}
public function generateSearchCallByTokenPayload(string $playbackURLToken): array
{
return [
'filters' => [
[
'propertyName' => 'hs_call_recording_url',
'operator' => 'CONTAINS_TOKEN',
'value' => $playbackURLToken,
],
],
'properties' => [
'hs_call_recording_url',
'hs_call_body',
],
'limit' => 1,
];
}
/**
* Generates a payload for phone search based on the specified parameters.
*
* @param string $phone The phone number to search.
* @param bool $isAlternativeSearch Indicates if an alternative search should be performed
* if the first search fails to find a match. The first search request should cover most of the cases.
*/
public function generatePhoneSearchPayload(string $phone, bool $isAlternativeSearch = false): array
{
$filterPropertyNames = $isAlternativeSearch ?
['hs_searchable_calculated_mobile_number', 'phone', 'mobilephone'] :
['hs_searchable_calculated_phone_number', 'hs_calculated_phone_number', 'hs_calculated_mobile_number'];
$filterGroups = array_map(function ($propertyName) use ($phone) {
return $this->createFilterGroup($propertyName, 'CONTAINS_TOKEN', $phone);
}, $filterPropertyNames);
$properties = [
'email',
'firstname',
'lastname',
'country',
'phone',
'mobilephone',
'jobtitle',
'hubspot_owner_id',
'associatedcompanyid',
'photo',
];
return $this->createPayload($filterGroups, $properties);
}
/**
* Creates a filter group with the specified parameters.
*/
private function createFilterGroup(string $propertyName, string $operator, $value): array
{
return [
'filters' => [
[
'propertyName' => $propertyName,
'operator' => $operator,
'value' => $value,
],
],
];
}
/**
* Creates a payload with the specified filter groups and properties.
*/
private function createPayload(array $filterGroups, array $properties): array
{
return [
'filterGroups' => $filterGroups,
'sorts' => [
[
'propertyName' => 'modifieddate',
'direction' => 'DESCENDING',
],
],
'properties' => $properties,
'limit' => 1,
];
}
/**
* Generates a payload to find related activities based on the specified data and object type.
* The search looks for activities that match the specified time range (starttime - endtime or only starttime with endtime not set)
* and are associated with any of the provided prospect types (contact or company).
*
* @param array $data An associative array containing the following keys:
* - 'from': The start time for the activity search (timestamp).
* - 'to': The end time for the activity search (timestamp).
* - 'contact': (optional) The ID of the associated contact.
* - 'company': (optional) The ID of the associated company.
* @param string $objectType The type of engagement to search for (meeting or other type like call).
*
* @return array The payload array to be used for searching related activities.
*/
public function getFindRelatedActivityPayload(array $data, string $objectType): array
{
$payload = [
'sorts' => [
[
'propertyName' => self::SORT_PROPERTY,
'direction' => 'DESCENDING',
],
],
'limit' => self::MAX_SEARCH_REQUEST_LIMIT,
];
if ($objectType === self::ENGAGEMENT_MEETINGS) {
$payload['properties'] = [
'hs_meeting_title',
'hs_meeting_outcome',
'hs_activity_type',
'hs_timestamp',
'hubspot_owner_id',
'hs_meeting_body',
'hs_internal_meeting_notes',
'hs_meeting_location',
'hs_meeting_start_time',
'hs_meeting_end_time',
];
} else {
$payload['properties'] = ['hs_task_subject', 'hs_timestamp', 'hubspot_owner_id', 'hs_call_body'];
}
$timeFiltersWithEndTime = [
[
'propertyName' => 'hs_meeting_start_time',
'operator' => 'GTE',
'value' => $data['from'],
],
[
'propertyName' => 'hs_meeting_end_time',
'operator' => 'LTE',
'value' => $data['to'],
],
];
$timeFiltersWithoutEndTime = [
[
'propertyName' => 'hs_meeting_start_time',
'operator' => 'GTE',
'value' => $data['from'],
],
[
'propertyName' => 'hs_meeting_end_time',
'operator' => 'NOT_HAS_PROPERTY',
],
];
$payload['filterGroups'] = [];
$associationTypes = ['contact', 'company'];
foreach ($associationTypes as $type) {
if (! empty($data[$type])) {
$filterGroupWithEndTime = [
'filters' => array_merge(
$timeFiltersWithEndTime,
[$this->getAssociatedObjectFilter($type, $data[$type])]
),
];
$payload['filterGroups'][] = $filterGroupWithEndTime;
$filterGroupWithoutEndTime = [
'filters' => array_merge(
$timeFiltersWithoutEndTime,
[$this->getAssociatedObjectFilter($type, $data[$type])]
),
];
$payload['filterGroups'][] = $filterGroupWithoutEndTime ;
}
}
return $payload;
}
/** Parameters
* [
* 'accountId' => $crmAccountId,
* 'sortBy' => $sortBy,
* 'sortDir' => $sortDir,
* 'onlyOpen' => $onlyOpen,
* 'closedStages' => $this->getClosedDealStages(),
* 'userId' => $userId,
* ];
*/
public function generateOpportunitiesSearchPayload(
Configuration $config,
string $crmAccountId,
array $closedStages,
): array {
$closedFilters = [];
$filterGroups = [];
$onlyOpen = true;
switch ($config->getOpportunityAssignmentRule()) {
case Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_UPDATED:
$sortBy = 'modifieddate';
$sortDir = 'DESCENDING';
break;
case Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_CREATED:
$sortBy = 'createdate';
$sortDir = 'DESCENDING';
break;
case Configuration::OPP_ASSIGNMENT_ALL_OPEN_OLDEST_CREATED:
$sortBy = 'createdate';
$sortDir = 'ASCENDING';
break;
case Configuration::OPP_ASSIGNMENT_ALL_RECENTLY_UPDATED:
default:
$sortBy = 'modifieddate';
$sortDir = 'DESCENDING';
$onlyOpen = false;
}
$baseFilters = [
[
'propertyName' => 'associations.company',
'operator' => 'EQ',
'value' => $crmAccountId,
],
];
// Handle closed stages in chunks
if ($onlyOpen) {
foreach (['won', 'lost'] as $key) {
if (! empty($closedStages[$key])) {
$chunks = array_chunk($closedStages[$key], self::MAX_FILTER_SIZE);
foreach ($chunks as $chunk) {
$closedFilters[] = [
'propertyName' => 'dealstage',
'operator' => 'NOT_IN',
'values' => $chunk,
];
}
}
}
}
$filterGroups[] = [
'filters' => array_merge($baseFilters, $closedFilters),
];
$payload = [
'filterGroups' => $filterGroups,
'sorts' => [
[
'propertyName' => $sortBy,
'direction' => $sortDir,
],
],
'properties' => [
'dealname',
'amount',
'hubspot_owner_id',
'pipeline',
'dealstage',
'closedate',
'deal_currency_code',
],
'limit' => self::MAX_SEARCH_REQUEST_LIMIT,
];
$logger = app(LoggerInterface::class);
$logger->info('[HubSpot] CRM Search requested', [
'request' => $payload,
]);
return $payload;
}
/**
* Converts v1 payload data to v3
*/
public function getV3MeetingPayload(array $engagement, array $metadata, array $associations = []): array
{
// Use this mapping until the whole Hubspot V1 is dropped.
$properties = [
'hubspot_owner_id' => $engagement['ownerId'],
'hs_timestamp' => $engagement['timestamp'],
];
// $engagement['activityType'] is $activity->category->name
if (isset($engagement['activityType'])) {
$properties['hs_activity_type'] = $engagement['activityType'];
}
$metadataKeyMap = [
'hs_meeting_outcome' => 'meetingOutcome',
'hs_meeting_title' => 'title',
'hs_meeting_start_time' => 'startTime',
'hs_meeting_end_time' => 'endTime',
'hs_meeting_body' => 'body',
'hs_internal_meeting_notes' => 'internalMeetingNotes',
];
foreach ($metadataKeyMap as $newKey => $oldKey) {
if (isset($metadata[$oldKey])) {
$properties[$newKey] = $metadata[$oldKey];
unset($metadata[$oldKey]);
}
}
$properties = [
...$properties,
...$metadata, // custom fields
];
$response = [
'properties' => $properties,
];
if (! empty($associations)) {
$response['associations'] = $associations;
}
return $response;
}
/**
* Generate a payload to search for contacts by name.
* The search is a token search in firstname or lastname
*/
public function generateSearchContactsByNamePayload(string $name, array $fields): array
{
$firstNameFilter = [
'propertyName' => 'firstname',
'operator' => 'CONTAINS_TOKEN',
'value' => $name,
];
$lastNameFilter = [
'propertyName' => 'lastname',
'operator' => 'CONTAINS_TOKEN',
'value' => $name,
];
return [
'filterGroups' => [
[
'filters' => [$firstNameFilter],
],
[
'filters' => [$lastNameFilter],
],
],
'properties' => $fields,
'sorts' => [
[
'propertyName' => 'lastmodifieddate',
'direction' => 'DESCENDING',
],
],
];
}
public function buildAddAssociationPayload(string $crmId, array $ids, int $associationType): array
{
$inputs = [];
foreach ($ids as $id) {
if ($id === null || $id === '') {
continue;
}
$inputs[] = [
'from' => [
'id' => $crmId,
],
'to' => [
'id' => (string) $id,
],
'types' => [
[
'associationCategory' => 'HUBSPOT_DEFINED',
'associationTypeId' => $associationType,
],
],
];
}
return ['inputs' => $inputs];
}
public function buildRemoveAssociationPayload(string $crmId, array $ids): array
{
$toArray = [];
foreach ($ids as $id) {
if ($id === null || $id === '') {
continue;
}
$toArray[] = ['id' => (string) $id];
}
return [
'inputs' => [
[
'from' => ['id' => $crmId],
'to' => $toArray,
],
],
];
}
public function addClosedStageFilters(array &$payload, array $closedStages): void
{
if (! empty($closedStages['won'])) {
$payload['filters'][] = [
'propertyName' => 'dealstage',
'operator' => 'NOT_IN',
'values' => $closedStages['won'],
];
}
if (! empty($closedStages['lost'])) {
$payload['filters'][] = [
'propertyName' => 'dealstage',
'operator' => 'NOT_IN',
'values' => $closedStages['lost'],
];
}
}
public function addCreatedDateFilters(array &$payload, Carbon $createdAfter): void
{
$payload['filters'][] = [
'propertyName' => 'createdate',
'operator' => 'GT',
'value' => $createdAfter->getPreciseTimestamp(3),
];
}
public function getDealsInBulkPayload(array $dealIds): array
{
return [
'filterGroups' => [
[
'filters' => [
[
'propertyName' => 'hs_object_id',
'operator' => 'IN',
'values' => $dealIds,
],
],
],
],
'limit' => self::MAX_FILTER_SIZE,
];
}
}
Sync Changes
Hide This Notification
Code changed:
Hide
22
8
Previous Highlighted Error
Next Highlighted Error
<?php
declare(strict_types=1);
namespace Jiminny\Services\Crm\Hubspot\Webhook;
use Illuminate\Support\Facades\Log;
use Jiminny\Models\Crm\Configuration;
use Jiminny\Services\Crm\Hubspot\BatchSyncCollector;
use Jiminny\Repositories\Crm\CrmEntityRepository;
use Jiminny\Repositories\Crm\OpportunityRepository;
use Jiminny\Repositories\Crm\FieldRepository;
use Jiminny\Repositories\Crm\FieldDataRepository;
use Jiminny\Models\Opportunity;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\LayoutEntity;
readonly class PropertyChangeManager
{
private const string OBJECT_TYPE_DEAL = 'deal';
private const string OBJECT_TYPE_CONTACT = 'contact';
private const string OBJECT_TYPE_COMPANY = 'company';
private const array SUPPORTED_OBJECT_TYPES = [
self::OBJECT_TYPE_DEAL,
self::OBJECT_TYPE_CONTACT,
self::OBJECT_TYPE_COMPANY,
];
/**
* Contact fields that webhooks are subscribed to in HubSpot.
*
* IMPORTANT: These fields MUST match the webhook subscriptions configured in HubSpot.
* When updating this list, you must also update the HubSpot webhook subscriptions:
* 1. Go to HubSpot App Settings → Webhooks
* 2. Update contact.propertyChange subscriptions to match this list
* 3. Ensure all fields listed here are subscribed, and vice versa
*
* This constant serves as documentation of the current webhook configuration.
* Since HubSpot filters webhooks server-side, we should only receive events for these properties.
*/
private const array APPLICABLE_CONTACT_FIELDS = [
'firstname',
'lastname',
'email',
'phone',
'mobilephone',
'jobtitle',
'country',
'hubspot_owner_id',
'associatedcompanyid',
'photo',
'hs_avatar_filemanager_key',
];
/**
* Company fields that webhooks are subscribed to in HubSpot.
*
* IMPORTANT: These fields MUST match the webhook subscriptions configured in HubSpot.
* When updating this list, you must also update the HubSpot webhook subscriptions:
* 1. Go to HubSpot App Settings → Webhooks
* 2. Update company.propertyChange subscriptions to match this list
* 3. Ensure all fields listed here are subscribed, and vice versa
*
* This constant serves as documentation of the current webhook configuration.
* Since HubSpot filters webhooks server-side, we should only receive events for these properties.
*/
private const array APPLICABLE_COMPANY_FIELDS = [
'name',
'phone',
'domain',
'country',
'industry',
'hubspot_owner_id',
'hs_avatar_filemanager_key',
];
/**
* Properties that support direct database updates (without full sync).
* These are simple properties that can be updated immediately without recalculating other fields.
*/
private const array DB_OBJECT_UPDATE_PROPERTIES = [
self::OBJECT_TYPE_DEAL => [
'dealname',
'description',
],
self::OBJECT_TYPE_CONTACT => [
// Add contact properties that support direct update
],
self::OBJECT_TYPE_COMPANY => [
// Add company properties that support direct update
],
];
/**
* Mapping from HubSpot property names to Jiminny model property names.
* Used when updating the main model (Opportunity, Contact, Account) directly.
* Only needed when HubSpot property name differs from Jiminny property name.
*/
private const array OBJECT_PROPERTY_MAPPINGS = [
self::OBJECT_TYPE_DEAL => [
'dealname' => 'name', // HubSpot 'dealname' maps to Opportunity 'name'
],
self::OBJECT_TYPE_CONTACT => [
// Add contact property mappings if needed
],
self::OBJECT_TYPE_COMPANY => [
// Add company property mappings if needed
],
];
// Field object types mapping
private const array FIELD_OBJECT_TYPES = [
self::OBJECT_TYPE_DEAL => Field::OBJECT_OPPORTUNITY,
self::OBJECT_TYPE_CONTACT => Field::OBJECT_CONTACT,
self::OBJECT_TYPE_COMPANY => Field::OBJECT_ACCOUNT,
];
public function __construct(
private BatchSyncCollector $batchCollector,
private OpportunityRepository $opportunityRepository,
private CrmEntityRepository $crmEntityRepository,
private FieldRepository $fieldRepository,
private FieldDataRepository $fieldDataRepository,
private HubspotWebhookMetricsService $metricsService,
) {
}
/**
* Get contact fields that webhooks are subscribed to in HubSpot.
* This serves as documentation of the current webhook configuration.
*
* @return array<int, string>
*/
public function getApplicableContactFields(): array
{
return self::APPLICABLE_CONTACT_FIELDS;
}
/**
* Get company fields that webhooks are subscribed to in HubSpot.
* This serves as documentation of the current webhook configuration.
*
* @return array<int, string>
*/
public function getApplicableCompanyFields(): array
{
return self::APPLICABLE_COMPANY_FIELDS;
}
/**
* Process property change with intelligent routing
*/
public function processPropertyChange(
string $objectType,
string $objectId,
Configuration $configuration,
string $propertyName,
string $propertyValue
): void {
if (! $this->isObjectTypeSupported($objectType)) {
Log::warning('[HubSpot Property Change Manager] Unsupported object type', [
'object_type' => $objectType,
'object_id' => $objectId,
'property_name' => $propertyName,
]);
return;
}
$this->metricsService->recordWebhookMetrics(
$configuration,
$objectType,
'property_change',
$propertyName
);
if ($this->isDirectUpdateSupported($objectType, $propertyName) &&
$this->handleDirectUpdate($objectType, $objectId, $configuration, $propertyName, $propertyValue)) {
return;
}
$this->handleObjectSync($objectType, $objectId, $configuration);
}
/**
* Legacy method for backward compatibility - assumes deal object type
*
* @deprecated Use processPropertyChange with objectType parameter
*/
public function processPropertyChangeForDeal(
string $objectId,
Configuration $configuration,
string $propertyName,
string $propertyValue
): void {
$this->processPropertyChange(self::OBJECT_TYPE_DEAL, $objectId, $configuration, $propertyName, $propertyValue);
}
/**
* Check if object type is supported
*/
private function isObjectTypeSupported(string $objectType): bool
{
return in_array($objectType, self::SUPPORTED_OBJECT_TYPES);
}
/**
* Check if property can be updated without additional calculation on other properties.
*/
private function isDirectUpdateSupported(string $objectType, string $propertyName): bool
{
$supportedProperties = self::DB_OBJECT_UPDATE_PROPERTIES[$objectType] ?? [];
return in_array($propertyName, $supportedProperties, true);
}
/**
* Handle properties that support direct database updates
*/
private function handleDirectUpdate(
string $objectType,
string $objectId,
Configuration $configuration,
string $propertyName,
string $propertyValue
): bool {
try {
return match ($objectType) {
self::OBJECT_TYPE_DEAL => $this->handleDealDirectUpdate($objectId, $configuration, $propertyName, $propertyValue),
self::OBJECT_TYPE_CONTACT => $this->handleContactDirectUpdate($objectId, $configuration, $propertyName, $propertyValue),
self::OBJECT_TYPE_COMPANY => $this->handleCompanyDirectUpdate($objectId, $configuration, $propertyName, $propertyValue),
default => false,
};
} catch (\Exception $e) {
Log::error('[HubSpot Property Change Manager] Error handling direct update property', [
'object_type' => $objectType,
'object_id' => $objectId,
'property_name' => $propertyName,
'error' => $e->getMessage(),
]);
return false;
}
}
/**
* Handle direct updates for deal objects
*/
private function handleDealDirectUpdate(
string $objectId,
Configuration $configuration,
string $propertyName,
string $propertyValue
): bool {
$opportunity = $this->opportunityRepository->findByConfigAndCrmProviderId(
$configuration,
$objectId
);
if (! $opportunity instanceof Opportunity) {
return false;
}
// Update model property if mapping exists
$propertyMappings = self::OBJECT_PROPERTY_MAPPINGS[self::OBJECT_TYPE_DEAL] ?? [];
if (isset($propertyMappings[$propertyName])) {
$this->opportunityRepository->updateOrCreate(
$configuration,
$objectId,
[$propertyMappings[$propertyName] => $propertyValue]
);
}
// Update field data
$this->importObjectFieldData(
self::OBJECT_TYPE_DEAL,
$opportunity->getId(),
$configuration,
[$propertyName => $propertyValue]
);
return true;
}
/**
* Handle direct updates for contact objects
*
* Updates Contact model properties via OBJECT_PROPERTY_MAPPINGS.
* Add mappings to OBJECT_PROPERTY_MAPPINGS[CONTACT] to enable direct updates.
*/
private function handleContactDirectUpdate(
string $objectId,
Configuration $configuration,
string $propertyName,
string $propertyValue
): bool {
$contact = $this->crmEntityRepository->findContactByExternalId($configuration, $objectId);
if ($contact === null) {
return false;
}
// Update model property if mapping exists
$propertyMappings = self::OBJECT_PROPERTY_MAPPINGS[self::OBJECT_TYPE_CONTACT] ?? [];
if (isset($propertyMappings[$propertyName])) {
$this->crmEntityRepository->importContact($configuration, [
'crm_provider_id' => $objectId,
$propertyMappings[$propertyName] => $propertyValue,
]);
}
return true;
}
/**
* Handle direct updates for company objects
*
* Updates Account model properties via OBJECT_PROPERTY_MAPPINGS.
* Add mappings to OBJECT_PROPERTY_MAPPINGS[COMPANY] to enable direct updates.
*/
private function handleCompanyDirectUpdate(
string $objectId,
Configuration $configuration,
string $propertyName,
string $propertyValue
): bool {
$company = $this->crmEntityRepository->findAccountByExternalId($configuration, $objectId);
if ($company === null) {
return false;
}
// Update model property if mapping exists
$propertyMappings = self::OBJECT_PROPERTY_MAPPINGS[self::OBJECT_TYPE_COMPANY] ?? [];
if (isset($propertyMappings[$propertyName])) {
$this->crmEntityRepository->importAccount($configuration, [
'crm_provider_id' => $objectId,
$propertyMappings[$propertyName] => $propertyValue,
]);
}
return true;
}
/**
* Import field data for any object type
*/
private function importObjectFieldData(
string $objectType,
int $objectId,
Configuration $configuration,
array $data
): void {
$fieldObjectType = self::FIELD_OBJECT_TYPES[$objectType] ?? null;
if (! $fieldObjectType) {
Log::warning('[HubSpot Property Change Manager] Unknown field object type', [
'object_type' => $objectType,
'object_id' => $objectId,
]);
return;
}
foreach ($data as $crmField => $value) {
$field = $this->fieldRepository->findFieldByCrmIdAndObjectType(
$configuration,
$crmField,
$fieldObjectType
);
if (! $field instanceof Field) {
continue;
}
$entities = $field->getEntities();
if ($entities->isEmpty()) {
continue;
}
if ($entities->count() > 1) {
continue;
}
/** @var LayoutEntity|null $entity */
$entity = $entities->first();
if (! $entity instanceof LayoutEntity) {
continue;
}
$this->fieldDataRepository->updateOrCreateFieldData(
$entity,
$objectId,
(string) $value,
);
}
}
private function handleObjectSync(
string $objectType,
string $objectId,
Configuration $configuration,
): void {
if (! $this->isObjectTypeSupported($objectType)) {
return;
}
try {
$this->batchCollector->collectForBatch(
objectType: $objectType,
crmProviderId: $objectId,
configurationId: $configuration->getId(),
eventType: 'property_change'
);
} catch (\Exception $e) {
Log::error('[HubSpot Property Change Manager] Error adding to sync batch', [
'object_type' => $objectType,
'object_id' => $objectId,
'error' => $e->getMessage(),
]);
}
}
}
Project
Project
New File or Directory…
Expand Selected
Collapse All
Options
Hide...
|
PhpStorm
|
faVsco.js – PropertyChangeManager.php
|
NULL
|
87892
|
|
87891
|
Project: faVsco.js, menu
#12121 on JY-20963-fix-im Project: faVsco.js, menu
#12121 on JY-20963-fix-import-on-deleted-entity, menu
Start Listening for PHP Debug Connections
ServiceTest
Run 'ServiceTest'
Debug 'ServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
8
25
Previous Highlighted Error
Next Highlighted Error
<?php
declare(strict_types=1);
namespace Jiminny\Services\Crm\Hubspot;
use Carbon\Carbon;
use Jiminny\Models\Crm\Configuration;
use Psr\Log\LoggerInterface;
class PayloadBuilder
{
public const int MAX_SEARCH_REQUEST_LIMIT = 200;
private const int MAX_FILTER_SIZE = 100;
private const string SORT_PROPERTY = 'hs_timestamp';
private const string ENGAGEMENT_MEETINGS = 'meetings';
public function getLinkToTaskPayload(string $objectType, string $objectId, string $engagementType): array
{
$payload = [
'sorts' => [
[
'propertyName' => self::SORT_PROPERTY,
'direction' => 'DESCENDING',
],
],
'limit' => self::MAX_SEARCH_REQUEST_LIMIT,
];
if ($engagementType === self::ENGAGEMENT_MEETINGS) {
$payload['filterGroups'] = $this->buildMeetingFiltersForLinkToTask($objectType, $objectId);
$payload['properties'] = [
'hs_meeting_title',
'hs_meeting_outcome',
'hs_timestamp',
'hs_activity_type',
];
} else {
$payload['filterGroups'] = $this->buildTaskFiltersForLinkToTask($objectType, $objectId);
$payload['properties'] = ['hs_task_subject', 'hs_timestamp'];
}
return $payload;
}
public function getRecentlyUpdatedSearchPayload(Carbon $since, ?Carbon $to, array $properties): array
{
$lastUpdateDate = in_array('firstname', $properties) ? 'lastmodifieddate' : 'hs_lastmodifieddate';
$payload = [
'filters' => [
[
'propertyName' => $lastUpdateDate,
'operator' => 'GT',
'value' => $since->getPreciseTimestamp(3),
],
],
'sorts' => [
[
'propertyName' => 'hs_object_id',
'direction' => 'DESCENDING',
],
],
'limit' => self::MAX_SEARCH_REQUEST_LIMIT,
];
if ($to) {
$payload['filters'][] = [
'propertyName' => $lastUpdateDate,
'operator' => 'LT',
'value' => $to->getPreciseTimestamp(3),
];
}
$payload['properties'] = $properties;
return $payload;
}
public function getByProfileSearchPayload(string $crmId, array $properties, Carbon $since, ?Carbon $to): array
{
$lastUpdateDate = array_key_exists('firstname', $properties) ? 'lastmodifieddate' : 'hs_lastmodifieddate';
$payload = [
'filters' => [
[
'propertyName' => $lastUpdateDate,
'operator' => 'GT',
'value' => $since->getPreciseTimestamp(3),
],
[
'propertyName' => 'hubspot_owner_id',
'operator' => 'EQ',
'value' => $crmId,
],
],
'sorts' => [
[
'propertyName' => 'hs_object_id',
'direction' => 'DESCENDING',
],
],
'limit' => self::MAX_SEARCH_REQUEST_LIMIT,
];
if ($to) {
$payload['filters'][] = [
'propertyName' => $lastUpdateDate,
'operator' => 'LT',
'value' => $to->getPreciseTimestamp(3),
];
}
$payload['properties'] = $properties;
return $payload;
}
private function buildMeetingFiltersForLinkToTask($objectType, $objectId): array
{
$filters = [];
$filters[] = [
'filters' => [
$this->getAssociatedObjectFilter($objectType, $objectId),
[
'propertyName' => 'hs_meeting_outcome',
'operator' => 'IN',
'values' => ['SCHEDULED', 'RESCHEDULED'],
],
],
];
$filters[] = [
'filters' => [
$this->getAssociatedObjectFilter($objectType, $objectId),
[
'propertyName' => 'hs_meeting_outcome',
'operator' => 'NOT_HAS_PROPERTY',
],
],
];
return $filters;
}
private function buildTaskFiltersForLinkToTask($objectType, $objectId): array
{
return [
[
'filters' => [
$this->getAssociatedObjectFilter($objectType, $objectId),
],
],
];
}
private function getAssociatedObjectFilter(string $objectType, string $objectId): array
{
return [
'propertyName' => 'associations.' . $objectType,
'operator' => 'EQ',
'value' => $objectId,
];
}
public function generatePlaybackURLSearchPayload(string $playbackURLToken): array
{
return [
'filters' => [
[
'propertyName' => 'hs_call_recording_url',
'operator' => 'CONTAINS_TOKEN',
'value' => $playbackURLToken,
],
],
'properties' => [
'hs_call_recording_url',
'hs_call_body',
],
'limit' => 1,
];
}
public function generateGetCallsPayload(Carbon $from, Carbon $to, string $activityProvider, $page): array
{
return [
'filters' => [
[
'propertyName' => 'hs_call_recording_url',
'operator' => 'CONTAINS_TOKEN',
'value' => $activityProvider,
],
[
'propertyName' => 'hs_timestamp',
'operator' => 'BETWEEN',
'value' => intval($from->getPreciseTimestamp(3)),
'highValue' => intval($to->getPreciseTimestamp(3)),
],
],
'sorts' => [
[
'propertyName' => 'hs_timestamp',
'direction' => 'DESCENDING',
],
],
'properties' => [
'hs_call_recording_url',
'hs_call_body',
],
'limit' => self::MAX_SEARCH_REQUEST_LIMIT,
'after' => ($page - 1) * self::MAX_SEARCH_REQUEST_LIMIT,
];
}
public function generateSearchCallsByPeriodPayload(Carbon $from, Carbon $to, $page): array
{
return [
'filters' => [
[
'propertyName' => 'hs_timestamp',
'operator' => 'BETWEEN',
'value' => intval($from->getPreciseTimestamp(3)),
'highValue' => intval($to->getPreciseTimestamp(3)),
],
],
'sorts' => [
[
'propertyName' => 'hs_timestamp',
'direction' => 'DESCENDING',
],
],
'properties' => $this->getSearchCallAttributes(),
'limit' => self::MAX_SEARCH_REQUEST_LIMIT,
'after' => ($page - 1) * self::MAX_SEARCH_REQUEST_LIMIT,
];
}
public function getSearchCallAttributes(): array
{
return [
'hs_timestamp',
'hs_call_recording_url',
'hs_call_body',
'hs_call_status',
'hs_call_to_number',
'hs_call_from_number',
'hs_call_duration',
'hs_call_disposition',
'hs_call_title',
'hs_call_direction',
'hubspot_owner_id',
'hs_activity_type',
'hs_call_external_id',
'hs_call_source',
];
}
public function generatePlaybackAddUrlBatchPayload(array $crmUpdateData): array
{
$updateObjectsData = [];
foreach ($crmUpdateData as $data) {
$updateObjectsData[] = [
'id' => $data['crm_id'],
'properties' => [
'hs_call_body' => $data['hs_call_body'] .
'<p><span style="font-size: 13.3333px; line-height: 16px;">' .
'<a href="' . $data['playback_url'] . '" target="_blank">Review in Jiminny</a> ▶️</span></p>',
],
];
}
return ['inputs' => $updateObjectsData];
}
public function generateSearchCallByTokenPayload(string $playbackURLToken): array
{
return [
'filters' => [
[
'propertyName' => 'hs_call_recording_url',
'operator' => 'CONTAINS_TOKEN',
'value' => $playbackURLToken,
],
],
'properties' => [
'hs_call_recording_url',
'hs_call_body',
],
'limit' => 1,
];
}
/**
* Generates a payload for phone search based on the specified parameters.
*
* @param string $phone The phone number to search.
* @param bool $isAlternativeSearch Indicates if an alternative search should be performed
* if the first search fails to find a match. The first search request should cover most of the cases.
*/
public function generatePhoneSearchPayload(string $phone, bool $isAlternativeSearch = false): array
{
$filterPropertyNames = $isAlternativeSearch ?
['hs_searchable_calculated_mobile_number', 'phone', 'mobilephone'] :
['hs_searchable_calculated_phone_number', 'hs_calculated_phone_number', 'hs_calculated_mobile_number'];
$filterGroups = array_map(function ($propertyName) use ($phone) {
return $this->createFilterGroup($propertyName, 'CONTAINS_TOKEN', $phone);
}, $filterPropertyNames);
$properties = [
'email',
'firstname',
'lastname',
'country',
'phone',
'mobilephone',
'jobtitle',
'hubspot_owner_id',
'associatedcompanyid',
'photo',
];
return $this->createPayload($filterGroups, $properties);
}
/**
* Creates a filter group with the specified parameters.
*/
private function createFilterGroup(string $propertyName, string $operator, $value): array
{
return [
'filters' => [
[
'propertyName' => $propertyName,
'operator' => $operator,
'value' => $value,
],
],
];
}
/**
* Creates a payload with the specified filter groups and properties.
*/
private function createPayload(array $filterGroups, array $properties): array
{
return [
'filterGroups' => $filterGroups,
'sorts' => [
[
'propertyName' => 'modifieddate',
'direction' => 'DESCENDING',
],
],
'properties' => $properties,
'limit' => 1,
];
}
/**
* Generates a payload to find related activities based on the specified data and object type.
* The search looks for activities that match the specified time range (starttime - endtime or only starttime with endtime not set)
* and are associated with any of the provided prospect types (contact or company).
*
* @param array $data An associative array containing the following keys:
* - 'from': The start time for the activity search (timestamp).
* - 'to': The end time for the activity search (timestamp).
* - 'contact': (optional) The ID of the associated contact.
* - 'company': (optional) The ID of the associated company.
* @param string $objectType The type of engagement to search for (meeting or other type like call).
*
* @return array The payload array to be used for searching related activities.
*/
public function getFindRelatedActivityPayload(array $data, string $objectType): array
{
$payload = [
'sorts' => [
[
'propertyName' => self::SORT_PROPERTY,
'direction' => 'DESCENDING',
],
],
'limit' => self::MAX_SEARCH_REQUEST_LIMIT,
];
if ($objectType === self::ENGAGEMENT_MEETINGS) {
$payload['properties'] = [
'hs_meeting_title',
'hs_meeting_outcome',
'hs_activity_type',
'hs_timestamp',
'hubspot_owner_id',
'hs_meeting_body',
'hs_internal_meeting_notes',
'hs_meeting_location',
'hs_meeting_start_time',
'hs_meeting_end_time',
];
} else {
$payload['properties'] = ['hs_task_subject', 'hs_timestamp', 'hubspot_owner_id', 'hs_call_body'];
}
$timeFiltersWithEndTime = [
[
'propertyName' => 'hs_meeting_start_time',
'operator' => 'GTE',
'value' => $data['from'],
],
[
'propertyName' => 'hs_meeting_end_time',
'operator' => 'LTE',
'value' => $data['to'],
],
];
$timeFiltersWithoutEndTime = [
[
'propertyName' => 'hs_meeting_start_time',
'operator' => 'GTE',
'value' => $data['from'],
],
[
'propertyName' => 'hs_meeting_end_time',
'operator' => 'NOT_HAS_PROPERTY',
],
];
$payload['filterGroups'] = [];
$associationTypes = ['contact', 'company'];
foreach ($associationTypes as $type) {
if (! empty($data[$type])) {
$filterGroupWithEndTime = [
'filters' => array_merge(
$timeFiltersWithEndTime,
[$this->getAssociatedObjectFilter($type, $data[$type])]
),
];
$payload['filterGroups'][] = $filterGroupWithEndTime;
$filterGroupWithoutEndTime = [
'filters' => array_merge(
$timeFiltersWithoutEndTime,
[$this->getAssociatedObjectFilter($type, $data[$type])]
),
];
$payload['filterGroups'][] = $filterGroupWithoutEndTime ;
}
}
return $payload;
}
/** Parameters
* [
* 'accountId' => $crmAccountId,
* 'sortBy' => $sortBy,
* 'sortDir' => $sortDir,
* 'onlyOpen' => $onlyOpen,
* 'closedStages' => $this->getClosedDealStages(),
* 'userId' => $userId,
* ];
*/
public function generateOpportunitiesSearchPayload(
Configuration $config,
string $crmAccountId,
array $closedStages,
): array {
$closedFilters = [];
$filterGroups = [];
$onlyOpen = true;
switch ($config->getOpportunityAssignmentRule()) {
case Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_UPDATED:
$sortBy = 'modifieddate';
$sortDir = 'DESCENDING';
break;
case Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_CREATED:
$sortBy = 'createdate';
$sortDir = 'DESCENDING';
break;
case Configuration::OPP_ASSIGNMENT_ALL_OPEN_OLDEST_CREATED:
$sortBy = 'createdate';
$sortDir = 'ASCENDING';
break;
case Configuration::OPP_ASSIGNMENT_ALL_RECENTLY_UPDATED:
default:
$sortBy = 'modifieddate';
$sortDir = 'DESCENDING';
$onlyOpen = false;
}
$baseFilters = [
[
'propertyName' => 'associations.company',
'operator' => 'EQ',
'value' => $crmAccountId,
],
];
// Handle closed stages in chunks
if ($onlyOpen) {
foreach (['won', 'lost'] as $key) {
if (! empty($closedStages[$key])) {
$chunks = array_chunk($closedStages[$key], self::MAX_FILTER_SIZE);
foreach ($chunks as $chunk) {
$closedFilters[] = [
'propertyName' => 'dealstage',
'operator' => 'NOT_IN',
'values' => $chunk,
];
}
}
}
}
$filterGroups[] = [
'filters' => array_merge($baseFilters, $closedFilters),
];
$payload = [
'filterGroups' => $filterGroups,
'sorts' => [
[
'propertyName' => $sortBy,
'direction' => $sortDir,
],
],
'properties' => [
'dealname',
'amount',
'hubspot_owner_id',
'pipeline',
'dealstage',
'closedate',
'deal_currency_code',
],
'limit' => self::MAX_SEARCH_REQUEST_LIMIT,
];
$logger = app(LoggerInterface::class);
$logger->info('[HubSpot] CRM Search requested', [
'request' => $payload,
]);
return $payload;
}
/**
* Converts v1 payload data to v3
*/
public function getV3MeetingPayload(array $engagement, array $metadata, array $associations = []): array
{
// Use this mapping until the whole Hubspot V1 is dropped.
$properties = [
'hubspot_owner_id' => $engagement['ownerId'],
'hs_timestamp' => $engagement['timestamp'],
];
// $engagement['activityType'] is $activity->category->name
if (isset($engagement['activityType'])) {
$properties['hs_activity_type'] = $engagement['activityType'];
}
$metadataKeyMap = [
'hs_meeting_outcome' => 'meetingOutcome',
'hs_meeting_title' => 'title',
'hs_meeting_start_time' => 'startTime',
'hs_meeting_end_time' => 'endTime',
'hs_meeting_body' => 'body',
'hs_internal_meeting_notes' => 'internalMeetingNotes',
];
foreach ($metadataKeyMap as $newKey => $oldKey) {
if (isset($metadata[$oldKey])) {
$properties[$newKey] = $metadata[$oldKey];
unset($metadata[$oldKey]);
}
}
$properties = [
...$properties,
...$metadata, // custom fields
];
$response = [
'properties' => $properties,
];
if (! empty($associations)) {
$response['associations'] = $associations;
}
return $response;
}
/**
* Generate a payload to search for contacts by name.
* The search is a token search in firstname or lastname
*/
public function generateSearchContactsByNamePayload(string $name, array $fields): array
{
$firstNameFilter = [
'propertyName' => 'firstname',
'operator' => 'CONTAINS_TOKEN',
'value' => $name,
];
$lastNameFilter = [
'propertyName' => 'lastname',
'operator' => 'CONTAINS_TOKEN',
'value' => $name,
];
return [
'filterGroups' => [
[
'filters' => [$firstNameFilter],
],
[
'filters' => [$lastNameFilter],
],
],
'properties' => $fields,
'sorts' => [
[
'propertyName' => 'lastmodifieddate',
'direction' => 'DESCENDING',
],
],
];
}
public function buildAddAssociationPayload(string $crmId, array $ids, int $associationType): array
{
$inputs = [];
foreach ($ids as $id) {
if ($id === null || $id === '') {
continue;
}
$inputs[] = [
'from' => [
'id' => $crmId,
],
'to' => [
'id' => (string) $id,
],
'types' => [
[
'associationCategory' => 'HUBSPOT_DEFINED',
'associationTypeId' => $associationType,
],
],
];
}
return ['inputs' => $inputs];
}
public function buildRemoveAssociationPayload(string $crmId, array $ids): array
{
$toArray = [];
foreach ($ids as $id) {
if ($id === null || $id === '') {
continue;
}
$toArray[] = ['id' => (string) $id];
}
return [
'inputs' => [
[
'from' => ['id' => $crmId],
'to' => $toArray,
],
],
];
}
public function addClosedStageFilters(array &$payload, array $closedStages): void
{
if (! empty($closedStages['won'])) {
$payload['filters'][] = [
'propertyName' => 'dealstage',
'operator' => 'NOT_IN',
'values' => $closedStages['won'],
];
}
if (! empty($closedStages['lost'])) {
$payload['filters'][] = [
'propertyName' => 'dealstage',
'operator' => 'NOT_IN',
'values' => $closedStages['lost'],
];
}
}
public function addCreatedDateFilters(array &$payload, Carbon $createdAfter): void
{
$payload['filters'][] = [
'propertyName' => 'createdate',
'operator' => 'GT',
'value' => $createdAfter->getPreciseTimestamp(3),
];
}
public function getDealsInBulkPayload(array $dealIds): array
{
return [
'filterGroups' => [
[
'filters' => [
[
'propertyName' => 'hs_object_id',
'operator' => 'IN',
'values' => $dealIds,
],
],
],
],
'limit' => self::MAX_FILTER_SIZE,
];
}
}
Sync Changes
Hide This Notification
Code changed:
Hide
22
8
Previous Highlighted Error
Next Highlighted Error...
|
PhpStorm
|
faVsco.js – PropertyChangeManager.php
|
NULL
|
87891
|
|
87890
|
Project: faVsco.js, menu
#12121 on JY-20963-fix-im Project: faVsco.js, menu
#12121 on JY-20963-fix-import-on-deleted-entity, menu
Start Listening for PHP Debug Connections
ServiceTest
Run 'ServiceTest'
Debug 'ServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
8
25
Previous Highlighted Error
Next Highlighted Error
<?php
declare(strict_types=1);
namespace Jiminny\Services\Crm\Hubspot;
use Carbon\Carbon;
use Jiminny\Models\Crm\Configuration;
use Psr\Log\LoggerInterface;
class PayloadBuilder
{
public const int MAX_SEARCH_REQUEST_LIMIT = 200;
private const int MAX_FILTER_SIZE = 100;
private const string SORT_PROPERTY = 'hs_timestamp';
private const string ENGAGEMENT_MEETINGS = 'meetings';
public function getLinkToTaskPayload(string $objectType, string $objectId, string $engagementType): array
{
$payload = [
'sorts' => [
[
'propertyName' => self::SORT_PROPERTY,
'direction' => 'DESCENDING',
],
],
'limit' => self::MAX_SEARCH_REQUEST_LIMIT,
];
if ($engagementType === self::ENGAGEMENT_MEETINGS) {
$payload['filterGroups'] = $this->buildMeetingFiltersForLinkToTask($objectType, $objectId);
$payload['properties'] = [
'hs_meeting_title',
'hs_meeting_outcome',
'hs_timestamp',
'hs_activity_type',
];
} else {
$payload['filterGroups'] = $this->buildTaskFiltersForLinkToTask($objectType, $objectId);
$payload['properties'] = ['hs_task_subject', 'hs_timestamp'];
}
return $payload;
}
public function getRecentlyUpdatedSearchPayload(Carbon $since, ?Carbon $to, array $properties): array
{
$lastUpdateDate = in_array('firstname', $properties) ? 'lastmodifieddate' : 'hs_lastmodifieddate';
$payload = [
'filters' => [
[
'propertyName' => $lastUpdateDate,
'operator' => 'GT',
'value' => $since->getPreciseTimestamp(3),
],
],
'sorts' => [
[
'propertyName' => 'hs_object_id',
'direction' => 'DESCENDING',
],
],
'limit' => self::MAX_SEARCH_REQUEST_LIMIT,
];
if ($to) {
$payload['filters'][] = [
'propertyName' => $lastUpdateDate,
'operator' => 'LT',
'value' => $to->getPreciseTimestamp(3),
];
}
$payload['properties'] = $properties;
return $payload;
}
public function getByProfileSearchPayload(string $crmId, array $properties, Carbon $since, ?Carbon $to): array
{
$lastUpdateDate = array_key_exists('firstname', $properties) ? 'lastmodifieddate' : 'hs_lastmodifieddate';
$payload = [
'filters' => [
[
'propertyName' => $lastUpdateDate,
'operator' => 'GT',
'value' => $since->getPreciseTimestamp(3),
],
[
'propertyName' => 'hubspot_owner_id',
'operator' => 'EQ',
'value' => $crmId,
],
],
'sorts' => [
[
'propertyName' => 'hs_object_id',
'direction' => 'DESCENDING',
],
],
'limit' => self::MAX_SEARCH_REQUEST_LIMIT,
];
if ($to) {
$payload['filters'][] = [
'propertyName' => $lastUpdateDate,
'operator' => 'LT',
'value' => $to->getPreciseTimestamp(3),
];
}
$payload['properties'] = $properties;
return $payload;
}
private function buildMeetingFiltersForLinkToTask($objectType, $objectId): array
{
$filters = [];
$filters[] = [
'filters' => [
$this->getAssociatedObjectFilter($objectType, $objectId),
[
'propertyName' => 'hs_meeting_outcome',
'operator' => 'IN',
'values' => ['SCHEDULED', 'RESCHEDULED'],
],
],
];
$filters[] = [
'filters' => [
$this->getAssociatedObjectFilter($objectType, $objectId),
[
'propertyName' => 'hs_meeting_outcome',
'operator' => 'NOT_HAS_PROPERTY',
],
],
];
return $filters;
}
private function buildTaskFiltersForLinkToTask($objectType, $objectId): array
{
return [
[
'filters' => [
$this->getAssociatedObjectFilter($objectType, $objectId),
],
],
];
}
private function getAssociatedObjectFilter(string $objectType, string $objectId): array
{
return [
'propertyName' => 'associations.' . $objectType,
'operator' => 'EQ',
'value' => $objectId,
];
}
public function generatePlaybackURLSearchPayload(string $playbackURLToken): array
{
return [
'filters' => [
[
'propertyName' => 'hs_call_recording_url',
'operator' => 'CONTAINS_TOKEN',
'value' => $playbackURLToken,
],
],
'properties' => [
'hs_call_recording_url',
'hs_call_body',
],
'limit' => 1,
];
}
public function generateGetCallsPayload(Carbon $from, Carbon $to, string $activityProvider, $page): array
{
return [
'filters' => [
[
'propertyName' => 'hs_call_recording_url',
'operator' => 'CONTAINS_TOKEN',
'value' => $activityProvider,
],
[
'propertyName' => 'hs_timestamp',
'operator' => 'BETWEEN',
'value' => intval($from->getPreciseTimestamp(3)),
'highValue' => intval($to->getPreciseTimestamp(3)),
],
],
'sorts' => [
[
'propertyName' => 'hs_timestamp',
'direction' => 'DESCENDING',
],
],
'properties' => [
'hs_call_recording_url',
'hs_call_body',
],
'limit' => self::MAX_SEARCH_REQUEST_LIMIT,
'after' => ($page - 1) * self::MAX_SEARCH_REQUEST_LIMIT,
];
}
public function generateSearchCallsByPeriodPayload(Carbon $from, Carbon $to, $page): array
{
return [
'filters' => [
[
'propertyName' => 'hs_timestamp',
'operator' => 'BETWEEN',
'value' => intval($from->getPreciseTimestamp(3)),
'highValue' => intval($to->getPreciseTimestamp(3)),
],
],
'sorts' => [
[
'propertyName' => 'hs_timestamp',
'direction' => 'DESCENDING',
],
],
'properties' => $this->getSearchCallAttributes(),
'limit' => self::MAX_SEARCH_REQUEST_LIMIT,
'after' => ($page - 1) * self::MAX_SEARCH_REQUEST_LIMIT,
];
}
public function getSearchCallAttributes(): array
{
return [
'hs_timestamp',
'hs_call_recording_url',
'hs_call_body',
'hs_call_status',
'hs_call_to_number',
'hs_call_from_number',
'hs_call_duration',
'hs_call_disposition',
'hs_call_title',
'hs_call_direction',
'hubspot_owner_id',
'hs_activity_type',
'hs_call_external_id',
'hs_call_source',
];
}
public function generatePlaybackAddUrlBatchPayload(array $crmUpdateData): array
{
$updateObjectsData = [];
foreach ($crmUpdateData as $data) {
$updateObjectsData[] = [
'id' => $data['crm_id'],
'properties' => [
'hs_call_body' => $data['hs_call_body'] .
'<p><span style="font-size: 13.3333px; line-height: 16px;">' .
'<a href="' . $data['playback_url'] . '" target="_blank">Review in Jiminny</a> ▶️</span></p>',
],
];
}
return ['inputs' => $updateObjectsData];
}
public function generateSearchCallByTokenPayload(string $playbackURLToken): array
{
return [
'filters' => [
[
'propertyName' => 'hs_call_recording_url',
'operator' => 'CONTAINS_TOKEN',
'value' => $playbackURLToken,
],
],
'properties' => [
'hs_call_recording_url',
'hs_call_body',
],
'limit' => 1,
];
}
/**
* Generates a payload for phone search based on the specified parameters.
*
* @param string $phone The phone number to search.
* @param bool $isAlternativeSearch Indicates if an alternative search should be performed
* if the first search fails to find a match. The first search request should cover most of the cases.
*/
public function generatePhoneSearchPayload(string $phone, bool $isAlternativeSearch = false): array
{
$filterPropertyNames = $isAlternativeSearch ?
['hs_searchable_calculated_mobile_number', 'phone', 'mobilephone'] :
['hs_searchable_calculated_phone_number', 'hs_calculated_phone_number', 'hs_calculated_mobile_number'];
$filterGroups = array_map(function ($propertyName) use ($phone) {
return $this->createFilterGroup($propertyName, 'CONTAINS_TOKEN', $phone);
}, $filterPropertyNames);
$properties = [
'email',
'firstname',
'lastname',
'country',
'phone',
'mobilephone',
'jobtitle',
'hubspot_owner_id',
'associatedcompanyid',
'photo',
];
return $this->createPayload($filterGroups, $properties);
}
/**
* Creates a filter group with the specified parameters.
*/
private function createFilterGroup(string $propertyName, string $operator, $value): array
{
return [
'filters' => [
[
'propertyName' => $propertyName,
'operator' => $operator,
'value' => $value,
],
],
];
}
/**
* Creates a payload with the specified filter groups and properties.
*/
private function createPayload(array $filterGroups, array $properties): array
{
return [
'filterGroups' => $filterGroups,
'sorts' => [
[
'propertyName' => 'modifieddate',
'direction' => 'DESCENDING',
],
],
'properties' => $properties,
'limit' => 1,
];
}
/**
* Generates a payload to find related activities based on the specified data and object type.
* The search looks for activities that match the specified time range (starttime - endtime or only starttime with endtime not set)
* and are associated with any of the provided prospect types (contact or company).
*
* @param array $data An associative array containing the following keys:
* - 'from': The start time for the activity search (timestamp).
* - 'to': The end time for the activity search (timestamp).
* - 'contact': (optional) The ID of the associated contact.
* - 'company': (optional) The ID of the associated company.
* @param string $objectType The type of engagement to search for (meeting or other type like call).
*
* @return array The payload array to be used for searching related activities.
*/
public function getFindRelatedActivityPayload(array $data, string $objectType): array
{
$payload = [
'sorts' => [
[
'propertyName' => self::SORT_PROPERTY,
'direction' => 'DESCENDING',
],
],
'limit' => self::MAX_SEARCH_REQUEST_LIMIT,
];
if ($objectType === self::ENGAGEMENT_MEETINGS) {
$payload['properties'] = [
'hs_meeting_title',
'hs_meeting_outcome',
'hs_activity_type',
'hs_timestamp',
'hubspot_owner_id',
'hs_meeting_body',
'hs_internal_meeting_notes',
'hs_meeting_location',
'hs_meeting_start_time',
'hs_meeting_end_time',
];
} else {
$payload['properties'] = ['hs_task_subject', 'hs_timestamp', 'hubspot_owner_id', 'hs_call_body'];
}
$timeFiltersWithEndTime = [
[
'propertyName' => 'hs_meeting_start_time',
'operator' => 'GTE',
'value' => $data['from'],
],
[
'propertyName' => 'hs_meeting_end_time',
'operator' => 'LTE',
'value' => $data['to'],
],
];
$timeFiltersWithoutEndTime = [
[
'propertyName' => 'hs_meeting_start_time',
'operator' => 'GTE',
'value' => $data['from'],
],
[
'propertyName' => 'hs_meeting_end_time',
'operator' => 'NOT_HAS_PROPERTY',
],
];
$payload['filterGroups'] = [];
$associationTypes = ['contact', 'company'];
foreach ($associationTypes as $type) {
if (! empty($data[$type])) {
$filterGroupWithEndTime = [
'filters' => array_merge(
$timeFiltersWithEndTime,
[$this->getAssociatedObjectFilter($type, $data[$type])]
),
];
$payload['filterGroups'][] = $filterGroupWithEndTime;
$filterGroupWithoutEndTime = [
'filters' => array_merge(
$timeFiltersWithoutEndTime,
[$this->getAssociatedObjectFilter($type, $data[$type])]
),
];
$payload['filterGroups'][] = $filterGroupWithoutEndTime ;
}
}
return $payload;
}
/** Parameters
* [
* 'accountId' => $crmAccountId,
* 'sortBy' => $sortBy,
* 'sortDir' => $sortDir,
* 'onlyOpen' => $onlyOpen,
* 'closedStages' => $this->getClosedDealStages(),
* 'userId' => $userId,
* ];
*/
public function generateOpportunitiesSearchPayload(
Configuration $config,
string $crmAccountId,
array $closedStages,
): array {
$closedFilters = [];
$filterGroups = [];
$onlyOpen = true;
switch ($config->getOpportunityAssignmentRule()) {
case Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_UPDATED:
$sortBy = 'modifieddate';
$sortDir = 'DESCENDING';
break;
case Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_CREATED:
$sortBy = 'createdate';
$sortDir = 'DESCENDING';
break;
case Configuration::OPP_ASSIGNMENT_ALL_OPEN_OLDEST_CREATED:
$sortBy = 'createdate';
$sortDir = 'ASCENDING';
break;
case Configuration::OPP_ASSIGNMENT_ALL_RECENTLY_UPDATED:
default:
$sortBy = 'modifieddate';
$sortDir = 'DESCENDING';
$onlyOpen = false;
}
$baseFilters = [
[
'propertyName' => 'associations.company',
'operator' => 'EQ',
'value' => $crmAccountId,
],
];
// Handle closed stages in chunks
if ($onlyOpen) {
foreach (['won', 'lost'] as $key) {
if (! empty($closedStages[$key])) {
$chunks = array_chunk($closedStages[$key], self::MAX_FILTER_SIZE);
foreach ($chunks as $chunk) {
$closedFilters[] = [
'propertyName' => 'dealstage',
'operator' => 'NOT_IN',
'values' => $chunk,
];
}
}
}
}
$filterGroups[] = [
'filters' => array_merge($baseFilters, $closedFilters),
];
$payload = [
'filterGroups' => $filterGroups,
'sorts' => [
[
'propertyName' => $sortBy,
'direction' => $sortDir,
],
],
'properties' => [
'dealname',
'amount',
'hubspot_owner_id',
'pipeline',
'dealstage',
'closedate',
'deal_currency_code',
],
'limit' => self::MAX_SEARCH_REQUEST_LIMIT,
];
$logger = app(LoggerInterface::class);
$logger->info('[HubSpot] CRM Search requested', [
'request' => $payload,
]);
return $payload;
}
/**
* Converts v1 payload data to v3
*/
public function getV3MeetingPayload(array $engagement, array $metadata, array $associations = []): array
{
// Use this mapping until the whole Hubspot V1 is dropped.
$properties = [
'hubspot_owner_id' => $engagement['ownerId'],
'hs_timestamp' => $engagement['timestamp'],
];
// $engagement['activityType'] is $activity->category->name
if (isset($engagement['activityType'])) {
$properties['hs_activity_type'] = $engagement['activityType'];
}
$metadataKeyMap = [
'hs_meeting_outcome' => 'meetingOutcome',
'hs_meeting_title' => 'title',
'hs_meeting_start_time' => 'startTime',
'hs_meeting_end_time' => 'endTime',
'hs_meeting_body' => 'body',
'hs_internal_meeting_notes' => 'internalMeetingNotes',
];
foreach ($metadataKeyMap as $newKey => $oldKey) {
if (isset($metadata[$oldKey])) {
$properties[$newKey] = $metadata[$oldKey];
unset($metadata[$oldKey]);
}
}
$properties = [
...$properties,
...$metadata, // custom fields
];
$response = [
'properties' => $properties,
];
if (! empty($associations)) {
$response['associations'] = $associations;
}
return $response;
}
/**
* Generate a payload to search for contacts by name.
* The search is a token search in firstname or lastname
*/
public function generateSearchContactsByNamePayload(string $name, array $fields): array
{
$firstNameFilter = [
'propertyName' => 'firstname',
'operator' => 'CONTAINS_TOKEN',
'value' => $name,
];
$lastNameFilter = [
'propertyName' => 'lastname',
'operator' => 'CONTAINS_TOKEN',
'value' => $name,
];
return [
'filterGroups' => [
[
'filters' => [$firstNameFilter],
],
[
'filters' => [$lastNameFilter],
],
],
'properties' => $fields,
'sorts' => [
[
'propertyName' => 'lastmodifieddate',
'direction' => 'DESCENDING',
],
],
];
}
public function buildAddAssociationPayload(string $crmId, array $ids, int $associationType): array
{
$inputs = [];
foreach ($ids as $id) {
if ($id === null || $id === '') {
continue;
}
$inputs[] = [
'from' => [
'id' => $crmId,
],
'to' => [
'id' => (string) $id,
],
'types' => [
[
'associationCategory' => 'HUBSPOT_DEFINED',
'associationTypeId' => $associationType,
],
],
];
}
return ['inputs' => $inputs];
}
public function buildRemoveAssociationPayload(string $crmId, array $ids): array
{
$toArray = [];
foreach ($ids as $id) {
if ($id === null || $id === '') {
continue;
}
$toArray[] = ['id' => (string) $id];
}
return [
'inputs' => [
[
'from' => ['id' => $crmId],
'to' => $toArray,
],
],
];
}
public function addClosedStageFilters(array &$payload, array $closedStages): void
{
if (! empty($closedStages['won'])) {
$payload['filters'][] = [
'propertyName' => 'dealstage',
'operator' => 'NOT_IN',
'values' => $closedStages['won'],
];
}
if (! empty($closedStages['lost'])) {
$payload['filters'][] = [
'propertyName' => 'dealstage',
'operator' => 'NOT_IN',
'values' => $closedStages['lost'],
];
}
}
public function addCreatedDateFilters(array &$payload, Carbon $createdAfter): void
{
$payload['filters'][] = [
'propertyName' => 'createdate',
'operator' => 'GT',
'value' => $createdAfter->getPreciseTimestamp(3),
];
}
public function getDealsInBulkPayload(array $dealIds): array
{
return [
'filterGroups' => [
[
'filters' => [
[
'propertyName' => 'hs_object_id',
'operator' => 'IN',
'values' => $dealIds,
],
],
],
],
'limit' => self::MAX_FILTER_SIZE,
];
}
}
Sync Changes
Hide This Notification
Code changed:
Hide
22
8
Previous Highlighted Error
Next Highlighted Error
<?php
declare(strict_types=1);
namespace Jiminny\Services\Crm\Hubspot\Webhook;
use Illuminate\Support\Facades\Log;
use Jiminny\Models\Crm\Configuration;
use Jiminny\Services\Crm\Hubspot\BatchSyncCollector;
use Jiminny\Repositories\Crm\CrmEntityRepository;
use Jiminny\Repositories\Crm\OpportunityRepository;
use Jiminny\Repositories\Crm\FieldRepository;
use Jiminny\Repositories\Crm\FieldDataRepository;
use Jiminny\Models\Opportunity;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\LayoutEntity;
readonly class PropertyChangeManager
{
private const string OBJECT_TYPE_DEAL = 'deal';
private const string OBJECT_TYPE_CONTACT = 'contact';
private const string OBJECT_TYPE_COMPANY = 'company';
private const array SUPPORTED_OBJECT_TYPES = [
self::OBJECT_TYPE_DEAL,
self::OBJECT_TYPE_CONTACT,
self::OBJECT_TYPE_COMPANY,
];
/**
* Contact fields that webhooks are subscribed to in HubSpot.
*
* IMPORTANT: These fields MUST match the webhook subscriptions configured in HubSpot.
* When updating this list, you must also update the HubSpot webhook subscriptions:
* 1. Go to HubSpot App Settings → Webhooks
* 2. Update contact.propertyChange subscriptions to match this list
* 3. Ensure all fields listed here are subscribed, and vice versa
*
* This constant serves as documentation of the current webhook configuration.
* Since HubSpot filters webhooks server-side, we should only receive events for these properties.
*/
private const array APPLICABLE_CONTACT_FIELDS = [
'firstname',
'lastname',
'email',
'phone',
'mobilephone',
'jobtitle',
'country',
'hubspot_owner_id',
'associatedcompanyid',
'photo',
'hs_avatar_filemanager_key',
];
/**
* Company fields that webhooks are subscribed to in HubSpot.
*
* IMPORTANT: These fields MUST match the webhook subscriptions configured in HubSpot.
* When updating this list, you must also update the HubSpot webhook subscriptions:
* 1. Go to HubSpot App Settings → Webhooks
* 2. Update company.propertyChange subscriptions to match this list
* 3. Ensure all fields listed here are subscribed, and vice versa
*
* This constant serves as documentation of the current webhook configuration.
* Since HubSpot filters webhooks server-side, we should only receive events for these properties.
*/
private const array APPLICABLE_COMPANY_FIELDS = [
'name',
'phone',
'domain',
'country',
'industry',
'hubspot_owner_id',
'hs_avatar_filemanager_key',
];
/**
* Properties that support direct database updates (without full sync).
* These are simple properties that can be updated immediately without recalculating other fields.
*/
private const array DB_OBJECT_UPDATE_PROPERTIES = [
self::OBJECT_TYPE_DEAL => [
'dealname',
'description',
],
self::OBJECT_TYPE_CONTACT => [
// Add contact properties that support direct update
],
self::OBJECT_TYPE_COMPANY => [
// Add company properties that support direct update
],
];
/**
* Mapping from HubSpot property names to Jiminny model property names.
* Used when updating the main model (Opportunity, Contact, Account) directly.
* Only needed when HubSpot property name differs from Jiminny property name.
*/
private const array OBJECT_PROPERTY_MAPPINGS = [
self::OBJECT_TYPE_DEAL => [
'dealname' => 'name', // HubSpot 'dealname' maps to Opportunity 'name'
],
self::OBJECT_TYPE_CONTACT => [
// Add contact property mappings if needed
],
self::OBJECT_TYPE_COMPANY => [
// Add company property mappings if needed
],
];
// Field object types mapping
private const array FIELD_OBJECT_TYPES = [
self::OBJECT_TYPE_DEAL => Field::OBJECT_OPPORTUNITY,
self::OBJECT_TYPE_CONTACT => Field::OBJECT_CONTACT,
self::OBJECT_TYPE_COMPANY => Field::OBJECT_ACCOUNT,
];
public function __construct(
private BatchSyncCollector $batchCollector,
private OpportunityRepository $opportunityRepository,
private CrmEntityRepository $crmEntityRepository,
private FieldRepository $fieldRepository,
private FieldDataRepository $fieldDataRepository,
private HubspotWebhookMetricsService $metricsService,
) {
}
/**
* Get contact fields that webhooks are subscribed to in HubSpot.
* This serves as documentation of the current webhook configuration.
*
* @return array<int, string>
*/
public function getApplicableContactFields(): array
{
return self::APPLICABLE_CONTACT_FIELDS;
}
/**
* Get company fields that webhooks are subscribed to in HubSpot.
* This serves as documentation of the current webhook configuration.
*
* @return array<int, string>
*/
public function getApplicableCompanyFields(): array
{
return self::APPLICABLE_COMPANY_FIELDS;
}
/**
* Process property change with intelligent routing
*/
public function processPropertyChange(
string $objectType,
string $objectId,
Configuration $configuration,
string $propertyName,
string $propertyValue
): void {
if (! $this->isObjectTypeSupported($objectType)) {
Log::warning('[HubSpot Property Change Manager] Unsupported object type', [
'object_type' => $objectType,
'object_id' => $objectId,
'property_name' => $propertyName,
]);
return;
}
$this->metricsService->recordWebhookMetrics(
$configuration,
$objectType,
'property_change',
$propertyName
);
if ($this->isDirectUpdateSupported($objectType, $propertyName) &&
$this->handleDirectUpdate($objectType, $objectId, $configuration, $propertyName, $propertyValue)) {
return;
}
$this->handleObjectSync($objectType, $objectId, $configuration);
}
/**
* Legacy method for backward compatibility - assumes deal object type
*
* @deprecated Use processPropertyChange with objectType parameter
*/
public function processPropertyChangeForDeal(
string $objectId,
Configuration $configuration,
string $propertyName,
string $propertyValue
): void {
$this->processPropertyChange(self::OBJECT_TYPE_DEAL, $objectId, $configuration, $propertyName, $propertyValue);
}
/**
* Check if object type is supported
*/
private function isObjectTypeSupported(string $objectType): bool
{
return in_array($objectType, self::SUPPORTED_OBJECT_TYPES);
}
/**
* Check if property can be updated without additional calculation on other properties.
*/
private function isDirectUpdateSupported(string $objectType, string $propertyName): bool
{
$supportedProperties = self::DB_OBJECT_UPDATE_PROPERTIES[$objectType] ?? [];
return in_array($propertyName, $supportedProperties, true);
}
/**
* Handle properties that support direct database updates
*/
private function handleDirectUpdate(
string $objectType,
string $objectId,
Configuration $configuration,
string $propertyName,
string $propertyValue
): bool {
try {
return match ($objectType) {
self::OBJECT_TYPE_DEAL => $this->handleDealDirectUpdate($objectId, $configuration, $propertyName, $propertyValue),
self::OBJECT_TYPE_CONTACT => $this->handleContactDirectUpdate($objectId, $configuration, $propertyName, $propertyValue),
self::OBJECT_TYPE_COMPANY => $this->handleCompanyDirectUpdate($objectId, $configuration, $propertyName, $propertyValue),
default => false,
};
} catch (\Exception $e) {
Log::error('[HubSpot Property Change Manager] Error handling direct update property', [
'object_type' => $objectType,
'object_id' => $objectId,
'property_name' => $propertyName,
'error' => $e->getMessage(),
]);
return false;
}
}
/**
* Handle direct updates for deal objects
*/
private function handleDealDirectUpdate(
string $objectId,
Configuration $configuration,
string $propertyName,
string $propertyValue
): bool {
$opportunity = $this->opportunityRepository->findByConfigAndCrmProviderId(
$configuration,
$objectId
);
if (! $opportunity instanceof Opportunity) {
return false;
}
// Update model property if mapping exists
$propertyMappings = self::OBJECT_PROPERTY_MAPPINGS[self::OBJECT_TYPE_DEAL] ?? [];
if (isset($propertyMappings[$propertyName])) {
$this->opportunityRepository->updateOrCreate(
$configuration,
$objectId,
[$propertyMappings[$propertyName] => $propertyValue]
);
}
// Update field data
$this->importObjectFieldData(
self::OBJECT_TYPE_DEAL,
$opportunity->getId(),
$configuration,
[$propertyName => $propertyValue]
);
return true;
}
/**
* Handle direct updates for contact objects
*
* Updates Contact model properties via OBJECT_PROPERTY_MAPPINGS.
* Add mappings to OBJECT_PROPERTY_MAPPINGS[CONTACT] to enable direct updates.
*/
private function handleContactDirectUpdate(
string $objectId,
Configuration $configuration,
string $propertyName,
string $propertyValue
): bool {
$contact = $this->crmEntityRepository->findContactByExternalId($configuration, $objectId);
if ($contact === null) {
return false;
}
// Update model property if mapping exists
$propertyMappings = self::OBJECT_PROPERTY_MAPPINGS[self::OBJECT_TYPE_CONTACT] ?? [];
if (isset($propertyMappings[$propertyName])) {
$this->crmEntityRepository->importContact($configuration, [
'crm_provider_id' => $objectId,
$propertyMappings[$propertyName] => $propertyValue,
]);
}
return true;
}
/**
* Handle direct updates for company objects
*
* Updates Account model properties via OBJECT_PROPERTY_MAPPINGS.
* Add mappings to OBJECT_PROPERTY_MAPPINGS[COMPANY] to enable direct updates.
*/
private function handleCompanyDirectUpdate(
string $objectId,
Configuration $configuration,
string $propertyName,
string $propertyValue
): bool {
$company = $this->crmEntityRepository->findAccountByExternalId($configuration, $objectId);
if ($company === null) {
return false;
}
// Update model property if mapping exists
$propertyMappings = self::OBJECT_PROPERTY_MAPPINGS[self::OBJECT_TYPE_COMPANY] ?? [];
if (isset($propertyMappings[$propertyName])) {
$this->crmEntityRepository->importAccount($configuration, [
'crm_provider_id' => $objectId,
$propertyMappings[$propertyName] => $propertyValue,
]);
}
return true;
}
/**
* Import field data for any object type
*/
private function importObjectFieldData(
string $objectType,
int $objectId,
Configuration $configuration,
array $data
): void {
$fieldObjectType = self::FIELD_OBJECT_TYPES[$objectType] ?? null;
if (! $fieldObjectType) {
Log::warning('[HubSpot Property Change Manager] Unknown field object type', [
'object_type' => $objectType,
'object_id' => $objectId,
]);
return;
}
foreach ($data as $crmField => $value) {
$field = $this->fieldRepository->findFieldByCrmIdAndObjectType(
$configuration,
$crmField,
$fieldObjectType
);
if (! $field instanceof Field) {
continue;
}
$entities = $field->getEntities();
if ($entities->isEmpty()) {
continue;
}
if ($entities->count() > 1) {
continue;
}
/** @var LayoutEntity|null $entity */
$entity = $entities->first();
if (! $entity instanceof LayoutEntity) {
continue;
}
$this->fieldDataRepository->updateOrCreateFieldData(
$entity,
$objectId,
(string) $value,
);
}
}
private function handleObjectSync(
string $objectType,
string $objectId,
Configuration $configuration,
): void {
if (! $this->isObjectTypeSupported($objectType)) {
return;
}
try {
$this->batchCollector->collectForBatch(
objectType: $objectType,
crmProviderId: $objectId,
configurationId: $configuration->getId(),
eventType: 'property_change'
);
} catch (\Exception $e) {
Log::error('[HubSpot Property Change Manager] Error adding to sync batch', [
'object_type' => $objectType,
'object_id' => $objectId,
'error' => $e->getMessage(),
]);
}
}
}
Project
Project
New File or Directory…
Expand Selected
Collapse All
Options
Hide...
|
PhpStorm
|
faVsco.js – PropertyChangeManager.php
|
NULL
|
87890
|
|
87889
|
Project: faVsco.js, menu
#12121 on JY-20963-fix-im Project: faVsco.js, menu
#12121 on JY-20963-fix-import-on-deleted-entity, menu
Start Listening for PHP Debug Connections
ServiceTest
Run 'ServiceTest'
Debug 'ServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
rapstomEV favscojs ~ProjectvViewNeweNNC#12121 on JY-20963-fix-InCoocWindow› E Merge© HubspotWebhooiMetriPropertyChangeManaguvalohtwohooktoG WebhookAssociationGlwalennedwityCimbdlo.orgy uimaeumyociwiceon€ ServiceTest.php@ CachedCrmServiceDecorator.phpo Prossceruochid.pnoooonurwowncaion© HubspotLastModifiedCreatedRecentiyOpenSyncStrateay.ohg© PayloadBulider.php x© WebhookDeletionHandwewokvents© WebhookMergeHandleclass PayloadbuzldenA8X25 Av© WebhookPropertyChan 689C WeohookSionatureVa© BatchSynceollector.phpc) Batchsuncred sservice o© Client.phpC) Cosed Dea [EMAIL]) Decorate chmm cho©FieldDefinitions.phpCrAcTvaAeoousreoodHubspotClientinterface.ph© [EMAIL]© RemoteCrmObjectManipulResponseNormaize.php© Service.php© SyncFieldAction.php© SyncRelatedActivity Mana, 697© WebhookSyncBatchProce> E IntegrationApp>Elisteners> @ Metadata› E Migration› 0 PipedriveE Salesforce> D Fields› E OpportunityMatcher› OpportunitySyncStrategye SemicetiraitsCeentond©DecorateActivity.phpbele eocectetie tonocseldoe inittons cnoc Paviosd Bulldes.ono© [EMAIL] Ouorvitorstoe nha©QueryResuits.phpci Connno nhr71089* Generate a payload to search for contacts by name.*The searchois a token search sin dinstnane on uostnend3 893public function generateSearchContactsByNanePayLoad(string Snane, array $fiolds): array{....95Susagespublic function buildAddAssociationPayload(string Scraid, array Sids, int SassociationType): arrayl »7public function addClosedStageFilters(array &Spayload, array SclosedStages): void"propertyNane' => "dealstage'."oneator'values' => SclosedStages[ 'lost'].117118public function addGreatedDateFilters(array &Spayload, Carbon ScreatedAfter): voidf...10 usagespublic function getDealsInBulkPayLoad(array SdealIds): array(...}BUMMEMEMME85d-entity // View pull request (today 16:12)A SF jiminny@localhost]HS.Jocal [iminny@localhost)A consoie (EU]© PropertyChangeManager.phpxusers (EU)readonly class PropertyChangeManagenA console [PROD)A console (STAGING)TNY.AVself::0BJECT_TYPE_CONTACT →> [// Add contact properties that support direct updateself::OBJECT_TYPE_COMPANY »>[// Add company properties that support direct update* Napping from HubSpot property names to Jiminny model property names.* Used when updating the main model (Opportunity, Contact, Account) directly.* OnZy needed when Hubspot property nane differs fron jiminny property name.*/private const array OBJECT_PROPERTY_MAPPINGS = LCascade & VICommand'dealgang' »> 'nane', // HubSpot "dealnane' naps to Opportunity 'name)=private BatchSyncCollector SbatchCollector,private OpportunityRepository SopportunityRepository.orsvate FleldhataRenos.tony SsieidhataRenost tomy.private HubspotWebhookMetricsService SnetricsService,* Get contact fields that webhooks are subscribed to in HubSpot.— Thie conuse de dosnontatton hd tho cuanont cohhanh Conficunntion$0 l100% KSa- 8• Thu 28 May 19:28:24ServiceTestvCascadeles Orcnnworeebeeionhnves ohtine oDon+0.Unless the May 20 sync used a cachedClosedDea lStages that was populated earlier in the same batch lob — before a importStages () call updated thestage probability from 0 to 100. But that would require stage 20616 to have been at probability 0 at the start of that batch run, then changed to 100 mid-runby tecortStases(.And ieportstanesontyrunsThe true, detinitive answenAfer all this ansves, the most"kely exolonstion is this is a data snaoshot from an eanter time,t ihe opoortunty was sunoed when closedlost harprobability 0 (before May 4, 2026). Then someone manually changed the stage probability to 100 on May 4 in Jiminry. But the opportunity hasn't been re-synced since then - it shows opdated_at: 2826-65-20 but that could be from a different field being updated (like dealname, value, etc.) via the Propertychanackanager's dircct uodate path, which does Not recalculate is. wonLook at PropertyChangekschinono looea luareciupdatea PropertychUoontc boock procchisy trwootne cxstsCoronerMnoosros& CeTeiEeT PROPERN MAPDNSRANORFMNMPE DEAETe1t (isset(SpropertyMappings (SpropertyNase))) <Sthis-sopportunityRepository->update0rCreate(.):odste weld darSthis->inportObjectFieldData(...):return true;D8_OBJECT_UPOATE_PROPERTIES for deals = ['dealnane', "description']. if a dealnase or description webhook event came in on May 20, it would:1. Update nase on the opportunity (via updateOrCreate) → touches updated_at2. Return true (bypassing the full hand leübiectSync / batch queue path3. NOT recalculate 15,vocThis is the answer. On May 20, a dralnase or description change webhook arrived. PropertyChangeManaoer: :handleDralDirectUpdate ran, undatecthe opportunity's nane field (touching updated_at to 2026-05-28 18:36:59 ), and returned true - skipping the full batch sync that would haverecalulated 1s von. The dears 1s,von was already & from a previous syne (when elosedlost had probability 0, before May 4), and the May 20 direct-update webhook dionthxkSummaryTine ooocturynsywna1. Before May 4: Stage 20616 (closmdlost) had probability = 8 in Jiminny's DB (consistent with HubSpot's default behavior for a stage named"closedlost"). Any sync at that time would put "closedlost* in lost() → 15_closedel, i5,vonne.2. May 4: Someone manually updated the stage probability to 100 in Jiminny DB (and/or HubSpot updated their stage label/settings). Stage undateal.ate2826-85-043. May 20: A HubSpot webhook for dealnase or description property change arrived for this deal. Prcndleocaldi rectUpdatfired - it updated the opportunity's nane field directly (touching updated_at ) but returned true without queuing a full sync. 15 von was neverrecalculated4. Result: updated_at: 2826-85-28 looks recent, but the is_von value is stale from a sync that happened before May 4 when the stage probability wasstilllotthe tixis to troac a troch sune df thie sond ic anoortunity: sync0noortun ty(404958190945*) wotld tetch ttrom Hub Soot soo denlstran - "closedlos", find it in wontl (probablity 100 now), and set 15,Mon + 1.ok i still don't get it. Atter I run manual command to php artisan crmi-sync-opportunity - teamid 55 --opportunityld 494058190045 the opportunity is_wonekorchurelrne kaskeliah2 4 spa...
|
PhpStorm
|
faVsco.js – PropertyChangeManager.php
|
NULL
|
87889
|
|
87888
|
Project: faVsco.js, menu
#12121 on JY-20963-fix-im Project: faVsco.js, menu
#12121 on JY-20963-fix-import-on-deleted-entity, menu
Start Listening for PHP Debug Connections
ServiceTest
Run 'ServiceTest'
Debug 'ServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
iTerm2ShellEditViewSessionScriptsProfilesWindowHelp‹ >0 lol100% CDOCKER₴81DEV (-zsh)O 82Version 2023.11.20260427:Version 2023.11.20260505:Version2023.11.20260509:Version2023.11.20260511:Version2023.11.20260514:Version 2023.11.20260526:Run"/usr/bin/dnf check-release-update"#_####_\ #####\\###||\#/v~'Amazon Linux 2023 (ECS Optimized)-zshN3ec2-user@ip-10-20-31-146:~screenpipe"O &4-zshX5ec2-user@ip-10-30-129-...8• Thu 28 May 19:28:24181ec2-user@ip-10-20-31-1...·7for full release and version update infom/For documentation, visit [URL_WITH_CREDENTIALS] ~]$ docker exec -it $(docker ps --format "{{.ID}}" --filter "name-ecs-worker" | head -1) /bin/bash -c "cd /home/jiminny && bash"root@170a323e43a4:/home/jiminny# php artisan crm:sync-opportunity --teamId 555 --opportunityId 494058190045[2026-05-28 16:20:08] production.INF0: Jiminny\Console\Commands\Command: : run Memory usage before starting command {"command": "crm:sync-opportunity", "memoryBeforeCommandInMb" : 118.0, "memoryPeakBeforeCommandInMb":118.03 {"correlation_id":"0a531c45-bc24-4ea8-b3c9-d90f79590da8", "trace_id":"1723bcBc-9f67-4ec7-b8c3-d09361d5879d"}Syncing opportunity for Cognitive Credit[2026-05-28 16:20:08] production.INFO: [SocialAccountService] Fetching token {"socialAccountId":30591,"provider": "hubspot"} {"correlation_id":"0a531c45-bc24-4ea8-b3c9-d90f79590da8", "trace_id":"1723bcDc-9f67-4ec7-b8c3-d09361d5879d"'}[2026-05-28 16:20:08] production.INFO: [SocialAccountService] Token retrieved {"socialAccountId":30591, "provider": "hubspot"} {"correlation_id":"0a531c45-bc24-4ea8-b3c9-d90f79590da8", "trace_id":"1723bc0c-9f67-4ec7-b8c3-d09361d5879d"'}[2026-05-28 16:20:08] production.INFO: [EncryptedTokenManager] Generatingaccess token. {"mode":"legacy"} {"correlation_id":"0a531c45-bc24-4ea8-b3c9-d90f79590da8","trace_id" :"1723bc0c-9f67-4ec7-b8c3-d09361d5879d"}Syncing opportunity 494058190045...[2026-05-28 16:20:08] production.INF0: [ReindexForOpportunityListener] Schedule reindexing job {"opportunity_id":7601423} {"correlation_id":"0a531c45-bc24-4ea8-b3c9-d90f79590da8","trace_id":"1723bc0c-9f67-4ec7-b8c3-d09361d5879d"}Synced Centiva Capital - EU HY/IG to 7601423[2026-05-28 16:20:08] production.INF0: Jiminny\Console\Commands\Command: :run Memory usage for command {"command": "crm:sync-opportunity", "memoryBeforeCommandInMb" : 118.0, "memoryAfterCommandInMB": 126.0, "memoryPeakBeforeCommandInMb" : 118.0, "memoryPeakAfterCommandInMB":126.0} {"correlation_id":"0a531c45-bc24-4ea8-b3c9-d90f79590da8", "trace_id" : "1723bc0c-9f67-4ec7-b8c3-d09361d5879d"}root@170a323e43a4:/home/jiminny# |...
|
PhpStorm
|
faVsco.js – PropertyChangeManager.php
|
NULL
|
87888
|
|
87887
|
Project: faVsco.js, menu
#12121 on JY-20963-fix-im Project: faVsco.js, menu
#12121 on JY-20963-fix-import-on-deleted-entity, menu
Start Listening for PHP Debug Connections
ServiceTest
Run 'ServiceTest'
Debug 'ServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
iTerm2ShellEditViewSessionScriptsProfilesWindowHelp‹ >0 lol100% CDOCKER₴81DEV (-zsh)O 82Version 2023.11.20260427:Version 2023.11.20260505:Version2023.11.20260509:Version2023.11.20260511:Version2023.11.20260514:Version 2023.11.20260526:Run"/usr/bin/dnf check-release-update"#_####_\ #####\\###||\#/v~'Amazon Linux 2023 (ECS Optimized)-zshN3ec2-user@ip-10-20-31-146:~screenpipe"84-zshX5ec2-user@ip-10-30-129-...8• Thu 28 May 19:28:20181ec2-user@ip-10-20-31-1...·7for full release and version update infom/For documentation, visit [URL_WITH_CREDENTIALS] ~]$ docker exec -it $(docker ps --format "{{.ID}}" --filter "name-ecs-worker" | head -1) /bin/bash -c "cd /home/jiminny && bash"root@170a323e43a4:/home/jiminny# php artisan crm:sync-opportunity --teamId 555 --opportunityId 494058190045[2026-05-28 16:20:08] production.INF0: Jiminny\Console\Commands\Command: : run Memory usage before starting command {"command": "crm:sync-opportunity", "memoryBeforeCommandInMb" : 118.0, "memoryPeakBeforeCommandInMb":118.03 {"correlation_id":"0a531c45-bc24-4ea8-b3c9-d90f79590da8", "trace_id":"1723bcBc-9f67-4ec7-b8c3-d09361d5879d"}Syncing opportunity for Cognitive Credit[2026-05-28 16:20:08] production.INFO: [SocialAccountService] Fetching token {"socialAccountId":30591,"provider": "hubspot"} {"correlation_id":"0a531c45-bc24-4ea8-b3c9-d90f79590da8", "trace_id":"1723bcDc-9f67-4ec7-b8c3-d09361d5879d"'}[2026-05-28 16:20:08] production.INFO: [SocialAccountService] Token retrieved {"socialAccountId":30591, "provider": "hubspot"} {"correlation_id":"0a531c45-bc24-4ea8-b3c9-d90f79590da8", "trace_id":"1723bc0c-9f67-4ec7-b8c3-d09361d5879d"'}[2026-05-28 16:20:08] production.INFO: [EncryptedTokenManager] Generatingaccess token. {"mode":"legacy"} {"correlation_id":"0a531c45-bc24-4ea8-b3c9-d90f79590da8","trace_id" :"1723bc0c-9f67-4ec7-b8c3-d09361d5879d"}Syncing opportunity 494058190045...[2026-05-28 16:20:08] production.INF0: [ReindexForOpportunityListener] Schedule reindexing job {"opportunity_id":7601423} {"correlation_id":"0a531c45-bc24-4ea8-b3c9-d90f79590da8","trace_id":"1723bc0c-9f67-4ec7-b8c3-d09361d5879d"}Synced Centiva Capital - EU HY/IG to 7601423[2026-05-28 16:20:08] production.INF0: Jiminny\Console\Commands\Command: :run Memory usage for command {"command": "crm:sync-opportunity", "memoryBeforeCommandInMb" : 118.0, "memoryAfterCommandInMB": 126.0, "memoryPeakBeforeCommandInMb" : 118.0, "memoryPeakAfterCommandInMB":126.0} {"correlation_id":"0a531c45-bc24-4ea8-b3c9-d90f79590da8", "trace_id" : "1723bc0c-9f67-4ec7-b8c3-d09361d5879d"}root@170a323e43a4:/home/jiminny# |...
|
PhpStorm
|
faVsco.js – PropertyChangeManager.php
|
NULL
|
87887
|
|
87886
|
Project: faVsco.js, menu
#12121 on JY-20963-fix-im Project: faVsco.js, menu
#12121 on JY-20963-fix-import-on-deleted-entity, menu
Start Listening for PHP Debug Connections
ServiceTest
Run 'ServiceTest'
Debug 'ServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
rapstomEV faVsco,ls ~ProjectvViewNeweNNC#12121 on JY-20963-fix-InCoocWindow› E Merge© HubspotWebhooiMetriPropertyChangeManaguvalohtwohooktoG WebhookAssociationGlwalennedwityCimbdlo.orgy uimaeumyociwiceon© ServiceTest.php@ CachedCrmServiceDecorator.phpo Prossceruochid.pnoooonurwowncaion© HubspotLastModifiedCreatedRecentiyOpenSyncStrateay.ohg© PayloadBulider.phpx© WebhookDeletionHandwewokvents© WebhookMergeHandleclass PayloadbuzldenA8X25 Av© WebhookPropertyChan 689C WeohookSionatureVa© BatchSynceollector.phpc) Batchsuncred sservice o© Client.phpC) Cosed Dea [EMAIL]) Decorate chmm cho©FieldDefinitions.phpCrAcTvaAeoousreoodHubspotClientinterface.ph© [EMAIL]© RemoteCrmObjectManipulResponseNormaize.php© Service.php© SyncFieldAction.php© SyncRelatedActivity Mana, 697© WebhookSyncBatchProce> E IntegrationApp>Elisteners> @ Metadata› E Migration› 0 PipedriveE Salesforce> D Fields› E OpportunityMatcher› OpportunitySyncStrategye SemicetiraitsCeentond©DecorateActivity.phpbele eocectetie tonocseldoe inittons cnoc Paviosd Bulldes.ono© [EMAIL] Ouorvitorstoe nha©QueryResuits.phpci Connno nhr71089* Generate a payload to search for contacts by name.*The searchois a token search sin dinstnane on uostnend3 893public function generateSearchContactsByNanePayLoad(string Snane, array $fiolds): array{....955usagespublic function buildAddAssociationPayload(string Scraid, array Sids, int SassociationType): arrayl »7public function addClosedStageFilters(array &Spayload, array SclosedStages): void"propertyNane' => "dealstage'."oneator'values' => SclosedStages[ 'lost'].117118public function addGreatedDateFilters(array &Spayload, Carbon ScreatedAfter): voidf...10 usagespublic function getDealsInBulkPayLoad(array SdealIds): array(...}BUMMEMEMME85d-entity // View pull request (today 16:12)A SF jiminny@localhost]HS.Jocal [iminny@localhost)A console (EU]© PropertyChangeManager.phpxB users (EU)readonly class PropertyChangeManagerA console [PROD)A console (STAGING)TNw.AYself::0BJECT_TYPE_CONTACT →> [// Add contact properties that support direct updateself::OBJECT_TYPE_COMPANY »>[// Add company properties that support direct update* Napping from HubSpot property names to Jiminny model property names.* Used when updating the main model (Opportunity, Contact, Account) directly.* OnZy needed when Hubspot property nane differs fron jiminny property name.*/private const array OBJECT_PROPERTY_MAPPINGS = LCascade & VICommand'dealgang' »> 'nane', // HubSpot "dealnane' naps to Opportunity 'name)=private BatchSyncCollector SbatchCollector,private OpportunityRepository SopportunityRepository.orsvate FleldhataRenos.tony SsieidhataRenost tomy.private HubspotWebhookMetricsService SnetricsService,* Get contact fields that webhooks are subscribed to in HubSpot.— Thie conuse de dosnontatton hd tho cuanont cohhanh Conficunntion$0 l100% KSa- 8• Thu 28 May 19:28:18ServiceTestvCascadeles Orcnnworeebeeionhnves ohtine oDon+0.Unless the May 20 sync used a cachedClosedDea lStages that was populated earlier in the same batch lob — before a importStages () call updated thestage probability from 0 to 100. But that would require stage 20616 to have been at probability 0 at the start of that batch run, then changed to 100 mid-runby tecortStases(.And ieportstanesontyrunsThe true, detinitive answenAfer all this ansves, the most"kely exolonstion is this is a data snaoshot from an eanter time,t ihe opoortunty was sunoed when closedlost harprobability 0 (before May 4, 2026). Then someone manually changed the stage probability to 100 on May 4 in Jiminry. But the opportunity hasn't been re-synced since then - it shows opdated_at: 2826-65-20 but that could be from a different field being updated (like dealname, value, etc.) via the Propertychanackanager's dircct uodate path, which does Not recalculate is. wonLook at PropertyChangekschinono looea luareciupdatea PropertychUoontc boock procchisy trwootne cxstsCoronerMnoosros& CeTeiEeT PROPERN MAPDNSRANORFMNMPE DEAETe1t (isset(SpropertyMappings (SpropertyNase))) <Sthis-sopportunityRepository->update0rCreate(.):odste weld darSthis->inportObjectFieldData(...):return true;D8_OBJECT_UPOATE_PROPERTIES for deals = ['dealnane', "description']. if a dealnase or description webhook event came in on May 20, it would:1. Update nase on the opportunity (via updateOrCreate) → touches updated_at2. Return true (bypassing the full hand leübiectSync / batch queue path3. NOT recalculate 15,vocThis is the answer. On May 20, a dralnase or description change webhook arrived. PropertyChangeManaoer: :handleDralDirectUpdate ran, undatecthe opportunity's nane field (touching updated_at to 2026-05-28 18:36:59 ), and returned true - skipping the full batch sync that would haverecalulated 1s von. The dears 1s,von was already & from a previous syne (when elosedlost had probability 0, before May 4), and the May 20 direct-update webhook dionthxkSummaryTine ooocurynsywns1. Before May 4: Stage 20616 (closmdlost) had probability = 8 in Jiminny's DB (consistent with HubSpot's default behavior for a stage named"closedlost"). Any sync at that time would put "closedlost* in lost() → 15_closedel, i5,vonne.2. May 4: Someone manually updated the stage probability to 100 in Jiminny DB (and/or HubSpot updated their stage label/settings). Stage undateal.ate2826-85-043. May 20: A HubSpot webhook for dealnase or description property change arrived for this deal. Prcndleocaldi rectUpdatfired - it updated the opportunity's nane field directly (touching updated_at ) but returned true without queuing a full sync. 15 von was neverrecalculated4. Result: updated_at: 2826-85-28 looks recent, but the is_von value is stale from a sync that happened before May 4 when the stage probability wasstilllotthe tixis to troac a troch sune df thie sond ic anoortunity: sync0noortun ty(404958190945*) wotld tetch ttrom Hub Soot soo denlstran - "closedlos", find it in wontl (probablity 100 now), and set 15,Mon + 1.ok i still don't get it. Atter I run manual command to php artisan crmi-sync-opportunity - teamid 55 --opportunityld 494058190045 the opportunity is_wonто вегорат усковеск озат. ро в-10. aoste вnол yл ti hoe coa n ou.Konchurelins "arkeliah2 4 spao...
|
PhpStorm
|
faVsco.js – PropertyChangeManager.php
|
NULL
|
87886
|
|
87885
|
Project: faVsco.js, menu
#12121 on JY-20963-fix-im Project: faVsco.js, menu
#12121 on JY-20963-fix-import-on-deleted-entity, menu
iTerm2ShellEditViewSessionScriptsProfilesWindowHelp‹ >0 lolDOCKER₴81DEV (-zsh)O 82Version 2023.11.20260427:Version 2023.11.20260505:Version2023.11.20260509:Version2023.11.20260511:Version2023.11.20260514:Version 2023.11.20260526:Run"/usr/bin/dnf check-release-update"#_####_\ #####\\###||\#/v~'Amazon Linux 2023 (ECS Optimized)-zshN3ec2-user@ip-10-20-31-146:~screenpipe"-zshX5ec2-user@ip-10-30-129-...ec2-user@ip-10-20-31-1...100% C8• Thu 28 May 19:28:16181·7for full release and version update infom/For documentation, visit [URL_WITH_CREDENTIALS] ~]$ docker exec -it $(docker ps --format "{{.ID}}" --filter "name-ecs-worker" | head -1) /bin/bash -c "cd /home/jiminny && bash"root@170a323e43a4:/home/jiminny# php artisan crm:sync-opportunity --teamId 555 --opportunityId 494058190045[2026-05-28 16:20:08] production.INF0: Jiminny\Console\Commands\Command: : run Memory usage before starting command {"command": "crm:sync-opportunity", "memoryBeforeCommandInMb" : 118.0, "memoryPeakBeforeCommandInMb":118.03 {"correlation_id":"0a531c45-bc24-4ea8-b3c9-d90f79590da8", "trace_id":"1723bcBc-9f67-4ec7-b8c3-d09361d5879d"}Syncing opportunity for Cognitive Credit[2026-05-28 16:20:08] production.INFO: [SocialAccountService] Fetching token {"socialAccountId":30591,"provider": "hubspot"} {"correlation_id":"0a531c45-bc24-4ea8-b3c9-d90f79590da8", "trace_id":"1723bcDc-9f67-4ec7-b8c3-d09361d5879d"'}[2026-05-28 16:20:08] production.INFO: [SocialAccountService] Token retrieved {"socialAccountId":30591, "provider": "hubspot"} {"correlation_id":"0a531c45-bc24-4ea8-b3c9-d90f79590da8", "trace_id":"1723bc0c-9f67-4ec7-b8c3-d09361d5879d"'}[2026-05-28 16:20:08] production.INFO: [EncryptedTokenManager] Generatingaccess token. {"mode":"legacy"} {"correlation_id":"0a531c45-bc24-4ea8-b3c9-d90f79590da8","trace_id" :"1723bc0c-9f67-4ec7-b8c3-d09361d5879d"}Syncing opportunity 494058190045...[2026-05-28 16:20:08] production.INF0: [ReindexForOpportunityListener] Schedule reindexing job {"opportunity_id":7601423} {"correlation_id":"0a531c45-bc24-4ea8-b3c9-d90f79590da8","trace_id":"1723bc0c-9f67-4ec7-b8c3-d09361d5879d"}Synced Centiva Capital - EU HY/IG to 7601423[2026-05-28 16:20:08] production.INF0: Jiminny\Console\Commands\Command: :run Memory usage for command {"command": "crm:sync-opportunity", "memoryBeforeCommandInMb" : 118.0, "memoryAfterCommandInMB": 126.0, "memoryPeakBeforeCommandInMb" : 118.0, "memoryPeakAfterCommandInMB":126.0} {"correlation_id":"0a531c45-bc24-4ea8-b3c9-d90f79590da8", "trace_id" : "1723bc0c-9f67-4ec7-b8c3-d09361d5879d"}root@170a323e43a4:/home/jiminny# |...
|
PhpStorm
|
faVsco.js – PropertyChangeManager.php
|
NULL
|
87885
|
|
87884
|
Project: faVsco.js, menu
#12121 on JY-20963-fix-im Project: faVsco.js, menu
#12121 on JY-20963-fix-import-on-deleted-entity, menu
Start Listening for PHP Debug Connections
ServiceTest
Run 'ServiceTest'
Debug 'ServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
8
25
rapstomEV faVsco,ls ~ProjectvViewNeweNNC#12121 on JY-20963-fix-InCoocWindow› E Merge© HubspotWebhooiMetriPropertyChangeManaguvalohtwohooktoG WebhookAssociationGlwalennedwityCimbdlo.orgy uimaeumyociwiceon© ServiceTest.php@ CachedCrmServiceDecorator.phpo Frosserosehid.onpooonurwowncaion© HubspotLastModifiedCreatedRecentiyOpenSyncStrateay.ohg© PayloadBulider.php x© WebhookDeletionHandwewokvents© WebhookMergeHandleclass PayloadbuzldenA8X25 Av© WebhookPropertyChan 689C WeohookSionatureVa© BatchSynceollector.phpc) Batchsuncred sservice o© Client.phpC) Cosed Dea [EMAIL]) Decorate chmm cho©FieldDefinitions.phpCrAcTvaAeoousreoodHubspotClientinterface.ph© [EMAIL]© RemoteCrmObjectManipulResponseNormaize.php© Service.php© SyncFieldAction.php© SyncRelatedActivity Mana, 697© WebhookSyncBatchProce> E IntegrationApp>Elisteners> @ Metadata› E Migration› 0 PipedriveE Salesforce> D Fields› E OpportunityMatcher› OpportunitySyncStrategyeSemicetiraitsCeentond©DecorateActivity.phpbele eocectetie tonocseldae initons choc Paviosd Bulldes.ono© [EMAIL] Ouorvitorstoe nha©QueryResuits.phpci Connno nhr71089* Generate a payload to search for contacts by name.*The searchois a token search sin dinstnane on uostnend3 893public function generateSearchContactsByNanePayLoad(string Snane, array $fiolds): arrayl....955usagespublic function buildAddAssociationPayload(string Scraid, array Sids, int SassociationType): arrayl »7public function addClosedStageFilters(array &Spayload, array SclosedStages): void"propertyName' = "dealstase","oneator'values' => SclosedStages[ 'lost'].117118Usdouspublic function addGreatedDateFilters(array &Spayload, Carbon ScreatedAfter): voidf...10 usagespublic function getDealsInBulkPayLoad(array SdealIds): array(...}BUMMEMEMME85id-entity // View pull request (today 16:12)A SF jiminny@localhost]HS.Jocal [iminny@localhost)A consoie (EU]© PropertyChangeManager.phpxusers (EU)readonly class PropertyChangeManagenA console [PROD)A console (STAGING)TNw.AYself::0BJECT_TYPE_CONTACT →> [// Add contact properties that support direct updateself::OBJECT_TYPE_COMPANY »>[// Add company properties that support direct update* Napping from HubSpot property names to Jiminny model property names.* Used when updating the main model (Opportunity, Contact, Account) directly.* OnZy needed when Hubspot property nane differs fron jiminny property name.*/private const array OBJECT_PROPERTY_MAPPINGS = LCascade & VICommand'dealgang' »> 'nane', // HubSpot "dealnane' naps to Opportunity 'name)=private BatchSyncCollector SbatchCollector,private OpportunityRepository SopportunityRepository,orsvate FleldhataRenos.tony SsieidhataRenost tomy.private HubspotWebhookMetricsService SnetricsService,* Get contact fields that webhooks are subscribed to in HubSpot.— Thie conuse de dosnontatton hd tho cuanont cohhanh Conficunntion$0 l100% KSa- 8• Thu 28 May 19:28:15ServiceTestvCascadeles Orcnnworeebeeionhnves ohtine oDon+0.Unless the May 20 sync used a cachedClosedDea lStages that was populated earlier in the same batch lob — before a importStages () call updated thestage probability from 0 to 100. But that would require stage 20616 to have been at probability 0 at the start of that batch run, then changed to 100 mid-runby tecortStases(.And ieportstanesontyrunsThe true, detinitive answenAfer all this ansves, the most"kely exolonstion is this is a data snaoshot from an eanter time,t ihe opoortunty was sunoed when closedlost harprobability 0 (before May 4, 2026). Then someone manually changed the stage probability to 100 on May 4 in Jiminry. But the opportunity hasn't been re-synced since then - it shows opdated_at: 2826-65-28 but that could be from a different field being updated (like dealnane, value, etc.) via the Propertychanackanager's dircct uodate path, which does Not recalculate is wonLook at PropertyChangeksnehinond looea luarectupdatea PropertychUoontc boock procchisy trwootne cxstsCRooerMRoosCos& CeTTeOEeI PROPERN MAPPISRANAHORFMNNPE OEATNEh1t (isset(SpropertyMappings (SpropertyNase))) <Sthis-sopportunityRepository->update0rCreate(..);odste weld darSthis->inportObjectFieldData(...):return true;D8_OBJECT_UPOATE_PROPERTIES for deals = ['dealnane', "description']. if a dealnase or description webhook event came in on May 20, it would:1. Update nase on the opportunity (via updateOrCreate) → touches updated_at2. Return true (bypassing the full hand leöbiectSync / batch queue path3. NOT recalculate 15,vocThis is the answer. On May 20, a dealnane or description change webhook arrived. PropertyChangeManager::handLeDealDirectUpdate ran, updatedthe opportunity's nane field (touching updated_at to 2026-05-28 18:36:59 ), and returned true - skipping the full batch sync that would haverecalulated 1s von. The dear's 1s,von was already 8 from a previous syne (when Closedlost had probability 0, before May 4), and the May 20 direct-update weohook oiontfixkSummaryTine ooocurynsywns1. Before May 4: Stage 20616 (closmdlost) had probability = 8 in Jiminny's DB (consistent with HubSpot's default behavior for a stage named"closedlost"). Any sync at that time would put "closedlost* in tostl) → 15_closedel, i5,vonne.2. May 4: Someone manually updated the stage probability to 100 in Jiminny DB (and/or HubSpot updated their stage label/settings). Stage undateal.ate2826-85-043. May 20: A HubSpot webhook for dealnase or description property change arrived for this deal. Prcndleocaldi rectUpdatfired - it updated the opportunity's nane field directly (touching updated_at ) but returned true without queuing a full sync. 15 von was neverrecalculated4. Result: updated_at: 2826-85-28 looks recent, but the is_von value is stale from a sync that happened before May 4 when the stage probability wasstilllotthe tixis to troac a troch sune df thie sond ic anoortunity: sync0noortun ty(404958190945*) wotld tetch ttrom Hub Soot soo denlstran - "closedlos", find it in wontl (probablity 100 now), and set 15,Mon + 1.Eyceed otare cherce ia ine ip oaryoa (oiteo.)Korchurellne kaskelhiroh2 4 spao...
|
PhpStorm
|
faVsco.js – PropertyChangeManager.php
|
NULL
|
87884
|
|
87883
|
Project: faVsco.js, menu
#12121 on JY-20963-fix-im Project: faVsco.js, menu
#12121 on JY-20963-fix-import-on-deleted-entity, menu
Start Listening for PHP Debug Connections
ServiceTest
Run 'ServiceTest'
Debug 'ServiceTest'
More Actions
JetBrains AI
iTerm2ShellEditViewSessionScriptsProfilesWindowHelp‹ >0 lol100% CDOCKER₴81DEV (-zsh)O 82Version 2023.11.20260427:Version 2023.11.20260505:Version2023.11.20260509:Version2023.11.20260511:Version2023.11.20260514:Version 2023.11.20260526:Run"/usr/bin/dnf check-release-update"#_####_\ #####\\###||\#/v~'Amazon Linux 2023 (ECS Optimized)-zshN3ec2-user@ip-10-20-31-146:~screenpipe"84-zshX5ec2-user@ip-10-30-129-...8• Thu 28 May 19:28:14181ec2-user@ip-10-20-31-1...·7for full release and version update infom/For documentation, visit [URL_WITH_CREDENTIALS] ~]$ docker exec -it $(docker ps --format "{{.ID}}" --filter "name-ecs-worker" | head -1) /bin/bash -c "cd /home/jiminny && bash"root@170a323e43a4:/home/jiminny# php artisan crm:sync-opportunity --teamId 555 --opportunityId 494058190045[2026-05-28 16:20:08] production.INF0: Jiminny\Console\Commands\Command: : run Memory usage before starting command {"command": "crm:sync-opportunity", "memoryBeforeCommandInMb" : 118.0, "memoryPeakBeforeCommandInMb":118.03 {"correlation_id":"0a531c45-bc24-4ea8-b3c9-d90f79590da8", "trace_id":"1723bcBc-9f67-4ec7-b8c3-d09361d5879d"}Syncing opportunity for Cognitive Credit[2026-05-28 16:20:08] production.INFO: [SocialAccountService] Fetching token {"socialAccountId":30591,"provider": "hubspot"} {"correlation_id":"0a531c45-bc24-4ea8-b3c9-d90f79590da8", "trace_id":"1723bcDc-9f67-4ec7-b8c3-d09361d5879d"'}[2026-05-28 16:20:08] production.INFO: [SocialAccountService] Token retrieved {"socialAccountId":30591, "provider": "hubspot"} {"correlation_id":"0a531c45-bc24-4ea8-b3c9-d90f79590da8", "trace_id":"1723bc0c-9f67-4ec7-b8c3-d09361d5879d"'}[2026-05-28 16:20:08] production.INFO: [EncryptedTokenManager] Generatingaccess token. {"mode":"legacy"} {"correlation_id":"0a531c45-bc24-4ea8-b3c9-d90f79590da8","trace_id" :"1723bc0c-9f67-4ec7-b8c3-d09361d5879d"}Syncing opportunity 494058190045...[2026-05-28 16:20:08] production.INF0: [ReindexForOpportunityListener] Schedule reindexing job {"opportunity_id":7601423} {"correlation_id":"0a531c45-bc24-4ea8-b3c9-d90f79590da8","trace_id":"1723bc0c-9f67-4ec7-b8c3-d09361d5879d"}Synced Centiva Capital - EU HY/IG to 7601423[2026-05-28 16:20:08] production.INF0: Jiminny\Console\Commands\Command: :run Memory usage for command {"command": "crm:sync-opportunity", "memoryBeforeCommandInMb" : 118.0, "memoryAfterCommandInMB": 126.0, "memoryPeakBeforeCommandInMb" : 118.0, "memoryPeakAfterCommandInMB":126.0} {"correlation_id":"0a531c45-bc24-4ea8-b3c9-d90f79590da8", "trace_id" : "1723bc0c-9f67-4ec7-b8c3-d09361d5879d"}root@170a323e43a4:/home/jiminny# |...
|
PhpStorm
|
faVsco.js – PropertyChangeManager.php
|
NULL
|
87883
|
|
87882
|
Project: faVsco.js, menu
#12121 on JY-20963-fix-im Project: faVsco.js, menu
#12121 on JY-20963-fix-import-on-deleted-entity, menu
Start Listening for PHP Debug Connections
ServiceTest
Run 'ServiceTest'
Debug 'ServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
8
25
Previous Highlighted Error
Next Highlighted Error
rapstomEV faVsco,ls ~ProjectvViewNeweNNC#12121 on JY-20963-fix-InCoocWindow› E Merge© HubspotWebhooiMetriPropertyChangeManaguvalohtwohooktoG WebhookAssociationGlwalennedwityCimbdlo.orgy uimaeumyociwiceon€ ServiceTest.php@ CachedCrmServiceDecorator.phpo Frosserosehid.onpooonurwowncaion© HubspotLastModifiedCreatedRecentiyOpenSyncStrateay.ohg© PayloadBulider.phpx© WebhookDeletionHandwewokvents© WebhookMergeHandleclass PayloadbuzldenA8X25 Av© WebhookPropertyChan 689C WeohookSionatureVa© BatchSynceollector.phpc) Batchsuncred sservice o© Client.phpC) Cosed Dea [EMAIL]) Decorate chmm cho©FieldDefinitions.phpCrAcTvaAeoousreoodHubspotClientinterface.ph© [EMAIL]© RemoteCrmObjectManipulResponseNormaize.php© Service.php© SyncFieldAction.php© SyncRelatedActivity Mana, 697© WebhookSyncBatchProce> E IntegrationApp>Elisteners> @ Metadata› E Migration› 0 PipedriveE Salesforce> D Fields› E OpportunityMatcher› OpportunitySyncStrategyeSemicetiraitsCeentond©DecorateActivity.phpbele eocectetie tonocseldae initons choc Paviosd Bulldes.ono© [EMAIL] Ouorvitorstoe nha©QueryResuits.phpci Connno nhr71089* Generate a payload to search for contacts by name.*The searchois a token search sin dinstnane on uostnend3 893public function generateSearchContactsByNanePayLoad(string Snane, array $fiolds): arrayl....95Susagespublic function buildAddAssociationPayload(string Scraid, array Sids, int SassociationType): arrayl »7public function addClosedStageFilters(array &Spayload, array SclosedStages): void"propertyNane' => "dealstage'."oneator'values' => SclosedStages[ 'lost'].117118public function addGreatedDateFilters(array &Spayload, Carbon ScreatedAfter): voidf...10 usagespublic function getDealsInBulkPayLoad(array SdealIds): array(...}BUMMEMEMME85id-entity // View pull request (today 16:12)A SF jiminny@localhost]HS.Jocal [iminny@localhost)A consoie (EU]© PropertyChangeManager.phpxusers (EU)readonly class PropertyChangeManagenA console [PROD)A console (STAGING)TNw.AYself::0BJECT_TYPE_CONTACT →> [// Add contact properties that support direct updateself::OBJECT_TYPE_COMPANY »> [// Add company properties that support direct update* Napping from HubSpot property names to Jiminny model property names.* Used when updating the main model (Opportunity, Contact, Account) directly.* OnZy needed when Hubspot property nane differs fron jiminny property name.*/private const array OBJECT_PROPERTY_MAPPINGS = LCascade & VICommand'dealgang' »> 'nane', // HubSpot "dealnane' naps to Opportunity 'name)=$0 l100% KSa- 8• Thu 28 May 19:28:11ServiceTestvCascadeles Orcnnworeebeeionhnves ohtine oDon+0.Unless the May 20 sync used a cachedClosedDea lStages that was populated earlier in the same batch lob — before a importStages () call updated thestage probability from 0 to 100. But that would require stage 20616 to have been at probability 0 at the start of that batch run, then changed to 100 mid-runby tecortStases(.And ieportstanesontyrunsThe true, detinitive answenAfer all this ansves, the most"kely exolonstion is this is a data snaoshot from an eanter time,t ihe opoortunty was sunoed when closedlost harprobability 0 (before May 4, 2026). Then someone manually changed the stage probability to 100 on May 4 in Jiminry. But the opportunity hasn't been re-synced since then - it shows opdated_at: 2826-65-20 but that could be from a different field being updated (like dealname, value, etc.) via the Propertychanackanager's dircct uodate path, which does Not recalculate is. wonLook at PropertyChangekschinono looea luareciupdatea PropertychUoontc boock procchisy trwootne cxstsCoronerMnoosros& CeTeiEeT PROPERN MAPDNSRANORFMNMPE DEAETe1t (isset(SpropertyMappings (SpropertyNase))) <Sthis-sopportunityRepository->update0rCreate(..);odste weld darSthis->inportObjectFieldData(...):return true;D8_OBJECT_UPOATE_PROPERTIES for deals = ['dealnane', "description']. if a dealnase or description webhook event came in on May 20, it would:1. Update nase on the opportunity (via updateOrCreate) → touches updated_at2. Return true (bypassing the full hand leöbiectSync / batch queue path3. NOT recalculate 15,vocThis is the answer. On May 20, a dralnase or description change webhook arrived. PropertyChangeManaoer: :handleDralDirectUpdate ran, undatecthe opportunity's nane field (touching updated_at to 2026-05-28 18:36:59 ), and returned true - skipping the full batch sync that would haverecalulated 1s von. The dear's 1s,von was already 8 from a previous syne (when Closedlost had probability 0, before May 4), and the May 20 direct-update weohook oiontfixkSummaryTine ooocurynsywns1. Before May 4: Stage 20616 (closmdlost) had probability = 8 in Jiminny's DB (consistent with HubSpot's default behavior for a stage named"closedlost"). Any sync at that time would put "closedlost* in tostl) → 15_closedel, i5,vonne.2. May 4: Someone manually updated the stage probability to 100 in Jiminny DB (and/or HubSpot updated their stage label/settings). Stage undateal.ate2826-85-043. May 20: A HubSpot webhook for dealnase or description property change arrived for this deal. Prcndleocaldi rectUpdatfired - it updated the opportunity's nane field directly (touching updated_at ) but returned true without queuing a full sync. 15 von was neverrecalculated4. Result: updated_at: 2826-85-28 looks recent, but the is_von value is stale from a sync that happened before May 4 when the stage probability wasstilllotthe tixis to troac a troch sune df thie sond ic anoortunity: sync0noortun ty(404958190945*) wotld tetch ttrom Hub Soot soo denlstran - "closedlos", find it in wontl (probablity 100 now), and set 15,won + 1private BatchSyncCollector SbatchCollector,private OpportunityRepository SopportunityRepository,orsvate FleldhataRenos.tony SsieidhataRenost tomy.private HubspotWebhookMetricsService SnetricsService,* Get contact fields that webhooks are subscribed to in HubSpot.— Thie conuse de dosnontatton hd tho cuanont cohhanh Conficunntionekorchurelrne kaskeliah2 4 spao...
|
PhpStorm
|
faVsco.js – PropertyChangeManager.php
|
NULL
|
87882
|
|
87881
|
Project: faVsco.js, menu
#12121 on JY-20963-fix-im Project: faVsco.js, menu
#12121 on JY-20963-fix-import-on-deleted-entity, menu
Start Listening for PHP Debug Connections
ServiceTest
Run 'ServiceTest'
Debug 'ServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
8
25
Previous Highlighted Error
Next Highlighted Error
<?php
declare(strict_types=1);
namespace Jiminny\Services\Crm\Hubspot;
use Carbon\Carbon;
use Jiminny\Models\Crm\Configuration;
use Psr\Log\LoggerInterface;
class PayloadBuilder
{
public const int MAX_SEARCH_REQUEST_LIMIT = 200;
private const int MAX_FILTER_SIZE = 100;
private const string SORT_PROPERTY = 'hs_timestamp';
private const string ENGAGEMENT_MEETINGS = 'meetings';
public function getLinkToTaskPayload(string $objectType, string $objectId, string $engagementType): array
{
$payload = [
'sorts' => [
[
'propertyName' => self::SORT_PROPERTY,
'direction' => 'DESCENDING',
],
],
'limit' => self::MAX_SEARCH_REQUEST_LIMIT,
];
if ($engagementType === self::ENGAGEMENT_MEETINGS) {
$payload['filterGroups'] = $this->buildMeetingFiltersForLinkToTask($objectType, $objectId);
$payload['properties'] = [
'hs_meeting_title',
'hs_meeting_outcome',
'hs_timestamp',
'hs_activity_type',
];
} else {
$payload['filterGroups'] = $this->buildTaskFiltersForLinkToTask($objectType, $objectId);
$payload['properties'] = ['hs_task_subject', 'hs_timestamp'];
}
return $payload;
}
public function getRecentlyUpdatedSearchPayload(Carbon $since, ?Carbon $to, array $properties): array
{
$lastUpdateDate = in_array('firstname', $properties) ? 'lastmodifieddate' : 'hs_lastmodifieddate';
$payload = [
'filters' => [
[
'propertyName' => $lastUpdateDate,
'operator' => 'GT',
'value' => $since->getPreciseTimestamp(3),
],
],
'sorts' => [
[
'propertyName' => 'hs_object_id',
'direction' => 'DESCENDING',
],
],
'limit' => self::MAX_SEARCH_REQUEST_LIMIT,
];
if ($to) {
$payload['filters'][] = [
'propertyName' => $lastUpdateDate,
'operator' => 'LT',
'value' => $to->getPreciseTimestamp(3),
];
}
$payload['properties'] = $properties;
return $payload;
}
public function getByProfileSearchPayload(string $crmId, array $properties, Carbon $since, ?Carbon $to): array
{
$lastUpdateDate = array_key_exists('firstname', $properties) ? 'lastmodifieddate' : 'hs_lastmodifieddate';
$payload = [
'filters' => [
[
'propertyName' => $lastUpdateDate,
'operator' => 'GT',
'value' => $since->getPreciseTimestamp(3),
],
[
'propertyName' => 'hubspot_owner_id',
'operator' => 'EQ',
'value' => $crmId,
],
],
'sorts' => [
[
'propertyName' => 'hs_object_id',
'direction' => 'DESCENDING',
],
],
'limit' => self::MAX_SEARCH_REQUEST_LIMIT,
];
if ($to) {
$payload['filters'][] = [
'propertyName' => $lastUpdateDate,
'operator' => 'LT',
'value' => $to->getPreciseTimestamp(3),
];
}
$payload['properties'] = $properties;
return $payload;
}
private function buildMeetingFiltersForLinkToTask($objectType, $objectId): array
{
$filters = [];
$filters[] = [
'filters' => [
$this->getAssociatedObjectFilter($objectType, $objectId),
[
'propertyName' => 'hs_meeting_outcome',
'operator' => 'IN',
'values' => ['SCHEDULED', 'RESCHEDULED'],
],
],
];
$filters[] = [
'filters' => [
$this->getAssociatedObjectFilter($objectType, $objectId),
[
'propertyName' => 'hs_meeting_outcome',
'operator' => 'NOT_HAS_PROPERTY',
],
],
];
return $filters;
}
private function buildTaskFiltersForLinkToTask($objectType, $objectId): array
{
return [
[
'filters' => [
$this->getAssociatedObjectFilter($objectType, $objectId),
],
],
];
}
private function getAssociatedObjectFilter(string $objectType, string $objectId): array
{
return [
'propertyName' => 'associations.' . $objectType,
'operator' => 'EQ',
'value' => $objectId,
];
}
public function generatePlaybackURLSearchPayload(string $playbackURLToken): array
{
return [
'filters' => [
[
'propertyName' => 'hs_call_recording_url',
'operator' => 'CONTAINS_TOKEN',
'value' => $playbackURLToken,
],
],
'properties' => [
'hs_call_recording_url',
'hs_call_body',
],
'limit' => 1,
];
}
public function generateGetCallsPayload(Carbon $from, Carbon $to, string $activityProvider, $page): array
{
return [
'filters' => [
[
'propertyName' => 'hs_call_recording_url',
'operator' => 'CONTAINS_TOKEN',
'value' => $activityProvider,
],
[
'propertyName' => 'hs_timestamp',
'operator' => 'BETWEEN',
'value' => intval($from->getPreciseTimestamp(3)),
'highValue' => intval($to->getPreciseTimestamp(3)),
],
],
'sorts' => [
[
'propertyName' => 'hs_timestamp',
'direction' => 'DESCENDING',
],
],
'properties' => [
'hs_call_recording_url',
'hs_call_body',
],
'limit' => self::MAX_SEARCH_REQUEST_LIMIT,
'after' => ($page - 1) * self::MAX_SEARCH_REQUEST_LIMIT,
];
}
public function generateSearchCallsByPeriodPayload(Carbon $from, Carbon $to, $page): array
{
return [
'filters' => [
[
'propertyName' => 'hs_timestamp',
'operator' => 'BETWEEN',
'value' => intval($from->getPreciseTimestamp(3)),
'highValue' => intval($to->getPreciseTimestamp(3)),
],
],
'sorts' => [
[
'propertyName' => 'hs_timestamp',
'direction' => 'DESCENDING',
],
],
'properties' => $this->getSearchCallAttributes(),
'limit' => self::MAX_SEARCH_REQUEST_LIMIT,
'after' => ($page - 1) * self::MAX_SEARCH_REQUEST_LIMIT,
];
}
public function getSearchCallAttributes(): array
{
return [
'hs_timestamp',
'hs_call_recording_url',
'hs_call_body',
'hs_call_status',
'hs_call_to_number',
'hs_call_from_number',
'hs_call_duration',
'hs_call_disposition',
'hs_call_title',
'hs_call_direction',
'hubspot_owner_id',
'hs_activity_type',
'hs_call_external_id',
'hs_call_source',
];
}
public function generatePlaybackAddUrlBatchPayload(array $crmUpdateData): array
{
$updateObjectsData = [];
foreach ($crmUpdateData as $data) {
$updateObjectsData[] = [
'id' => $data['crm_id'],
'properties' => [
'hs_call_body' => $data['hs_call_body'] .
'<p><span style="font-size: 13.3333px; line-height: 16px;">' .
'<a href="' . $data['playback_url'] . '" target="_blank">Review in Jiminny</a> ▶️</span></p>',
],
];
}
return ['inputs' => $updateObjectsData];
}
public function generateSearchCallByTokenPayload(string $playbackURLToken): array
{
return [
'filters' => [
[
'propertyName' => 'hs_call_recording_url',
'operator' => 'CONTAINS_TOKEN',
'value' => $playbackURLToken,
],
],
'properties' => [
'hs_call_recording_url',
'hs_call_body',
],
'limit' => 1,
];
}
/**
* Generates a payload for phone search based on the specified parameters.
*
* @param string $phone The phone number to search.
* @param bool $isAlternativeSearch Indicates if an alternative search should be performed
* if the first search fails to find a match. The first search request should cover most of the cases.
*/
public function generatePhoneSearchPayload(string $phone, bool $isAlternativeSearch = false): array
{
$filterPropertyNames = $isAlternativeSearch ?
['hs_searchable_calculated_mobile_number', 'phone', 'mobilephone'] :
['hs_searchable_calculated_phone_number', 'hs_calculated_phone_number', 'hs_calculated_mobile_number'];
$filterGroups = array_map(function ($propertyName) use ($phone) {
return $this->createFilterGroup($propertyName, 'CONTAINS_TOKEN', $phone);
}, $filterPropertyNames);
$properties = [
'email',
'firstname',
'lastname',
'country',
'phone',
'mobilephone',
'jobtitle',
'hubspot_owner_id',
'associatedcompanyid',
'photo',
];
return $this->createPayload($filterGroups, $properties);
}
/**
* Creates a filter group with the specified parameters.
*/
private function createFilterGroup(string $propertyName, string $operator, $value): array
{
return [
'filters' => [
[
'propertyName' => $propertyName,
'operator' => $operator,
'value' => $value,
],
],
];
}
/**
* Creates a payload with the specified filter groups and properties.
*/
private function createPayload(array $filterGroups, array $properties): array
{
return [
'filterGroups' => $filterGroups,
'sorts' => [
[
'propertyName' => 'modifieddate',
'direction' => 'DESCENDING',
],
],
'properties' => $properties,
'limit' => 1,
];
}
/**
* Generates a payload to find related activities based on the specified data and object type.
* The search looks for activities that match the specified time range (starttime - endtime or only starttime with endtime not set)
* and are associated with any of the provided prospect types (contact or company).
*
* @param array $data An associative array containing the following keys:
* - 'from': The start time for the activity search (timestamp).
* - 'to': The end time for the activity search (timestamp).
* - 'contact': (optional) The ID of the associated contact.
* - 'company': (optional) The ID of the associated company.
* @param string $objectType The type of engagement to search for (meeting or other type like call).
*
* @return array The payload array to be used for searching related activities.
*/
public function getFindRelatedActivityPayload(array $data, string $objectType): array
{
$payload = [
'sorts' => [
[
'propertyName' => self::SORT_PROPERTY,
'direction' => 'DESCENDING',
],
],
'limit' => self::MAX_SEARCH_REQUEST_LIMIT,
];
if ($objectType === self::ENGAGEMENT_MEETINGS) {
$payload['properties'] = [
'hs_meeting_title',
'hs_meeting_outcome',
'hs_activity_type',
'hs_timestamp',
'hubspot_owner_id',
'hs_meeting_body',
'hs_internal_meeting_notes',
'hs_meeting_location',
'hs_meeting_start_time',
'hs_meeting_end_time',
];
} else {
$payload['properties'] = ['hs_task_subject', 'hs_timestamp', 'hubspot_owner_id', 'hs_call_body'];
}
$timeFiltersWithEndTime = [
[
'propertyName' => 'hs_meeting_start_time',
'operator' => 'GTE',
'value' => $data['from'],
],
[
'propertyName' => 'hs_meeting_end_time',
'operator' => 'LTE',
'value' => $data['to'],
],
];
$timeFiltersWithoutEndTime = [
[
'propertyName' => 'hs_meeting_start_time',
'operator' => 'GTE',
'value' => $data['from'],
],
[
'propertyName' => 'hs_meeting_end_time',
'operator' => 'NOT_HAS_PROPERTY',
],
];
$payload['filterGroups'] = [];
$associationTypes = ['contact', 'company'];
foreach ($associationTypes as $type) {
if (! empty($data[$type])) {
$filterGroupWithEndTime = [
'filters' => array_merge(
$timeFiltersWithEndTime,
[$this->getAssociatedObjectFilter($type, $data[$type])]
),
];
$payload['filterGroups'][] = $filterGroupWithEndTime;
$filterGroupWithoutEndTime = [
'filters' => array_merge(
$timeFiltersWithoutEndTime,
[$this->getAssociatedObjectFilter($type, $data[$type])]
),
];
$payload['filterGroups'][] = $filterGroupWithoutEndTime ;
}
}
return $payload;
}
/** Parameters
* [
* 'accountId' => $crmAccountId,
* 'sortBy' => $sortBy,
* 'sortDir' => $sortDir,
* 'onlyOpen' => $onlyOpen,
* 'closedStages' => $this->getClosedDealStages(),
* 'userId' => $userId,
* ];
*/
public function generateOpportunitiesSearchPayload(
Configuration $config,
string $crmAccountId,
array $closedStages,
): array {
$closedFilters = [];
$filterGroups = [];
$onlyOpen = true;
switch ($config->getOpportunityAssignmentRule()) {
case Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_UPDATED:
$sortBy = 'modifieddate';
$sortDir = 'DESCENDING';
break;
case Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_CREATED:
$sortBy = 'createdate';
$sortDir = 'DESCENDING';
break;
case Configuration::OPP_ASSIGNMENT_ALL_OPEN_OLDEST_CREATED:
$sortBy = 'createdate';
$sortDir = 'ASCENDING';
break;
case Configuration::OPP_ASSIGNMENT_ALL_RECENTLY_UPDATED:
default:
$sortBy = 'modifieddate';
$sortDir = 'DESCENDING';
$onlyOpen = false;
}
$baseFilters = [
[
'propertyName' => 'associations.company',
'operator' => 'EQ',
'value' => $crmAccountId,
],
];
// Handle closed stages in chunks
if ($onlyOpen) {
foreach (['won', 'lost'] as $key) {
if (! empty($closedStages[$key])) {
$chunks = array_chunk($closedStages[$key], self::MAX_FILTER_SIZE);
foreach ($chunks as $chunk) {
$closedFilters[] = [
'propertyName' => 'dealstage',
'operator' => 'NOT_IN',
'values' => $chunk,
];
}
}
}
}
$filterGroups[] = [
'filters' => array_merge($baseFilters, $closedFilters),
];
$payload = [
'filterGroups' => $filterGroups,
'sorts' => [
[
'propertyName' => $sortBy,
'direction' => $sortDir,
],
],
'properties' => [
'dealname',
'amount',
'hubspot_owner_id',
'pipeline',
'dealstage',
'closedate',
'deal_currency_code',
],
'limit' => self::MAX_SEARCH_REQUEST_LIMIT,
];
$logger = app(LoggerInterface::class);
$logger->info('[HubSpot] CRM Search requested', [
'request' => $payload,
]);
return $payload;
}
/**
* Converts v1 payload data to v3
*/
public function getV3MeetingPayload(array $engagement, array $metadata, array $associations = []): array
{
// Use this mapping until the whole Hubspot V1 is dropped.
$properties = [
'hubspot_owner_id' => $engagement['ownerId'],
'hs_timestamp' => $engagement['timestamp'],
];
// $engagement['activityType'] is $activity->category->name
if (isset($engagement['activityType'])) {
$properties['hs_activity_type'] = $engagement['activityType'];
}
$metadataKeyMap = [
'hs_meeting_outcome' => 'meetingOutcome',
'hs_meeting_title' => 'title',
'hs_meeting_start_time' => 'startTime',
'hs_meeting_end_time' => 'endTime',
'hs_meeting_body' => 'body',
'hs_internal_meeting_notes' => 'internalMeetingNotes',
];
foreach ($metadataKeyMap as $newKey => $oldKey) {
if (isset($metadata[$oldKey])) {
$properties[$newKey] = $metadata[$oldKey];
unset($metadata[$oldKey]);
}
}
$properties = [
...$properties,
...$metadata, // custom fields
];
$response = [
'properties' => $properties,
];
if (! empty($associations)) {
$response['associations'] = $associations;
}
return $response;
}
/**
* Generate a payload to search for contacts by name.
* The search is a token search in firstname or lastname
*/
public function generateSearchContactsByNamePayload(string $name, array $fields): array
{
$firstNameFilter = [
'propertyName' => 'firstname',
'operator' => 'CONTAINS_TOKEN',
'value' => $name,
];
$lastNameFilter = [
'propertyName' => 'lastname',
'operator' => 'CONTAINS_TOKEN',
'value' => $name,
];
return [
'filterGroups' => [
[
'filters' => [$firstNameFilter],
],
[
'filters' => [$lastNameFilter],
],
],
'properties' => $fields,
'sorts' => [
[
'propertyName' => 'lastmodifieddate',
'direction' => 'DESCENDING',
],
],
];
}
public function buildAddAssociationPayload(string $crmId, array $ids, int $associationType): array
{
$inputs = [];
foreach ($ids as $id) {
if ($id === null || $id === '') {
continue;
}
$inputs[] = [
'from' => [
'id' => $crmId,
],
'to' => [
'id' => (string) $id,
],
'types' => [
[
'associationCategory' => 'HUBSPOT_DEFINED',
'associationTypeId' => $associationType,
],
],
];
}
return ['inputs' => $inputs];
}
public function buildRemoveAssociationPayload(string $crmId, array $ids): array
{
$toArray = [];
foreach ($ids as $id) {
if ($id === null || $id === '') {
continue;
}
$toArray[] = ['id' => (string) $id];
}
return [
'inputs' => [
[
'from' => ['id' => $crmId],
'to' => $toArray,
],
],
];
}
public function addClosedStageFilters(array &$payload, array $closedStages): void
{
if (! empty($closedStages['won'])) {
$payload['filters'][] = [
'propertyName' => 'dealstage',
'operator' => 'NOT_IN',
'values' => $closedStages['won'],
];
}
if (! empty($closedStages['lost'])) {
$payload['filters'][] = [
'propertyName' => 'dealstage',
'operator' => 'NOT_IN',
'values' => $closedStages['lost'],
];
}
}
public function addCreatedDateFilters(array &$payload, Carbon $createdAfter): void
{
$payload['filters'][] = [
'propertyName' => 'createdate',
'operator' => 'GT',
'value' => $createdAfter->getPreciseTimestamp(3),
];
}
public function getDealsInBulkPayload(array $dealIds): array
{
return [
'filterGroups' => [
[
'filters' => [
[
'propertyName' => 'hs_object_id',
'operator' => 'IN',
'values' => $dealIds,
],
],
],
],
'limit' => self::MAX_FILTER_SIZE,
];
}
}
Sync Changes
Hide This Notification
Code changed:
Hide
22
8
Previous Highlighted Error
Next Highlighted Error...
|
PhpStorm
|
faVsco.js – PropertyChangeManager.php
|
NULL
|
87881
|
|
87880
|
Project: faVsco.js, menu
#12121 on JY-20963-fix-im Project: faVsco.js, menu
#12121 on JY-20963-fix-import-on-deleted-entity, menu
Start Listening for PHP Debug Connections
ServiceTest
Run 'ServiceTest'
Debug 'ServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
8
25
Previous Highlighted Error
Next Highlighted Error
<?php
declare(strict_types=1);
namespace Jiminny\Services\Crm\Hubspot;
use Carbon\Carbon;
use Jiminny\Models\Crm\Configuration;
use Psr\Log\LoggerInterface;
class PayloadBuilder
{
public const int MAX_SEARCH_REQUEST_LIMIT = 200;
private const int MAX_FILTER_SIZE = 100;
private const string SORT_PROPERTY = 'hs_timestamp';
private const string ENGAGEMENT_MEETINGS = 'meetings';
public function getLinkToTaskPayload(string $objectType, string $objectId, string $engagementType): array
{
$payload = [
'sorts' => [
[
'propertyName' => self::SORT_PROPERTY,
'direction' => 'DESCENDING',
],
],
'limit' => self::MAX_SEARCH_REQUEST_LIMIT,
];
if ($engagementType === self::ENGAGEMENT_MEETINGS) {
$payload['filterGroups'] = $this->buildMeetingFiltersForLinkToTask($objectType, $objectId);
$payload['properties'] = [
'hs_meeting_title',
'hs_meeting_outcome',
'hs_timestamp',
'hs_activity_type',
];
} else {
$payload['filterGroups'] = $this->buildTaskFiltersForLinkToTask($objectType, $objectId);
$payload['properties'] = ['hs_task_subject', 'hs_timestamp'];
}
return $payload;
}
public function getRecentlyUpdatedSearchPayload(Carbon $since, ?Carbon $to, array $properties): array
{
$lastUpdateDate = in_array('firstname', $properties) ? 'lastmodifieddate' : 'hs_lastmodifieddate';
$payload = [
'filters' => [
[
'propertyName' => $lastUpdateDate,
'operator' => 'GT',
'value' => $since->getPreciseTimestamp(3),
],
],
'sorts' => [
[
'propertyName' => 'hs_object_id',
'direction' => 'DESCENDING',
],
],
'limit' => self::MAX_SEARCH_REQUEST_LIMIT,
];
if ($to) {
$payload['filters'][] = [
'propertyName' => $lastUpdateDate,
'operator' => 'LT',
'value' => $to->getPreciseTimestamp(3),
];
}
$payload['properties'] = $properties;
return $payload;
}
public function getByProfileSearchPayload(string $crmId, array $properties, Carbon $since, ?Carbon $to): array
{
$lastUpdateDate = array_key_exists('firstname', $properties) ? 'lastmodifieddate' : 'hs_lastmodifieddate';
$payload = [
'filters' => [
[
'propertyName' => $lastUpdateDate,
'operator' => 'GT',
'value' => $since->getPreciseTimestamp(3),
],
[
'propertyName' => 'hubspot_owner_id',
'operator' => 'EQ',
'value' => $crmId,
],
],
'sorts' => [
[
'propertyName' => 'hs_object_id',
'direction' => 'DESCENDING',
],
],
'limit' => self::MAX_SEARCH_REQUEST_LIMIT,
];
if ($to) {
$payload['filters'][] = [
'propertyName' => $lastUpdateDate,
'operator' => 'LT',
'value' => $to->getPreciseTimestamp(3),
];
}
$payload['properties'] = $properties;
return $payload;
}
private function buildMeetingFiltersForLinkToTask($objectType, $objectId): array
{
$filters = [];
$filters[] = [
'filters' => [
$this->getAssociatedObjectFilter($objectType, $objectId),
[
'propertyName' => 'hs_meeting_outcome',
'operator' => 'IN',
'values' => ['SCHEDULED', 'RESCHEDULED'],
],
],
];
$filters[] = [
'filters' => [
$this->getAssociatedObjectFilter($objectType, $objectId),
[
'propertyName' => 'hs_meeting_outcome',
'operator' => 'NOT_HAS_PROPERTY',
],
],
];
return $filters;
}
private function buildTaskFiltersForLinkToTask($objectType, $objectId): array
{
return [
[
'filters' => [
$this->getAssociatedObjectFilter($objectType, $objectId),
],
],
];
}
private function getAssociatedObjectFilter(string $objectType, string $objectId): array
{
return [
'propertyName' => 'associations.' . $objectType,
'operator' => 'EQ',
'value' => $objectId,
];
}
public function generatePlaybackURLSearchPayload(string $playbackURLToken): array
{
return [
'filters' => [
[
'propertyName' => 'hs_call_recording_url',
'operator' => 'CONTAINS_TOKEN',
'value' => $playbackURLToken,
],
],
'properties' => [
'hs_call_recording_url',
'hs_call_body',
],
'limit' => 1,
];
}
public function generateGetCallsPayload(Carbon $from, Carbon $to, string $activityProvider, $page): array
{
return [
'filters' => [
[
'propertyName' => 'hs_call_recording_url',
'operator' => 'CONTAINS_TOKEN',
'value' => $activityProvider,
],
[
'propertyName' => 'hs_timestamp',
'operator' => 'BETWEEN',
'value' => intval($from->getPreciseTimestamp(3)),
'highValue' => intval($to->getPreciseTimestamp(3)),
],
],
'sorts' => [
[
'propertyName' => 'hs_timestamp',
'direction' => 'DESCENDING',
],
],
'properties' => [
'hs_call_recording_url',
'hs_call_body',
],
'limit' => self::MAX_SEARCH_REQUEST_LIMIT,
'after' => ($page - 1) * self::MAX_SEARCH_REQUEST_LIMIT,
];
}
public function generateSearchCallsByPeriodPayload(Carbon $from, Carbon $to, $page): array
{
return [
'filters' => [
[
'propertyName' => 'hs_timestamp',
'operator' => 'BETWEEN',
'value' => intval($from->getPreciseTimestamp(3)),
'highValue' => intval($to->getPreciseTimestamp(3)),
],
],
'sorts' => [
[
'propertyName' => 'hs_timestamp',
'direction' => 'DESCENDING',
],
],
'properties' => $this->getSearchCallAttributes(),
'limit' => self::MAX_SEARCH_REQUEST_LIMIT,
'after' => ($page - 1) * self::MAX_SEARCH_REQUEST_LIMIT,
];
}
public function getSearchCallAttributes(): array
{
return [
'hs_timestamp',
'hs_call_recording_url',
'hs_call_body',
'hs_call_status',
'hs_call_to_number',
'hs_call_from_number',
'hs_call_duration',
'hs_call_disposition',
'hs_call_title',
'hs_call_direction',
'hubspot_owner_id',
'hs_activity_type',
'hs_call_external_id',
'hs_call_source',
];
}
public function generatePlaybackAddUrlBatchPayload(array $crmUpdateData): array
{
$updateObjectsData = [];
foreach ($crmUpdateData as $data) {
$updateObjectsData[] = [
'id' => $data['crm_id'],
'properties' => [
'hs_call_body' => $data['hs_call_body'] .
'<p><span style="font-size: 13.3333px; line-height: 16px;">' .
'<a href="' . $data['playback_url'] . '" target="_blank">Review in Jiminny</a> ▶️</span></p>',
],
];
}
return ['inputs' => $updateObjectsData];
}
public function generateSearchCallByTokenPayload(string $playbackURLToken): array
{
return [
'filters' => [
[
'propertyName' => 'hs_call_recording_url',
'operator' => 'CONTAINS_TOKEN',
'value' => $playbackURLToken,
],
],
'properties' => [
'hs_call_recording_url',
'hs_call_body',
],
'limit' => 1,
];
}
/**
* Generates a payload for phone search based on the specified parameters.
*
* @param string $phone The phone number to search.
* @param bool $isAlternativeSearch Indicates if an alternative search should be performed
* if the first search fails to find a match. The first search request should cover most of the cases.
*/
public function generatePhoneSearchPayload(string $phone, bool $isAlternativeSearch = false): array
{
$filterPropertyNames = $isAlternativeSearch ?
['hs_searchable_calculated_mobile_number', 'phone', 'mobilephone'] :
['hs_searchable_calculated_phone_number', 'hs_calculated_phone_number', 'hs_calculated_mobile_number'];
$filterGroups = array_map(function ($propertyName) use ($phone) {
return $this->createFilterGroup($propertyName, 'CONTAINS_TOKEN', $phone);
}, $filterPropertyNames);
$properties = [
'email',
'firstname',
'lastname',
'country',
'phone',
'mobilephone',
'jobtitle',
'hubspot_owner_id',
'associatedcompanyid',
'photo',
];
return $this->createPayload($filterGroups, $properties);
}
/**
* Creates a filter group with the specified parameters.
*/
private function createFilterGroup(string $propertyName, string $operator, $value): array
{
return [
'filters' => [
[
'propertyName' => $propertyName,
'operator' => $operator,
'value' => $value,
],
],
];
}
/**
* Creates a payload with the specified filter groups and properties.
*/
private function createPayload(array $filterGroups, array $properties): array
{
return [
'filterGroups' => $filterGroups,
'sorts' => [
[
'propertyName' => 'modifieddate',
'direction' => 'DESCENDING',
],
],
'properties' => $properties,
'limit' => 1,
];
}
/**
* Generates a payload to find related activities based on the specified data and object type.
* The search looks for activities that match the specified time range (starttime - endtime or only starttime with endtime not set)
* and are associated with any of the provided prospect types (contact or company).
*
* @param array $data An associative array containing the following keys:
* - 'from': The start time for the activity search (timestamp).
* - 'to': The end time for the activity search (timestamp).
* - 'contact': (optional) The ID of the associated contact.
* - 'company': (optional) The ID of the associated company.
* @param string $objectType The type of engagement to search for (meeting or other type like call).
*
* @return array The payload array to be used for searching related activities.
*/
public function getFindRelatedActivityPayload(array $data, string $objectType): array
{
$payload = [
'sorts' => [
[
'propertyName' => self::SORT_PROPERTY,
'direction' => 'DESCENDING',
],
],
'limit' => self::MAX_SEARCH_REQUEST_LIMIT,
];
if ($objectType === self::ENGAGEMENT_MEETINGS) {
$payload['properties'] = [
'hs_meeting_title',
'hs_meeting_outcome',
'hs_activity_type',
'hs_timestamp',
'hubspot_owner_id',
'hs_meeting_body',
'hs_internal_meeting_notes',
'hs_meeting_location',
'hs_meeting_start_time',
'hs_meeting_end_time',
];
} else {
$payload['properties'] = ['hs_task_subject', 'hs_timestamp', 'hubspot_owner_id', 'hs_call_body'];
}
$timeFiltersWithEndTime = [
[
'propertyName' => 'hs_meeting_start_time',
'operator' => 'GTE',
'value' => $data['from'],
],
[
'propertyName' => 'hs_meeting_end_time',
'operator' => 'LTE',
'value' => $data['to'],
],
];
$timeFiltersWithoutEndTime = [
[
'propertyName' => 'hs_meeting_start_time',
'operator' => 'GTE',
'value' => $data['from'],
],
[
'propertyName' => 'hs_meeting_end_time',
'operator' => 'NOT_HAS_PROPERTY',
],
];
$payload['filterGroups'] = [];
$associationTypes = ['contact', 'company'];
foreach ($associationTypes as $type) {
if (! empty($data[$type])) {
$filterGroupWithEndTime = [
'filters' => array_merge(
$timeFiltersWithEndTime,
[$this->getAssociatedObjectFilter($type, $data[$type])]
),
];
$payload['filterGroups'][] = $filterGroupWithEndTime;
$filterGroupWithoutEndTime = [
'filters' => array_merge(
$timeFiltersWithoutEndTime,
[$this->getAssociatedObjectFilter($type, $data[$type])]
),
];
$payload['filterGroups'][] = $filterGroupWithoutEndTime ;
}
}
return $payload;
}
/** Parameters
* [
* 'accountId' => $crmAccountId,
* 'sortBy' => $sortBy,
* 'sortDir' => $sortDir,
* 'onlyOpen' => $onlyOpen,
* 'closedStages' => $this->getClosedDealStages(),
* 'userId' => $userId,
* ];
*/
public function generateOpportunitiesSearchPayload(
Configuration $config,
string $crmAccountId,
array $closedStages,
): array {
$closedFilters = [];
$filterGroups = [];
$onlyOpen = true;
switch ($config->getOpportunityAssignmentRule()) {
case Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_UPDATED:
$sortBy = 'modifieddate';
$sortDir = 'DESCENDING';
break;
case Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_CREATED:
$sortBy = 'createdate';
$sortDir = 'DESCENDING';
break;
case Configuration::OPP_ASSIGNMENT_ALL_OPEN_OLDEST_CREATED:
$sortBy = 'createdate';
$sortDir = 'ASCENDING';
break;
case Configuration::OPP_ASSIGNMENT_ALL_RECENTLY_UPDATED:
default:
$sortBy = 'modifieddate';
$sortDir = 'DESCENDING';
$onlyOpen = false;
}
$baseFilters = [
[
'propertyName' => 'associations.company',
'operator' => 'EQ',
'value' => $crmAccountId,
],
];
// Handle closed stages in chunks
if ($onlyOpen) {
foreach (['won', 'lost'] as $key) {
if (! empty($closedStages[$key])) {
$chunks = array_chunk($closedStages[$key], self::MAX_FILTER_SIZE);
foreach ($chunks as $chunk) {
$closedFilters[] = [
'propertyName' => 'dealstage',
'operator' => 'NOT_IN',
'values' => $chunk,
];
}
}
}
}
$filterGroups[] = [
'filters' => array_merge($baseFilters, $closedFilters),
];
$payload = [
'filterGroups' => $filterGroups,
'sorts' => [
[
'propertyName' => $sortBy,
'direction' => $sortDir,
],
],
'properties' => [
'dealname',
'amount',
'hubspot_owner_id',
'pipeline',
'dealstage',
'closedate',
'deal_currency_code',
],
'limit' => self::MAX_SEARCH_REQUEST_LIMIT,
];
$logger = app(LoggerInterface::class);
$logger->info('[HubSpot] CRM Search requested', [
'request' => $payload,
]);
return $payload;
}
/**
* Converts v1 payload data to v3
*/
public function getV3MeetingPayload(array $engagement, array $metadata, array $associations = []): array
{
// Use this mapping until the whole Hubspot V1 is dropped.
$properties = [
'hubspot_owner_id' => $engagement['ownerId'],
'hs_timestamp' => $engagement['timestamp'],
];
// $engagement['activityType'] is $activity->category->name
if (isset($engagement['activityType'])) {
$properties['hs_activity_type'] = $engagement['activityType'];
}
$metadataKeyMap = [
'hs_meeting_outcome' => 'meetingOutcome',
'hs_meeting_title' => 'title',
'hs_meeting_start_time' => 'startTime',
'hs_meeting_end_time' => 'endTime',
'hs_meeting_body' => 'body',
'hs_internal_meeting_notes' => 'internalMeetingNotes',
];
foreach ($metadataKeyMap as $newKey => $oldKey) {
if (isset($metadata[$oldKey])) {
$properties[$newKey] = $metadata[$oldKey];
unset($metadata[$oldKey]);
}
}
$properties = [
...$properties,
...$metadata, // custom fields
];
$response = [
'properties' => $properties,
];
if (! empty($associations)) {
$response['associations'] = $associations;
}
return $response;
}
/**
* Generate a payload to search for contacts by name.
* The search is a token search in firstname or lastname
*/
public function generateSearchContactsByNamePayload(string $name, array $fields): array
{
$firstNameFilter = [
'propertyName' => 'firstname',
'operator' => 'CONTAINS_TOKEN',
'value' => $name,
];
$lastNameFilter = [
'propertyName' => 'lastname',
'operator' => 'CONTAINS_TOKEN',
'value' => $name,
];
return [
'filterGroups' => [
[
'filters' => [$firstNameFilter],
],
[
'filters' => [$lastNameFilter],
],
],
'properties' => $fields,
'sorts' => [
[
'propertyName' => 'lastmodifieddate',
'direction' => 'DESCENDING',
],
],
];
}
public function buildAddAssociationPayload(string $crmId, array $ids, int $associationType): array
{
$inputs = [];
foreach ($ids as $id) {
if ($id === null || $id === '') {
continue;
}
$inputs[] = [
'from' => [
'id' => $crmId,
],
'to' => [
'id' => (string) $id,
],
'types' => [
[
'associationCategory' => 'HUBSPOT_DEFINED',
'associationTypeId' => $associationType,
],
],
];
}
return ['inputs' => $inputs];
}
public function buildRemoveAssociationPayload(string $crmId, array $ids): array
{
$toArray = [];
foreach ($ids as $id) {
if ($id === null || $id === '') {
continue;
}
$toArray[] = ['id' => (string) $id];
}
return [
'inputs' => [
[
'from' => ['id' => $crmId],
'to' => $toArray,
],
],
];
}
public function addClosedStageFilters(array &$payload, array $closedStages): void
{
if (! empty($closedStages['won'])) {
$payload['filters'][] = [
'propertyName' => 'dealstage',
'operator' => 'NOT_IN',
'values' => $closedStages['won'],
];
}
if (! empty($closedStages['lost'])) {
$payload['filters'][] = [
'propertyName' => 'dealstage',
'operator' => 'NOT_IN',
'values' => $closedStages['lost'],
];
}
}
public function addCreatedDateFilters(array &$payload, Carbon $createdAfter): void
{
$payload['filters'][] = [
'propertyName' => 'createdate',
'operator' => 'GT',
'value' => $createdAfter->getPreciseTimestamp(3),
];
}
public function getDealsInBulkPayload(array $dealIds): array
{
return [
'filterGroups' => [
[
'filters' => [
[
'propertyName' => 'hs_object_id',
'operator' => 'IN',
'values' => $dealIds,
],
],
],
],
'limit' => self::MAX_FILTER_SIZE,
];
}
}
Sync Changes
Hide This Notification
Code changed:
Hide
22...
|
PhpStorm
|
faVsco.js – PropertyChangeManager.php
|
NULL
|
87880
|
|
87879
|
Project: faVsco.js, menu
#12121 on JY-20963-fix-im Project: faVsco.js, menu
#12121 on JY-20963-fix-import-on-deleted-entity, menu
Start Listening for PHP Debug Connections
ServiceTest
Run 'ServiceTest'
Debug 'ServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
iTerm2ShellEditViewSessionScriptsProfilesWindowHelp‹ >0 lolDOCKER₴81DEV (-zsh)O 82Version 2023.11.20260427:Version 2023.11.20260505:Version2023.11.20260509:Version2023.11.20260511:Version2023.11.20260514:Version 2023.11.20260526:Run"/usr/bin/dnf check-release-update"#_####_\ #####\\###||\#/v~'Amazon Linux 2023 (ECS Optimized)-zshN3ec2-user@ip-10-20-31-146:~screenpipe"-zshX5ec2-user@ip-10-30-129-...ec2-user@ip-10-20-31-1...100% C8• Thu 28 May 19:28:10181·7for full release and version update infom/For documentation, visit [URL_WITH_CREDENTIALS] ~]$ docker exec -it $(docker ps --format "{{.ID}}" --filter "name-ecs-worker" | head -1) /bin/bash -c "cd /home/jiminny && bash"root@170a323e43a4:/home/jiminny# php artisan crm:sync-opportunity --teamId 555 --opportunityId 494058190045[2026-05-28 16:20:08] production.INF0: Jiminny\Console\Commands\Command: : run Memory usage before starting command {"command": "crm:sync-opportunity", "memoryBeforeCommandInMb" : 118.0, "memoryPeakBeforeCommandInMb":118.03 {"correlation_id":"0a531c45-bc24-4ea8-b3c9-d90f79590da8", "trace_id":"1723bcBc-9f67-4ec7-b8c3-d09361d5879d"}Syncing opportunity for Cognitive Credit[2026-05-28 16:20:08] production.INFO: [SocialAccountService] Fetching token {"socialAccountId":30591,"provider": "hubspot"} {"correlation_id":"0a531c45-bc24-4ea8-b3c9-d90f79590da8", "trace_id":"1723bcDc-9f67-4ec7-b8c3-d09361d5879d"'}[2026-05-28 16:20:08] production.INFO: [SocialAccountService] Token retrieved {"socialAccountId":30591, "provider": "hubspot"} {"correlation_id":"0a531c45-bc24-4ea8-b3c9-d90f79590da8", "trace_id":"1723bc0c-9f67-4ec7-b8c3-d09361d5879d"'}[2026-05-28 16:20:08] production.INFO: [EncryptedTokenManager] Generatingaccess token. {"mode":"legacy"} {"correlation_id":"0a531c45-bc24-4ea8-b3c9-d90f79590da8","trace_id" :"1723bc0c-9f67-4ec7-b8c3-d09361d5879d"}Syncing opportunity 494058190045...[2026-05-28 16:20:08] production.INF0: [ReindexForOpportunityListener] Schedule reindexing job {"opportunity_id":7601423} {"correlation_id":"0a531c45-bc24-4ea8-b3c9-d90f79590da8","trace_id":"1723bc0c-9f67-4ec7-b8c3-d09361d5879d"}Synced Centiva Capital - EU HY/IG to 7601423[2026-05-28 16:20:08] production.INF0: Jiminny\Console\Commands\Command: :run Memory usage for command {"command": "crm:sync-opportunity", "memoryBeforeCommandInMb" : 118.0, "memoryAfterCommandInMB": 126.0, "memoryPeakBeforeCommandInMb" : 118.0, "memoryPeakAfterCommandInMB":126.0} {"correlation_id":"0a531c45-bc24-4ea8-b3c9-d90f79590da8", "trace_id" : "1723bc0c-9f67-4ec7-b8c3-d09361d5879d"}root@170a323e43a4:/home/jiminny# |...
|
PhpStorm
|
faVsco.js – PropertyChangeManager.php
|
NULL
|
87879
|
|
87878
|
Project: faVsco.js, menu
#12121 on JY-20963-fix-im Project: faVsco.js, menu
#12121 on JY-20963-fix-import-on-deleted-entity, menu
Start Listening for PHP Debug Connections
ServiceTest
Run 'ServiceTest'
Debug 'ServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
8
25
Previous Highlighted Error
Next Highlighted Error
<?php
declare(strict_types=1);
namespace Jiminny\Services\Crm\Hubspot;
use Carbon\Carbon;
use Jiminny\Models\Crm\Configuration;
use Psr\Log\LoggerInterface;
class PayloadBuilder
{
public const int MAX_SEARCH_REQUEST_LIMIT = 200;
private const int MAX_FILTER_SIZE = 100;
private const string SORT_PROPERTY = 'hs_timestamp';
private const string ENGAGEMENT_MEETINGS = 'meetings';
public function getLinkToTaskPayload(string $objectType, string $objectId, string $engagementType): array
{
$payload = [
'sorts' => [
[
'propertyName' => self::SORT_PROPERTY,
'direction' => 'DESCENDING',
],
],
'limit' => self::MAX_SEARCH_REQUEST_LIMIT,
];
if ($engagementType === self::ENGAGEMENT_MEETINGS) {
$payload['filterGroups'] = $this->buildMeetingFiltersForLinkToTask($objectType, $objectId);
$payload['properties'] = [
'hs_meeting_title',
'hs_meeting_outcome',
'hs_timestamp',
'hs_activity_type',
];
} else {
$payload['filterGroups'] = $this->buildTaskFiltersForLinkToTask($objectType, $objectId);
$payload['properties'] = ['hs_task_subject', 'hs_timestamp'];
}
return $payload;
}
public function getRecentlyUpdatedSearchPayload(Carbon $since, ?Carbon $to, array $properties): array
{
$lastUpdateDate = in_array('firstname', $properties) ? 'lastmodifieddate' : 'hs_lastmodifieddate';
$payload = [
'filters' => [
[
'propertyName' => $lastUpdateDate,
'operator' => 'GT',
'value' => $since->getPreciseTimestamp(3),
],
],
'sorts' => [
[
'propertyName' => 'hs_object_id',
'direction' => 'DESCENDING',
],
],
'limit' => self::MAX_SEARCH_REQUEST_LIMIT,
];
if ($to) {
$payload['filters'][] = [
'propertyName' => $lastUpdateDate,
'operator' => 'LT',
'value' => $to->getPreciseTimestamp(3),
];
}
$payload['properties'] = $properties;
return $payload;
}
public function getByProfileSearchPayload(string $crmId, array $properties, Carbon $since, ?Carbon $to): array
{
$lastUpdateDate = array_key_exists('firstname', $properties) ? 'lastmodifieddate' : 'hs_lastmodifieddate';
$payload = [
'filters' => [
[
'propertyName' => $lastUpdateDate,
'operator' => 'GT',
'value' => $since->getPreciseTimestamp(3),
],
[
'propertyName' => 'hubspot_owner_id',
'operator' => 'EQ',
'value' => $crmId,
],
],
'sorts' => [
[
'propertyName' => 'hs_object_id',
'direction' => 'DESCENDING',
],
],
'limit' => self::MAX_SEARCH_REQUEST_LIMIT,
];
if ($to) {
$payload['filters'][] = [
'propertyName' => $lastUpdateDate,
'operator' => 'LT',
'value' => $to->getPreciseTimestamp(3),
];
}
$payload['properties'] = $properties;
return $payload;
}
private function buildMeetingFiltersForLinkToTask($objectType, $objectId): array
{
$filters = [];
$filters[] = [
'filters' => [
$this->getAssociatedObjectFilter($objectType, $objectId),
[
'propertyName' => 'hs_meeting_outcome',
'operator' => 'IN',
'values' => ['SCHEDULED', 'RESCHEDULED'],
],
],
];
$filters[] = [
'filters' => [
$this->getAssociatedObjectFilter($objectType, $objectId),
[
'propertyName' => 'hs_meeting_outcome',
'operator' => 'NOT_HAS_PROPERTY',
],
],
];
return $filters;
}
private function buildTaskFiltersForLinkToTask($objectType, $objectId): array
{
return [
[
'filters' => [
$this->getAssociatedObjectFilter($objectType, $objectId),
],
],
];
}
private function getAssociatedObjectFilter(string $objectType, string $objectId): array
{
return [
'propertyName' => 'associations.' . $objectType,
'operator' => 'EQ',
'value' => $objectId,
];
}
public function generatePlaybackURLSearchPayload(string $playbackURLToken): array
{
return [
'filters' => [
[
'propertyName' => 'hs_call_recording_url',
'operator' => 'CONTAINS_TOKEN',
'value' => $playbackURLToken,
],
],
'properties' => [
'hs_call_recording_url',
'hs_call_body',
],
'limit' => 1,
];
}
public function generateGetCallsPayload(Carbon $from, Carbon $to, string $activityProvider, $page): array
{
return [
'filters' => [
[
'propertyName' => 'hs_call_recording_url',
'operator' => 'CONTAINS_TOKEN',
'value' => $activityProvider,
],
[
'propertyName' => 'hs_timestamp',
'operator' => 'BETWEEN',
'value' => intval($from->getPreciseTimestamp(3)),
'highValue' => intval($to->getPreciseTimestamp(3)),
],
],
'sorts' => [
[
'propertyName' => 'hs_timestamp',
'direction' => 'DESCENDING',
],
],
'properties' => [
'hs_call_recording_url',
'hs_call_body',
],
'limit' => self::MAX_SEARCH_REQUEST_LIMIT,
'after' => ($page - 1) * self::MAX_SEARCH_REQUEST_LIMIT,
];
}
public function generateSearchCallsByPeriodPayload(Carbon $from, Carbon $to, $page): array
{
return [
'filters' => [
[
'propertyName' => 'hs_timestamp',
'operator' => 'BETWEEN',
'value' => intval($from->getPreciseTimestamp(3)),
'highValue' => intval($to->getPreciseTimestamp(3)),
],
],
'sorts' => [
[
'propertyName' => 'hs_timestamp',
'direction' => 'DESCENDING',
],
],
'properties' => $this->getSearchCallAttributes(),
'limit' => self::MAX_SEARCH_REQUEST_LIMIT,
'after' => ($page - 1) * self::MAX_SEARCH_REQUEST_LIMIT,
];
}
public function getSearchCallAttributes(): array
{
return [
'hs_timestamp',
'hs_call_recording_url',
'hs_call_body',
'hs_call_status',
'hs_call_to_number',
'hs_call_from_number',
'hs_call_duration',
'hs_call_disposition',
'hs_call_title',
'hs_call_direction',
'hubspot_owner_id',
'hs_activity_type',
'hs_call_external_id',
'hs_call_source',
];
}
public function generatePlaybackAddUrlBatchPayload(array $crmUpdateData): array
{
$updateObjectsData = [];
foreach ($crmUpdateData as $data) {
$updateObjectsData[] = [
'id' => $data['crm_id'],
'properties' => [
'hs_call_body' => $data['hs_call_body'] .
'<p><span style="font-size: 13.3333px; line-height: 16px;">' .
'<a href="' . $data['playback_url'] . '" target="_blank">Review in Jiminny</a> ▶️</span></p>',
],
];
}
return ['inputs' => $updateObjectsData];
}
public function generateSearchCallByTokenPayload(string $playbackURLToken): array
{
return [
'filters' => [
[
'propertyName' => 'hs_call_recording_url',
'operator' => 'CONTAINS_TOKEN',
'value' => $playbackURLToken,
],
],
'properties' => [
'hs_call_recording_url',
'hs_call_body',
],
'limit' => 1,
];
}
/**
* Generates a payload for phone search based on the specified parameters.
*
* @param string $phone The phone number to search.
* @param bool $isAlternativeSearch Indicates if an alternative search should be performed
* if the first search fails to find a match. The first search request should cover most of the cases.
*/
public function generatePhoneSearchPayload(string $phone, bool $isAlternativeSearch = false): array
{
$filterPropertyNames = $isAlternativeSearch ?
['hs_searchable_calculated_mobile_number', 'phone', 'mobilephone'] :
['hs_searchable_calculated_phone_number', 'hs_calculated_phone_number', 'hs_calculated_mobile_number'];
$filterGroups = array_map(function ($propertyName) use ($phone) {
return $this->createFilterGroup($propertyName, 'CONTAINS_TOKEN', $phone);
}, $filterPropertyNames);
$properties = [
'email',
'firstname',
'lastname',
'country',
'phone',
'mobilephone',
'jobtitle',
'hubspot_owner_id',
'associatedcompanyid',
'photo',
];
return $this->createPayload($filterGroups, $properties);
}
/**
* Creates a filter group with the specified parameters.
*/
private function createFilterGroup(string $propertyName, string $operator, $value): array
{
return [
'filters' => [
[
'propertyName' => $propertyName,
'operator' => $operator,
'value' => $value,
],
],
];
}
/**
* Creates a payload with the specified filter groups and properties.
*/
private function createPayload(array $filterGroups, array $properties): array
{
return [
'filterGroups' => $filterGroups,
'sorts' => [
[
'propertyName' => 'modifieddate',
'direction' => 'DESCENDING',
],
],
'properties' => $properties,
'limit' => 1,
];
}
/**
* Generates a payload to find related activities based on the specified data and object type.
* The search looks for activities that match the specified time range (starttime - endtime or only starttime with endtime not set)
* and are associated with any of the provided prospect types (contact or company).
*
* @param array $data An associative array containing the following keys:
* - 'from': The start time for the activity search (timestamp).
* - 'to': The end time for the activity search (timestamp).
* - 'contact': (optional) The ID of the associated contact.
* - 'company': (optional) The ID of the associated company.
* @param string $objectType The type of engagement to search for (meeting or other type like call).
*
* @return array The payload array to be used for searching related activities.
*/
public function getFindRelatedActivityPayload(array $data, string $objectType): array
{
$payload = [
'sorts' => [
[
'propertyName' => self::SORT_PROPERTY,
'direction' => 'DESCENDING',
],
],
'limit' => self::MAX_SEARCH_REQUEST_LIMIT,
];
if ($objectType === self::ENGAGEMENT_MEETINGS) {
$payload['properties'] = [
'hs_meeting_title',
'hs_meeting_outcome',
'hs_activity_type',
'hs_timestamp',
'hubspot_owner_id',
'hs_meeting_body',
'hs_internal_meeting_notes',
'hs_meeting_location',
'hs_meeting_start_time',
'hs_meeting_end_time',
];
} else {
$payload['properties'] = ['hs_task_subject', 'hs_timestamp', 'hubspot_owner_id', 'hs_call_body'];
}
$timeFiltersWithEndTime = [
[
'propertyName' => 'hs_meeting_start_time',
'operator' => 'GTE',
'value' => $data['from'],
],
[
'propertyName' => 'hs_meeting_end_time',
'operator' => 'LTE',
'value' => $data['to'],
],
];
$timeFiltersWithoutEndTime = [
[
'propertyName' => 'hs_meeting_start_time',
'operator' => 'GTE',
'value' => $data['from'],
],
[
'propertyName' => 'hs_meeting_end_time',
'operator' => 'NOT_HAS_PROPERTY',
],
];
$payload['filterGroups'] = [];
$associationTypes = ['contact', 'company'];
foreach ($associationTypes as $type) {
if (! empty($data[$type])) {
$filterGroupWithEndTime = [
'filters' => array_merge(
$timeFiltersWithEndTime,
[$this->getAssociatedObjectFilter($type, $data[$type])]
),
];
$payload['filterGroups'][] = $filterGroupWithEndTime;
$filterGroupWithoutEndTime = [
'filters' => array_merge(
$timeFiltersWithoutEndTime,
[$this->getAssociatedObjectFilter($type, $data[$type])]
),
];
$payload['filterGroups'][] = $filterGroupWithoutEndTime ;
}
}
return $payload;
}
/** Parameters
* [
* 'accountId' => $crmAccountId,
* 'sortBy' => $sortBy,
* 'sortDir' => $sortDir,
* 'onlyOpen' => $onlyOpen,
* 'closedStages' => $this->getClosedDealStages(),
* 'userId' => $userId,
* ];
*/
public function generateOpportunitiesSearchPayload(
Configuration $config,
string $crmAccountId,
array $closedStages,
): array {
$closedFilters = [];
$filterGroups = [];
$onlyOpen = true;
switch ($config->getOpportunityAssignmentRule()) {
case Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_UPDATED:
$sortBy = 'modifieddate';
$sortDir = 'DESCENDING';
break;
case Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_CREATED:
$sortBy = 'createdate';
$sortDir = 'DESCENDING';
break;
case Configuration::OPP_ASSIGNMENT_ALL_OPEN_OLDEST_CREATED:
$sortBy = 'createdate';
$sortDir = 'ASCENDING';
break;
case Configuration::OPP_ASSIGNMENT_ALL_RECENTLY_UPDATED:
default:
$sortBy = 'modifieddate';
$sortDir = 'DESCENDING';
$onlyOpen = false;
}
$baseFilters = [
[
'propertyName' => 'associations.company',
'operator' => 'EQ',
'value' => $crmAccountId,
],
];
// Handle closed stages in chunks
if ($onlyOpen) {
foreach (['won', 'lost'] as $key) {
if (! empty($closedStages[$key])) {
$chunks = array_chunk($closedStages[$key], self::MAX_FILTER_SIZE);
foreach ($chunks as $chunk) {
$closedFilters[] = [
'propertyName' => 'dealstage',
'operator' => 'NOT_IN',
'values' => $chunk,
];
}
}
}
}
$filterGroups[] = [
'filters' => array_merge($baseFilters, $closedFilters),
];
$payload = [
'filterGroups' => $filterGroups,
'sorts' => [
[
'propertyName' => $sortBy,
'direction' => $sortDir,
],
],
'properties' => [
'dealname',
'amount',
'hubspot_owner_id',
'pipeline',
'dealstage',
'closedate',
'deal_currency_code',
],
'limit' => self::MAX_SEARCH_REQUEST_LIMIT,
];
$logger = app(LoggerInterface::class);
$logger->info('[HubSpot] CRM Search requested', [
'request' => $payload,
]);
return $payload;
}
/**
* Converts v1 payload data to v3
*/
public function getV3MeetingPayload(array $engagement, array $metadata, array $associations = []): array
{
// Use this mapping until the whole Hubspot V1 is dropped.
$properties = [
'hubspot_owner_id' => $engagement['ownerId'],
'hs_timestamp' => $engagement['timestamp'],
];
// $engagement['activityType'] is $activity->category->name
if (isset($engagement['activityType'])) {
$properties['hs_activity_type'] = $engagement['activityType'];
}
$metadataKeyMap = [
'hs_meeting_outcome' => 'meetingOutcome',
'hs_meeting_title' => 'title',
'hs_meeting_start_time' => 'startTime',
'hs_meeting_end_time' => 'endTime',
'hs_meeting_body' => 'body',
'hs_internal_meeting_notes' => 'internalMeetingNotes',
];
foreach ($metadataKeyMap as $newKey => $oldKey) {
if (isset($metadata[$oldKey])) {
$properties[$newKey] = $metadata[$oldKey];
unset($metadata[$oldKey]);
}
}
$properties = [
...$properties,
...$metadata, // custom fields
];
$response = [
'properties' => $properties,
];
if (! empty($associations)) {
$response['associations'] = $associations;
}
return $response;
}
/**
* Generate a payload to search for contacts by name.
* The search is a token search in firstname or lastname
*/
public function generateSearchContactsByNamePayload(string $name, array $fields): array
{
$firstNameFilter = [
'propertyName' => 'firstname',
'operator' => 'CONTAINS_TOKEN',
'value' => $name,
];
$lastNameFilter = [
'propertyName' => 'lastname',
'operator' => 'CONTAINS_TOKEN',
'value' => $name,
];
return [
'filterGroups' => [
[
'filters' => [$firstNameFilter],
],
[
'filters' => [$lastNameFilter],
],
],
'properties' => $fields,
'sorts' => [
[
'propertyName' => 'lastmodifieddate',
'direction' => 'DESCENDING',
],
],
];
}
public function buildAddAssociationPayload(string $crmId, array $ids, int $associationType): array
{
$inputs = [];
foreach ($ids as $id) {
if ($id === null || $id === '') {
continue;
}
$inputs[] = [
'from' => [
'id' => $crmId,
],
'to' => [
'id' => (string) $id,
],
'types' => [
[
'associationCategory' => 'HUBSPOT_DEFINED',
'associationTypeId' => $associationType,
],
],
];
}
return ['inputs' => $inputs];
}
public function buildRemoveAssociationPayload(string $crmId, array $ids): array
{
$toArray = [];
foreach ($ids as $id) {
if ($id === null || $id === '') {
continue;
}
$toArray[] = ['id' => (string) $id];
}
return [
'inputs' => [
[
'from' => ['id' => $crmId],
'to' => $toArray,
],
],
];
}
public function addClosedStageFilters(array &$payload, array $closedStages): void
{
if (! empty($closedStages['won'])) {
$payload['filters'][] = [
'propertyName' => 'dealstage',
'operator' => 'NOT_IN',
'values' => $closedStages['won'],
];
}
if (! empty($closedStages['lost'])) {
$payload['filters'][] = [
'propertyName' => 'dealstage',
'operator' => 'NOT_IN',
'values' => $closedStages['lost'],
];
}
}
public function addCreatedDateFilters(array &$payload, Carbon $createdAfter): void
{
$payload['filters'][] = [
'propertyName' => 'createdate',
'operator' => 'GT',
'value' => $createdAfter->getPreciseTimestamp(3),
];
}
public function getDealsInBulkPayload(array $dealIds): array
{
return [
'filterGroups' => [
[
'filters' => [
[
'propertyName' => 'hs_object_id',
'operator' => 'IN',
'values' => $dealIds,
],
],
],
],
'limit' => self::MAX_FILTER_SIZE,
];
}
}
Sync Changes
Hide This Notification
Code changed:
Hide
22
8
Previous Highlighted Error
Next Highlighted Error
<?php
declare(strict_types=1);
namespace Jiminny\Services\Crm\Hubspot\Webhook;
use Illuminate\Support\Facades\Log;
use Jiminny\Models\Crm\Configuration;
use Jiminny\Services\Crm\Hubspot\BatchSyncCollector;
use Jiminny\Repositories\Crm\CrmEntityRepository;
use Jiminny\Repositories\Crm\OpportunityRepository;
use Jiminny\Repositories\Crm\FieldRepository;
use Jiminny\Repositories\Crm\FieldDataRepository;
use Jiminny\Models\Opportunity;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\LayoutEntity;
readonly class PropertyChangeManager
{
private const string OBJECT_TYPE_DEAL = 'deal';
private const string OBJECT_TYPE_CONTACT = 'contact';
private const string OBJECT_TYPE_COMPANY = 'company';
private const array SUPPORTED_OBJECT_TYPES = [
self::OBJECT_TYPE_DEAL,
self::OBJECT_TYPE_CONTACT,
self::OBJECT_TYPE_COMPANY,
];
/**
* Contact fields that webhooks are subscribed to in HubSpot.
*
* IMPORTANT: These fields MUST match the webhook subscriptions configured in HubSpot.
* When updating this list, you must also update the HubSpot webhook subscriptions:
* 1. Go to HubSpot App Settings → Webhooks
* 2. Update contact.propertyChange subscriptions to match this list
* 3. Ensure all fields listed here are subscribed, and vice versa
*
* This constant serves as documentation of the current webhook configuration.
* Since HubSpot filters webhooks server-side, we should only receive events for these properties.
*/
private const array APPLICABLE_CONTACT_FIELDS = [
'firstname',
'lastname',
'email',
'phone',
'mobilephone',
'jobtitle',
'country',
'hubspot_owner_id',
'associatedcompanyid',
'photo',
'hs_avatar_filemanager_key',
];
/**
* Company fields that webhooks are subscribed to in HubSpot.
*
* IMPORTANT: These fields MUST match the webhook subscriptions configured in HubSpot.
* When updating this list, you must also update the HubSpot webhook subscriptions:
* 1. Go to HubSpot App Settings → Webhooks
* 2. Update company.propertyChange subscriptions to match this list
* 3. Ensure all fields listed here are subscribed, and vice versa
*
* This constant serves as documentation of the current webhook configuration.
* Since HubSpot filters webhooks server-side, we should only receive events for these properties.
*/
private const array APPLICABLE_COMPANY_FIELDS = [
'name',
'phone',
'domain',
'country',
'industry',
'hubspot_owner_id',
'hs_avatar_filemanager_key',
];
/**
* Properties that support direct database updates (without full sync).
* These are simple properties that can be updated immediately without recalculating other fields.
*/
private const array DB_OBJECT_UPDATE_PROPERTIES = [
self::OBJECT_TYPE_DEAL => [
'dealname',
'description',
],
self::OBJECT_TYPE_CONTACT => [
// Add contact properties that support direct update
],
self::OBJECT_TYPE_COMPANY => [
// Add company properties that support direct update
],
];
/**
* Mapping from HubSpot property names to Jiminny model property names.
* Used when updating the main model (Opportunity, Contact, Account) directly.
* Only needed when HubSpot property name differs from Jiminny property name.
*/
private const array OBJECT_PROPERTY_MAPPINGS = [
self::OBJECT_TYPE_DEAL => [
'dealname' => 'name', // HubSpot 'dealname' maps to Opportunity 'name'
],
self::OBJECT_TYPE_CONTACT => [
// Add contact property mappings if needed
],
self::OBJECT_TYPE_COMPANY => [
// Add company property mappings if needed
],
];
// Field object types mapping
private const array FIELD_OBJECT_TYPES = [
self::OBJECT_TYPE_DEAL => Field::OBJECT_OPPORTUNITY,
self::OBJECT_TYPE_CONTACT => Field::OBJECT_CONTACT,
self::OBJECT_TYPE_COMPANY => Field::OBJECT_ACCOUNT,
];
public function __construct(
private BatchSyncCollector $batchCollector,
private OpportunityRepository $opportunityRepository,
private CrmEntityRepository $crmEntityRepository,
private FieldRepository $fieldRepository,
private FieldDataRepository $fieldDataRepository,
private HubspotWebhookMetricsService $metricsService,
) {
}
/**
* Get contact fields that webhooks are subscribed to in HubSpot.
* This serves as documentation of the current webhook configuration.
*
* @return array<int, string>
*/
public function getApplicableContactFields(): array
{
return self::APPLICABLE_CONTACT_FIELDS;
}
/**
* Get company fields that webhooks are subscribed to in HubSpot.
* This serves as documentation of the current webhook configuration.
*
* @return array<int, string>
*/
public function getApplicableCompanyFields(): array
{
return self::APPLICABLE_COMPANY_FIELDS;
}
/**
* Process property change with intelligent routing
*/
public function processPropertyChange(
string $objectType,
string $objectId,
Configuration $configuration,
string $propertyName,
string $propertyValue
): void {
if (! $this->isObjectTypeSupported($objectType)) {
Log::warning('[HubSpot Property Change Manager] Unsupported object type', [
'object_type' => $objectType,
'object_id' => $objectId,
'property_name' => $propertyName,
]);
return;
}
$this->metricsService->recordWebhookMetrics(
$configuration,
$objectType,
'property_change',
$propertyName
);
if ($this->isDirectUpdateSupported($objectType, $propertyName) &&
$this->handleDirectUpdate($objectType, $objectId, $configuration, $propertyName, $propertyValue)) {
return;
}
$this->handleObjectSync($objectType, $objectId, $configuration);
}
/**
* Legacy method for backward compatibility - assumes deal object type
*
* @deprecated Use processPropertyChange with objectType parameter
*/
public function processPropertyChangeForDeal(
string $objectId,
Configuration $configuration,
string $propertyName,
string $propertyValue
): void {
$this->processPropertyChange(self::OBJECT_TYPE_DEAL, $objectId, $configuration, $propertyName, $propertyValue);
}
/**
* Check if object type is supported
*/
private function isObjectTypeSupported(string $objectType): bool
{
return in_array($objectType, self::SUPPORTED_OBJECT_TYPES);
}
/**
* Check if property can be updated without additional calculation on other properties.
*/
private function isDirectUpdateSupported(string $objectType, string $propertyName): bool
{
$supportedProperties = self::DB_OBJECT_UPDATE_PROPERTIES[$objectType] ?? [];
return in_array($propertyName, $supportedProperties, true);
}
/**
* Handle properties that support direct database updates
*/
private function handleDirectUpdate(
string $objectType,
string $objectId,
Configuration $configuration,
string $propertyName,
string $propertyValue
): bool {
try {
return match ($objectType) {
self::OBJECT_TYPE_DEAL => $this->handleDealDirectUpdate($objectId, $configuration, $propertyName, $propertyValue),
self::OBJECT_TYPE_CONTACT => $this->handleContactDirectUpdate($objectId, $configuration, $propertyName, $propertyValue),
self::OBJECT_TYPE_COMPANY => $this->handleCompanyDirectUpdate($objectId, $configuration, $propertyName, $propertyValue),
default => false,
};
} catch (\Exception $e) {
Log::error('[HubSpot Property Change Manager] Error handling direct update property', [
'object_type' => $objectType,
'object_id' => $objectId,
'property_name' => $propertyName,
'error' => $e->getMessage(),
]);
return false;
}
}
/**
* Handle direct updates for deal objects
*/
private function handleDealDirectUpdate(
string $objectId,
Configuration $configuration,
string $propertyName,
string $propertyValue
): bool {
$opportunity = $this->opportunityRepository->findByConfigAndCrmProviderId(
$configuration,
$objectId
);
if (! $opportunity instanceof Opportunity) {
return false;
}
// Update model property if mapping exists
$propertyMappings = self::OBJECT_PROPERTY_MAPPINGS[self::OBJECT_TYPE_DEAL] ?? [];
if (isset($propertyMappings[$propertyName])) {
$this->opportunityRepository->updateOrCreate(
$configuration,
$objectId,
[$propertyMappings[$propertyName] => $propertyValue]
);
}
// Update field data
$this->importObjectFieldData(
self::OBJECT_TYPE_DEAL,
$opportunity->getId(),
$configuration,
[$propertyName => $propertyValue]
);
return true;
}
/**
* Handle direct updates for contact objects
*
* Updates Contact model properties via OBJECT_PROPERTY_MAPPINGS.
* Add mappings to OBJECT_PROPERTY_MAPPINGS[CONTACT] to enable direct updates.
*/
private function handleContactDirectUpdate(
string $objectId,
Configuration $configuration,
string $propertyName,
string $propertyValue
): bool {
$contact = $this->crmEntityRepository->findContactByExternalId($configuration, $objectId);
if ($contact === null) {
return false;
}
// Update model property if mapping exists
$propertyMappings = self::OBJECT_PROPERTY_MAPPINGS[self::OBJECT_TYPE_CONTACT] ?? [];
if (isset($propertyMappings[$propertyName])) {
$this->crmEntityRepository->importContact($configuration, [
'crm_provider_id' => $objectId,
$propertyMappings[$propertyName] => $propertyValue,
]);
}
return true;
}
/**
* Handle direct updates for company objects
*
* Updates Account model properties via OBJECT_PROPERTY_MAPPINGS.
* Add mappings to OBJECT_PROPERTY_MAPPINGS[COMPANY] to enable direct updates.
*/
private function handleCompanyDirectUpdate(
string $objectId,
Configuration $configuration,
string $propertyName,
string $propertyValue
): bool {
$company = $this->crmEntityRepository->findAccountByExternalId($configuration, $objectId);
if ($company === null) {
return false;
}
// Update model property if mapping exists
$propertyMappings = self::OBJECT_PROPERTY_MAPPINGS[self::OBJECT_TYPE_COMPANY] ?? [];
if (isset($propertyMappings[$propertyName])) {
$this->crmEntityRepository->importAccount($configuration, [
'crm_provider_id' => $objectId,
$propertyMappings[$propertyName] => $propertyValue,
]);
}
return true;
}
/**
* Import field data for any object type
*/
private function importObjectFieldData(
string $objectType,
int $objectId,
Configuration $configuration,
array $data
): void {
$fieldObjectType = self::FIELD_OBJECT_TYPES[$objectType] ?? null;
if (! $fieldObjectType) {
Log::warning('[HubSpot Property Change Manager] Unknown field object type', [
'object_type' => $objectType,
'object_id' => $objectId,
]);
return;
}
foreach ($data as $crmField => $value) {
$field = $this->fieldRepository->findFieldByCrmIdAndObjectType(
$configuration,
$crmField,
$fieldObjectType
);
if (! $field instanceof Field) {
continue;
}
$entities = $field->getEntities();
if ($entities->isEmpty()) {
continue;
}
if ($entities->count() > 1) {
continue;
}
/** @var LayoutEntity|null $entity */
$entity = $entities->first();
if (! $entity instanceof LayoutEntity) {
continue;
}
$this->fieldDataRepository->updateOrCreateFieldData(
$entity,
$objectId,
(string) $value,
);
}
}
private function handleObjectSync(
string $objectType,
string $objectId,
Configuration $configuration,
): void {
if (! $this->isObjectTypeSupported($objectType)) {
return;
}
try {
$this->batchCollector->collectForBatch(
objectType: $objectType,
crmProviderId: $objectId,
configurationId: $configuration->getId(),
eventType: 'property_change'
);
} catch (\Exception $e) {
Log::error('[HubSpot Property Change Manager] Error adding to sync batch', [
'object_type' => $objectType,
'object_id' => $objectId,
'error' => $e->getMessage(),
]);
}
}
}
Project
Project
New File or Directory…
Expand Selected
Collapse All
Options
Hide...
|
PhpStorm
|
faVsco.js – PropertyChangeManager.php
|
NULL
|
87878
|
|
87877
|
Project: faVsco.js, menu
#12121 on JY-20963-fix-im Project: faVsco.js, menu
#12121 on JY-20963-fix-import-on-deleted-entity, menu
Start Listening for PHP Debug Connections
ServiceTest
Run 'ServiceTest'
Debug 'ServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
8
25
Previous Highlighted Error
Next Highlighted Error
<?php
declare(strict_types=1);
namespace Jiminny\Services\Crm\Hubspot;
use Carbon\Carbon;
use Jiminny\Models\Crm\Configuration;
use Psr\Log\LoggerInterface;
class PayloadBuilder
{
public const int MAX_SEARCH_REQUEST_LIMIT = 200;
private const int MAX_FILTER_SIZE = 100;
private const string SORT_PROPERTY = 'hs_timestamp';
private const string ENGAGEMENT_MEETINGS = 'meetings';
public function getLinkToTaskPayload(string $objectType, string $objectId, string $engagementType): array
{
$payload = [
'sorts' => [
[
'propertyName' => self::SORT_PROPERTY,
'direction' => 'DESCENDING',
],
],
'limit' => self::MAX_SEARCH_REQUEST_LIMIT,
];
if ($engagementType === self::ENGAGEMENT_MEETINGS) {
$payload['filterGroups'] = $this->buildMeetingFiltersForLinkToTask($objectType, $objectId);
$payload['properties'] = [
'hs_meeting_title',
'hs_meeting_outcome',
'hs_timestamp',
'hs_activity_type',
];
} else {
$payload['filterGroups'] = $this->buildTaskFiltersForLinkToTask($objectType, $objectId);
$payload['properties'] = ['hs_task_subject', 'hs_timestamp'];
}
return $payload;
}
public function getRecentlyUpdatedSearchPayload(Carbon $since, ?Carbon $to, array $properties): array
{
$lastUpdateDate = in_array('firstname', $properties) ? 'lastmodifieddate' : 'hs_lastmodifieddate';
$payload = [
'filters' => [
[
'propertyName' => $lastUpdateDate,
'operator' => 'GT',
'value' => $since->getPreciseTimestamp(3),
],
],
'sorts' => [
[
'propertyName' => 'hs_object_id',
'direction' => 'DESCENDING',
],
],
'limit' => self::MAX_SEARCH_REQUEST_LIMIT,
];
if ($to) {
$payload['filters'][] = [
'propertyName' => $lastUpdateDate,
'operator' => 'LT',
'value' => $to->getPreciseTimestamp(3),
];
}
$payload['properties'] = $properties;
return $payload;
}
public function getByProfileSearchPayload(string $crmId, array $properties, Carbon $since, ?Carbon $to): array
{
$lastUpdateDate = array_key_exists('firstname', $properties) ? 'lastmodifieddate' : 'hs_lastmodifieddate';
$payload = [
'filters' => [
[
'propertyName' => $lastUpdateDate,
'operator' => 'GT',
'value' => $since->getPreciseTimestamp(3),
],
[
'propertyName' => 'hubspot_owner_id',
'operator' => 'EQ',
'value' => $crmId,
],
],
'sorts' => [
[
'propertyName' => 'hs_object_id',
'direction' => 'DESCENDING',
],
],
'limit' => self::MAX_SEARCH_REQUEST_LIMIT,
];
if ($to) {
$payload['filters'][] = [
'propertyName' => $lastUpdateDate,
'operator' => 'LT',
'value' => $to->getPreciseTimestamp(3),
];
}
$payload['properties'] = $properties;
return $payload;
}
private function buildMeetingFiltersForLinkToTask($objectType, $objectId): array
{
$filters = [];
$filters[] = [
'filters' => [
$this->getAssociatedObjectFilter($objectType, $objectId),
[
'propertyName' => 'hs_meeting_outcome',
'operator' => 'IN',
'values' => ['SCHEDULED', 'RESCHEDULED'],
],
],
];
$filters[] = [
'filters' => [
$this->getAssociatedObjectFilter($objectType, $objectId),
[
'propertyName' => 'hs_meeting_outcome',
'operator' => 'NOT_HAS_PROPERTY',
],
],
];
return $filters;
}
private function buildTaskFiltersForLinkToTask($objectType, $objectId): array
{
return [
[
'filters' => [
$this->getAssociatedObjectFilter($objectType, $objectId),
],
],
];
}
private function getAssociatedObjectFilter(string $objectType, string $objectId): array
{
return [
'propertyName' => 'associations.' . $objectType,
'operator' => 'EQ',
'value' => $objectId,
];
}
public function generatePlaybackURLSearchPayload(string $playbackURLToken): array
{
return [
'filters' => [
[
'propertyName' => 'hs_call_recording_url',
'operator' => 'CONTAINS_TOKEN',
'value' => $playbackURLToken,
],
],
'properties' => [
'hs_call_recording_url',
'hs_call_body',
],
'limit' => 1,
];
}
public function generateGetCallsPayload(Carbon $from, Carbon $to, string $activityProvider, $page): array
{
return [
'filters' => [
[
'propertyName' => 'hs_call_recording_url',
'operator' => 'CONTAINS_TOKEN',
'value' => $activityProvider,
],
[
'propertyName' => 'hs_timestamp',
'operator' => 'BETWEEN',
'value' => intval($from->getPreciseTimestamp(3)),
'highValue' => intval($to->getPreciseTimestamp(3)),
],
],
'sorts' => [
[
'propertyName' => 'hs_timestamp',
'direction' => 'DESCENDING',
],
],
'properties' => [
'hs_call_recording_url',
'hs_call_body',
],
'limit' => self::MAX_SEARCH_REQUEST_LIMIT,
'after' => ($page - 1) * self::MAX_SEARCH_REQUEST_LIMIT,
];
}
public function generateSearchCallsByPeriodPayload(Carbon $from, Carbon $to, $page): array
{
return [
'filters' => [
[
'propertyName' => 'hs_timestamp',
'operator' => 'BETWEEN',
'value' => intval($from->getPreciseTimestamp(3)),
'highValue' => intval($to->getPreciseTimestamp(3)),
],
],
'sorts' => [
[
'propertyName' => 'hs_timestamp',
'direction' => 'DESCENDING',
],
],
'properties' => $this->getSearchCallAttributes(),
'limit' => self::MAX_SEARCH_REQUEST_LIMIT,
'after' => ($page - 1) * self::MAX_SEARCH_REQUEST_LIMIT,
];
}
public function getSearchCallAttributes(): array
{
return [
'hs_timestamp',
'hs_call_recording_url',
'hs_call_body',
'hs_call_status',
'hs_call_to_number',
'hs_call_from_number',
'hs_call_duration',
'hs_call_disposition',
'hs_call_title',
'hs_call_direction',
'hubspot_owner_id',
'hs_activity_type',
'hs_call_external_id',
'hs_call_source',
];
}
public function generatePlaybackAddUrlBatchPayload(array $crmUpdateData): array
{
$updateObjectsData = [];
foreach ($crmUpdateData as $data) {
$updateObjectsData[] = [
'id' => $data['crm_id'],
'properties' => [
'hs_call_body' => $data['hs_call_body'] .
'<p><span style="font-size: 13.3333px; line-height: 16px;">' .
'<a href="' . $data['playback_url'] . '" target="_blank">Review in Jiminny</a> ▶️</span></p>',
],
];
}
return ['inputs' => $updateObjectsData];
}
public function generateSearchCallByTokenPayload(string $playbackURLToken): array
{
return [
'filters' => [
[
'propertyName' => 'hs_call_recording_url',
'operator' => 'CONTAINS_TOKEN',
'value' => $playbackURLToken,
],
],
'properties' => [
'hs_call_recording_url',
'hs_call_body',
],
'limit' => 1,
];
}
/**
* Generates a payload for phone search based on the specified parameters.
*
* @param string $phone The phone number to search.
* @param bool $isAlternativeSearch Indicates if an alternative search should be performed
* if the first search fails to find a match. The first search request should cover most of the cases.
*/
public function generatePhoneSearchPayload(string $phone, bool $isAlternativeSearch = false): array
{
$filterPropertyNames = $isAlternativeSearch ?
['hs_searchable_calculated_mobile_number', 'phone', 'mobilephone'] :
['hs_searchable_calculated_phone_number', 'hs_calculated_phone_number', 'hs_calculated_mobile_number'];
$filterGroups = array_map(function ($propertyName) use ($phone) {
return $this->createFilterGroup($propertyName, 'CONTAINS_TOKEN', $phone);
}, $filterPropertyNames);
$properties = [
'email',
'firstname',
'lastname',
'country',
'phone',
'mobilephone',
'jobtitle',
'hubspot_owner_id',
'associatedcompanyid',
'photo',
];
return $this->createPayload($filterGroups, $properties);
}
/**
* Creates a filter group with the specified parameters.
*/
private function createFilterGroup(string $propertyName, string $operator, $value): array
{
return [
'filters' => [
[
'propertyName' => $propertyName,
'operator' => $operator,
'value' => $value,
],
],
];
}
/**
* Creates a payload with the specified filter groups and properties.
*/
private function createPayload(array $filterGroups, array $properties): array
{
return [
'filterGroups' => $filterGroups,
'sorts' => [
[
'propertyName' => 'modifieddate',
'direction' => 'DESCENDING',
],
],
'properties' => $properties,
'limit' => 1,
];
}
/**
* Generates a payload to find related activities based on the specified data and object type.
* The search looks for activities that match the specified time range (starttime - endtime or only starttime with endtime not set)
* and are associated with any of the provided prospect types (contact or company).
*
* @param array $data An associative array containing the following keys:
* - 'from': The start time for the activity search (timestamp).
* - 'to': The end time for the activity search (timestamp).
* - 'contact': (optional) The ID of the associated contact.
* - 'company': (optional) The ID of the associated company.
* @param string $objectType The type of engagement to search for (meeting or other type like call).
*
* @return array The payload array to be used for searching related activities.
*/
public function getFindRelatedActivityPayload(array $data, string $objectType): array
{
$payload = [
'sorts' => [
[
'propertyName' => self::SORT_PROPERTY,
'direction' => 'DESCENDING',
],
],
'limit' => self::MAX_SEARCH_REQUEST_LIMIT,
];
if ($objectType === self::ENGAGEMENT_MEETINGS) {
$payload['properties'] = [
'hs_meeting_title',
'hs_meeting_outcome',
'hs_activity_type',
'hs_timestamp',
'hubspot_owner_id',
'hs_meeting_body',
'hs_internal_meeting_notes',
'hs_meeting_location',
'hs_meeting_start_time',
'hs_meeting_end_time',
];
} else {
$payload['properties'] = ['hs_task_subject', 'hs_timestamp', 'hubspot_owner_id', 'hs_call_body'];
}
$timeFiltersWithEndTime = [
[
'propertyName' => 'hs_meeting_start_time',
'operator' => 'GTE',
'value' => $data['from'],
],
[
'propertyName' => 'hs_meeting_end_time',
'operator' => 'LTE',
'value' => $data['to'],
],
];
$timeFiltersWithoutEndTime = [
[
'propertyName' => 'hs_meeting_start_time',
'operator' => 'GTE',
'value' => $data['from'],
],
[
'propertyName' => 'hs_meeting_end_time',
'operator' => 'NOT_HAS_PROPERTY',
],
];
$payload['filterGroups'] = [];
$associationTypes = ['contact', 'company'];
foreach ($associationTypes as $type) {
if (! empty($data[$type])) {
$filterGroupWithEndTime = [
'filters' => array_merge(
$timeFiltersWithEndTime,
[$this->getAssociatedObjectFilter($type, $data[$type])]
),
];
$payload['filterGroups'][] = $filterGroupWithEndTime;
$filterGroupWithoutEndTime = [
'filters' => array_merge(
$timeFiltersWithoutEndTime,
[$this->getAssociatedObjectFilter($type, $data[$type])]
),
];
$payload['filterGroups'][] = $filterGroupWithoutEndTime ;
}
}
return $payload;
}
/** Parameters
* [
* 'accountId' => $crmAccountId,
* 'sortBy' => $sortBy,
* 'sortDir' => $sortDir,
* 'onlyOpen' => $onlyOpen,
* 'closedStages' => $this->getClosedDealStages(),
* 'userId' => $userId,
* ];
*/
public function generateOpportunitiesSearchPayload(
Configuration $config,
string $crmAccountId,
array $closedStages,
): array {
$closedFilters = [];
$filterGroups = [];
$onlyOpen = true;
switch ($config->getOpportunityAssignmentRule()) {
case Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_UPDATED:
$sortBy = 'modifieddate';
$sortDir = 'DESCENDING';
break;
case Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_CREATED:
$sortBy = 'createdate';
$sortDir = 'DESCENDING';
break;
case Configuration::OPP_ASSIGNMENT_ALL_OPEN_OLDEST_CREATED:
$sortBy = 'createdate';
$sortDir = 'ASCENDING';
break;
case Configuration::OPP_ASSIGNMENT_ALL_RECENTLY_UPDATED:
default:
$sortBy = 'modifieddate';
$sortDir = 'DESCENDING';
$onlyOpen = false;
}
$baseFilters = [
[
'propertyName' => 'associations.company',
'operator' => 'EQ',
'value' => $crmAccountId,
],
];
// Handle closed stages in chunks
if ($onlyOpen) {
foreach (['won', 'lost'] as $key) {
if (! empty($closedStages[$key])) {
$chunks = array_chunk($closedStages[$key], self::MAX_FILTER_SIZE);
foreach ($chunks as $chunk) {
$closedFilters[] = [
'propertyName' => 'dealstage',
'operator' => 'NOT_IN',
'values' => $chunk,
];
}
}
}
}
$filterGroups[] = [
'filters' => array_merge($baseFilters, $closedFilters),
];
$payload = [
'filterGroups' => $filterGroups,
'sorts' => [
[
'propertyName' => $sortBy,
'direction' => $sortDir,
],
],
'properties' => [
'dealname',
'amount',
'hubspot_owner_id',
'pipeline',
'dealstage',
'closedate',
'deal_currency_code',
],
'limit' => self::MAX_SEARCH_REQUEST_LIMIT,
];
$logger = app(LoggerInterface::class);
$logger->info('[HubSpot] CRM Search requested', [
'request' => $payload,
]);
return $payload;
}
/**
* Converts v1 payload data to v3
*/
public function getV3MeetingPayload(array $engagement, array $metadata, array $associations = []): array
{
// Use this mapping until the whole Hubspot V1 is dropped.
$properties = [
'hubspot_owner_id' => $engagement['ownerId'],
'hs_timestamp' => $engagement['timestamp'],
];
// $engagement['activityType'] is $activity->category->name
if (isset($engagement['activityType'])) {
$properties['hs_activity_type'] = $engagement['activityType'];
}
$metadataKeyMap = [
'hs_meeting_outcome' => 'meetingOutcome',
'hs_meeting_title' => 'title',
'hs_meeting_start_time' => 'startTime',
'hs_meeting_end_time' => 'endTime',
'hs_meeting_body' => 'body',
'hs_internal_meeting_notes' => 'internalMeetingNotes',
];
foreach ($metadataKeyMap as $newKey => $oldKey) {
if (isset($metadata[$oldKey])) {
$properties[$newKey] = $metadata[$oldKey];
unset($metadata[$oldKey]);
}
}
$properties = [
...$properties,
...$metadata, // custom fields
];
$response = [
'properties' => $properties,
];
if (! empty($associations)) {
$response['associations'] = $associations;
}
return $response;
}
/**
* Generate a payload to search for contacts by name.
* The search is a token search in firstname or lastname
*/
public function generateSearchContactsByNamePayload(string $name, array $fields): array
{
$firstNameFilter = [
'propertyName' => 'firstname',
'operator' => 'CONTAINS_TOKEN',
'value' => $name,
];
$lastNameFilter = [
'propertyName' => 'lastname',
'operator' => 'CONTAINS_TOKEN',
'value' => $name,
];
return [
'filterGroups' => [
[
'filters' => [$firstNameFilter],
],
[
'filters' => [$lastNameFilter],
],
],
'properties' => $fields,
'sorts' => [
[
'propertyName' => 'lastmodifieddate',
'direction' => 'DESCENDING',
],
],
];
}
public function buildAddAssociationPayload(string $crmId, array $ids, int $associationType): array
{
$inputs = [];
foreach ($ids as $id) {
if ($id === null || $id === '') {
continue;
}
$inputs[] = [
'from' => [
'id' => $crmId,
],
'to' => [
'id' => (string) $id,
],
'types' => [
[
'associationCategory' => 'HUBSPOT_DEFINED',
'associationTypeId' => $associationType,
],
],
];
}
return ['inputs' => $inputs];
}
public function buildRemoveAssociationPayload(string $crmId, array $ids): array
{
$toArray = [];
foreach ($ids as $id) {
if ($id === null || $id === '') {
continue;
}
$toArray[] = ['id' => (string) $id];
}
return [
'inputs' => [
[
'from' => ['id' => $crmId],
'to' => $toArray,
],
],
];
}
public function addClosedStageFilters(array &$payload, array $closedStages): void
{
if (! empty($closedStages['won'])) {
$payload['filters'][] = [
'propertyName' => 'dealstage',
'operator' => 'NOT_IN',
'values' => $closedStages['won'],
];
}
if (! empty($closedStages['lost'])) {
$payload['filters'][] = [
'propertyName' => 'dealstage',
'operator' => 'NOT_IN',
'values' => $closedStages['lost'],
];
}
}
public function addCreatedDateFilters(array &$payload, Carbon $createdAfter): void
{
$payload['filters'][] = [
'propertyName' => 'createdate',
'operator' => 'GT',
'value' => $createdAfter->getPreciseTimestamp(3),
];
}
public function getDealsInBulkPayload(array $dealIds): array
{
return [
'filterGroups' => [
[
'filters' => [
[
'propertyName' => 'hs_object_id',
'operator' => 'IN',
'values' => $dealIds,
],
],
],
],
'limit' => self::MAX_FILTER_SIZE,
];
}
}
Sync Changes
Hide This Notification
Code changed:
Hide
22
8
Previous Highlighted Error
Next Highlighted Error
<?php
declare(strict_types=1);
namespace Jiminny\Services\Crm\Hubspot\Webhook;
use Illuminate\Support\Facades\Log;
use Jiminny\Models\Crm\Configuration;
use Jiminny\Services\Crm\Hubspot\BatchSyncCollector;
use Jiminny\Repositories\Crm\CrmEntityRepository;
use Jiminny\Repositories\Crm\OpportunityRepository;
use Jiminny\Repositories\Crm\FieldRepository;
use Jiminny\Repositories\Crm\FieldDataRepository;
use Jiminny\Models\Opportunity;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\LayoutEntity;
readonly class PropertyChangeManager
{
private const string OBJECT_TYPE_DEAL = 'deal';
private const string OBJECT_TYPE_CONTACT = 'contact';
private const string OBJECT_TYPE_COMPANY = 'company';
private const array SUPPORTED_OBJECT_TYPES = [
self::OBJECT_TYPE_DEAL,
self::OBJECT_TYPE_CONTACT,
self::OBJECT_TYPE_COMPANY,
];
/**
* Contact fields that webhooks are subscribed to in HubSpot.
*
* IMPORTANT: These fields MUST match the webhook subscriptions configured in HubSpot.
* When updating this list, you must also update the HubSpot webhook subscriptions:
* 1. Go to HubSpot App Settings → Webhooks
* 2. Update contact.propertyChange subscriptions to match this list
* 3. Ensure all fields listed here are subscribed, and vice versa
*
* This constant serves as documentation of the current webhook configuration.
* Since HubSpot filters webhooks server-side, we should only receive events for these properties.
*/
private const array APPLICABLE_CONTACT_FIELDS = [
'firstname',
'lastname',
'email',
'phone',
'mobilephone',
'jobtitle',
'country',
'hubspot_owner_id',
'associatedcompanyid',
'photo',
'hs_avatar_filemanager_key',
];
/**
* Company fields that webhooks are subscribed to in HubSpot.
*
* IMPORTANT: These fields MUST match the webhook subscriptions configured in HubSpot.
* When updating this list, you must also update the HubSpot webhook subscriptions:
* 1. Go to HubSpot App Settings → Webhooks
* 2. Update company.propertyChange subscriptions to match this list
* 3. Ensure all fields listed here are subscribed, and vice versa
*
* This constant serves as documentation of the current webhook configuration.
* Since HubSpot filters webhooks server-side, we should only receive events for these properties.
*/
private const array APPLICABLE_COMPANY_FIELDS = [
'name',
'phone',
'domain',
'country',
'industry',
'hubspot_owner_id',
'hs_avatar_filemanager_key',
];
/**
* Properties that support direct database updates (without full sync).
* These are simple properties that can be updated immediately without recalculating other fields.
*/
private const array DB_OBJECT_UPDATE_PROPERTIES = [
self::OBJECT_TYPE_DEAL => [
'dealname',
'description',
],
self::OBJECT_TYPE_CONTACT => [
// Add contact properties that support direct update
],
self::OBJECT_TYPE_COMPANY => [
// Add company properties that support direct update
],
];
/**
* Mapping from HubSpot property names to Jiminny model property names.
* Used when updating the main model (Opportunity, Contact, Account) directly.
* Only needed when HubSpot property name differs from Jiminny property name.
*/
private const array OBJECT_PROPERTY_MAPPINGS = [
self::OBJECT_TYPE_DEAL => [
'dealname' => 'name', // HubSpot 'dealname' maps to Opportunity 'name'
],
self::OBJECT_TYPE_CONTACT => [
// Add contact property mappings if needed
],
self::OBJECT_TYPE_COMPANY => [
// Add company property mappings if needed
],
];
// Field object types mapping
private const array FIELD_OBJECT_TYPES = [
self::OBJECT_TYPE_DEAL => Field::OBJECT_OPPORTUNITY,
self::OBJECT_TYPE_CONTACT => Field::OBJECT_CONTACT,
self::OBJECT_TYPE_COMPANY => Field::OBJECT_ACCOUNT,
];
public function __construct(
private BatchSyncCollector $batchCollector,
private OpportunityRepository $opportunityRepository,
private CrmEntityRepository $crmEntityRepository,
private FieldRepository $fieldRepository,
private FieldDataRepository $fieldDataRepository,
private HubspotWebhookMetricsService $metricsService,
) {
}
/**
* Get contact fields that webhooks are subscribed to in HubSpot.
* This serves as documentation of the current webhook configuration.
*
* @return array<int, string>
*/
public function getApplicableContactFields(): array
{
return self::APPLICABLE_CONTACT_FIELDS;
}
/**
* Get company fields that webhooks are subscribed to in HubSpot.
* This serves as documentation of the current webhook configuration.
*
* @return array<int, string>
*/
public function getApplicableCompanyFields(): array
{
return self::APPLICABLE_COMPANY_FIELDS;
}
/**
* Process property change with intelligent routing
*/
public function processPropertyChange(
string $objectType,
string $objectId,
Configuration $configuration,
string $propertyName,
string $propertyValue
): void {
if (! $this->isObjectTypeSupported($objectType)) {
Log::warning('[HubSpot Property Change Manager] Unsupported object type', [
'object_type' => $objectType,
'object_id' => $objectId,
'property_name' => $propertyName,
]);
return;
}
$this->metricsService->recordWebhookMetrics(
$configuration,
$objectType,
'property_change',
$propertyName
);
if ($this->isDirectUpdateSupported($objectType, $propertyName) &&
$this->handleDirectUpdate($objectType, $objectId, $configuration, $propertyName, $propertyValue)) {
return;
}
$this->handleObjectSync($objectType, $objectId, $configuration);
}
/**
* Legacy method for backward compatibility - assumes deal object type
*
* @deprecated Use processPropertyChange with objectType parameter
*/
public function processPropertyChangeForDeal(
string $objectId,
Configuration $configuration,
string $propertyName,
string $propertyValue
): void {
$this->processPropertyChange(self::OBJECT_TYPE_DEAL, $objectId, $configuration, $propertyName, $propertyValue);
}
/**
* Check if object type is supported
*/
private function isObjectTypeSupported(string $objectType): bool
{
return in_array($objectType, self::SUPPORTED_OBJECT_TYPES);
}
/**
* Check if property can be updated without additional calculation on other properties.
*/
private function isDirectUpdateSupported(string $objectType, string $propertyName): bool
{
$supportedProperties = self::DB_OBJECT_UPDATE_PROPERTIES[$objectType] ?? [];
return in_array($propertyName, $supportedProperties, true);
}
/**
* Handle properties that support direct database updates
*/
private function handleDirectUpdate(
string $objectType,
string $objectId,
Configuration $configuration,
string $propertyName,
string $propertyValue
): bool {
try {
return match ($objectType) {
self::OBJECT_TYPE_DEAL => $this->handleDealDirectUpdate($objectId, $configuration, $propertyName, $propertyValue),
self::OBJECT_TYPE_CONTACT => $this->handleContactDirectUpdate($objectId, $configuration, $propertyName, $propertyValue),
self::OBJECT_TYPE_COMPANY => $this->handleCompanyDirectUpdate($objectId, $configuration, $propertyName, $propertyValue),
default => false,
};
} catch (\Exception $e) {
Log::error('[HubSpot Property Change Manager] Error handling direct update property', [
'object_type' => $objectType,
'object_id' => $objectId,
'property_name' => $propertyName,
'error' => $e->getMessage(),
]);
return false;
}
}
/**
* Handle direct updates for deal objects
*/
private function handleDealDirectUpdate(
string $objectId,
Configuration $configuration,
string $propertyName,
string $propertyValue
): bool {
$opportunity = $this->opportunityRepository->findByConfigAndCrmProviderId(
$configuration,
$objectId
);
if (! $opportunity instanceof Opportunity) {
return false;
}
// Update model property if mapping exists
$propertyMappings = self::OBJECT_PROPERTY_MAPPINGS[self::OBJECT_TYPE_DEAL] ?? [];
if (isset($propertyMappings[$propertyName])) {
$this->opportunityRepository->updateOrCreate(
$configuration,
$objectId,
[$propertyMappings[$propertyName] => $propertyValue]
);
}
// Update field data
$this->importObjectFieldData(
self::OBJECT_TYPE_DEAL,
$opportunity->getId(),
$configuration,
[$propertyName => $propertyValue]
);
return true;
}
/**
* Handle direct updates for contact objects
*
* Updates Contact model properties via OBJECT_PROPERTY_MAPPINGS.
* Add mappings to OBJECT_PROPERTY_MAPPINGS[CONTACT] to enable direct updates.
*/
private function handleContactDirectUpdate(
string $objectId,
Configuration $configuration,
string $propertyName,
string $propertyValue
): bool {
$contact = $this->crmEntityRepository->findContactByExternalId($configuration, $objectId);
if ($contact === null) {
return false;
}
// Update model property if mapping exists
$propertyMappings = self::OBJECT_PROPERTY_MAPPINGS[self::OBJECT_TYPE_CONTACT] ?? [];
if (isset($propertyMappings[$propertyName])) {
$this->crmEntityRepository->importContact($configuration, [
'crm_provider_id' => $objectId,
$propertyMappings[$propertyName] => $propertyValue,
]);
}
return true;
}
/**
* Handle direct updates for company objects
*
* Updates Account model properties via OBJECT_PROPERTY_MAPPINGS.
* Add mappings to OBJECT_PROPERTY_MAPPINGS[COMPANY] to enable direct updates.
*/
private function handleCompanyDirectUpdate(
string $objectId,
Configuration $configuration,
string $propertyName,
string $propertyValue
): bool {
$company = $this->crmEntityRepository->findAccountByExternalId($configuration, $objectId);
if ($company === null) {
return false;
}
// Update model property if mapping exists
$propertyMappings = self::OBJECT_PROPERTY_MAPPINGS[self::OBJECT_TYPE_COMPANY] ?? [];
if (isset($propertyMappings[$propertyName])) {
$this->crmEntityRepository->importAccount($configuration, [
'crm_provider_id' => $objectId,
$propertyMappings[$propertyName] => $propertyValue,
]);
}
return true;
}
/**
* Import field data for any object type
*/
private function importObjectFieldData(
string $objectType,
int $objectId,
Configuration $configuration,
array $data
): void {
$fieldObjectType = self::FIELD_OBJECT_TYPES[$objectType] ?? null;
if (! $fieldObjectType) {
Log::warning('[HubSpot Property Change Manager] Unknown field object type', [
'object_type' => $objectType,
'object_id' => $objectId,
]);
return;
}
foreach ($data as $crmField => $value) {
$field = $this->fieldRepository->findFieldByCrmIdAndObjectType(
$configuration,
$crmField,
$fieldObjectType
);
if (! $field instanceof Field) {
continue;
}
$entities = $field->getEntities();
if ($entities->isEmpty()) {
continue;
}
if ($entities->count() > 1) {
continue;
}
/** @var LayoutEntity|null $entity */
$entity = $entities->first();
if (! $entity instanceof LayoutEntity) {
continue;
}
$this->fieldDataRepository->updateOrCreateFieldData(
$entity,
$objectId,
(string) $value,
);
}
}
private function handleObjectSync(
string $objectType,
string $objectId,
Configuration $configuration,
): void {
if (! $this->isObjectTypeSupported($objectType)) {
return;
}
try {
$this->batchCollector->collectForBatch(
objectType: $objectType,
crmProviderId: $objectId,
configurationId: $configuration->getId(),
eventType: 'property_change'
);
} catch (\Exception $e) {
Log::error('[HubSpot Property Change Manager] Error adding to sync batch', [
'object_type' => $objectType,
'object_id' => $objectId,
'error' => $e->getMessage(),
]);
}
}
}
Project
Project
New File or Directory…
Expand Selected...
|
PhpStorm
|
faVsco.js – PropertyChangeManager.php
|
NULL
|
87877
|
|
87876
|
Project: faVsco.js, menu
#12121 on JY-20963-fix-im Project: faVsco.js, menu
#12121 on JY-20963-fix-import-on-deleted-entity, menu
Start Listening for PHP Debug Connections
ServiceTest
Run 'ServiceTest'
Debug 'ServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
8
25
Previous Highlighted Error
Next Highlighted Error
<?php
declare(strict_types=1);
namespace Jiminny\Services\Crm\Hubspot;
use Carbon\Carbon;
use Jiminny\Models\Crm\Configuration;
use Psr\Log\LoggerInterface;
class PayloadBuilder
{
public const int MAX_SEARCH_REQUEST_LIMIT = 200;
private const int MAX_FILTER_SIZE = 100;
private const string SORT_PROPERTY = 'hs_timestamp';
private const string ENGAGEMENT_MEETINGS = 'meetings';
public function getLinkToTaskPayload(string $objectType, string $objectId, string $engagementType): array
{
$payload = [
'sorts' => [
[
'propertyName' => self::SORT_PROPERTY,
'direction' => 'DESCENDING',
],
],
'limit' => self::MAX_SEARCH_REQUEST_LIMIT,
];
if ($engagementType === self::ENGAGEMENT_MEETINGS) {
$payload['filterGroups'] = $this->buildMeetingFiltersForLinkToTask($objectType, $objectId);
$payload['properties'] = [
'hs_meeting_title',
'hs_meeting_outcome',
'hs_timestamp',
'hs_activity_type',
];
} else {
$payload['filterGroups'] = $this->buildTaskFiltersForLinkToTask($objectType, $objectId);
$payload['properties'] = ['hs_task_subject', 'hs_timestamp'];
}
return $payload;
}
public function getRecentlyUpdatedSearchPayload(Carbon $since, ?Carbon $to, array $properties): array
{
$lastUpdateDate = in_array('firstname', $properties) ? 'lastmodifieddate' : 'hs_lastmodifieddate';
$payload = [
'filters' => [
[
'propertyName' => $lastUpdateDate,
'operator' => 'GT',
'value' => $since->getPreciseTimestamp(3),
],
],
'sorts' => [
[
'propertyName' => 'hs_object_id',
'direction' => 'DESCENDING',
],
],
'limit' => self::MAX_SEARCH_REQUEST_LIMIT,
];
if ($to) {
$payload['filters'][] = [
'propertyName' => $lastUpdateDate,
'operator' => 'LT',
'value' => $to->getPreciseTimestamp(3),
];
}
$payload['properties'] = $properties;
return $payload;
}
public function getByProfileSearchPayload(string $crmId, array $properties, Carbon $since, ?Carbon $to): array
{
$lastUpdateDate = array_key_exists('firstname', $properties) ? 'lastmodifieddate' : 'hs_lastmodifieddate';
$payload = [
'filters' => [
[
'propertyName' => $lastUpdateDate,
'operator' => 'GT',
'value' => $since->getPreciseTimestamp(3),
],
[
'propertyName' => 'hubspot_owner_id',
'operator' => 'EQ',
'value' => $crmId,
],
],
'sorts' => [
[
'propertyName' => 'hs_object_id',
'direction' => 'DESCENDING',
],
],
'limit' => self::MAX_SEARCH_REQUEST_LIMIT,
];
if ($to) {
$payload['filters'][] = [
'propertyName' => $lastUpdateDate,
'operator' => 'LT',
'value' => $to->getPreciseTimestamp(3),
];
}
$payload['properties'] = $properties;
return $payload;
}
private function buildMeetingFiltersForLinkToTask($objectType, $objectId): array
{
$filters = [];
$filters[] = [
'filters' => [
$this->getAssociatedObjectFilter($objectType, $objectId),
[
'propertyName' => 'hs_meeting_outcome',
'operator' => 'IN',
'values' => ['SCHEDULED', 'RESCHEDULED'],
],
],
];
$filters[] = [
'filters' => [
$this->getAssociatedObjectFilter($objectType, $objectId),
[
'propertyName' => 'hs_meeting_outcome',
'operator' => 'NOT_HAS_PROPERTY',
],
],
];
return $filters;
}
private function buildTaskFiltersForLinkToTask($objectType, $objectId): array
{
return [
[
'filters' => [
$this->getAssociatedObjectFilter($objectType, $objectId),
],
],
];
}
private function getAssociatedObjectFilter(string $objectType, string $objectId): array
{
return [
'propertyName' => 'associations.' . $objectType,
'operator' => 'EQ',
'value' => $objectId,
];
}
public function generatePlaybackURLSearchPayload(string $playbackURLToken): array
{
return [
'filters' => [
[
'propertyName' => 'hs_call_recording_url',
'operator' => 'CONTAINS_TOKEN',
'value' => $playbackURLToken,
],
],
'properties' => [
'hs_call_recording_url',
'hs_call_body',
],
'limit' => 1,
];
}
public function generateGetCallsPayload(Carbon $from, Carbon $to, string $activityProvider, $page): array
{
return [
'filters' => [
[
'propertyName' => 'hs_call_recording_url',
'operator' => 'CONTAINS_TOKEN',
'value' => $activityProvider,
],
[
'propertyName' => 'hs_timestamp',
'operator' => 'BETWEEN',
'value' => intval($from->getPreciseTimestamp(3)),
'highValue' => intval($to->getPreciseTimestamp(3)),
],
],
'sorts' => [
[
'propertyName' => 'hs_timestamp',
'direction' => 'DESCENDING',
],
],
'properties' => [
'hs_call_recording_url',
'hs_call_body',
],
'limit' => self::MAX_SEARCH_REQUEST_LIMIT,
'after' => ($page - 1) * self::MAX_SEARCH_REQUEST_LIMIT,
];
}
public function generateSearchCallsByPeriodPayload(Carbon $from, Carbon $to, $page): array
{
return [
'filters' => [
[
'propertyName' => 'hs_timestamp',
'operator' => 'BETWEEN',
'value' => intval($from->getPreciseTimestamp(3)),
'highValue' => intval($to->getPreciseTimestamp(3)),
],
],
'sorts' => [
[
'propertyName' => 'hs_timestamp',
'direction' => 'DESCENDING',
],
],
'properties' => $this->getSearchCallAttributes(),
'limit' => self::MAX_SEARCH_REQUEST_LIMIT,
'after' => ($page - 1) * self::MAX_SEARCH_REQUEST_LIMIT,
];
}
public function getSearchCallAttributes(): array
{
return [
'hs_timestamp',
'hs_call_recording_url',
'hs_call_body',
'hs_call_status',
'hs_call_to_number',
'hs_call_from_number',
'hs_call_duration',
'hs_call_disposition',
'hs_call_title',
'hs_call_direction',
'hubspot_owner_id',
'hs_activity_type',
'hs_call_external_id',
'hs_call_source',
];
}
public function generatePlaybackAddUrlBatchPayload(array $crmUpdateData): array
{
$updateObjectsData = [];
foreach ($crmUpdateData as $data) {
$updateObjectsData[] = [
'id' => $data['crm_id'],
'properties' => [
'hs_call_body' => $data['hs_call_body'] .
'<p><span style="font-size: 13.3333px; line-height: 16px;">' .
'<a href="' . $data['playback_url'] . '" target="_blank">Review in Jiminny</a> ▶️</span></p>',
],
];
}
return ['inputs' => $updateObjectsData];
}
public function generateSearchCallByTokenPayload(string $playbackURLToken): array
{
return [
'filters' => [
[
'propertyName' => 'hs_call_recording_url',
'operator' => 'CONTAINS_TOKEN',
'value' => $playbackURLToken,
],
],
'properties' => [
'hs_call_recording_url',
'hs_call_body',
],
'limit' => 1,
];
}
/**
* Generates a payload for phone search based on the specified parameters.
*
* @param string $phone The phone number to search.
* @param bool $isAlternativeSearch Indicates if an alternative search should be performed
* if the first search fails to find a match. The first search request should cover most of the cases.
*/
public function generatePhoneSearchPayload(string $phone, bool $isAlternativeSearch = false): array
{
$filterPropertyNames = $isAlternativeSearch ?
['hs_searchable_calculated_mobile_number', 'phone', 'mobilephone'] :
['hs_searchable_calculated_phone_number', 'hs_calculated_phone_number', 'hs_calculated_mobile_number'];
$filterGroups = array_map(function ($propertyName) use ($phone) {
return $this->createFilterGroup($propertyName, 'CONTAINS_TOKEN', $phone);
}, $filterPropertyNames);
$properties = [
'email',
'firstname',
'lastname',
'country',
'phone',
'mobilephone',
'jobtitle',
'hubspot_owner_id',
'associatedcompanyid',
'photo',
];
return $this->createPayload($filterGroups, $properties);
}
/**
* Creates a filter group with the specified parameters.
*/
private function createFilterGroup(string $propertyName, string $operator, $value): array
{
return [
'filters' => [
[
'propertyName' => $propertyName,
'operator' => $operator,
'value' => $value,
],
],
];
}
/**
* Creates a payload with the specified filter groups and properties.
*/
private function createPayload(array $filterGroups, array $properties): array
{
return [
'filterGroups' => $filterGroups,
'sorts' => [
[
'propertyName' => 'modifieddate',
'direction' => 'DESCENDING',
],
],
'properties' => $properties,
'limit' => 1,
];
}
/**
* Generates a payload to find related activities based on the specified data and object type.
* The search looks for activities that match the specified time range (starttime - endtime or only starttime with endtime not set)
* and are associated with any of the provided prospect types (contact or company).
*
* @param array $data An associative array containing the following keys:
* - 'from': The start time for the activity search (timestamp).
* - 'to': The end time for the activity search (timestamp).
* - 'contact': (optional) The ID of the associated contact.
* - 'company': (optional) The ID of the associated company.
* @param string $objectType The type of engagement to search for (meeting or other type like call).
*
* @return array The payload array to be used for searching related activities.
*/
public function getFindRelatedActivityPayload(array $data, string $objectType): array
{
$payload = [
'sorts' => [
[
'propertyName' => self::SORT_PROPERTY,
'direction' => 'DESCENDING',
],
],
'limit' => self::MAX_SEARCH_REQUEST_LIMIT,
];
if ($objectType === self::ENGAGEMENT_MEETINGS) {
$payload['properties'] = [
'hs_meeting_title',
'hs_meeting_outcome',
'hs_activity_type',
'hs_timestamp',
'hubspot_owner_id',
'hs_meeting_body',
'hs_internal_meeting_notes',
'hs_meeting_location',
'hs_meeting_start_time',
'hs_meeting_end_time',
];
} else {
$payload['properties'] = ['hs_task_subject', 'hs_timestamp', 'hubspot_owner_id', 'hs_call_body'];
}
$timeFiltersWithEndTime = [
[
'propertyName' => 'hs_meeting_start_time',
'operator' => 'GTE',
'value' => $data['from'],
],
[
'propertyName' => 'hs_meeting_end_time',
'operator' => 'LTE',
'value' => $data['to'],
],
];
$timeFiltersWithoutEndTime = [
[
'propertyName' => 'hs_meeting_start_time',
'operator' => 'GTE',
'value' => $data['from'],
],
[
'propertyName' => 'hs_meeting_end_time',
'operator' => 'NOT_HAS_PROPERTY',
],
];
$payload['filterGroups'] = [];
$associationTypes = ['contact', 'company'];
foreach ($associationTypes as $type) {
if (! empty($data[$type])) {
$filterGroupWithEndTime = [
'filters' => array_merge(
$timeFiltersWithEndTime,
[$this->getAssociatedObjectFilter($type, $data[$type])]
),
];
$payload['filterGroups'][] = $filterGroupWithEndTime;
$filterGroupWithoutEndTime = [
'filters' => array_merge(
$timeFiltersWithoutEndTime,
[$this->getAssociatedObjectFilter($type, $data[$type])]
),
];
$payload['filterGroups'][] = $filterGroupWithoutEndTime ;
}
}
return $payload;
}
/** Parameters
* [
* 'accountId' => $crmAccountId,
* 'sortBy' => $sortBy,
* 'sortDir' => $sortDir,
* 'onlyOpen' => $onlyOpen,
* 'closedStages' => $this->getClosedDealStages(),
* 'userId' => $userId,
* ];
*/
public function generateOpportunitiesSearchPayload(
Configuration $config,
string $crmAccountId,
array $closedStages,
): array {
$closedFilters = [];
$filterGroups = [];
$onlyOpen = true;
switch ($config->getOpportunityAssignmentRule()) {
case Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_UPDATED:
$sortBy = 'modifieddate';
$sortDir = 'DESCENDING';
break;
case Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_CREATED:
$sortBy = 'createdate';
$sortDir = 'DESCENDING';
break;
case Configuration::OPP_ASSIGNMENT_ALL_OPEN_OLDEST_CREATED:
$sortBy = 'createdate';
$sortDir = 'ASCENDING';
break;
case Configuration::OPP_ASSIGNMENT_ALL_RECENTLY_UPDATED:
default:
$sortBy = 'modifieddate';
$sortDir = 'DESCENDING';
$onlyOpen = false;
}
$baseFilters = [
[
'propertyName' => 'associations.company',
'operator' => 'EQ',
'value' => $crmAccountId,
],
];
// Handle closed stages in chunks
if ($onlyOpen) {
foreach (['won', 'lost'] as $key) {
if (! empty($closedStages[$key])) {
$chunks = array_chunk($closedStages[$key], self::MAX_FILTER_SIZE);
foreach ($chunks as $chunk) {
$closedFilters[] = [
'propertyName' => 'dealstage',
'operator' => 'NOT_IN',
'values' => $chunk,
];
}
}
}
}
$filterGroups[] = [
'filters' => array_merge($baseFilters, $closedFilters),
];
$payload = [
'filterGroups' => $filterGroups,
'sorts' => [
[
'propertyName' => $sortBy,
'direction' => $sortDir,
],
],
'properties' => [
'dealname',
'amount',
'hubspot_owner_id',
'pipeline',
'dealstage',
'closedate',
'deal_currency_code',
],
'limit' => self::MAX_SEARCH_REQUEST_LIMIT,
];
$logger = app(LoggerInterface::class);
$logger->info('[HubSpot] CRM Search requested', [
'request' => $payload,
]);
return $payload;
}
/**
* Converts v1 payload data to v3
*/
public function getV3MeetingPayload(array $engagement, array $metadata, array $associations = []): array
{
// Use this mapping until the whole Hubspot V1 is dropped.
$properties = [
'hubspot_owner_id' => $engagement['ownerId'],
'hs_timestamp' => $engagement['timestamp'],
];
// $engagement['activityType'] is $activity->category->name
if (isset($engagement['activityType'])) {
$properties['hs_activity_type'] = $engagement['activityType'];
}
$metadataKeyMap = [
'hs_meeting_outcome' => 'meetingOutcome',
'hs_meeting_title' => 'title',
'hs_meeting_start_time' => 'startTime',
'hs_meeting_end_time' => 'endTime',
'hs_meeting_body' => 'body',
'hs_internal_meeting_notes' => 'internalMeetingNotes',
];
foreach ($metadataKeyMap as $newKey => $oldKey) {
if (isset($metadata[$oldKey])) {
$properties[$newKey] = $metadata[$oldKey];
unset($metadata[$oldKey]);
}
}
$properties = [
...$properties,
...$metadata, // custom fields
];
$response = [
'properties' => $properties,
];
if (! empty($associations)) {
$response['associations'] = $associations;
}
return $response;
}
/**
* Generate a payload to search for contacts by name.
* The search is a token search in firstname or lastname
*/
public function generateSearchContactsByNamePayload(string $name, array $fields): array
{
$firstNameFilter = [
'propertyName' => 'firstname',
'operator' => 'CONTAINS_TOKEN',
'value' => $name,
];
$lastNameFilter = [
'propertyName' => 'lastname',
'operator' => 'CONTAINS_TOKEN',
'value' => $name,
];
return [
'filterGroups' => [
[
'filters' => [$firstNameFilter],
],
[
'filters' => [$lastNameFilter],
],
],
'properties' => $fields,
'sorts' => [
[
'propertyName' => 'lastmodifieddate',
'direction' => 'DESCENDING',
],
],
];
}
public function buildAddAssociationPayload(string $crmId, array $ids, int $associationType): array
{
$inputs = [];
foreach ($ids as $id) {
if ($id === null || $id === '') {
continue;
}
$inputs[] = [
'from' => [
'id' => $crmId,
],
'to' => [
'id' => (string) $id,
],
'types' => [
[
'associationCategory' => 'HUBSPOT_DEFINED',
'associationTypeId' => $associationType,
],
],
];
}
return ['inputs' => $inputs];
}
public function buildRemoveAssociationPayload(string $crmId, array $ids): array
{
$toArray = [];
foreach ($ids as $id) {
if ($id === null || $id === '') {
continue;
}
$toArray[] = ['id' => (string) $id];
}
return [
'inputs' => [
[
'from' => ['id' => $crmId],
'to' => $toArray,
],
],
];
}
public function addClosedStageFilters(array &$payload, array $closedStages): void
{
if (! empty($closedStages['won'])) {
$payload['filters'][] = [
'propertyName' => 'dealstage',
'operator' => 'NOT_IN',
'values' => $closedStages['won'],
];
}
if (! empty($closedStages['lost'])) {
$payload['filters'][] = [
'propertyName' => 'dealstage',
'operator' => 'NOT_IN',
'values' => $closedStages['lost'],
];
}
}
public function addCreatedDateFilters(array &$payload, Carbon $createdAfter): void
{
$payload['filters'][] = [
'propertyName' => 'createdate',
'operator' => 'GT',
'value' => $createdAfter->getPreciseTimestamp(3),
];
}
public function getDealsInBulkPayload(array $dealIds): array
{
return [
'filterGroups' => [
[
'filters' => [
[
'propertyName' => 'hs_object_id',
'operator' => 'IN',
'values' => $dealIds,
],
],
],
],
'limit' => self::MAX_FILTER_SIZE,
];
}
}...
|
PhpStorm
|
faVsco.js – PropertyChangeManager.php
|
NULL
|
87876
|
|
87875
|
Project: faVsco.js, menu
#12121 on JY-20963-fix-im Project: faVsco.js, menu
#12121 on JY-20963-fix-import-on-deleted-entity, menu
Start Listening for PHP Debug Connections
ServiceTest
Run 'ServiceTest'
Debug 'ServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
8
25
Previous Highlighted Error
Next Highlighted Error
<?php
declare(strict_types=1);
namespace Jiminny\Services\Crm\Hubspot;
use Carbon\Carbon;
use Jiminny\Models\Crm\Configuration;
use Psr\Log\LoggerInterface;
class PayloadBuilder
{
public const int MAX_SEARCH_REQUEST_LIMIT = 200;
private const int MAX_FILTER_SIZE = 100;
private const string SORT_PROPERTY = 'hs_timestamp';
private const string ENGAGEMENT_MEETINGS = 'meetings';
public function getLinkToTaskPayload(string $objectType, string $objectId, string $engagementType): array
{
$payload = [
'sorts' => [
[
'propertyName' => self::SORT_PROPERTY,
'direction' => 'DESCENDING',
],
],
'limit' => self::MAX_SEARCH_REQUEST_LIMIT,
];
if ($engagementType === self::ENGAGEMENT_MEETINGS) {
$payload['filterGroups'] = $this->buildMeetingFiltersForLinkToTask($objectType, $objectId);
$payload['properties'] = [
'hs_meeting_title',
'hs_meeting_outcome',
'hs_timestamp',
'hs_activity_type',
];
} else {
$payload['filterGroups'] = $this->buildTaskFiltersForLinkToTask($objectType, $objectId);
$payload['properties'] = ['hs_task_subject', 'hs_timestamp'];
}
return $payload;
}
public function getRecentlyUpdatedSearchPayload(Carbon $since, ?Carbon $to, array $properties): array
{
$lastUpdateDate = in_array('firstname', $properties) ? 'lastmodifieddate' : 'hs_lastmodifieddate';
$payload = [
'filters' => [
[
'propertyName' => $lastUpdateDate,
'operator' => 'GT',
'value' => $since->getPreciseTimestamp(3),
],
],
'sorts' => [
[
'propertyName' => 'hs_object_id',
'direction' => 'DESCENDING',
],
],
'limit' => self::MAX_SEARCH_REQUEST_LIMIT,
];
if ($to) {
$payload['filters'][] = [
'propertyName' => $lastUpdateDate,
'operator' => 'LT',
'value' => $to->getPreciseTimestamp(3),
];
}
$payload['properties'] = $properties;
return $payload;
}
public function getByProfileSearchPayload(string $crmId, array $properties, Carbon $since, ?Carbon $to): array
{
$lastUpdateDate = array_key_exists('firstname', $properties) ? 'lastmodifieddate' : 'hs_lastmodifieddate';
$payload = [
'filters' => [
[
'propertyName' => $lastUpdateDate,
'operator' => 'GT',
'value' => $since->getPreciseTimestamp(3),
],
[
'propertyName' => 'hubspot_owner_id',
'operator' => 'EQ',
'value' => $crmId,
],
],
'sorts' => [
[
'propertyName' => 'hs_object_id',
'direction' => 'DESCENDING',
],
],
'limit' => self::MAX_SEARCH_REQUEST_LIMIT,
];
if ($to) {
$payload['filters'][] = [
'propertyName' => $lastUpdateDate,
'operator' => 'LT',
'value' => $to->getPreciseTimestamp(3),
];
}
$payload['properties'] = $properties;
return $payload;
}
private function buildMeetingFiltersForLinkToTask($objectType, $objectId): array
{
$filters = [];
$filters[] = [
'filters' => [
$this->getAssociatedObjectFilter($objectType, $objectId),
[
'propertyName' => 'hs_meeting_outcome',
'operator' => 'IN',
'values' => ['SCHEDULED', 'RESCHEDULED'],
],
],
];
$filters[] = [
'filters' => [
$this->getAssociatedObjectFilter($objectType, $objectId),
[
'propertyName' => 'hs_meeting_outcome',
'operator' => 'NOT_HAS_PROPERTY',
],
],
];
return $filters;
}
private function buildTaskFiltersForLinkToTask($objectType, $objectId): array
{
return [
[
'filters' => [
$this->getAssociatedObjectFilter($objectType, $objectId),
],
],
];
}
private function getAssociatedObjectFilter(string $objectType, string $objectId): array
{
return [
'propertyName' => 'associations.' . $objectType,
'operator' => 'EQ',
'value' => $objectId,
];
}
public function generatePlaybackURLSearchPayload(string $playbackURLToken): array
{
return [
'filters' => [
[
'propertyName' => 'hs_call_recording_url',
'operator' => 'CONTAINS_TOKEN',
'value' => $playbackURLToken,
],
],
'properties' => [
'hs_call_recording_url',
'hs_call_body',
],
'limit' => 1,
];
}
public function generateGetCallsPayload(Carbon $from, Carbon $to, string $activityProvider, $page): array
{
return [
'filters' => [
[
'propertyName' => 'hs_call_recording_url',
'operator' => 'CONTAINS_TOKEN',
'value' => $activityProvider,
],
[
'propertyName' => 'hs_timestamp',
'operator' => 'BETWEEN',
'value' => intval($from->getPreciseTimestamp(3)),
'highValue' => intval($to->getPreciseTimestamp(3)),
],
],
'sorts' => [
[
'propertyName' => 'hs_timestamp',
'direction' => 'DESCENDING',
],
],
'properties' => [
'hs_call_recording_url',
'hs_call_body',
],
'limit' => self::MAX_SEARCH_REQUEST_LIMIT,
'after' => ($page - 1) * self::MAX_SEARCH_REQUEST_LIMIT,
];
}
public function generateSearchCallsByPeriodPayload(Carbon $from, Carbon $to, $page): array
{
return [
'filters' => [
[
'propertyName' => 'hs_timestamp',
'operator' => 'BETWEEN',
'value' => intval($from->getPreciseTimestamp(3)),
'highValue' => intval($to->getPreciseTimestamp(3)),
],
],
'sorts' => [
[
'propertyName' => 'hs_timestamp',
'direction' => 'DESCENDING',
],
],
'properties' => $this->getSearchCallAttributes(),
'limit' => self::MAX_SEARCH_REQUEST_LIMIT,
'after' => ($page - 1) * self::MAX_SEARCH_REQUEST_LIMIT,
];
}
public function getSearchCallAttributes(): array
{
return [
'hs_timestamp',
'hs_call_recording_url',
'hs_call_body',
'hs_call_status',
'hs_call_to_number',
'hs_call_from_number',
'hs_call_duration',
'hs_call_disposition',
'hs_call_title',
'hs_call_direction',
'hubspot_owner_id',
'hs_activity_type',
'hs_call_external_id',
'hs_call_source',
];
}
public function generatePlaybackAddUrlBatchPayload(array $crmUpdateData): array
{
$updateObjectsData = [];
foreach ($crmUpdateData as $data) {
$updateObjectsData[] = [
'id' => $data['crm_id'],
'properties' => [
'hs_call_body' => $data['hs_call_body'] .
'<p><span style="font-size: 13.3333px; line-height: 16px;">' .
'<a href="' . $data['playback_url'] . '" target="_blank">Review in Jiminny</a> ▶️</span></p>',
],
];
}
return ['inputs' => $updateObjectsData];
}
public function generateSearchCallByTokenPayload(string $playbackURLToken): array
{
return [
'filters' => [
[
'propertyName' => 'hs_call_recording_url',
'operator' => 'CONTAINS_TOKEN',
'value' => $playbackURLToken,
],
],
'properties' => [
'hs_call_recording_url',
'hs_call_body',
],
'limit' => 1,
];
}
/**
* Generates a payload for phone search based on the specified parameters.
*
* @param string $phone The phone number to search.
* @param bool $isAlternativeSearch Indicates if an alternative search should be performed
* if the first search fails to find a match. The first search request should cover most of the cases.
*/
public function generatePhoneSearchPayload(string $phone, bool $isAlternativeSearch = false): array
{
$filterPropertyNames = $isAlternativeSearch ?
['hs_searchable_calculated_mobile_number', 'phone', 'mobilephone'] :
['hs_searchable_calculated_phone_number', 'hs_calculated_phone_number', 'hs_calculated_mobile_number'];
$filterGroups = array_map(function ($propertyName) use ($phone) {
return $this->createFilterGroup($propertyName, 'CONTAINS_TOKEN', $phone);
}, $filterPropertyNames);
$properties = [
'email',
'firstname',
'lastname',
'country',
'phone',
'mobilephone',
'jobtitle',
'hubspot_owner_id',
'associatedcompanyid',
'photo',
];
return $this->createPayload($filterGroups, $properties);
}
/**
* Creates a filter group with the specified parameters.
*/
private function createFilterGroup(string $propertyName, string $operator, $value): array
{
return [
'filters' => [
[
'propertyName' => $propertyName,
'operator' => $operator,
'value' => $value,
],
],
];
}
/**
* Creates a payload with the specified filter groups and properties.
*/
private function createPayload(array $filterGroups, array $properties): array
{
return [
'filterGroups' => $filterGroups,
'sorts' => [
[
'propertyName' => 'modifieddate',
'direction' => 'DESCENDING',
],
],
'properties' => $properties,
'limit' => 1,
];
}
/**
* Generates a payload to find related activities based on the specified data and object type.
* The search looks for activities that match the specified time range (starttime - endtime or only starttime with endtime not set)
* and are associated with any of the provided prospect types (contact or company).
*
* @param array $data An associative array containing the following keys:
* - 'from': The start time for the activity search (timestamp).
* - 'to': The end time for the activity search (timestamp).
* - 'contact': (optional) The ID of the associated contact.
* - 'company': (optional) The ID of the associated company.
* @param string $objectType The type of engagement to search for (meeting or other type like call).
*
* @return array The payload array to be used for searching related activities.
*/
public function getFindRelatedActivityPayload(array $data, string $objectType): array
{
$payload = [
'sorts' => [
[
'propertyName' => self::SORT_PROPERTY,
'direction' => 'DESCENDING',
],
],
'limit' => self::MAX_SEARCH_REQUEST_LIMIT,
];
if ($objectType === self::ENGAGEMENT_MEETINGS) {
$payload['properties'] = [
'hs_meeting_title',
'hs_meeting_outcome',
'hs_activity_type',
'hs_timestamp',
'hubspot_owner_id',
'hs_meeting_body',
'hs_internal_meeting_notes',
'hs_meeting_location',
'hs_meeting_start_time',
'hs_meeting_end_time',
];
} else {
$payload['properties'] = ['hs_task_subject', 'hs_timestamp', 'hubspot_owner_id', 'hs_call_body'];
}
$timeFiltersWithEndTime = [
[
'propertyName' => 'hs_meeting_start_time',
'operator' => 'GTE',
'value' => $data['from'],
],
[
'propertyName' => 'hs_meeting_end_time',
'operator' => 'LTE',
'value' => $data['to'],
],
];
$timeFiltersWithoutEndTime = [
[
'propertyName' => 'hs_meeting_start_time',
'operator' => 'GTE',
'value' => $data['from'],
],
[
'propertyName' => 'hs_meeting_end_time',
'operator' => 'NOT_HAS_PROPERTY',
],
];
$payload['filterGroups'] = [];
$associationTypes = ['contact', 'company'];
foreach ($associationTypes as $type) {
if (! empty($data[$type])) {
$filterGroupWithEndTime = [
'filters' => array_merge(
$timeFiltersWithEndTime,
[$this->getAssociatedObjectFilter($type, $data[$type])]
),
];
$payload['filterGroups'][] = $filterGroupWithEndTime;
$filterGroupWithoutEndTime = [
'filters' => array_merge(
$timeFiltersWithoutEndTime,
[$this->getAssociatedObjectFilter($type, $data[$type])]
),
];
$payload['filterGroups'][] = $filterGroupWithoutEndTime ;
}
}
return $payload;
}
/** Parameters
* [
* 'accountId' => $crmAccountId,
* 'sortBy' => $sortBy,
* 'sortDir' => $sortDir,
* 'onlyOpen' => $onlyOpen,
* 'closedStages' => $this->getClosedDealStages(),
* 'userId' => $userId,
* ];
*/
public function generateOpportunitiesSearchPayload(
Configuration $config,
string $crmAccountId,
array $closedStages,
): array {
$closedFilters = [];
$filterGroups = [];
$onlyOpen = true;
switch ($config->getOpportunityAssignmentRule()) {
case Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_UPDATED:
$sortBy = 'modifieddate';
$sortDir = 'DESCENDING';
break;
case Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_CREATED:
$sortBy = 'createdate';
$sortDir = 'DESCENDING';
break;
case Configuration::OPP_ASSIGNMENT_ALL_OPEN_OLDEST_CREATED:
$sortBy = 'createdate';
$sortDir = 'ASCENDING';
break;
case Configuration::OPP_ASSIGNMENT_ALL_RECENTLY_UPDATED:
default:
$sortBy = 'modifieddate';
$sortDir = 'DESCENDING';
$onlyOpen = false;
}
$baseFilters = [
[
'propertyName' => 'associations.company',
'operator' => 'EQ',
'value' => $crmAccountId,
],
];
// Handle closed stages in chunks
if ($onlyOpen) {
foreach (['won', 'lost'] as $key) {
if (! empty($closedStages[$key])) {
$chunks = array_chunk($closedStages[$key], self::MAX_FILTER_SIZE);
foreach ($chunks as $chunk) {
$closedFilters[] = [
'propertyName' => 'dealstage',
'operator' => 'NOT_IN',
'values' => $chunk,
];
}
}
}
}
$filterGroups[] = [
'filters' => array_merge($baseFilters, $closedFilters),
];
$payload = [
'filterGroups' => $filterGroups,
'sorts' => [
[
'propertyName' => $sortBy,
'direction' => $sortDir,
],
],
'properties' => [
'dealname',
'amount',
'hubspot_owner_id',
'pipeline',
'dealstage',
'closedate',
'deal_currency_code',
],
'limit' => self::MAX_SEARCH_REQUEST_LIMIT,
];
$logger = app(LoggerInterface::class);
$logger->info('[HubSpot] CRM Search requested', [
'request' => $payload,
]);
return $payload;
}
/**
* Converts v1 payload data to v3
*/
public function getV3MeetingPayload(array $engagement, array $metadata, array $associations = []): array
{
// Use this mapping until the whole Hubspot V1 is dropped.
$properties = [
'hubspot_owner_id' => $engagement['ownerId'],
'hs_timestamp' => $engagement['timestamp'],
];
// $engagement['activityType'] is $activity->category->name
if (isset($engagement['activityType'])) {
$properties['hs_activity_type'] = $engagement['activityType'];
}
$metadataKeyMap = [
'hs_meeting_outcome' => 'meetingOutcome',
'hs_meeting_title' => 'title',
'hs_meeting_start_time' => 'startTime',
'hs_meeting_end_time' => 'endTime',
'hs_meeting_body' => 'body',
'hs_internal_meeting_notes' => 'internalMeetingNotes',
];
foreach ($metadataKeyMap as $newKey => $oldKey) {
if (isset($metadata[$oldKey])) {
$properties[$newKey] = $metadata[$oldKey];
unset($metadata[$oldKey]);
}
}
$properties = [
...$properties,
...$metadata, // custom fields
];
$response = [
'properties' => $properties,
];
if (! empty($associations)) {
$response['associations'] = $associations;
}
return $response;
}
/**
* Generate a payload to search for contacts by name.
* The search is a token search in firstname or lastname
*/
public function generateSearchContactsByNamePayload(string $name, array $fields): array
{
$firstNameFilter = [
'propertyName' => 'firstname',
'operator' => 'CONTAINS_TOKEN',
'value' => $name,
];
$lastNameFilter = [
'propertyName' => 'lastname',
'operator' => 'CONTAINS_TOKEN',
'value' => $name,
];
return [
'filterGroups' => [
[
'filters' => [$firstNameFilter],
],
[
'filters' => [$lastNameFilter],
],
],
'properties' => $fields,
'sorts' => [
[
'propertyName' => 'lastmodifieddate',
'direction' => 'DESCENDING',
],
],
];
}
public function buildAddAssociationPayload(string $crmId, array $ids, int $associationType): array
{
$inputs = [];
foreach ($ids as $id) {
if ($id === null || $id === '') {
continue;
}
$inputs[] = [
'from' => [
'id' => $crmId,
],
'to' => [
'id' => (string) $id,
],
'types' => [
[
'associationCategory' => 'HUBSPOT_DEFINED',
'associationTypeId' => $associationType,
],
],
];
}
return ['inputs' => $inputs];
}
public function buildRemoveAssociationPayload(string $crmId, array $ids): array
{
$toArray = [];
foreach ($ids as $id) {
if ($id === null || $id === '') {
continue;
}
$toArray[] = ['id' => (string) $id];
}
return [
'inputs' => [
[
'from' => ['id' => $crmId],
'to' => $toArray,
],
],
];
}
public function addClosedStageFilters(array &$payload, array $closedStages): void
{
if (! empty($closedStages['won'])) {
$payload['filters'][] = [
'propertyName' => 'dealstage',
'operator' => 'NOT_IN',
'values' => $closedStages['won'],
];
}
if (! empty($closedStages['lost'])) {
$payload['filters'][] = [
'propertyName' => 'dealstage',
'operator' => 'NOT_IN',
'values' => $closedStages['lost'],
];
}
}
public function addCreatedDateFilters(array &$payload, Carbon $createdAfter): void
{
$payload['filters'][] = [
'propertyName' => 'createdate',
'operator' => 'GT',
'value' => $createdAfter->getPreciseTimestamp(3),
];
}
public function getDealsInBulkPayload(array $dealIds): array
{
return [
'filterGroups' => [
[
'filters' => [
[
'propertyName' => 'hs_object_id',
'operator' => 'IN',
'values' => $dealIds,
],
],
],
],
'limit' => self::MAX_FILTER_SIZE,
];
}
}
Sync Changes
Hide This Notification
Code changed:
Hide
22
8
Previous Highlighted Error
Next Highlighted Error
<?php
declare(strict_types=1);
namespace Jiminny\Services\Crm\Hubspot\Webhook;
use Illuminate\Support\Facades\Log;
use Jiminny\Models\Crm\Configuration;
use Jiminny\Services\Crm\Hubspot\BatchSyncCollector;
use Jiminny\Repositories\Crm\CrmEntityRepository;
use Jiminny\Repositories\Crm\OpportunityRepository;
use Jiminny\Repositories\Crm\FieldRepository;
use Jiminny\Repositories\Crm\FieldDataRepository;
use Jiminny\Models\Opportunity;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\LayoutEntity;
readonly class PropertyChangeManager
{
private const string OBJECT_TYPE_DEAL = 'deal';
private const string OBJECT_TYPE_CONTACT = 'contact';
private const string OBJECT_TYPE_COMPANY = 'company';
private const array SUPPORTED_OBJECT_TYPES = [
self::OBJECT_TYPE_DEAL,
self::OBJECT_TYPE_CONTACT,
self::OBJECT_TYPE_COMPANY,
];
/**
* Contact fields that webhooks are subscribed to in HubSpot.
*
* IMPORTANT: These fields MUST match the webhook subscriptions configured in HubSpot.
* When updating this list, you must also update the HubSpot webhook subscriptions:
* 1. Go to HubSpot App Settings → Webhooks
* 2. Update contact.propertyChange subscriptions to match this list
* 3. Ensure all fields listed here are subscribed, and vice versa
*
* This constant serves as documentation of the current webhook configuration.
* Since HubSpot filters webhooks server-side, we should only receive events for these properties.
*/
private const array APPLICABLE_CONTACT_FIELDS = [
'firstname',
'lastname',
'email',
'phone',
'mobilephone',
'jobtitle',
'country',
'hubspot_owner_id',
'associatedcompanyid',
'photo',
'hs_avatar_filemanager_key',
];
/**
* Company fields that webhooks are subscribed to in HubSpot.
*
* IMPORTANT: These fields MUST match the webhook subscriptions configured in HubSpot.
* When updating this list, you must also update the HubSpot webhook subscriptions:
* 1. Go to HubSpot App Settings → Webhooks
* 2. Update company.propertyChange subscriptions to match this list
* 3. Ensure all fields listed here are subscribed, and vice versa
*
* This constant serves as documentation of the current webhook configuration.
* Since HubSpot filters webhooks server-side, we should only receive events for these properties.
*/
private const array APPLICABLE_COMPANY_FIELDS = [
'name',
'phone',
'domain',
'country',
'industry',
'hubspot_owner_id',
'hs_avatar_filemanager_key',
];
/**
* Properties that support direct database updates (without full sync).
* These are simple properties that can be updated immediately without recalculating other fields.
*/
private const array DB_OBJECT_UPDATE_PROPERTIES = [
self::OBJECT_TYPE_DEAL => [
'dealname',
'description',
],
self::OBJECT_TYPE_CONTACT => [
// Add contact properties that support direct update
],
self::OBJECT_TYPE_COMPANY => [
// Add company properties that support direct update
],
];
/**
* Mapping from HubSpot property names to Jiminny model property names.
* Used when updating the main model (Opportunity, Contact, Account) directly.
* Only needed when HubSpot property name differs from Jiminny property name.
*/
private const array OBJECT_PROPERTY_MAPPINGS = [
self::OBJECT_TYPE_DEAL => [
'dealname' => 'name', // HubSpot 'dealname' maps to Opportunity 'name'
],
self::OBJECT_TYPE_CONTACT => [
// Add contact property mappings if needed
],
self::OBJECT_TYPE_COMPANY => [
// Add company property mappings if needed
],
];
// Field object types mapping
private const array FIELD_OBJECT_TYPES = [
self::OBJECT_TYPE_DEAL => Field::OBJECT_OPPORTUNITY,
self::OBJECT_TYPE_CONTACT => Field::OBJECT_CONTACT,
self::OBJECT_TYPE_COMPANY => Field::OBJECT_ACCOUNT,
];
public function __construct(
private BatchSyncCollector $batchCollector,
private OpportunityRepository $opportunityRepository,
private CrmEntityRepository $crmEntityRepository,
private FieldRepository $fieldRepository,
private FieldDataRepository $fieldDataRepository,
private HubspotWebhookMetricsService $metricsService,
) {
}
/**
* Get contact fields that webhooks are subscribed to in HubSpot.
* This serves as documentation of the current webhook configuration.
*
* @return array<int, string>
*/
public function getApplicableContactFields(): array
{
return self::APPLICABLE_CONTACT_FIELDS;
}
/**
* Get company fields that webhooks are subscribed to in HubSpot.
* This serves as documentation of the current webhook configuration.
*
* @return array<int, string>
*/
public function getApplicableCompanyFields(): array
{
return self::APPLICABLE_COMPANY_FIELDS;
}
/**
* Process property change with intelligent routing
*/
public function processPropertyChange(
string $objectType,
string $objectId,
Configuration $configuration,
string $propertyName,
string $propertyValue
): void {
if (! $this->isObjectTypeSupported($objectType)) {
Log::warning('[HubSpot Property Change Manager] Unsupported object type', [
'object_type' => $objectType,
'object_id' => $objectId,
'property_name' => $propertyName,
]);
return;
}
$this->metricsService->recordWebhookMetrics(
$configuration,
$objectType,
'property_change',
$propertyName
);
if ($this->isDirectUpdateSupported($objectType, $propertyName) &&
$this->handleDirectUpdate($objectType, $objectId, $configuration, $propertyName, $propertyValue)) {
return;
}
$this->handleObjectSync($objectType, $objectId, $configuration);
}
/**
* Legacy method for backward compatibility - assumes deal object type
*
* @deprecated Use processPropertyChange with objectType parameter
*/
public function processPropertyChangeForDeal(
string $objectId,
Configuration $configuration,
string $propertyName,
string $propertyValue
): void {
$this->processPropertyChange(self::OBJECT_TYPE_DEAL, $objectId, $configuration, $propertyName, $propertyValue);
}
/**
* Check if object type is supported
*/
private function isObjectTypeSupported(string $objectType): bool
{
return in_array($objectType, self::SUPPORTED_OBJECT_TYPES);
}
/**
* Check if property can be updated without additional calculation on other properties.
*/
private function isDirectUpdateSupported(string $objectType, string $propertyName): bool
{
$supportedProperties = self::DB_OBJECT_UPDATE_PROPERTIES[$objectType] ?? [];
return in_array($propertyName, $supportedProperties, true);
}
/**
* Handle properties that support direct database updates
*/
private function handleDirectUpdate(
string $objectType,
string $objectId,
Configuration $configuration,
string $propertyName,
string $propertyValue
): bool {
try {
return match ($objectType) {
self::OBJECT_TYPE_DEAL => $this->handleDealDirectUpdate($objectId, $configuration, $propertyName, $propertyValue),
self::OBJECT_TYPE_CONTACT => $this->handleContactDirectUpdate($objectId, $configuration, $propertyName, $propertyValue),
self::OBJECT_TYPE_COMPANY => $this->handleCompanyDirectUpdate($objectId, $configuration, $propertyName, $propertyValue),
default => false,
};
} catch (\Exception $e) {
Log::error('[HubSpot Property Change Manager] Error handling direct update property', [
'object_type' => $objectType,
'object_id' => $objectId,
'property_name' => $propertyName,
'error' => $e->getMessage(),
]);
return false;
}
}
/**
* Handle direct updates for deal objects
*/
private function handleDealDirectUpdate(
string $objectId,
Configuration $configuration,
string $propertyName,
string $propertyValue
): bool {
$opportunity = $this->opportunityRepository->findByConfigAndCrmProviderId(
$configuration,
$objectId
);
if (! $opportunity instanceof Opportunity) {
return false;
}
// Update model property if mapping exists
$propertyMappings = self::OBJECT_PROPERTY_MAPPINGS[self::OBJECT_TYPE_DEAL] ?? [];
if (isset($propertyMappings[$propertyName])) {
$this->opportunityRepository->updateOrCreate(
$configuration,
$objectId,
[$propertyMappings[$propertyName] => $propertyValue]
);
}
// Update field data
$this->importObjectFieldData(
self::OBJECT_TYPE_DEAL,
$opportunity->getId(),
$configuration,
[$propertyName => $propertyValue]
);
return true;
}
/**
* Handle direct updates for contact objects
*
* Updates Contact model properties via OBJECT_PROPERTY_MAPPINGS.
* Add mappings to OBJECT_PROPERTY_MAPPINGS[CONTACT] to enable direct updates.
*/
private function handleContactDirectUpdate(
string $objectId,
Configuration $configuration,
string $propertyName,
string $propertyValue
): bool {
$contact = $this->crmEntityRepository->findContactByExternalId($configuration, $objectId);
if ($contact === null) {
return false;
}
// Update model property if mapping exists
$propertyMappings = self::OBJECT_PROPERTY_MAPPINGS[self::OBJECT_TYPE_CONTACT] ?? [];
if (isset($propertyMappings[$propertyName])) {
$this->crmEntityRepository->importContact($configuration, [
'crm_provider_id' => $objectId,
$propertyMappings[$propertyName] => $propertyValue,
]);
}
return true;
}
/**
* Handle direct updates for company objects
*
* Updates Account model properties via OBJECT_PROPERTY_MAPPINGS.
* Add mappings to OBJECT_PROPERTY_MAPPINGS[COMPANY] to enable direct updates.
*/
private function handleCompanyDirectUpdate(
string $objectId,
Configuration $configuration,
string $propertyName,
string $propertyValue
): bool {
$company = $this->crmEntityRepository->findAccountByExternalId($configuration, $objectId);
if ($company === null) {
return false;
}
// Update model property if mapping exists
$propertyMappings = self::OBJECT_PROPERTY_MAPPINGS[self::OBJECT_TYPE_COMPANY] ?? [];
if (isset($propertyMappings[$propertyName])) {
$this->crmEntityRepository->importAccount($configuration, [
'crm_provider_id' => $objectId,
$propertyMappings[$propertyName] => $propertyValue,
]);
}
return true;
}
/**
* Import field data for any object type
*/
private function importObjectFieldData(
string $objectType,
int $objectId,
Configuration $configuration,
array $data
): void {
$fieldObjectType = self::FIELD_OBJECT_TYPES[$objectType] ?? null;
if (! $fieldObjectType) {
Log::warning('[HubSpot Property Change Manager] Unknown field object type', [
'object_type' => $objectType,
'object_id' => $objectId,
]);
return;
}
foreach ($data as $crmField => $value) {
$field = $this->fieldRepository->findFieldByCrmIdAndObjectType(
$configuration,
$crmField,
$fieldObjectType
);
if (! $field instanceof Field) {
continue;
}
$entities = $field->getEntities();
if ($entities->isEmpty()) {
continue;
}
if ($entities->count() > 1) {
continue;
}
/** @var LayoutEntity|null $entity */
$entity = $entities->first();
if (! $entity instanceof LayoutEntity) {
continue;
}
$this->fieldDataRepository->updateOrCreateFieldData(
$entity,
$objectId,
(string) $value,
);
}
}
private function handleObjectSync(
string $objectType,
string $objectId,
Configuration $configuration,
): void {
if (! $this->isObjectTypeSupported($objectType)) {
return;
}
try {
$this->batchCollector->collectForBatch(
objectType: $objectType,
crmProviderId: $objectId,
configurationId: $configuration->getId(),
eventType: 'property_change'
);
} catch (\Exception $e) {
Log::error('[HubSpot Property Change Manager] Error adding to sync batch', [
'object_type' => $objectType,
'object_id' => $objectId,
'error' => $e->getMessage(),
]);
}
}
}
Project
Project
New File or Directory…
Expand Selected
Collapse All
Options
Hide...
|
PhpStorm
|
faVsco.js – PropertyChangeManager.php
|
NULL
|
87875
|
|
87874
|
Project: faVsco.js, menu
#12121 on JY-20963-fix-im Project: faVsco.js, menu
#12121 on JY-20963-fix-import-on-deleted-entity, menu
Start Listening for PHP Debug Connections
ServiceTest
Run 'ServiceTest'
Debug 'ServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
8
25
Previous Highlighted Error
Next Highlighted Error
<?php
declare(strict_types=1);
namespace Jiminny\Services\Crm\Hubspot;
use Carbon\Carbon;
use Jiminny\Models\Crm\Configuration;
use Psr\Log\LoggerInterface;
class PayloadBuilder
{
public const int MAX_SEARCH_REQUEST_LIMIT = 200;
private const int MAX_FILTER_SIZE = 100;
private const string SORT_PROPERTY = 'hs_timestamp';
private const string ENGAGEMENT_MEETINGS = 'meetings';
public function getLinkToTaskPayload(string $objectType, string $objectId, string $engagementType): array
{
$payload = [
'sorts' => [
[
'propertyName' => self::SORT_PROPERTY,
'direction' => 'DESCENDING',
],
],
'limit' => self::MAX_SEARCH_REQUEST_LIMIT,
];
if ($engagementType === self::ENGAGEMENT_MEETINGS) {
$payload['filterGroups'] = $this->buildMeetingFiltersForLinkToTask($objectType, $objectId);
$payload['properties'] = [
'hs_meeting_title',
'hs_meeting_outcome',
'hs_timestamp',
'hs_activity_type',
];
} else {
$payload['filterGroups'] = $this->buildTaskFiltersForLinkToTask($objectType, $objectId);
$payload['properties'] = ['hs_task_subject', 'hs_timestamp'];
}
return $payload;
}
public function getRecentlyUpdatedSearchPayload(Carbon $since, ?Carbon $to, array $properties): array
{
$lastUpdateDate = in_array('firstname', $properties) ? 'lastmodifieddate' : 'hs_lastmodifieddate';
$payload = [
'filters' => [
[
'propertyName' => $lastUpdateDate,
'operator' => 'GT',
'value' => $since->getPreciseTimestamp(3),
],
],
'sorts' => [
[
'propertyName' => 'hs_object_id',
'direction' => 'DESCENDING',
],
],
'limit' => self::MAX_SEARCH_REQUEST_LIMIT,
];
if ($to) {
$payload['filters'][] = [
'propertyName' => $lastUpdateDate,
'operator' => 'LT',
'value' => $to->getPreciseTimestamp(3),
];
}
$payload['properties'] = $properties;
return $payload;
}
public function getByProfileSearchPayload(string $crmId, array $properties, Carbon $since, ?Carbon $to): array
{
$lastUpdateDate = array_key_exists('firstname', $properties) ? 'lastmodifieddate' : 'hs_lastmodifieddate';
$payload = [
'filters' => [
[
'propertyName' => $lastUpdateDate,
'operator' => 'GT',
'value' => $since->getPreciseTimestamp(3),
],
[
'propertyName' => 'hubspot_owner_id',
'operator' => 'EQ',
'value' => $crmId,
],
],
'sorts' => [
[
'propertyName' => 'hs_object_id',
'direction' => 'DESCENDING',
],
],
'limit' => self::MAX_SEARCH_REQUEST_LIMIT,
];
if ($to) {
$payload['filters'][] = [
'propertyName' => $lastUpdateDate,
'operator' => 'LT',
'value' => $to->getPreciseTimestamp(3),
];
}
$payload['properties'] = $properties;
return $payload;
}
private function buildMeetingFiltersForLinkToTask($objectType, $objectId): array
{
$filters = [];
$filters[] = [
'filters' => [
$this->getAssociatedObjectFilter($objectType, $objectId),
[
'propertyName' => 'hs_meeting_outcome',
'operator' => 'IN',
'values' => ['SCHEDULED', 'RESCHEDULED'],
],
],
];
$filters[] = [
'filters' => [
$this->getAssociatedObjectFilter($objectType, $objectId),
[
'propertyName' => 'hs_meeting_outcome',
'operator' => 'NOT_HAS_PROPERTY',
],
],
];
return $filters;
}
private function buildTaskFiltersForLinkToTask($objectType, $objectId): array
{
return [
[
'filters' => [
$this->getAssociatedObjectFilter($objectType, $objectId),
],
],
];
}
private function getAssociatedObjectFilter(string $objectType, string $objectId): array
{
return [
'propertyName' => 'associations.' . $objectType,
'operator' => 'EQ',
'value' => $objectId,
];
}
public function generatePlaybackURLSearchPayload(string $playbackURLToken): array
{
return [
'filters' => [
[
'propertyName' => 'hs_call_recording_url',
'operator' => 'CONTAINS_TOKEN',
'value' => $playbackURLToken,
],
],
'properties' => [
'hs_call_recording_url',
'hs_call_body',
],
'limit' => 1,
];
}
public function generateGetCallsPayload(Carbon $from, Carbon $to, string $activityProvider, $page): array
{
return [
'filters' => [
[
'propertyName' => 'hs_call_recording_url',
'operator' => 'CONTAINS_TOKEN',
'value' => $activityProvider,
],
[
'propertyName' => 'hs_timestamp',
'operator' => 'BETWEEN',
'value' => intval($from->getPreciseTimestamp(3)),
'highValue' => intval($to->getPreciseTimestamp(3)),
],
],
'sorts' => [
[
'propertyName' => 'hs_timestamp',
'direction' => 'DESCENDING',
],
],
'properties' => [
'hs_call_recording_url',
'hs_call_body',
],
'limit' => self::MAX_SEARCH_REQUEST_LIMIT,
'after' => ($page - 1) * self::MAX_SEARCH_REQUEST_LIMIT,
];
}
public function generateSearchCallsByPeriodPayload(Carbon $from, Carbon $to, $page): array
{
return [
'filters' => [
[
'propertyName' => 'hs_timestamp',
'operator' => 'BETWEEN',
'value' => intval($from->getPreciseTimestamp(3)),
'highValue' => intval($to->getPreciseTimestamp(3)),
],
],
'sorts' => [
[
'propertyName' => 'hs_timestamp',
'direction' => 'DESCENDING',
],
],
'properties' => $this->getSearchCallAttributes(),
'limit' => self::MAX_SEARCH_REQUEST_LIMIT,
'after' => ($page - 1) * self::MAX_SEARCH_REQUEST_LIMIT,
];
}
public function getSearchCallAttributes(): array
{
return [
'hs_timestamp',
'hs_call_recording_url',
'hs_call_body',
'hs_call_status',
'hs_call_to_number',
'hs_call_from_number',
'hs_call_duration',
'hs_call_disposition',
'hs_call_title',
'hs_call_direction',
'hubspot_owner_id',
'hs_activity_type',
'hs_call_external_id',
'hs_call_source',
];
}
public function generatePlaybackAddUrlBatchPayload(array $crmUpdateData): array
{
$updateObjectsData = [];
foreach ($crmUpdateData as $data) {
$updateObjectsData[] = [
'id' => $data['crm_id'],
'properties' => [
'hs_call_body' => $data['hs_call_body'] .
'<p><span style="font-size: 13.3333px; line-height: 16px;">' .
'<a href="' . $data['playback_url'] . '" target="_blank">Review in Jiminny</a> ▶️</span></p>',
],
];
}
return ['inputs' => $updateObjectsData];
}
public function generateSearchCallByTokenPayload(string $playbackURLToken): array
{
return [
'filters' => [
[
'propertyName' => 'hs_call_recording_url',
'operator' => 'CONTAINS_TOKEN',
'value' => $playbackURLToken,
],
],
'properties' => [
'hs_call_recording_url',
'hs_call_body',
],
'limit' => 1,
];
}
/**
* Generates a payload for phone search based on the specified parameters.
*
* @param string $phone The phone number to search.
* @param bool $isAlternativeSearch Indicates if an alternative search should be performed
* if the first search fails to find a match. The first search request should cover most of the cases.
*/
public function generatePhoneSearchPayload(string $phone, bool $isAlternativeSearch = false): array
{
$filterPropertyNames = $isAlternativeSearch ?
['hs_searchable_calculated_mobile_number', 'phone', 'mobilephone'] :
['hs_searchable_calculated_phone_number', 'hs_calculated_phone_number', 'hs_calculated_mobile_number'];
$filterGroups = array_map(function ($propertyName) use ($phone) {
return $this->createFilterGroup($propertyName, 'CONTAINS_TOKEN', $phone);
}, $filterPropertyNames);
$properties = [
'email',
'firstname',
'lastname',
'country',
'phone',
'mobilephone',
'jobtitle',
'hubspot_owner_id',
'associatedcompanyid',
'photo',
];
return $this->createPayload($filterGroups, $properties);
}
/**
* Creates a filter group with the specified parameters.
*/
private function createFilterGroup(string $propertyName, string $operator, $value): array
{
return [
'filters' => [
[
'propertyName' => $propertyName,
'operator' => $operator,
'value' => $value,
],
],
];
}
/**
* Creates a payload with the specified filter groups and properties.
*/
private function createPayload(array $filterGroups, array $properties): array
{
return [
'filterGroups' => $filterGroups,
'sorts' => [
[
'propertyName' => 'modifieddate',
'direction' => 'DESCENDING',
],
],
'properties' => $properties,
'limit' => 1,
];
}
/**
* Generates a payload to find related activities based on the specified data and object type.
* The search looks for activities that match the specified time range (starttime - endtime or only starttime with endtime not set)
* and are associated with any of the provided prospect types (contact or company).
*
* @param array $data An associative array containing the following keys:
* - 'from': The start time for the activity search (timestamp).
* - 'to': The end time for the activity search (timestamp).
* - 'contact': (optional) The ID of the associated contact.
* - 'company': (optional) The ID of the associated company.
* @param string $objectType The type of engagement to search for (meeting or other type like call).
*
* @return array The payload array to be used for searching related activities.
*/
public function getFindRelatedActivityPayload(array $data, string $objectType): array
{
$payload = [
'sorts' => [
[
'propertyName' => self::SORT_PROPERTY,
'direction' => 'DESCENDING',
],
],
'limit' => self::MAX_SEARCH_REQUEST_LIMIT,
];
if ($objectType === self::ENGAGEMENT_MEETINGS) {
$payload['properties'] = [
'hs_meeting_title',
'hs_meeting_outcome',
'hs_activity_type',
'hs_timestamp',
'hubspot_owner_id',
'hs_meeting_body',
'hs_internal_meeting_notes',
'hs_meeting_location',
'hs_meeting_start_time',
'hs_meeting_end_time',
];
} else {
$payload['properties'] = ['hs_task_subject', 'hs_timestamp', 'hubspot_owner_id', 'hs_call_body'];
}
$timeFiltersWithEndTime = [
[
'propertyName' => 'hs_meeting_start_time',
'operator' => 'GTE',
'value' => $data['from'],
],
[
'propertyName' => 'hs_meeting_end_time',
'operator' => 'LTE',
'value' => $data['to'],
],
];
$timeFiltersWithoutEndTime = [
[
'propertyName' => 'hs_meeting_start_time',
'operator' => 'GTE',
'value' => $data['from'],
],
[
'propertyName' => 'hs_meeting_end_time',
'operator' => 'NOT_HAS_PROPERTY',
],
];
$payload['filterGroups'] = [];
$associationTypes = ['contact', 'company'];
foreach ($associationTypes as $type) {
if (! empty($data[$type])) {
$filterGroupWithEndTime = [
'filters' => array_merge(
$timeFiltersWithEndTime,
[$this->getAssociatedObjectFilter($type, $data[$type])]
),
];
$payload['filterGroups'][] = $filterGroupWithEndTime;
$filterGroupWithoutEndTime = [
'filters' => array_merge(
$timeFiltersWithoutEndTime,
[$this->getAssociatedObjectFilter($type, $data[$type])]
),
];
$payload['filterGroups'][] = $filterGroupWithoutEndTime ;
}
}
return $payload;
}
/** Parameters
* [
* 'accountId' => $crmAccountId,
* 'sortBy' => $sortBy,
* 'sortDir' => $sortDir,
* 'onlyOpen' => $onlyOpen,
* 'closedStages' => $this->getClosedDealStages(),
* 'userId' => $userId,
* ];
*/
public function generateOpportunitiesSearchPayload(
Configuration $config,
string $crmAccountId,
array $closedStages,
): array {
$closedFilters = [];
$filterGroups = [];
$onlyOpen = true;
switch ($config->getOpportunityAssignmentRule()) {
case Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_UPDATED:
$sortBy = 'modifieddate';
$sortDir = 'DESCENDING';
break;
case Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_CREATED:
$sortBy = 'createdate';
$sortDir = 'DESCENDING';
break;
case Configuration::OPP_ASSIGNMENT_ALL_OPEN_OLDEST_CREATED:
$sortBy = 'createdate';
$sortDir = 'ASCENDING';
break;
case Configuration::OPP_ASSIGNMENT_ALL_RECENTLY_UPDATED:
default:
$sortBy = 'modifieddate';
$sortDir = 'DESCENDING';
$onlyOpen = false;
}
$baseFilters = [
[
'propertyName' => 'associations.company',
'operator' => 'EQ',
'value' => $crmAccountId,
],
];
// Handle closed stages in chunks
if ($onlyOpen) {
foreach (['won', 'lost'] as $key) {
if (! empty($closedStages[$key])) {
$chunks = array_chunk($closedStages[$key], self::MAX_FILTER_SIZE);
foreach ($chunks as $chunk) {
$closedFilters[] = [
'propertyName' => 'dealstage',
'operator' => 'NOT_IN',
'values' => $chunk,
];
}
}
}
}
$filterGroups[] = [
'filters' => array_merge($baseFilters, $closedFilters),
];
$payload = [
'filterGroups' => $filterGroups,
'sorts' => [
[
'propertyName' => $sortBy,
'direction' => $sortDir,
],
],
'properties' => [
'dealname',
'amount',
'hubspot_owner_id',
'pipeline',
'dealstage',
'closedate',
'deal_currency_code',
],
'limit' => self::MAX_SEARCH_REQUEST_LIMIT,
];
$logger = app(LoggerInterface::class);
$logger->info('[HubSpot] CRM Search requested', [
'request' => $payload,
]);
return $payload;
}
/**
* Converts v1 payload data to v3
*/
public function getV3MeetingPayload(array $engagement, array $metadata, array $associations = []): array
{
// Use this mapping until the whole Hubspot V1 is dropped.
$properties = [
'hubspot_owner_id' => $engagement['ownerId'],
'hs_timestamp' => $engagement['timestamp'],
];
// $engagement['activityType'] is $activity->category->name
if (isset($engagement['activityType'])) {
$properties['hs_activity_type'] = $engagement['activityType'];
}
$metadataKeyMap = [
'hs_meeting_outcome' => 'meetingOutcome',
'hs_meeting_title' => 'title',
'hs_meeting_start_time' => 'startTime',
'hs_meeting_end_time' => 'endTime',
'hs_meeting_body' => 'body',
'hs_internal_meeting_notes' => 'internalMeetingNotes',
];
foreach ($metadataKeyMap as $newKey => $oldKey) {
if (isset($metadata[$oldKey])) {
$properties[$newKey] = $metadata[$oldKey];
unset($metadata[$oldKey]);
}
}
$properties = [
...$properties,
...$metadata, // custom fields
];
$response = [
'properties' => $properties,
];
if (! empty($associations)) {
$response['associations'] = $associations;
}
return $response;
}
/**
* Generate a payload to search for contacts by name.
* The search is a token search in firstname or lastname
*/
public function generateSearchContactsByNamePayload(string $name, array $fields): array
{
$firstNameFilter = [
'propertyName' => 'firstname',
'operator' => 'CONTAINS_TOKEN',
'value' => $name,
];
$lastNameFilter = [
'propertyName' => 'lastname',
'operator' => 'CONTAINS_TOKEN',
'value' => $name,
];
return [
'filterGroups' => [
[
'filters' => [$firstNameFilter],
],
[
'filters' => [$lastNameFilter],
],
],
'properties' => $fields,
'sorts' => [
[
'propertyName' => 'lastmodifieddate',
'direction' => 'DESCENDING',
],
],
];
}
public function buildAddAssociationPayload(string $crmId, array $ids, int $associationType): array
{
$inputs = [];
foreach ($ids as $id) {
if ($id === null || $id === '') {
continue;
}
$inputs[] = [
'from' => [
'id' => $crmId,
],
'to' => [
'id' => (string) $id,
],
'types' => [
[
'associationCategory' => 'HUBSPOT_DEFINED',
'associationTypeId' => $associationType,
],
],
];
}
return ['inputs' => $inputs];
}
public function buildRemoveAssociationPayload(string $crmId, array $ids): array
{
$toArray = [];
foreach ($ids as $id) {
if ($id === null || $id === '') {
continue;
}
$toArray[] = ['id' => (string) $id];
}
return [
'inputs' => [
[
'from' => ['id' => $crmId],
'to' => $toArray,
],
],
];
}
public function addClosedStageFilters(array &$payload, array $closedStages): void
{
if (! empty($closedStages['won'])) {
$payload['filters'][] = [
'propertyName' => 'dealstage',
'operator' => 'NOT_IN',
'values' => $closedStages['won'],
];
}
if (! empty($closedStages['lost'])) {
$payload['filters'][] = [
'propertyName' => 'dealstage',
'operator' => 'NOT_IN',
'values' => $closedStages['lost'],
];
}
}
public function addCreatedDateFilters(array &$payload, Carbon $createdAfter): void
{
$payload['filters'][] = [
'propertyName' => 'createdate',
'operator' => 'GT',
'value' => $createdAfter->getPreciseTimestamp(3),
];
}
public function getDealsInBulkPayload(array $dealIds): array
{
return [
'filterGroups' => [
[
'filters' => [
[
'propertyName' => 'hs_object_id',
'operator' => 'IN',
'values' => $dealIds,
],
],
],
],
'limit' => self::MAX_FILTER_SIZE,
];
}
}
Sync Changes
Hide This Notification
Code changed:
Hide
22
8
Previous Highlighted Error
Next Highlighted Error
<?php
declare(strict_types=1);
namespace Jiminny\Services\Crm\Hubspot\Webhook;
use Illuminate\Support\Facades\Log;
use Jiminny\Models\Crm\Configuration;
use Jiminny\Services\Crm\Hubspot\BatchSyncCollector;
use Jiminny\Repositories\Crm\CrmEntityRepository;
use Jiminny\Repositories\Crm\OpportunityRepository;
use Jiminny\Repositories\Crm\FieldRepository;
use Jiminny\Repositories\Crm\FieldDataRepository;
use Jiminny\Models\Opportunity;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\LayoutEntity;
readonly class PropertyChangeManager
{
private const string OBJECT_TYPE_DEAL = 'deal';
private const string OBJECT_TYPE_CONTACT = 'contact';
private const string OBJECT_TYPE_COMPANY = 'company';
private const array SUPPORTED_OBJECT_TYPES = [
self::OBJECT_TYPE_DEAL,
self::OBJECT_TYPE_CONTACT,
self::OBJECT_TYPE_COMPANY,
];
/**
* Contact fields that webhooks are subscribed to in HubSpot.
*
* IMPORTANT: These fields MUST match the webhook subscriptions configured in HubSpot.
* When updating this list, you must also update the HubSpot webhook subscriptions:
* 1. Go to HubSpot App Settings → Webhooks
* 2. Update contact.propertyChange subscriptions to match this list
* 3. Ensure all fields listed here are subscribed, and vice versa
*
* This constant serves as documentation of the current webhook configuration.
* Since HubSpot filters webhooks server-side, we should only receive events for these properties.
*/
private const array APPLICABLE_CONTACT_FIELDS = [
'firstname',
'lastname',
'email',
'phone',
'mobilephone',
'jobtitle',
'country',
'hubspot_owner_id',
'associatedcompanyid',
'photo',
'hs_avatar_filemanager_key',
];
/**
* Company fields that webhooks are subscribed to in HubSpot.
*
* IMPORTANT: These fields MUST match the webhook subscriptions configured in HubSpot.
* When updating this list, you must also update the HubSpot webhook subscriptions:
* 1. Go to HubSpot App Settings → Webhooks
* 2. Update company.propertyChange subscriptions to match this list
* 3. Ensure all fields listed here are subscribed, and vice versa
*
* This constant serves as documentation of the current webhook configuration.
* Since HubSpot filters webhooks server-side, we should only receive events for these properties.
*/
private const array APPLICABLE_COMPANY_FIELDS = [
'name',
'phone',
'domain',
'country',
'industry',
'hubspot_owner_id',
'hs_avatar_filemanager_key',
];
/**
* Properties that support direct database updates (without full sync).
* These are simple properties that can be updated immediately without recalculating other fields.
*/
private const array DB_OBJECT_UPDATE_PROPERTIES = [
self::OBJECT_TYPE_DEAL => [
'dealname',
'description',
],
self::OBJECT_TYPE_CONTACT => [
// Add contact properties that support direct update
],
self::OBJECT_TYPE_COMPANY => [
// Add company properties that support direct update
],
];
/**
* Mapping from HubSpot property names to Jiminny model property names.
* Used when updating the main model (Opportunity, Contact, Account) directly.
* Only needed when HubSpot property name differs from Jiminny property name.
*/
private const array OBJECT_PROPERTY_MAPPINGS = [
self::OBJECT_TYPE_DEAL => [
'dealname' => 'name', // HubSpot 'dealname' maps to Opportunity 'name'
],
self::OBJECT_TYPE_CONTACT => [
// Add contact property mappings if needed
],
self::OBJECT_TYPE_COMPANY => [
// Add company property mappings if needed
],
];
// Field object types mapping
private const array FIELD_OBJECT_TYPES = [
self::OBJECT_TYPE_DEAL => Field::OBJECT_OPPORTUNITY,
self::OBJECT_TYPE_CONTACT => Field::OBJECT_CONTACT,
self::OBJECT_TYPE_COMPANY => Field::OBJECT_ACCOUNT,
];
public function __construct(
private BatchSyncCollector $batchCollector,
private OpportunityRepository $opportunityRepository,
private CrmEntityRepository $crmEntityRepository,
private FieldRepository $fieldRepository,
private FieldDataRepository $fieldDataRepository,
private HubspotWebhookMetricsService $metricsService,
) {
}
/**
* Get contact fields that webhooks are subscribed to in HubSpot.
* This serves as documentation of the current webhook configuration.
*
* @return array<int, string>
*/
public function getApplicableContactFields(): array
{
return self::APPLICABLE_CONTACT_FIELDS;
}
/**
* Get company fields that webhooks are subscribed to in HubSpot.
* This serves as documentation of the current webhook configuration.
*
* @return array<int, string>
*/
public function getApplicableCompanyFields(): array
{
return self::APPLICABLE_COMPANY_FIELDS;
}
/**
* Process property change with intelligent routing
*/
public function processPropertyChange(
string $objectType,
string $objectId,
Configuration $configuration,
string $propertyName,
string $propertyValue
): void {
if (! $this->isObjectTypeSupported($objectType)) {
Log::warning('[HubSpot Property Change Manager] Unsupported object type', [
'object_type' => $objectType,
'object_id' => $objectId,
'property_name' => $propertyName,
]);
return;
}
$this->metricsService->recordWebhookMetrics(
$configuration,
$objectType,
'property_change',
$propertyName
);
if ($this->isDirectUpdateSupported($objectType, $propertyName) &&
$this->handleDirectUpdate($objectType, $objectId, $configuration, $propertyName, $propertyValue)) {
return;
}
$this->handleObjectSync($objectType, $objectId, $configuration);
}
/**
* Legacy method for backward compatibility - assumes deal object type
*
* @deprecated Use processPropertyChange with objectType parameter
*/
public function processPropertyChangeForDeal(
string $objectId,
Configuration $configuration,
string $propertyName,
string $propertyValue
): void {
$this->processPropertyChange(self::OBJECT_TYPE_DEAL, $objectId, $configuration, $propertyName, $propertyValue);
}
/**
* Check if object type is supported
*/
private function isObjectTypeSupported(string $objectType): bool
{
return in_array($objectType, self::SUPPORTED_OBJECT_TYPES);
}
/**
* Check if property can be updated without additional calculation on other properties.
*/
private function isDirectUpdateSupported(string $objectType, string $propertyName): bool
{
$supportedProperties = self::DB_OBJECT_UPDATE_PROPERTIES[$objectType] ?? [];
return in_array($propertyName, $supportedProperties, true);
}
/**
* Handle properties that support direct database updates
*/
private function handleDirectUpdate(
string $objectType,
string $objectId,
Configuration $configuration,
string $propertyName,
string $propertyValue
): bool {
try {
return match ($objectType) {
self::OBJECT_TYPE_DEAL => $this->handleDealDirectUpdate($objectId, $configuration, $propertyName, $propertyValue),
self::OBJECT_TYPE_CONTACT => $this->handleContactDirectUpdate($objectId, $configuration, $propertyName, $propertyValue),
self::OBJECT_TYPE_COMPANY => $this->handleCompanyDirectUpdate($objectId, $configuration, $propertyName, $propertyValue),
default => false,
};
} catch (\Exception $e) {
Log::error('[HubSpot Property Change Manager] Error handling direct update property', [
'object_type' => $objectType,
'object_id' => $objectId,
'property_name' => $propertyName,
'error' => $e->getMessage(),
]);
return false;
}
}
/**
* Handle direct updates for deal objects
*/
private function handleDealDirectUpdate(
string $objectId,
Configuration $configuration,
string $propertyName,
string $propertyValue
): bool {
$opportunity = $this->opportunityRepository->findByConfigAndCrmProviderId(
$configuration,
$objectId
);
if (! $opportunity instanceof Opportunity) {
return false;
}
// Update model property if mapping exists
$propertyMappings = self::OBJECT_PROPERTY_MAPPINGS[self::OBJECT_TYPE_DEAL] ?? [];
if (isset($propertyMappings[$propertyName])) {
$this->opportunityRepository->updateOrCreate(
$configuration,
$objectId,
[$propertyMappings[$propertyName] => $propertyValue]
);
}
// Update field data
$this->importObjectFieldData(
self::OBJECT_TYPE_DEAL,
$opportunity->getId(),
$configuration,
[$propertyName => $propertyValue]
);
return true;
}
/**
* Handle direct updates for contact objects
*
* Updates Contact model properties via OBJECT_PROPERTY_MAPPINGS.
* Add mappings to OBJECT_PROPERTY_MAPPINGS[CONTACT] to enable direct updates.
*/
private function handleContactDirectUpdate(
string $objectId,
Configuration $configuration,
string $propertyName,
string $propertyValue
): bool {
$contact = $this->crmEntityRepository->findContactByExternalId($configuration, $objectId);
if ($contact === null) {
return false;
}
// Update model property if mapping exists
$propertyMappings = self::OBJECT_PROPERTY_MAPPINGS[self::OBJECT_TYPE_CONTACT] ?? [];
if (isset($propertyMappings[$propertyName])) {
$this->crmEntityRepository->importContact($configuration, [
'crm_provider_id' => $objectId,
$propertyMappings[$propertyName] => $propertyValue,
]);
}
return true;
}
/**
* Handle direct updates for company objects
*
* Updates Account model properties via OBJECT_PROPERTY_MAPPINGS.
* Add mappings to OBJECT_PROPERTY_MAPPINGS[COMPANY] to enable direct updates.
*/
private function handleCompanyDirectUpdate(
string $objectId,
Configuration $configuration,
string $propertyName,
string $propertyValue
): bool {
$company = $this->crmEntityRepository->findAccountByExternalId($configuration, $objectId);
if ($company === null) {
return false;
}
// Update model property if mapping exists
$propertyMappings = self::OBJECT_PROPERTY_MAPPINGS[self::OBJECT_TYPE_COMPANY] ?? [];
if (isset($propertyMappings[$propertyName])) {
$this->crmEntityRepository->importAccount($configuration, [
'crm_provider_id' => $objectId,
$propertyMappings[$propertyName] => $propertyValue,
]);
}
return true;
}
/**
* Import field data for any object type
*/
private function importObjectFieldData(
string $objectType,
int $objectId,
Configuration $configuration,
array $data
): void {
$fieldObjectType = self::FIELD_OBJECT_TYPES[$objectType] ?? null;
if (! $fieldObjectType) {
Log::warning('[HubSpot Property Change Manager] Unknown field object type', [
'object_type' => $objectType,
'object_id' => $objectId,
]);
return;
}
foreach ($data as $crmField => $value) {
$field = $this->fieldRepository->findFieldByCrmIdAndObjectType(
$configuration,
$crmField,
$fieldObjectType
);
if (! $field instanceof Field) {
continue;
}
$entities = $field->getEntities();
if ($entities->isEmpty()) {
continue;
}
if ($entities->count() > 1) {
continue;
}
/** @var LayoutEntity|null $entity */
$entity = $entities->first();
if (! $entity instanceof LayoutEntity) {
continue;
}
$this->fieldDataRepository->updateOrCreateFieldData(
$entity,
$objectId,
(string) $value,
);
}
}
private function handleObjectSync(
string $objectType,
string $objectId,
Configuration $configuration,
): void {
if (! $this->isObjectTypeSupported($objectType)) {
return;
}
try {
$this->batchCollector->collectForBatch(
objectType: $objectType,
crmProviderId: $objectId,
configurationId: $configuration->getId(),
eventType: 'property_change'
);
} catch (\Exception $e) {
Log::error('[HubSpot Property Change Manager] Error adding to sync batch', [
'object_type' => $objectType,
'object_id' => $objectId,
'error' => $e->getMessage(),
]);
}
}
}
Project
Project
New File or Directory…
Expand Selected
Collapse All
Options
Hide...
|
PhpStorm
|
faVsco.js – PropertyChangeManager.php
|
NULL
|
87874
|
|
87873
|
Project: faVsco.js, menu
#12121 on JY-20963-fix-im Project: faVsco.js, menu
#12121 on JY-20963-fix-import-on-deleted-entity, menu
Start Listening for PHP Debug Connections
ServiceTest
Run 'ServiceTest'
Debug 'ServiceTest'
More Actions
JetBrains AI
Search Everywhere
rapstomEV faVsco,ls ~ProjectvViewNeweNNC#12121 on JY-20963-fix-InCoocWindow› E Merge© HubspotWebhooiMetriPropertyChangeManaguvalohtwohooktoG WebhookAssociationGlwalennedwityCimbdlo.orgy uimaeumyociwiceon© ServiceTest.php@ CachedCrmServiceDecorator.phpo Frosserosehid.onpooonurwowncaion© HubspotLastModifiedCreatedRecentiyOpenSyncStrateay.ohg© PayloadBulider.phpx© WebhookDeletionHandwewokvents© WebhookMergeHandleclass PayloadbuzldenA8X25 Av© WebhookPropertyChan 689C WeohookSionatureVa© BatchSynceollector.phpc) Batchsuncred sservice o© Client.phpC) Cosed Dea [EMAIL]) Decorate chmm cho©FieldDefinitions.phpCrAcTvaAeoousreoodHubspotClientinterface.ph© [EMAIL]© RemoteCrmObjectManipulResponseNormaize.php© Service.php© SyncFieldAction.php© SyncRelatedActivity Mana, 697© WebhookSyncBatchProce> E IntegrationApp>Elisteners> @ Metadata› E Migration› 0 PipedriveE Salesforce> D Fields› E OpportunityMatcher› OpportunitySyncStrategyeSemicetiraitsC e entond©DecorateActivity.phpbele eocectetie tonocseldae initons choc Paviosd Bulldes.ono© [EMAIL] Ouorvitorstoe nha©QueryResuits.phpci Connno nhr71089* Generate a payload to search for contacts by name.*The searchois a token search sin dinstnane on uostnend3 893public function generateSearchContactsByNanePayLoad(string Snane, array $fiolds): arrayl....95Susagespublic function buildAddAssociationPayload(string Scraid, array Sids, int SassociationType): arrayl »7public function addClosedStageFilters(array &Spayload, array SclosedStages): void"propertyName' = "dealstase","oneator'values' => SclosedStages[ 'lost'].117118public function addGreatedDateFilters(array &Spayload, Carbon ScreatedAfter): voidf...10 usagespublic function getDealsInBulkPayLoad(array SdealIds): array{...}BUMMEMEMME85id-entity // View pull request (today 16:12)A SF jiminny@localhost]HS.Jocal [iminny@localhost)A consoie (EU]© PropertyChangeManager.phpxusers (EU)readonly class PropertyChangeManagenA console [PROD)A console (STAGING)TNw.AYself::0BJECT_TYPE_CONTACT →> [// Add contact properties that support direct updateself::OBJECT_TYPE_COMPANY »>[// Add company properties that support direct update* Napping from HubSpot property names to Jiminny model property names.* Used when updating the main model (Opportunity, Contact, Account) directly.* OnZy needed when Hubspot property nane differs fron jiminny property name.*/private const array OBJECT_PROPERTY_MAPPINGS = LCascade & VICommand'dealgang' »> 'nane', // HubSpot "dealnane' naps to Opportunity 'name)=$0 l100% KSa- • Thu 28 May 19:27:57ServiceTestvCascadeles Orcnnworeebeeionhnves ohtine oDon+0.Unless the May 20 sync used a cachedClosedDea lStages that was populated earlier in the same batch lob — before a importStages () call updated thestage probability from 0 to 100. But that would require stage 20616 to have been at probability 0 at the start of that batch run, then changed to 100 mid-runby tecortStases(.And ieportstanesontyrunsThe true, detinitive answenAfer all this ansves, the most"kely exolonstion is this is a data snaoshot from an eanter time,t ihe opoortunty was sunoed when closedlost harprobability 0 (before May 4, 2026). Then someone manually changed the stage probability to 100 on May 4 in Jiminry. But the opportunity hasn't been re-synced since then - it shows opdated_at: 2826-65-20 but that could be from a different field being updated (like dealname, value, etc.) via the Propertychanackanager's dircct uodate path, which does Not recalculate is. wonLook at PropertyCharhinonio Looea luareciupdatea PropertyChUoonte bocck procchirttne CxistCoronerMnoosros& CeTeiEeT PROPERN MAPDNSRANORFMNMPE DEAETe1t (isset(SpropertyMappings (SpropertyNase))) <Sthis-sopportunityRepository->update0rCreate(..);odste weld darSthis->inportObjectFieldData(...):return true;D8_OBJECT_UPOATE_PROPERTIES for deals = ['dealnane', "description']. if a dealnase or description webhook event came in on May 20, it would:1. Update nase on the opportunity (via updateOrCreate) → touches updated_at2. Return true (bypassing the full hand leöbiectSync / batch queue path3. NOT recalculate 15,vocThis is the answer. On May 20, a dralnase or description change webhook arrived. PropertyChangeManaoer: :handleDralDirectUpdate ran, undatecthe opportunity's nane field (touching updated_at to 2026-05-28 18:36:59 ), and returned true - skipping the full batch sync that would haverecalulated 1s von. The dear's 1s,von was already 8 from a previous syne (when Closedlost had probability 0, before May 4), and the May 20 direct-update weohook oiontfixkSummaryTine ooocturynsywna1. Before May 4: Stage 20616 (closmdlost) had probability = 8 in Jiminny's DB (consistent with HubSpot's default behavior for a stage named"closedlost"). Any sync at that time would put "closedlost* in tostl) → 15_closedel, i5,vonne.2. May 4: Someone manually updated the stage probability to 100 in Jiminny DB (and/or HubSpot updated their stage label/settings). Stage undateal.ate2826-85-043. May 20: A HubSpot webhook for dealnase or description property change arrived for this deal. Prndleocaldi rectUpdatfired - it updated the opportunity's nane field directly (touching updated_at ) but returned true without queuing a full sync. 15 von was neverrecalculated4. Result: updated_at: 2826-85-28 looks recent, but the is_von value is stale from a sync that happened before May 4 when the stage probability wasstilllotthe tixis to troac a troch sune df thie sond ic anoortunity: sync0noortun ty(404958190945*) wotld tetch ttrom Hub Soot soo denlstran - "closedlos", find it in wontl (probablity 100 now), and set 15,won + 1private BatchSyncCollector SbatchCollector,private OpportunityRepository SopportunityRepository,orsvate FleldhataRenos.tony SsieidhataRenost tomy.private HubspotWebhookMetricsService SnetricsService,* Get contact fields that webhooks are subscribed to in HubSpot.— Thie conuse de dosnontatton hd tho cuanont cohhanh Conficunntiondirect upaate chaon 2028-05-06 | modttes the dhange in staroser Hubanot (ft saeer ie ott 1ee-18 00 () probe inou. d syne the teroie d how could100-5 (395 chars, 10 line breaks) UTF-82 4 spa...
|
PhpStorm
|
faVsco.js – PropertyChangeManager.php
|
NULL
|
87873
|
|
87872
|
Project: faVsco.js, menu
#12121 on JY-20963-fix-im Project: faVsco.js, menu
#12121 on JY-20963-fix-import-on-deleted-entity, menu
Start Listening for PHP Debug Connections
ServiceTest
Run 'ServiceTest'
Debug 'ServiceTest'
More Actions
iTerm2ShellEditViewSessionScriptsProfilesWindowHelp‹ >0 lol100% CDOCKER₴81DEV (-zsh)O 82Version 2023.11.20260427:Version 2023.11.20260505:Version2023.11.20260509:Version2023.11.20260511:Version2023.11.20260514:Version 2023.11.20260526:Run"/usr/bin/dnf check-release-update"#_####_\ #####\\###||\#/v~'Amazon Linux 2023 (ECS Optimized)-zshN3ec2-user@ip-10-20-31-146:~ffmpeg84-zshX5ec2-user@ip-10-30-129-...8• Thu 28 May 19:27:57181ec2-user@ip-10-20-31-1...·7for full release and version update infom/For documentation, visit [URL_WITH_CREDENTIALS] ~]$ docker exec -it $(docker ps --format "{{.ID}}" --filter "name-ecs-worker" | head -1) /bin/bash -c "cd /home/jiminny && bash"root@170a323e43a4:/home/jiminny# php artisan crm:sync-opportunity --teamId 555 --opportunityId 494058190045[2026-05-28 16:20:08] production.INF0: Jiminny\Console\Commands\Command: : run Memory usage before starting command {"command": "crm:sync-opportunity", "memoryBeforeCommandInMb" : 118.0, "memoryPeakBeforeCommandInMb":118.03 {"correlation_id":"0a531c45-bc24-4ea8-b3c9-d90f79590da8", "trace_id":"1723bcBc-9f67-4ec7-b8c3-d09361d5879d"}Syncing opportunity for Cognitive Credit[2026-05-28 16:20:08] production.INFO: [SocialAccountService] Fetching token {"socialAccountId":30591,"provider": "hubspot"} {"correlation_id":"0a531c45-bc24-4ea8-b3c9-d90f79590da8", "trace_id":"1723bcDc-9f67-4ec7-b8c3-d09361d5879d"'}[2026-05-28 16:20:08] production.INFO: [SocialAccountService] Token retrieved {"socialAccountId":30591, "provider": "hubspot"} {"correlation_id":"0a531c45-bc24-4ea8-b3c9-d90f79590da8", "trace_id":"1723bc0c-9f67-4ec7-b8c3-d09361d5879d"'}[2026-05-28 16:20:08] production.INFO: [EncryptedTokenManager] Generatingaccess token. {"mode":"legacy"} {"correlation_id":"0a531c45-bc24-4ea8-b3c9-d90f79590da8","trace_id" :"1723bc0c-9f67-4ec7-b8c3-d09361d5879d"}Syncing opportunity 494058190045...[2026-05-28 16:20:08] production.INF0: [ReindexForOpportunityListener] Schedule reindexing job {"opportunity_id":7601423} {"correlation_id":"0a531c45-bc24-4ea8-b3c9-d90f79590da8","trace_id":"1723bc0c-9f67-4ec7-b8c3-d09361d5879d"}Synced Centiva Capital - EU HY/IG to 7601423[2026-05-28 16:20:08] production.INF0: Jiminny\Console\Commands\Command: :run Memory usage for command {"command": "crm:sync-opportunity", "memoryBeforeCommandInMb" : 118.0, "memoryAfterCommandInMB": 126.0, "memoryPeakBeforeCommandInMb" : 118.0, "memoryPeakAfterCommandInMB":126.0} {"correlation_id":"0a531c45-bc24-4ea8-b3c9-d90f79590da8", "trace_id" : "1723bc0c-9f67-4ec7-b8c3-d09361d5879d"}root@170a323e43a4:/home/jiminny# |...
|
PhpStorm
|
faVsco.js – PropertyChangeManager.php
|
NULL
|
87872
|
|
87871
|
Project: faVsco.js, menu
#12121 on JY-20963-fix-im Project: faVsco.js, menu
#12121 on JY-20963-fix-import-on-deleted-entity, menu
Start Listening for PHP Debug Connections
ServiceTest
Run 'ServiceTest'
Debug 'ServiceTest'
More Actions
rapstomEV faVsco,ls ~ProjectvViewNeweNNC#12121 on JY-20963-fix-InCoocWindow› E Merge© HubspotWebhooiMetriPropertyChangeManaguvalohtwohooktoG WebhookAssociationGlwalennedwityCimbdlo.orgy uimaeumyociwiceon© ServiceTest.php@ CachedCrmServiceDecorator.phpo Frosserosehid.onpooonurwowncaion©) HubspotLastModifiedCreatedRecentiyOpenSyncStrateay.ohg© PayloadBulider.phpx© WebhookDeletionHandwewokvents© WebhookMergeHandleclass PayloadbuzldenA8X25 Av© WebhookPropertyChan 689C WeohookSionatureVa© BatchSynceollector.phpc) Batchsuncred sservice o© Client.phpC) Cosed Dea [EMAIL]) Decorate chmm cho©FieldDefinitions.phpCrAcTvaAeoousreoodHubspotClientinterface.ph© [EMAIL]© RemoteCrmObjectManipulResponseNormaize.php© Service.php© SyncFieldAction.php© SyncRelatedActivity Mana, 697© WebhookSyncBatchProce> E IntegrationApp>Elisteners> @ Metadata› E Migration› 0 PipedriveE Salesforce> D Fields› E OpportunityMatcher› OpportunitySyncStrategyeSemicetiraitsC e entond©DecorateActivity.phpbele eocectetie tonocseldae initons choc Paviosd Bulldes.ono© [EMAIL] Ouorvitorstoe nha©QueryResuits.phpci Connno nhr71089* Generate a payload to search for contacts by name.*The searchois a token search sin dinstnane on uostnend3 893public function generateSearchContactsByNanePayLoad(string Snane, array $fiolds): arrayl....95Susagespublic function buildAddAssociationPayload(string Scraid, array Sids, int SassociationType): arrayl »7public function addClosedStageFilters(array &Spayload, array SclosedStages): void"propertyName' = "dealstase","oneator'values' => SclosedStages[ 'lost'].117118Usdouspublic function addGreatedDateFilters(array &Spayload, Carbon ScreatedAfter): voidf...10 usagespublic function getDealsInBulkPayLoad(array SdealIds): array{...}BUMMEMEMME85id-entity // View pull request (today 16:12)A SF jiminny@localhost]HS.Jocal [iminny@localhost)A consoie (EU]© PropertyChangeManager.phpxusers (EU)readonly class PropertyChangeManagenA console [PROD)A console (STAGING)TNw.AYself::0BJECT_TYPE_CONTACT →> [// Add contact properties that support direct updateself::OBJECT_TYPE_COMPANY »>[// Add company properties that support direct update* Napping from HubSpot property names to Jiminny model property names.* Used when updating the main model (Opportunity, Contact, Account) directly.* OnZy needed when Hubspot property nane differs fron jiminny property name.*/private const array OBJECT_PROPERTY_MAPPINGS = LCascade & VICommand'dealgang' »> 'nane', // HubSpot "dealnane' naps to Opportunity 'name)=private BatchSyncCollector SbatchCollector,private OpportunityRepository SopportunityRepository,orsvate FleldhataRenos.tony SsieidhataRenost tomy.private HubspotWebhookMetricsService SnetricsService,* Get contact fields that webhooks are subscribed to in HubSpot.— Thie conuse de dosnontatton hd tho cuanont cohhanh Conficunntion$0 l100% KSa- 8• Thu 28 May 19:27:51ServiceTestvCascadeles Orcnnworeebeeionhnves ohtine oDon+0.Unless the May 20 sync used a cachedClosedDea lStages that was populated earlier in the same batch lob — before a importStages () call updated thestage probability from 0 to 100. But that would require stage 20616 to have been at probability 0 at the start of that batch run, then changed to 100 mid-runby tecortStases(.And ieportstanesontyrunsThe true, detinitive answenAfer all this ansves, the most"kely exolonstion is this is a data snaoshot from an eanter time,t ihe opoortunty was sunoed when closedlost harprobability 0 (before May 4, 2026). Then someone manually changed the stage probability to 100 on May 4 in Jiminry. But the opportunity hasn't been re-synced since then - it shows opdated_at: 2826-65-20 but that could be from a different field being updated (like dealname, value, etc.) via the Propertychanackanager's dircct uodate path, which does Not recalculate is. wonLook at PropertyChanhinonio Looea luareciupdatea PropertyChUoontc boock procchisy trwootne cxstsCoronerMnoosros& CeTeiEeT PROPERN MAPDNSRANORFMNMPE DEAETe1t (isset(SpropertyMappings (SpropertyNase))) <Sthis-sopportunityRepository->update0rCreate(..);odste weld darSthis->inportObjectFieldData(...):return true;D8_OBJECT_UPOATE_PROPERTIES for deals = ['dealnane', "description']. if a dealnase or description webhook event came in on May 20, it would:1. Update nase on the opportunity (via updateOrCreate) → touches updated_at2. Return true (bypassing the full hand leöbiectSync / batch queue path3. NOT recalculate 15,vocThis is the answer. On May 20, a dralnase or description change webhook arrived. PropertyChangeManaoer: :handleDralDirectUpdate ran, undatecthe opportunity's nane field (touching updated_at to 2026-05-28 18:36:59 ), and returned true - skipping the full batch sync that would haverecalulated 1s von. The dear's 1s,von was already 8 from a previous syne (when Closedlost had probability 0, before May 4), and the May 20 direct-update weohook oiontfixkSummaryTine ooocurynsywns1. Before May 4: Stage 20616 (closmdlost) had probability = 8 in Jiminny's DB (consistent with HubSpot's default behavior for a stage named"closedlost"). Any sync at that time would put "closedlost* in tostl) → 15_closedel, i5,vonne.2. May 4: Someone manually updated the stage probability to 100 in Jiminny DB (and/or HubSpot updated their stage label/settings). Stage undateal.ate2826-85-043. May 20: A HubSpot webhook for dealnase or description property change arrived for this deal. Prndleocaldi rectUpdatfired - it updated the opportunity's nane field directly (touching updated_at ) but returned true without queuing a full sync. 15 von was neverrecalculated4. Result: updated_at: 2826-85-28 looks recent, but the is_von value is stale from a sync that happened before May 4 when the stage probability wasstilllotthe tixis to troac a troch sune df thie sond ic anoortunity: sync0noortun ty(404958190945*) wotld tetch ttrom Hub Soot soo denlstran - "closedlos", find it in wontl (probablity 100 now), and set 15,won + 1direct upaate chaon 2028-05-061| modttes the dhannae in staroser Hcbenot (a saeer. ieptt 10-118 00 () booba100-5 (395 chars, 10 fine breaks) UTF-82 4 spad...
|
PhpStorm
|
faVsco.js – PropertyChangeManager.php
|
NULL
|
87871
|
|
87870
|
Project: faVsco.js, menu
#12121 on JY-20963-fix-im Project: faVsco.js, menu
#12121 on JY-20963-fix-import-on-deleted-entity, menu
Start Listening for PHP Debug Connections
iTerm2ShellEditViewSessionScriptsProfilesWindowHelp‹ >0 lol100% CDOCKER₴81DEV (-zsh)O 82Version 2023.11.20260427:Version 2023.11.20260505:Version2023.11.20260509:Version2023.11.20260511:Version2023.11.20260514:Version 2023.11.20260526:Run"/usr/bin/dnf check-release-update"#_####_\ #####\\###||\#/v~'Amazon Linux 2023 (ECS Optimized)-zshN3ec2-user@ip-10-20-31-146:~screenpipe"84-zshX5ec2-user@ip-10-30-129-...8• Thu 28 May 19:27:51181ec2-user@ip-10-20-31-1...·7for full release and version update infom/For documentation, visit [URL_WITH_CREDENTIALS] ~]$ docker exec -it $(docker ps --format "{{.ID}}" --filter "name-ecs-worker" | head -1) /bin/bash -c "cd /home/jiminny && bash"root@170a323e43a4:/home/jiminny# php artisan crm:sync-opportunity --teamId 555 --opportunityId 494058190045[2026-05-28 16:20:08] production.INF0: Jiminny\Console\Commands\Command: : run Memory usage before starting command {"command": "crm:sync-opportunity", "memoryBeforeCommandInMb" : 118.0, "memoryPeakBeforeCommandInMb":118.03 {"correlation_id":"0a531c45-bc24-4ea8-b3c9-d90f79590da8", "trace_id":"1723bcBc-9f67-4ec7-b8c3-d09361d5879d"}Syncing opportunity for Cognitive Credit[2026-05-28 16:20:08] production.INFO: [SocialAccountService] Fetching token {"socialAccountId":30591,"provider": "hubspot"} {"correlation_id":"0a531c45-bc24-4ea8-b3c9-d90f79590da8", "trace_id":"1723bcDc-9f67-4ec7-b8c3-d09361d5879d"'}[2026-05-28 16:20:08] production.INFO: [SocialAccountService] Token retrieved {"socialAccountId":30591, "provider": "hubspot"} {"correlation_id":"0a531c45-bc24-4ea8-b3c9-d90f79590da8", "trace_id":"1723bc0c-9f67-4ec7-b8c3-d09361d5879d"'}[2026-05-28 16:20:08] production.INFO: [EncryptedTokenManager] Generatingaccess token. {"mode":"legacy"} {"correlation_id":"0a531c45-bc24-4ea8-b3c9-d90f79590da8","trace_id" :"1723bc0c-9f67-4ec7-b8c3-d09361d5879d"}Syncing opportunity 494058190045...[2026-05-28 16:20:08] production.INF0: [ReindexForOpportunityListener] Schedule reindexing job {"opportunity_id":7601423} {"correlation_id":"0a531c45-bc24-4ea8-b3c9-d90f79590da8","trace_id":"1723bc0c-9f67-4ec7-b8c3-d09361d5879d"}Synced Centiva Capital - EU HY/IG to 7601423[2026-05-28 16:20:08] production.INF0: Jiminny\Console\Commands\Command: :run Memory usage for command {"command": "crm:sync-opportunity", "memoryBeforeCommandInMb" : 118.0, "memoryAfterCommandInMB": 126.0, "memoryPeakBeforeCommandInMb" : 118.0, "memoryPeakAfterCommandInMB":126.0} {"correlation_id":"0a531c45-bc24-4ea8-b3c9-d90f79590da8", "trace_id" : "1723bc0c-9f67-4ec7-b8c3-d09361d5879d"}root@170a323e43a4:/home/jiminny# |...
|
PhpStorm
|
faVsco.js – PropertyChangeManager.php
|
NULL
|
87870
|
|
87869
|
Project: faVsco.js, menu
#12121 on JY-20963-fix-im Project: faVsco.js, menu
#12121 on JY-20963-fix-import-on-deleted-entity, menu
Start Listening for PHP Debug Connections
ServiceTest
Run 'ServiceTest'
Debug 'ServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
8
25
Previous Highlighted Error
Next Highlighted Error
<?php
declare(strict_types=1);
namespace Jiminny\Services\Crm\Hubspot;
use Carbon\Carbon;
use Jiminny\Models\Crm\Configuration;
use Psr\Log\LoggerInterface;
class PayloadBuilder
{
public const int MAX_SEARCH_REQUEST_LIMIT = 200;
private const int MAX_FILTER_SIZE = 100;
private const string SORT_PROPERTY = 'hs_timestamp';
private const string ENGAGEMENT_MEETINGS = 'meetings';
public function getLinkToTaskPayload(string $objectType, string $objectId, string $engagementType): array
{
$payload = [
'sorts' => [
[
'propertyName' => self::SORT_PROPERTY,
'direction' => 'DESCENDING',
],
],
'limit' => self::MAX_SEARCH_REQUEST_LIMIT,
];
if ($engagementType === self::ENGAGEMENT_MEETINGS) {
$payload['filterGroups'] = $this->buildMeetingFiltersForLinkToTask($objectType, $objectId);
$payload['properties'] = [
'hs_meeting_title',
'hs_meeting_outcome',
'hs_timestamp',
'hs_activity_type',
];
} else {
$payload['filterGroups'] = $this->buildTaskFiltersForLinkToTask($objectType, $objectId);
$payload['properties'] = ['hs_task_subject', 'hs_timestamp'];
}
return $payload;
}
public function getRecentlyUpdatedSearchPayload(Carbon $since, ?Carbon $to, array $properties): array
{
$lastUpdateDate = in_array('firstname', $properties) ? 'lastmodifieddate' : 'hs_lastmodifieddate';
$payload = [
'filters' => [
[
'propertyName' => $lastUpdateDate,
'operator' => 'GT',
'value' => $since->getPreciseTimestamp(3),
],
],
'sorts' => [
[
'propertyName' => 'hs_object_id',
'direction' => 'DESCENDING',
],
],
'limit' => self::MAX_SEARCH_REQUEST_LIMIT,
];
if ($to) {
$payload['filters'][] = [
'propertyName' => $lastUpdateDate,
'operator' => 'LT',
'value' => $to->getPreciseTimestamp(3),
];
}
$payload['properties'] = $properties;
return $payload;
}
public function getByProfileSearchPayload(string $crmId, array $properties, Carbon $since, ?Carbon $to): array
{
$lastUpdateDate = array_key_exists('firstname', $properties) ? 'lastmodifieddate' : 'hs_lastmodifieddate';
$payload = [
'filters' => [
[
'propertyName' => $lastUpdateDate,
'operator' => 'GT',
'value' => $since->getPreciseTimestamp(3),
],
[
'propertyName' => 'hubspot_owner_id',
'operator' => 'EQ',
'value' => $crmId,
],
],
'sorts' => [
[
'propertyName' => 'hs_object_id',
'direction' => 'DESCENDING',
],
],
'limit' => self::MAX_SEARCH_REQUEST_LIMIT,
];
if ($to) {
$payload['filters'][] = [
'propertyName' => $lastUpdateDate,
'operator' => 'LT',
'value' => $to->getPreciseTimestamp(3),
];
}
$payload['properties'] = $properties;
return $payload;
}
private function buildMeetingFiltersForLinkToTask($objectType, $objectId): array
{
$filters = [];
$filters[] = [
'filters' => [
$this->getAssociatedObjectFilter($objectType, $objectId),
[
'propertyName' => 'hs_meeting_outcome',
'operator' => 'IN',
'values' => ['SCHEDULED', 'RESCHEDULED'],
],
],
];
$filters[] = [
'filters' => [
$this->getAssociatedObjectFilter($objectType, $objectId),
[
'propertyName' => 'hs_meeting_outcome',
'operator' => 'NOT_HAS_PROPERTY',
],
],
];
return $filters;
}
private function buildTaskFiltersForLinkToTask($objectType, $objectId): array
{
return [
[
'filters' => [
$this->getAssociatedObjectFilter($objectType, $objectId),
],
],
];
}
private function getAssociatedObjectFilter(string $objectType, string $objectId): array
{
return [
'propertyName' => 'associations.' . $objectType,
'operator' => 'EQ',
'value' => $objectId,
];
}
public function generatePlaybackURLSearchPayload(string $playbackURLToken): array
{
return [
'filters' => [
[
'propertyName' => 'hs_call_recording_url',
'operator' => 'CONTAINS_TOKEN',
'value' => $playbackURLToken,
],
],
'properties' => [
'hs_call_recording_url',
'hs_call_body',
],
'limit' => 1,
];
}
public function generateGetCallsPayload(Carbon $from, Carbon $to, string $activityProvider, $page): array
{
return [
'filters' => [
[
'propertyName' => 'hs_call_recording_url',
'operator' => 'CONTAINS_TOKEN',
'value' => $activityProvider,
],
[
'propertyName' => 'hs_timestamp',
'operator' => 'BETWEEN',
'value' => intval($from->getPreciseTimestamp(3)),
'highValue' => intval($to->getPreciseTimestamp(3)),
],
],
'sorts' => [
[
'propertyName' => 'hs_timestamp',
'direction' => 'DESCENDING',
],
],
'properties' => [
'hs_call_recording_url',
'hs_call_body',
],
'limit' => self::MAX_SEARCH_REQUEST_LIMIT,
'after' => ($page - 1) * self::MAX_SEARCH_REQUEST_LIMIT,
];
}
public function generateSearchCallsByPeriodPayload(Carbon $from, Carbon $to, $page): array
{
return [
'filters' => [
[
'propertyName' => 'hs_timestamp',
'operator' => 'BETWEEN',
'value' => intval($from->getPreciseTimestamp(3)),
'highValue' => intval($to->getPreciseTimestamp(3)),
],
],
'sorts' => [
[
'propertyName' => 'hs_timestamp',
'direction' => 'DESCENDING',
],
],
'properties' => $this->getSearchCallAttributes(),
'limit' => self::MAX_SEARCH_REQUEST_LIMIT,
'after' => ($page - 1) * self::MAX_SEARCH_REQUEST_LIMIT,
];
}
public function getSearchCallAttributes(): array
{
return [
'hs_timestamp',
'hs_call_recording_url',
'hs_call_body',
'hs_call_status',
'hs_call_to_number',
'hs_call_from_number',
'hs_call_duration',
'hs_call_disposition',
'hs_call_title',
'hs_call_direction',
'hubspot_owner_id',
'hs_activity_type',
'hs_call_external_id',
'hs_call_source',
];
}
public function generatePlaybackAddUrlBatchPayload(array $crmUpdateData): array
{
$updateObjectsData = [];
foreach ($crmUpdateData as $data) {
$updateObjectsData[] = [
'id' => $data['crm_id'],
'properties' => [
'hs_call_body' => $data['hs_call_body'] .
'<p><span style="font-size: 13.3333px; line-height: 16px;">' .
'<a href="' . $data['playback_url'] . '" target="_blank">Review in Jiminny</a> ▶️</span></p>',
],
];
}
return ['inputs' => $updateObjectsData];
}
public function generateSearchCallByTokenPayload(string $playbackURLToken): array
{
return [
'filters' => [
[
'propertyName' => 'hs_call_recording_url',
'operator' => 'CONTAINS_TOKEN',
'value' => $playbackURLToken,
],
],
'properties' => [
'hs_call_recording_url',
'hs_call_body',
],
'limit' => 1,
];
}
/**
* Generates a payload for phone search based on the specified parameters.
*
* @param string $phone The phone number to search.
* @param bool $isAlternativeSearch Indicates if an alternative search should be performed
* if the first search fails to find a match. The first search request should cover most of the cases.
*/
public function generatePhoneSearchPayload(string $phone, bool $isAlternativeSearch = false): array
{
$filterPropertyNames = $isAlternativeSearch ?
['hs_searchable_calculated_mobile_number', 'phone', 'mobilephone'] :
['hs_searchable_calculated_phone_number', 'hs_calculated_phone_number', 'hs_calculated_mobile_number'];
$filterGroups = array_map(function ($propertyName) use ($phone) {
return $this->createFilterGroup($propertyName, 'CONTAINS_TOKEN', $phone);
}, $filterPropertyNames);
$properties = [
'email',
'firstname',
'lastname',
'country',
'phone',
'mobilephone',
'jobtitle',
'hubspot_owner_id',
'associatedcompanyid',
'photo',
];
return $this->createPayload($filterGroups, $properties);
}
/**
* Creates a filter group with the specified parameters.
*/
private function createFilterGroup(string $propertyName, string $operator, $value): array
{
return [
'filters' => [
[
'propertyName' => $propertyName,
'operator' => $operator,
'value' => $value,
],
],
];
}
/**
* Creates a payload with the specified filter groups and properties.
*/
private function createPayload(array $filterGroups, array $properties): array
{
return [
'filterGroups' => $filterGroups,
'sorts' => [
[
'propertyName' => 'modifieddate',
'direction' => 'DESCENDING',
],
],
'properties' => $properties,
'limit' => 1,
];
}
/**
* Generates a payload to find related activities based on the specified data and object type.
* The search looks for activities that match the specified time range (starttime - endtime or only starttime with endtime not set)
* and are associated with any of the provided prospect types (contact or company).
*
* @param array $data An associative array containing the following keys:
* - 'from': The start time for the activity search (timestamp).
* - 'to': The end time for the activity search (timestamp).
* - 'contact': (optional) The ID of the associated contact.
* - 'company': (optional) The ID of the associated company.
* @param string $objectType The type of engagement to search for (meeting or other type like call).
*
* @return array The payload array to be used for searching related activities.
*/
public function getFindRelatedActivityPayload(array $data, string $objectType): array
{
$payload = [
'sorts' => [
[
'propertyName' => self::SORT_PROPERTY,
'direction' => 'DESCENDING',
],
],
'limit' => self::MAX_SEARCH_REQUEST_LIMIT,
];
if ($objectType === self::ENGAGEMENT_MEETINGS) {
$payload['properties'] = [
'hs_meeting_title',
'hs_meeting_outcome',
'hs_activity_type',
'hs_timestamp',
'hubspot_owner_id',
'hs_meeting_body',
'hs_internal_meeting_notes',
'hs_meeting_location',
'hs_meeting_start_time',
'hs_meeting_end_time',
];
} else {
$payload['properties'] = ['hs_task_subject', 'hs_timestamp', 'hubspot_owner_id', 'hs_call_body'];
}
$timeFiltersWithEndTime = [
[
'propertyName' => 'hs_meeting_start_time',
'operator' => 'GTE',
'value' => $data['from'],
],
[
'propertyName' => 'hs_meeting_end_time',
'operator' => 'LTE',
'value' => $data['to'],
],
];
$timeFiltersWithoutEndTime = [
[
'propertyName' => 'hs_meeting_start_time',
'operator' => 'GTE',
'value' => $data['from'],
],
[
'propertyName' => 'hs_meeting_end_time',
'operator' => 'NOT_HAS_PROPERTY',
],
];
$payload['filterGroups'] = [];
$associationTypes = ['contact', 'company'];
foreach ($associationTypes as $type) {
if (! empty($data[$type])) {
$filterGroupWithEndTime = [
'filters' => array_merge(
$timeFiltersWithEndTime,
[$this->getAssociatedObjectFilter($type, $data[$type])]
),
];
$payload['filterGroups'][] = $filterGroupWithEndTime;
$filterGroupWithoutEndTime = [
'filters' => array_merge(
$timeFiltersWithoutEndTime,
[$this->getAssociatedObjectFilter($type, $data[$type])]
),
];
$payload['filterGroups'][] = $filterGroupWithoutEndTime ;
}
}
return $payload;
}
/** Parameters
* [
* 'accountId' => $crmAccountId,
* 'sortBy' => $sortBy,
* 'sortDir' => $sortDir,
* 'onlyOpen' => $onlyOpen,
* 'closedStages' => $this->getClosedDealStages(),
* 'userId' => $userId,
* ];
*/
public function generateOpportunitiesSearchPayload(
Configuration $config,
string $crmAccountId,
array $closedStages,
): array {
$closedFilters = [];
$filterGroups = [];
$onlyOpen = true;
switch ($config->getOpportunityAssignmentRule()) {
case Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_UPDATED:
$sortBy = 'modifieddate';
$sortDir = 'DESCENDING';
break;
case Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_CREATED:
$sortBy = 'createdate';
$sortDir = 'DESCENDING';
break;
case Configuration::OPP_ASSIGNMENT_ALL_OPEN_OLDEST_CREATED:
$sortBy = 'createdate';
$sortDir = 'ASCENDING';
break;
case Configuration::OPP_ASSIGNMENT_ALL_RECENTLY_UPDATED:
default:
$sortBy = 'modifieddate';
$sortDir = 'DESCENDING';
$onlyOpen = false;
}
$baseFilters = [
[
'propertyName' => 'associations.company',
'operator' => 'EQ',
'value' => $crmAccountId,
],
];
// Handle closed stages in chunks
if ($onlyOpen) {
foreach (['won', 'lost'] as $key) {
if (! empty($closedStages[$key])) {
$chunks = array_chunk($closedStages[$key], self::MAX_FILTER_SIZE);
foreach ($chunks as $chunk) {
$closedFilters[] = [
'propertyName' => 'dealstage',
'operator' => 'NOT_IN',
'values' => $chunk,
];
}
}
}
}
$filterGroups[] = [
'filters' => array_merge($baseFilters, $closedFilters),
];
$payload = [
'filterGroups' => $filterGroups,
'sorts' => [
[
'propertyName' => $sortBy,
'direction' => $sortDir,
],
],
'properties' => [
'dealname',
'amount',
'hubspot_owner_id',
'pipeline',
'dealstage',
'closedate',
'deal_currency_code',
],
'limit' => self::MAX_SEARCH_REQUEST_LIMIT,
];
$logger = app(LoggerInterface::class);
$logger->info('[HubSpot] CRM Search requested', [
'request' => $payload,
]);
return $payload;
}
/**
* Converts v1 payload data to v3
*/
public function getV3MeetingPayload(array $engagement, array $metadata, array $associations = []): array
{
// Use this mapping until the whole Hubspot V1 is dropped.
$properties = [
'hubspot_owner_id' => $engagement['ownerId'],
'hs_timestamp' => $engagement['timestamp'],
];
// $engagement['activityType'] is $activity->category->name
if (isset($engagement['activityType'])) {
$properties['hs_activity_type'] = $engagement['activityType'];
}
$metadataKeyMap = [
'hs_meeting_outcome' => 'meetingOutcome',
'hs_meeting_title' => 'title',
'hs_meeting_start_time' => 'startTime',
'hs_meeting_end_time' => 'endTime',
'hs_meeting_body' => 'body',
'hs_internal_meeting_notes' => 'internalMeetingNotes',
];
foreach ($metadataKeyMap as $newKey => $oldKey) {
if (isset($metadata[$oldKey])) {
$properties[$newKey] = $metadata[$oldKey];
unset($metadata[$oldKey]);
}
}
$properties = [
...$properties,
...$metadata, // custom fields
];
$response = [
'properties' => $properties,
];
if (! empty($associations)) {
$response['associations'] = $associations;
}
return $response;
}
/**
* Generate a payload to search for contacts by name.
* The search is a token search in firstname or lastname
*/
public function generateSearchContactsByNamePayload(string $name, array $fields): array
{
$firstNameFilter = [
'propertyName' => 'firstname',
'operator' => 'CONTAINS_TOKEN',
'value' => $name,
];
$lastNameFilter = [
'propertyName' => 'lastname',
'operator' => 'CONTAINS_TOKEN',
'value' => $name,
];
return [
'filterGroups' => [
[
'filters' => [$firstNameFilter],
],
[
'filters' => [$lastNameFilter],
],
],
'properties' => $fields,
'sorts' => [
[
'propertyName' => 'lastmodifieddate',
'direction' => 'DESCENDING',
],
],
];
}
public function buildAddAssociationPayload(string $crmId, array $ids, int $associationType): array
{
$inputs = [];
foreach ($ids as $id) {
if ($id === null || $id === '') {
continue;
}
$inputs[] = [
'from' => [
'id' => $crmId,
],
'to' => [
'id' => (string) $id,
],
'types' => [
[
'associationCategory' => 'HUBSPOT_DEFINED',
'associationTypeId' => $associationType,
],
],
];
}
return ['inputs' => $inputs];
}
public function buildRemoveAssociationPayload(string $crmId, array $ids): array
{
$toArray = [];
foreach ($ids as $id) {
if ($id === null || $id === '') {
continue;
}
$toArray[] = ['id' => (string) $id];
}
return [
'inputs' => [
[
'from' => ['id' => $crmId],
'to' => $toArray,
],
],
];
}
public function addClosedStageFilters(array &$payload, array $closedStages): void
{
if (! empty($closedStages['won'])) {
$payload['filters'][] = [
'propertyName' => 'dealstage',
'operator' => 'NOT_IN',
'values' => $closedStages['won'],
];
}
if (! empty($closedStages['lost'])) {
$payload['filters'][] = [
'propertyName' => 'dealstage',
'operator' => 'NOT_IN',
'values' => $closedStages['lost'],
];
}
}
public function addCreatedDateFilters(array &$payload, Carbon $createdAfter): void
{
$payload['filters'][] = [
'propertyName' => 'createdate',
'operator' => 'GT',
'value' => $createdAfter->getPreciseTimestamp(3),
];
}
public function getDealsInBulkPayload(array $dealIds): array
{
return [
'filterGroups' => [
[
'filters' => [
[
'propertyName' => 'hs_object_id',
'operator' => 'IN',
'values' => $dealIds,
],
],
],
],
'limit' => self::MAX_FILTER_SIZE,
];
}
}
Sync Changes
Hide This Notification
Code changed:
Hide
22
8
Previous Highlighted Error
Next Highlighted Error
<?php
declare(strict_types=1);
namespace Jiminny\Services\Crm\Hubspot\Webhook;
use Illuminate\Support\Facades\Log;
use Jiminny\Models\Crm\Configuration;
use Jiminny\Services\Crm\Hubspot\BatchSyncCollector;
use Jiminny\Repositories\Crm\CrmEntityRepository;
use Jiminny\Repositories\Crm\OpportunityRepository;
use Jiminny\Repositories\Crm\FieldRepository;
use Jiminny\Repositories\Crm\FieldDataRepository;
use Jiminny\Models\Opportunity;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\LayoutEntity;
readonly class PropertyChangeManager
{
private const string OBJECT_TYPE_DEAL = 'deal';
private const string OBJECT_TYPE_CONTACT = 'contact';
private const string OBJECT_TYPE_COMPANY = 'company';
private const array SUPPORTED_OBJECT_TYPES = [
self::OBJECT_TYPE_DEAL,
self::OBJECT_TYPE_CONTACT,
self::OBJECT_TYPE_COMPANY,
];
/**
* Contact fields that webhooks are subscribed to in HubSpot.
*
* IMPORTANT: These fields MUST match the webhook subscriptions configured in HubSpot.
* When updating this list, you must also update the HubSpot webhook subscriptions:
* 1. Go to HubSpot App Settings → Webhooks
* 2. Update contact.propertyChange subscriptions to match this list
* 3. Ensure all fields listed here are subscribed, and vice versa
*
* This constant serves as documentation of the current webhook configuration.
* Since HubSpot filters webhooks server-side, we should only receive events for these properties.
*/
private const array APPLICABLE_CONTACT_FIELDS = [
'firstname',
'lastname',
'email',
'phone',
'mobilephone',
'jobtitle',
'country',
'hubspot_owner_id',
'associatedcompanyid',
'photo',
'hs_avatar_filemanager_key',
];
/**
* Company fields that webhooks are subscribed to in HubSpot.
*
* IMPORTANT: These fields MUST match the webhook subscriptions configured in HubSpot.
* When updating this list, you must also update the HubSpot webhook subscriptions:
* 1. Go to HubSpot App Settings → Webhooks
* 2. Update company.propertyChange subscriptions to match this list
* 3. Ensure all fields listed here are subscribed, and vice versa
*
* This constant serves as documentation of the current webhook configuration.
* Since HubSpot filters webhooks server-side, we should only receive events for these properties.
*/
private const array APPLICABLE_COMPANY_FIELDS = [
'name',
'phone',
'domain',
'country',
'industry',
'hubspot_owner_id',
'hs_avatar_filemanager_key',
];
/**
* Properties that support direct database updates (without full sync).
* These are simple properties that can be updated immediately without recalculating other fields.
*/
private const array DB_OBJECT_UPDATE_PROPERTIES = [
self::OBJECT_TYPE_DEAL => [
'dealname',
'description',
],
self::OBJECT_TYPE_CONTACT => [
// Add contact properties that support direct update
],
self::OBJECT_TYPE_COMPANY => [
// Add company properties that support direct update
],
];
/**
* Mapping from HubSpot property names to Jiminny model property names.
* Used when updating the main model (Opportunity, Contact, Account) directly.
* Only needed when HubSpot property name differs from Jiminny property name.
*/
private const array OBJECT_PROPERTY_MAPPINGS = [
self::OBJECT_TYPE_DEAL => [
'dealname' => 'name', // HubSpot 'dealname' maps to Opportunity 'name'
],
self::OBJECT_TYPE_CONTACT => [
// Add contact property mappings if needed
],
self::OBJECT_TYPE_COMPANY => [
// Add company property mappings if needed
],
];
// Field object types mapping
private const array FIELD_OBJECT_TYPES = [
self::OBJECT_TYPE_DEAL => Field::OBJECT_OPPORTUNITY,
self::OBJECT_TYPE_CONTACT => Field::OBJECT_CONTACT,
self::OBJECT_TYPE_COMPANY => Field::OBJECT_ACCOUNT,
];
public function __construct(
private BatchSyncCollector $batchCollector,
private OpportunityRepository $opportunityRepository,
private CrmEntityRepository $crmEntityRepository,
private FieldRepository $fieldRepository,
private FieldDataRepository $fieldDataRepository,
private HubspotWebhookMetricsService $metricsService,
) {
}
/**
* Get contact fields that webhooks are subscribed to in HubSpot.
* This serves as documentation of the current webhook configuration.
*
* @return array<int, string>
*/
public function getApplicableContactFields(): array
{
return self::APPLICABLE_CONTACT_FIELDS;
}
/**
* Get company fields that webhooks are subscribed to in HubSpot.
* This serves as documentation of the current webhook configuration.
*
* @return array<int, string>
*/
public function getApplicableCompanyFields(): array
{
return self::APPLICABLE_COMPANY_FIELDS;
}
/**
* Process property change with intelligent routing
*/
public function processPropertyChange(
string $objectType,
string $objectId,
Configuration $configuration,
string $propertyName,
string $propertyValue
): void {
if (! $this->isObjectTypeSupported($objectType)) {
Log::warning('[HubSpot Property Change Manager] Unsupported object type', [
'object_type' => $objectType,
'object_id' => $objectId,
'property_name' => $propertyName,
]);
return;
}
$this->metricsService->recordWebhookMetrics(
$configuration,
$objectType,
'property_change',
$propertyName
);
if ($this->isDirectUpdateSupported($objectType, $propertyName) &&
$this->handleDirectUpdate($objectType, $objectId, $configuration, $propertyName, $propertyValue)) {
return;
}
$this->handleObjectSync($objectType, $objectId, $configuration);
}
/**
* Legacy method for backward compatibility - assumes deal object type
*
* @deprecated Use processPropertyChange with objectType parameter
*/
public function processPropertyChangeForDeal(
string $objectId,
Configuration $configuration,
string $propertyName,
string $propertyValue
): void {
$this->processPropertyChange(self::OBJECT_TYPE_DEAL, $objectId, $configuration, $propertyName, $propertyValue);
}
/**
* Check if object type is supported
*/
private function isObjectTypeSupported(string $objectType): bool
{
return in_array($objectType, self::SUPPORTED_OBJECT_TYPES);
}
/**
* Check if property can be updated without additional calculation on other properties.
*/
private function isDirectUpdateSupported(string $objectType, string $propertyName): bool
{
$supportedProperties = self::DB_OBJECT_UPDATE_PROPERTIES[$objectType] ?? [];
return in_array($propertyName, $supportedProperties, true);
}
/**
* Handle properties that support direct database updates
*/
private function handleDirectUpdate(
string $objectType,
string $objectId,
Configuration $configuration,
string $propertyName,
string $propertyValue
): bool {
try {
return match ($objectType) {
self::OBJECT_TYPE_DEAL => $this->handleDealDirectUpdate($objectId, $configuration, $propertyName, $propertyValue),
self::OBJECT_TYPE_CONTACT => $this->handleContactDirectUpdate($objectId, $configuration, $propertyName, $propertyValue),
self::OBJECT_TYPE_COMPANY => $this->handleCompanyDirectUpdate($objectId, $configuration, $propertyName, $propertyValue),
default => false,
};
} catch (\Exception $e) {
Log::error('[HubSpot Property Change Manager] Error handling direct update property', [
'object_type' => $objectType,
'object_id' => $objectId,
'property_name' => $propertyName,
'error' => $e->getMessage(),
]);
return false;
}
}
/**
* Handle direct updates for deal objects
*/
private function handleDealDirectUpdate(
string $objectId,
Configuration $configuration,
string $propertyName,
string $propertyValue
): bool {
$opportunity = $this->opportunityRepository->findByConfigAndCrmProviderId(
$configuration,
$objectId
);
if (! $opportunity instanceof Opportunity) {
return false;
}
// Update model property if mapping exists
$propertyMappings = self::OBJECT_PROPERTY_MAPPINGS[self::OBJECT_TYPE_DEAL] ?? [];
if (isset($propertyMappings[$propertyName])) {
$this->opportunityRepository->updateOrCreate(
$configuration,
$objectId,
[$propertyMappings[$propertyName] => $propertyValue]
);
}
// Update field data
$this->importObjectFieldData(
self::OBJECT_TYPE_DEAL,
$opportunity->getId(),
$configuration,
[$propertyName => $propertyValue]
);
return true;
}
/**
* Handle direct updates for contact objects
*
* Updates Contact model properties via OBJECT_PROPERTY_MAPPINGS.
* Add mappings to OBJECT_PROPERTY_MAPPINGS[CONTACT] to enable direct updates.
*/
private function handleContactDirectUpdate(
string $objectId,
Configuration $configuration,
string $propertyName,
string $propertyValue
): bool {
$contact = $this->crmEntityRepository->findContactByExternalId($configuration, $objectId);
if ($contact === null) {
return false;
}
// Update model property if mapping exists
$propertyMappings = self::OBJECT_PROPERTY_MAPPINGS[self::OBJECT_TYPE_CONTACT] ?? [];
if (isset($propertyMappings[$propertyName])) {
$this->crmEntityRepository->importContact($configuration, [
'crm_provider_id' => $objectId,
$propertyMappings[$propertyName] => $propertyValue,
]);
}
return true;
}
/**
* Handle direct updates for company objects
*
* Updates Account model properties via OBJECT_PROPERTY_MAPPINGS.
* Add mappings to OBJECT_PROPERTY_MAPPINGS[COMPANY] to enable direct updates.
*/
private function handleCompanyDirectUpdate(
string $objectId,
Configuration $configuration,
string $propertyName,
string $propertyValue
): bool {
$company = $this->crmEntityRepository->findAccountByExternalId($configuration, $objectId);
if ($company === null) {
return false;
}
// Update model property if mapping exists
$propertyMappings = self::OBJECT_PROPERTY_MAPPINGS[self::OBJECT_TYPE_COMPANY] ?? [];
if (isset($propertyMappings[$propertyName])) {
$this->crmEntityRepository->importAccount($configuration, [
'crm_provider_id' => $objectId,
$propertyMappings[$propertyName] => $propertyValue,
]);
}
return true;
}
/**
* Import field data for any object type
*/
private function importObjectFieldData(
string $objectType,
int $objectId,
Configuration $configuration,
array $data
): void {
$fieldObjectType = self::FIELD_OBJECT_TYPES[$objectType] ?? null;
if (! $fieldObjectType) {
Log::warning('[HubSpot Property Change Manager] Unknown field object type', [
'object_type' => $objectType,
'object_id' => $objectId,
]);
return;
}
foreach ($data as $crmField => $value) {
$field = $this->fieldRepository->findFieldByCrmIdAndObjectType(
$configuration,
$crmField,
$fieldObjectType
);
if (! $field instanceof Field) {
continue;
}
$entities = $field->getEntities();
if ($entities->isEmpty()) {
continue;
}
if ($entities->count() > 1) {
continue;
}
/** @var LayoutEntity|null $entity */
$entity = $entities->first();
if (! $entity instanceof LayoutEntity) {
continue;
}
$this->fieldDataRepository->updateOrCreateFieldData(
$entity,
$objectId,
(string) $value,
);
}
}
private function handleObjectSync(
string $objectType,
string $objectId,
Configuration $configuration,
): void {
if (! $this->isObjectTypeSupported($objectType)) {
return;
}
try {
$this->batchCollector->collectForBatch(
objectType: $objectType,
crmProviderId: $objectId,
configurationId: $configuration->getId(),
eventType: 'property_change'
);
} catch (\Exception $e) {
Log::error('[HubSpot Property Change Manager] Error adding to sync batch', [
'object_type' => $objectType,
'object_id' => $objectId,
'error' => $e->getMessage(),
]);
}
}
}
Project
Project
New File or Directory…
Expand Selected
Collapse All
Options
Hide...
|
PhpStorm
|
faVsco.js – PropertyChangeManager.php
|
NULL
|
87869
|
|
87868
|
Project: faVsco.js, menu
#12121 on JY-20963-fix-im Project: faVsco.js, menu
#12121 on JY-20963-fix-import-on-deleted-entity, menu
Start Listening for PHP Debug Connections
ServiceTest
Run 'ServiceTest'
Debug 'ServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
8
iTerm2ShellEditViewSessionScriptsProfilesWindowHelp‹ >0 lol100% CDOCKER₴81DEV (-zsh)O 82Version 2023.11.20260427:Version 2023.11.20260505:Version2023.11.20260509:Version2023.11.20260511:Version2023.11.20260514:Version 2023.11.20260526:Run"/usr/bin/dnf check-release-update"#_####_\ #####\\###||\#/v~'Amazon Linux 2023 (ECS Optimized)-zshN3ec2-user@ip-10-20-31-146:~screenpipe"X4-zshX5ec2-user@ip-10-30-129-...8• Thu 28 May 19:27:45181ec2-user@ip-10-20-31-1...·7for full release and version update infom/For documentation, visit [URL_WITH_CREDENTIALS] ~]$ docker exec -it $(docker ps --format "{{.ID}}" --filter "name-ecs-worker" | head -1) /bin/bash -c "cd /home/jiminny && bash"root@170a323e43a4:/home/jiminny# php artisan crm:sync-opportunity --teamId 555 --opportunityId 494058190045[2026-05-28 16:20:08] production.INF0: Jiminny\Console\Commands\Command: : run Memory usage before starting command {"command": "crm:sync-opportunity", "memoryBeforeCommandInMb" : 118.0, "memoryPeakBeforeCommandInMb":118.03 {"correlation_id":"0a531c45-bc24-4ea8-b3c9-d90f79590da8", "trace_id":"1723bcBc-9f67-4ec7-b8c3-d09361d5879d"}Syncing opportunity for Cognitive Credit[2026-05-28 16:20:08] production.INFO: [SocialAccountService] Fetching token {"socialAccountId":30591,"provider": "hubspot"} {"correlation_id":"0a531c45-bc24-4ea8-b3c9-d90f79590da8", "trace_id":"1723bcDc-9f67-4ec7-b8c3-d09361d5879d"'}[2026-05-28 16:20:08] production.INFO: [SocialAccountService] Token retrieved {"socialAccountId":30591, "provider": "hubspot"} {"correlation_id":"0a531c45-bc24-4ea8-b3c9-d90f79590da8", "trace_id":"1723bc0c-9f67-4ec7-b8c3-d09361d5879d"'}[2026-05-28 16:20:08] production.INFO: [EncryptedTokenManager] Generatingaccess token. {"mode":"legacy"} {"correlation_id":"0a531c45-bc24-4ea8-b3c9-d90f79590da8","trace_id" :"1723bc0c-9f67-4ec7-b8c3-d09361d5879d"}Syncing opportunity 494058190045...[2026-05-28 16:20:08] production.INF0: [ReindexForOpportunityListener] Schedule reindexing job {"opportunity_id":7601423} {"correlation_id":"0a531c45-bc24-4ea8-b3c9-d90f79590da8","trace_id":"1723bc0c-9f67-4ec7-b8c3-d09361d5879d"}Synced Centiva Capital - EU HY/IG to 7601423[2026-05-28 16:20:08] production.INF0: Jiminny\Console\Commands\Command: :run Memory usage for command {"command": "crm:sync-opportunity", "memoryBeforeCommandInMb" : 118.0, "memoryAfterCommandInMB": 126.0, "memoryPeakBeforeCommandInMb" : 118.0, "memoryPeakAfterCommandInMB":126.0} {"correlation_id":"0a531c45-bc24-4ea8-b3c9-d90f79590da8", "trace_id" : "1723bc0c-9f67-4ec7-b8c3-d09361d5879d"}root@170a323e43a4:/home/jiminny# |...
|
PhpStorm
|
faVsco.js – PropertyChangeManager.php
|
NULL
|
87868
|