|
9852
|
445
|
0
|
2026-05-08T13:38:08.728822+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-08/1778 /Users/lukas/.screenpipe/data/data/2026-05-08/1778247488728_m1.jpg...
|
PhpStorm
|
faVsco.js – Client.php
|
1
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
iTerm2Shell Edit ViewSessionScripts|ProfilesWind iTerm2Shell Edit ViewSessionScripts|ProfilesWindowHelp‹ >0 lobl100% C8APP (-zsh)DOCKERDEV (docker)882JY-20773-fix-automated-reports-user-pilot-trackingJY-20157-AJ-report-not-send-notificationJY-20508-notify-before-AJ-report-expirationJY-20372-ai-reports-promotion-pagesJY-20352-sync-opportunities-without-a-local-owner-user-id-is-nullJY-20738-debug-AJ-tracking-UPAPP (-zsh)-zshJY-18909-automated-reports-ask-jiminnyJY-20692-fix-integration-app-[API_KEY]@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ co -b JY-20725-handle-HS-search-rate-limitSwitched to a new branch 'JY-20725-handle-HS-search-rate-limit'Lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (JY-20725-handle-HS-search-rate-limit) $ I• 84screenpipe*•$5-zshFri 8 May 16:38:12T₴1|₴6APP...
|
NULL
|
309448045769396116
|
NULL
|
click
|
ocr
|
NULL
|
iTerm2Shell Edit ViewSessionScripts|ProfilesWind iTerm2Shell Edit ViewSessionScripts|ProfilesWindowHelp‹ >0 lobl100% C8APP (-zsh)DOCKERDEV (docker)882JY-20773-fix-automated-reports-user-pilot-trackingJY-20157-AJ-report-not-send-notificationJY-20508-notify-before-AJ-report-expirationJY-20372-ai-reports-promotion-pagesJY-20352-sync-opportunities-without-a-local-owner-user-id-is-nullJY-20738-debug-AJ-tracking-UPAPP (-zsh)-zshJY-18909-automated-reports-ask-jiminnyJY-20692-fix-integration-app-[API_KEY]@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ co -b JY-20725-handle-HS-search-rate-limitSwitched to a new branch 'JY-20725-handle-HS-search-rate-limit'Lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (JY-20725-handle-HS-search-rate-limit) $ I• 84screenpipe*•$5-zshFri 8 May 16:38:12T₴1|₴6APP...
|
9848
|
NULL
|
NULL
|
NULL
|
|
9851
|
446
|
1
|
2026-05-08T13:38:06.039309+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-08/1778 /Users/lukas/.screenpipe/data/data/2026-05-08/1778247486039_m2.jpg...
|
PhpStorm
|
faVsco.js – Client.php
|
1
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
JY-20725-handle-HS-search Project: faVsco.js, menu
JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
19
Previous Highlighted Error
Next Highlighted Error
[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {
"headers":{
"Date":["Thu,07 May 2026 14:21:15 GMT"],
"Content-Type":["application/json;charset=utf-8"],
"Transfer-Encoding":["chunked"],
"Connection":["keep-alive"],
"CF-Ray":["9f80deb8db60dc3a-SOF"],
"CF-Cache-Status":["DYNAMIC"],
"Strict-Transport-Security":["max-age=31536000; includeSubDomains; preload"],
"Vary":["origin,
accept-encoding"],
"access-control-allow-credentials":["false"],
"server-timing":["hcid;desc=\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\",
cfr;desc=\"9f80deb8e7c6dc3a-IAD\""],
"x-content-type-options":["nosniff"],
"x-hubspot-correlation-id":["019e02d0-6fd8-7812-bdba-885b7ccb3ee3"],
"Set-Cookie":["__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-[IP_ADDRESS]-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,
07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None"],
"Report-To":["{
\"endpoints\":[{
\"url\":\"https:\\/\\/a.nel.cloudflare.com\\/report\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\"}],
\"group\":\"cf-nel\",
\"max_age\":604800}"],
"NEL":["{
\"success_fraction\":0.01,
\"report_to\":\"cf-nel\",
\"max_age\":604800}"],
"Server":["cloudflare"]}} {
"correlation_id":"95236535-ec98-4541-b92a-adfa73b69eab",
"trace_id":"c7ab8365-903f-46d4-9403-0e5b551e3545"}
Show Replace Field
Search History
rateLimiter
New Line
Match Case
Words
Regex
Replace History
Replace
New Line
Preserve case
0 results
Previous Occurrence
Next Occurrence
Filter Search Results
Open in Window, Multiple Cursors
Click to highlight
Close
Sync Changes
Hide This Notification
Code changed:
Hide
2
64
1
1
Previous Highlighted Error
Next Highlighted Error
<?php
declare(strict_types=1);
namespace Jiminny\Services\Crm\Hubspot;
use HubSpot\Client\Crm\Deals\ApiException as DealApiException;
use HubSpot\Client\Crm\Contacts\ApiException as ContactApiException;
use HubSpot\Client\Crm\Companies\ApiException as CompanyApiException;
use HubSpot\Client\Crm\Contacts\Model\SimplePublicObjectWithAssociations as ContactsWithAssociations;
use HubSpot\Client\Crm\Companies\Model\SimplePublicObjectWithAssociations as CompaniesWithAssociations;
use HubSpot\Client\Crm\Deals\Model\SimplePublicObjectWithAssociations as DealWithAssociations;
use HubSpot\Client\Crm\Objects\Model\SimplePublicObjectInput;
use HubSpot\Client\Crm\Objects\Model\SimplePublicObjectWithAssociations as ObjectWithAssociations;
use HubSpot\Client\Crm\Pipelines\Model\Error;
use HubSpot\Client\Crm\Pipelines\Model\PipelineStage;
use HubSpot\Client\Crm\Properties\Model\Property;
use HubSpot\Discovery\Discovery;
use Jiminny\Exceptions\CrmException;
use Jiminny\Exceptions\RateLimitException;
use Jiminny\Exceptions\SocialAccountTokenInvalidException;
use Jiminny\Jobs\Crm\NoteObject;
use Jiminny\Models\Crm\Field;
use Jiminny\Services\Crm\BaseClient;
use Jiminny\Services\Crm\Hubspot\DTO\Response\Owner;
use Jiminny\Services\SocialAccountService;
use SevenShores\Hubspot\Exceptions\BadRequest;
use SevenShores\Hubspot\Exceptions\HubspotException;
use SevenShores\Hubspot\Factory;
use SevenShores\Hubspot\Http\Response;
use Jiminny\Services\Crm\Hubspot\Pagination\HubspotPaginationService;
use Throwable;
/**
* @phpstan-type CrmFieldOption array{id:string, label:string, value?:string}
*/
class Client extends BaseClient implements HubspotClientInterface
{
public const string MIN_API_VERSION = '2';
public const string BASE_URL = '[URL_WITH_CREDENTIALS] T
* @param callable(): T $apiCall
* @return T
*
* @throws RateLimitException
*/
private function executeRequest(callable $apiCall)
{
try {
return $apiCall();
} catch (Throwable $e) {
if ($this->isHubspotRateLimit($e)) {
$retryAfter = $this->parseRetryAfter($e);
$this->log->warning('[Hubspot] Received 429 from API', [
'team_id' => $this->config->team_id,
'config_id' => $this->config->getId(),
'retry_after' => $retryAfter,
'reason' => $e->getMessage(),
]);
throw new RateLimitException('Hubspot returned 429', $retryAfter, $e);
}
throw $e;
}
}
public function isHubspotRateLimit(Throwable $e): bool
{
if ($e instanceof BadRequest
|| $e instanceof DealApiException
|| $e instanceof ContactApiException
|| $e instanceof CompanyApiException
|| $e instanceof \GuzzleHttp\Exception\RequestException
) {
return (int) $e->getCode() === 429;
}
return false;
}
public function parseRetryAfter(Throwable $e): int
{
// First try to get Retry-After from response headers
if (method_exists($e, 'getResponseHeaders')) {
$headers = $e->getResponseHeaders() ?: [];
$value = $headers['Retry-After'] ?? $headers['retry-after'] ?? null;
if (is_array($value)) {
$value = $value[0] ?? null;
}
if (is_numeric($value)) {
return (int) $value;
}
}
if (method_exists($e, 'getResponseBody')) {
$body = $e->getResponseBody();
if (is_string($body)) {
$body = json_decode($body, true) ?? [];
}
$policyName = $body['policyName'] ?? $body['policy'] ?? $body['context']['policyName'] ?? null;
if ($policyName === 'TEN_SECONDLY_ROLLING' || $policyName === 'ten_secondly_rolling') {
return 10;
}
if ($policyName === 'SECONDLY' || $policyName === 'secondly') {
return 1;
}
}
$this->log->warning('[Hubspot] No retry-after header or policy name found, using default', [
'exception_class' => get_class($e),
]);
return 10;
}
public function getMinimumApiVersion(): string
{
return self::MIN_API_VERSION;
}
public function getInstance(): Factory
{
return new Factory([
'key' => $this->accessToken,
'oauth2' => true,
'base_url' => $this->baseUrl,
]);
}
public function getNewInstance(): Discovery
{
return \HubSpot\Factory::createWithAccessToken($this->accessToken);
}
/**
* Secondly and daily limits for Hubspot API
*
* Product Tier: Free & Starter | Professional & Enterprise | API add-on (any tier)
* Burst: 100/10 seconds | 150/10 seconds | 200/10 seconds
* Daily: 250,000 | 500,000 | 1,000,000
*
* Official documentation states: The search endpoints are rate limited to five requests per second.
* Since with 5 RPS were still hitting secondly rate limits we lowered it to 4
*/
public function getPaginatedData(array $payload, string $type, int $offset = 0): array
{
$total = 0;
$lastId = null;
$rows = [];
foreach ($this->getPaginatedDataGenerator($payload, $type, $offset, $total, $lastId) as $row) {
$rows[] = $row;
}
return ['results' => $rows, 'total' => $total, 'last_record' => $lastId];
}
/**
* @throws HubspotException
* @throws SocialAccountTokenInvalidException
* @throws BadRequest
*/
public function getPaginatedDataGenerator(
array $payload,
string $type,
int $offset = 0,
int &$total = 0,
?string &$lastRecordId = null
): \Generator {
return $this->paginationService->getPaginatedDataGenerator(
$this,
$payload,
$type,
$offset,
$total,
$lastRecordId
);
}
/**
* Execute a search request against HubSpot CRM objects with rate limiting.
*
* @param string $objectType The object type ('deals', 'companies', 'contacts', 'calls')
* @param array<string, mixed> $payload The search payload with filters, sorts, properties, etc.
* @return array The search response with 'results', 'total', 'paging' keys
* @throws RateLimitException When rate limit is hit
* @throws HubspotException On API errors
*/
public function search(string $objectType, array $payload): array
{
$endpoint = self::BASE_URL . "/crm/v3/objects/{$objectType}/search";
return $this->executeRequest(function () use ($endpoint, $payload) {
$response = $this->getInstance()->getClient()->request('POST', $endpoint, ['json' => $payload]);
return $response->toArray();
});
}
/**
* @throws DealApiException
* @throws CrmException
*/
public function getOpportunityById(string $crmId, array $fields): array
{
try {
// $deal = $this->executeRequest(fn () => $this->getNewInstance()->crm()->deals()->basicApi()->getById(
$deal = $this->getNewInstance()->crm()->deals()->basicApi()->getById(
$crmId,
implode(',', $fields),
'companies,contacts'
);
} catch (DealApiException $e) {
$this->log->info('[Hubspot] Failed to fetch opportunity', [
'crm_id' => $crmId,
'reason' => $e->getMessage(),
]);
throw $e;
}
if (! $deal instanceof DealWithAssociations) {
throw new CrmException('Deal not found');
}
return [
'id' => $deal->getId(),
'properties' => $deal->getProperties(),
'associations' => $deal->getAssociations(),
];
}
/**
* Generic batch read method for HubSpot objects
*
* @param string $objectType The object type ('deals', 'companies', 'contacts')
* @param array<string> $crmIds Array of HubSpot object IDs (max 100)
* @param array<string> $fields Array of property names to fetch
*
* @return array<string, array> Array keyed by CRM ID with object data
*/
private function batchReadObjects(string $objectType, array $crmIds, array $fields): array
{
if (empty($crmIds)) {
return [];
}
$this->validateBatchSize($objectType, $crmIds);
$this->ensureValidToken();
try {
$batchConfig = $this->createBatchConfiguration($objectType);
$batchReadRequest = $this->prepareBatchRequest($batchConfig, $crmIds, $fields);
$response = $batchConfig['api']->read($batchReadRequest);
$this->validateApiResponse($response, $objectType);
$results = $this->processApiResults($response);
$this->logBatchResults($objectType, $crmIds, $results);
return $results;
} catch (\Throwable $e) {
$this->handleBatchError($e, $objectType, $crmIds);
}
}
private function validateBatchSize(string $objectType, array $crmIds): void
{
if (count($crmIds) > 100) {
throw new \InvalidArgumentException("Batch size cannot exceed 100 {$objectType}");
}
}
private function createBatchConfiguration(string $objectType): array
{
$configurations = [
'deals' => [
'batchReadRequest' => new \HubSpot\Client\Crm\Deals\Model\BatchReadInputSimplePublicObjectId(),
'inputClass' => \HubSpot\Client\Crm\Deals\Model\SimplePublicObjectId::class,
'api' => $this->getNewInstance()->crm()->deals()->batchApi(),
],
'companies' => [
'batchReadRequest' => new \HubSpot\Client\Crm\Companies\Model\BatchReadInputSimplePublicObjectId(),
'inputClass' => \HubSpot\Client\Crm\Companies\Model\SimplePublicObjectId::class,
'api' => $this->getNewInstance()->crm()->companies()->batchApi(),
],
'contacts' => [
'batchReadRequest' => new \HubSpot\Client\Crm\Contacts\Model\BatchReadInputSimplePublicObjectId(),
'inputClass' => \HubSpot\Client\Crm\Contacts\Model\SimplePublicObjectId::class,
'api' => $this->getNewInstance()->crm()->contacts()->batchApi(),
],
];
if (! isset($configurations[$objectType])) {
throw new \InvalidArgumentException("Unsupported object type: {$objectType}");
}
return $configurations[$objectType];
}
private function prepareBatchRequest(array $batchConfig, array $crmIds, array $fields): object
{
$batchReadRequest = $batchConfig['batchReadRequest'];
$inputClass = $batchConfig['inputClass'];
$inputs = array_map(function ($crmId) use ($inputClass) {
$input = new $inputClass();
$input->setId($crmId);
return $input;
}, $crmIds);
$batchReadRequest->setInputs($inputs);
$batchReadRequest->setProperties($fields);
return $batchReadRequest;
}
private function validateApiResponse($response, string $objectType): void
{
if (! $response) {
throw new CrmException("HubSpot API returned null response for {$objectType} batch read");
}
}
private function processApiResults($response): array
{
$results = [];
$responseResults = $response->getResults();
if ($responseResults) {
foreach ($responseResults as $object) {
if ($object && $object->getId()) {
$results[$object->getId()] = [
'id' => $object->getId(),
'properties' => $object->getProperties() ?: [],
];
}
}
}
return $results;
}
private function logBatchResults(string $objectType, array $crmIds, array $results): void
{
$this->log->info("[HubSpot] Batch fetched {$objectType}", [
'requested_count' => count($crmIds),
'returned_count' => count($results),
'crm_ids' => $crmIds,
]);
}
private function handleBatchError(\Throwable $e, string $objectType, array $crmIds): void
{
$errorMessage = $e->getMessage() ?: 'Unknown error';
$errorTrace = $e->getTraceAsString() ?: 'No trace available';
$this->log->error("[HubSpot] Failed to batch fetch {$objectType}", [
'crm_ids' => $crmIds,
'error' => $errorMessage,
'trace' => $errorTrace,
]);
throw new CrmException("Failed to batch fetch {$objectType}: " . $errorMessage);
}
/**
* Batch read multiple opportunities by their CRM IDs
*
* @param array<string> $crmIds Array of HubSpot deal IDs (max 100)
* @param array<string> $fields Array of property names to fetch
*
* @return array<string, array> Array keyed by CRM ID with opportunity data
*/
public function getOpportunitiesByIds(array $crmIds, array $fields): array
{
return $this->batchReadObjects('deals', $crmIds, $fields);
}
/**
* Batch read multiple companies by their CRM IDs
*
* @param array<string> $crmIds Array of HubSpot company IDs (max 100)
* @param array<string> $fields Array of property names to fetch
*
* @return array<string, array> Array keyed by CRM ID with company data
*/
public function getCompaniesByIds(array $crmIds, array $fields): array
{
return $this->batchReadObjects('companies', $crmIds, $fields);
}
/**
* Batch read multiple contacts by their CRM IDs
*
* @param array<string> $crmIds Array of HubSpot contact IDs (max 100)
* @param array<string> $fields Array of property names to fetch
*
* @return array<string, array> Array keyed by CRM ID with contact data
*/
public function getContactsByIds(array $crmIds, array $fields): array
{
return $this->batchReadObjects('contacts', $crmIds, $fields);
}
/**
* @throws CompanyApiException
* @throws CrmException
*/
public function getAccountById(string $crmId, array $fields): array
{
try {
$company = $this->getNewInstance()->crm()->companies()->basicApi()->getById(
$crmId,
implode(',', $fields),
);
} catch (CompanyApiException $e) {
$this->log->info('[Hubspot] Failed to fetch account', [
'crm_id' => $crmId,
'reason' => $e->getMessage(),
]);
throw $e;
}
if (! $company instanceof CompaniesWithAssociations) {
throw new CrmException('Account not found');
}
return [
'id' => $company->getId(),
'properties' => $company->getProperties(),
];
}
/**
* @throws ContactApiException
* @throws CrmException
*/
public function getContactById(string $crmId, array $fields): array
{
try {
$contact = $this->getNewInstance()->crm()->contacts()->basicApi()->getById(
$crmId,
implode(',', $fields)
);
} catch (ContactApiException $e) {
$this->log->info('[Hubspot] Failed to fetch contact', [
'crm_id' => $crmId,
'reason' => $e->getMessage(),
]);
throw $e;
}
if (! $contact instanceof ContactsWithAssociations) {
throw new CrmException('Contact not found');
}
return [
'id' => $contact->getId(),
'properties' => $contact->getProperties(),
];
}
/**
* This is email search request that Hubspot offers as GET (more generous quota)
*/
public function getContactByEmail(string $email, array $fields = []): array
{
try {
$contact = $this->getNewInstance()->crm()->contacts()->basicApi()->getById(
$email,
implode(',', $fields),
null,
false,
'email'
);
return [
'id' => $contact->getId(),
'properties' => $contact->getProperties(),
];
} catch (ContactApiException $e) {
$this->log->info('[Hubspot] Failed to fetch contact', [
'email' => $email,
'reason' => $e->getMessage(),
]);
return [];
}
}
/**
* @throws CrmException
*/
public function fetchProperty(string $objectType, string $propertyId): Property
{
$result = $this->getNewInstance()->crm()->properties()->coreApi()->getByName($objectType, $propertyId);
if (! $result instanceof Property) {
$this->log->error('[Hubspot] Failed to fetch property', [
'object_type' => $objectType,
'property_id' => $propertyId,
'reason' => $result->getMessage(),
]);
throw new CrmException('Failed to fetch property');
}
return $result;
}
/**
* @return array<CrmFieldOption>
*/
public function fetchPropertyOptions(string $objectType, string $propertyId): array
{
/** @var array<CrmFieldOption> */
return $this->fetchProperty($objectType, $propertyId)->getOptions();
}
/**
* @return array<array{id:string, label:string, deleted:bool}>
*/
public function fetchCallDispositions(): array
{
/** @var Response $response */
$response = $this->getInstance()->engagements()->getCallDispositions();
/**
* @var array<array{
* id:string,
* label:string,
* deleted: bool
* }>
*/
return $response->toArray();
}
/**
* @return array<CrmFieldOption>
*/
public function fetchOpportunityPipelineStages(): array
{
$stages = [];
$apiResponse = $this->getNewInstance()->crm()->pipelines()->pipelinesApi()->getAll('deals');
if ($apiResponse instanceof Error) {
$this->log->error('[Hubspot] Failed to fetch opportunity pipelines', [
'reason' => $apiResponse->getMessage(),
]);
return [];
}
foreach ($apiResponse->getResults() as $pipeline) {
$pipelineStages = array_map(
static function (PipelineStage $stage) {
return [
'id' => $stage->getId(),
'label' => $stage->getLabel(),
];
},
$pipeline->getStages()
);
$stages = array_merge($stages, $pipelineStages);
}
return $stages;
}
public function fetchOpportunityPipelines(): array
{
$pipelines = [];
try {
$apiResponse = $this->makeRequest('/crm/v3/pipelines/deals');
} catch (\Exception $e) {
$this->log->info('[Hubspot] Failed to fetch opportunity pipelines', [
'reason' => $e->getMessage(),
]);
return [];
}
$response = $apiResponse->toArray();
foreach ($response['results'] as $pipeline) {
$pipelines[] = [
'id' => $pipeline['id'],
'label' => $pipeline['label'],
];
}
return $pipelines;
}
/**
* @return array<CrmFieldOption>
*/
public function fetchMeetingOutcomeFieldOptions(Field $field): array
{
return $field->getCrmProviderId() === 'meetingOutcome'
? $this->fetchMeetingOutcomeTypes()
: $this->fetchCallActivityTypes();
}
public function fetchMeetingOutcomeTypes(): array
{
return $this->extractMeetingTypeOptions(
'[URL_WITH_CREDENTIALS] Response $response */
$response = $this->getInstance()
->getClient()
->request('GET', $endpoint);
/**
* @var array<array{
* value: string,
* label: string,
* displayOrder: int
* }> $optionData
*/
$optionData = $response->toArray()['options'] ?? [];
$options = [];
foreach ($optionData as $item) {
$options[] = [
'id' => $item['value'],
'value' => $item['value'],
'label' => $item['label'],
'display_order' => $item['displayOrder'],
];
}
return $options;
}
/**
* @return array<CrmFieldOption>
*/
public function fetchDispositionFieldOptions(): array
{
$options = [];
$dispositions = $this->fetchCallDispositions();
foreach ($dispositions as $disposition) {
if ($disposition['deleted'] !== false) {
continue;
}
$option['value'] = $disposition['id'];
$option['id'] = $disposition['id'];
$option['label'] = $disposition['label'];
$options[] = $option;
}
return $options;
}
/**
* @return array<CrmFieldOption>
*/
public function fetchOpportunityFieldOptions(Field $field): array
{
if ($field->isStageField()) {
return $this->fetchOpportunityPipelineStages();
}
if ($field->isPipelineField()) {
return $this->fetchOpportunityPipelines();
}
return $this->fetchPropertyOptions('deals', $field->getCrmProviderId());
}
/**
* @throws BadRequest
* @throws HubspotException
*/
public function makeRequest(string $endpoint, $method = 'GET', $payload = [], ?string $queryString = null)
{
$endpoint = self::BASE_URL . $endpoint;
if ($method === 'GET') {
$response = $this->getInstance()->getClient()?->request(
method: $method,
endpoint: $endpoint,
query_string: $queryString
);
} else {
$response = $this->getInstance()->getClient()->request($method, $endpoint, [
'json' => ($payload),
]);
}
//
// $max = $response->getHeaderLine('X-HubSpot-RateLimit-Max'); // "110"
// $remaining = $response->getHeaderLine('X-HubSpot-RateLimit-Remaining'); // "109"
// $interval = $response->getHeaderLine('X-HubSpot-RateLimit-Interval-Milliseconds'); // "10000"
// $body = json_decode((string) $response->getBody(), true);
//
// \Illuminate\Support\Facades\Log::channel('custom_channel')->info('$max ' . PHP_EOL . print_r($max, true));
// \Illuminate\Support\Facades\Log::channel('custom_channel')->info('$remaining ' . PHP_EOL . print_r($remaining, true));
// \Illuminate\Support\Facades\Log::channel('custom_channel')->info('$interval ' . PHP_EOL . print_r($interval, true));
// \Illuminate\Support\Facades\Log::channel('custom_channel')->info('$body ' . PHP_EOL . print_r($body, true));
return $response;
}
/**
* @throws BadRequest
* @throws HubspotException
*/
public function createMeeting(array $payload): Response
{
$endpoint = '/crm/v3/objects/meetings';
return $this->makeRequest($endpoint, 'POST', $payload);
}
/**
* @throws BadRequest
* @throws HubspotException
*/
public function updateMeeting(string $meetingId, array $payload): Response
{
$endpoint = '/crm/v3/objects/meetings/' . $meetingId;
return $this->makeRequest($endpoint, 'PATCH', $payload);
}
/**
* @throws \Exception
*/
public function createNote(
string $body,
string $ownerId,
int $timestamp,
string $objectId,
NoteObject $noteObject
): ?string {
try {
$noteInput = new SimplePublicObjectInput([
'properties' => [
'hs_note_body' => $body,
'hubspot_owner_id' => $ownerId,
'hs_timestamp' => $timestamp,
],
]);
// Create note
$note = $this->getNewInstance()->crm()->objects()->basicApi()->create('note', $noteInput);
$this->getNewInstance()->crm()->objects()->associationsApi()->create(
'note',
$note->getId(),
$this->getNoteObject($noteObject),
$objectId,
$this->getNoteAssociationType($noteObject),
);
return $note->getId();
} catch (\Exception $e) {
$this->log->error('[Hubspot] Failed to create note', [
'objectId' => $objectId,
'noteObject' => $noteObject->getObjectType(),
'reason' => $e->getMessage(),
]);
\Sentry::captureException($e);
}
return null;
}
public function updateEngagement(string $objectId, array $engagement, array $metadata): void
{
$this->getInstance()->engagements()->update($objectId, $engagement, $metadata);
}
public function getEngagementData(string $engagementId): array
{
$engagement = $this->getInstance()->engagements()->get($engagementId);
return $engagement->toArray();
}
public function createEngagement(array $engagement, array $associations, array $metadata): Response
{
return $this->getInstance()
->engagements()
->create($engagement, $associations, $metadata);
}
public function isUnauthorizedException(\Exception $e): bool
{
if ($e instanceof BadRequest
|| $e instanceof DealApiException
|| $e instanceof ContactApiException
|| $e instanceof CompanyApiException
) {
return (int) $e->getCode() === 401;
}
if ($e instanceof \GuzzleHttp\Exception\RequestException && $e->hasResponse()) {
return $e->getResponse()?->getStatusCode() === 401;
}
$message = strtolower($e->getMessage());
return str_contains($message, '401 unauthorized') ||
str_contains($message, 'http 401') ||
str_contains($message, 'status code 401') ||
(preg_match('/\b401\b/', $message) === 1 && str_contains($message, 'unauthorized'));
}
/**
* Validates and refreshes the access token if needed before API requests.
* This ensures long-running processes don't fail due to token expiration.
*
* @throws SocialAccountTokenInvalidException
*/
public function ensureValidToken(): void
{
if ($this->oauthAccount === null) {
return;
}
$newToken = $this->tokenManager->ensureValidToken($this->oauthAccount);
if ($newToken !== null) {
$this->accessToken = $newToken;
}
}
public function getConfig()
{
return $this->config;
}
// returns only active (archived=false)
public function getOwners(): array
{
return $this->getNewInstance()->crm()->owners()->getAll();
}
/**
* @param bool $archived
*
* @return array<Owner>|[]
*/
public function getOwnersArchived(bool $archived = true): array
{
$endpoint = '/crm/v3/owners';
$queryParams = [
'archived' => $archived ? 'true' : 'false',
];
$queryString = http_build_query($queryParams);
$owners = [];
try {
$response = $this->makeRequest(endpoint: $endpoint, queryString: $queryString);
$responseData = $response?->toArray();
foreach ($responseData['results'] as $result) {
try {
$owners[] = Owner::create($result);
} catch (Throwable $e) {
$this->log->error('[HubSpot] Failed to process owner data', [
'result' => $result,
'error' => $e->getMessage(),
]);
continue;
}
}
} catch (Throwable $e) {
$this->log->error('HubSpot] Failed to fetch owners', [
'archived' => $archived,
'error' => $e->getMessage(),
]);
return [];
}
return $owners;
}
public function getMeeting(string $engagementId): ObjectWithAssociations
{
return $this->getNewInstance()->crm()->objects()->basicApi()
->getById('meeting', $engagementId, null, 'contact,company,deal');
}
public function deleteEngagement(string $engagementId): void
{
$this->getInstance()->engagements()->delete((int) $engagementId);
}
public function getAssociationsData(array $ids, string $fromObject, string $toObject): array
{
$associationData = [];
$idChunks = array_chunk($ids, self::ASSOCIATIONS_BATCH_SIZE_LIMIT);
foreach ($idChunks as $idChunk) {
try {
$batchInput = new \HubSpot\Client\Crm\Associations\Model\BatchInputPublicObjectId();
$batchInput->setInputs(array_map(function ($id) {
$publicObjectId = new \HubSpot\Client\Crm\Associations\Model\PublicObjectId();
$publicObjectId->setId($id);
return $publicObjectId;
}, $idChunk));
$associatedObjectsData = $this
->getNewInstance()
->crm()
->associations()
->batchApi()
->read($fromObject, $toObject, $batchInput);
if ($associatedObjectsData instanceof \HubSpot\Client\Crm\Associations\Model\BatchResponsePublicAssociationMulti) {
foreach ($associatedObjectsData->getResults() as $association) {
$from = $association->getFrom()->getId();
$toAssociations = $association->getTo();
if (! empty($toAssociations)) {
$associationData[$from] = array_map(function ($item) {
return $item->getId();
}, $toAssociations);
}
}
}
} catch (RateLimitException $e) {
throw $e;
} catch (\Exception $e) {
$this->log->error('[Hubspot] Failed to fetch associations', [
'from_object' => $fromObject,
'to_object' => $toObject,
'reason' => $e->getMessage(),
]);
}
}
return $associationData;
}
/**
* @throws \Exception
*/
private function getNoteAssociationType(NoteObject $noteObject): string
{
return match($noteObject) {
NoteObject::Opportunity => 'note_to_deal',
NoteObject::Lead, NoteObject::Contact => 'note_to_contact', // or 'note_to_lead' if your portal supports it
NoteObject::Account => 'note_to_company',
NoteObject::Call, NoteObject::Event => throw new \Exception('Not supported'),
};
}
/**
* @throws \Exception
*/
private function getNoteObject(NoteObject $noteObject): string
{
return match($noteObject) {
NoteObject::Opportunity => 'deal',
NoteObject::Lead, NoteObject::Contact => 'contact',
NoteObject::Account => 'company',
NoteObject::Call, NoteObject::Event => throw new \Exception('Not supported'),
};
}
public function addAssociations(string $objectType, string $associationType, array $payload): Response
{
$endpoint = "/crm/v4/associations/$objectType/$associationType/batch/create";
return $this->makeRequest($endpoint, 'POST', $payload);
}
public function removeAssociations(string $objectType, string $associationType, array $payload): Response
{
$endpoint = "/crm/v4/associations/$objectType/$associationType/batch/archive";
return $this->makeRequest($endpoint, 'POST', $payload);
}
}
Project
Project
New File or Directory…
Expand Selected
Collapse All
Options
Hide...
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"bounds":{"left":0.025930852,"top":0.019952115,"width":0.03856383,"height":0.025538707},"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JY-20725-handle-HS-search-rate-limit, menu","depth":5,"bounds":{"left":0.064494684,"top":0.019952115,"width":0.09541223,"height":0.025538707},"on_screen":true,"help_text":"Git Branch: JY-20725-handle-HS-search-rate-limit","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"bounds":{"left":0.8081782,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"AskJiminnyReportActivityServiceTest","depth":6,"bounds":{"left":0.8234708,"top":0.019952115,"width":0.09208777,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'AskJiminnyReportActivityServiceTest'","depth":6,"bounds":{"left":0.9155585,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'AskJiminnyReportActivityServiceTest'","depth":6,"bounds":{"left":0.9268617,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"bounds":{"left":0.9381649,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"bounds":{"left":0.96609044,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"bounds":{"left":0.9773936,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"IDE and Project Settings","depth":5,"bounds":{"left":0.9886968,"top":0.019952115,"width":0.011303186,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.042220745,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"19","depth":4,"bounds":{"left":0.6615692,"top":0.10055866,"width":0.009640957,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"bounds":{"left":0.67287236,"top":0.09896249,"width":0.00731383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"bounds":{"left":0.68018615,"top":0.09896249,"width":0.006981383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {\n\"headers\":{\n\"Date\":[\"Thu,07 May 2026 14:21:15 GMT\"],\n \"Content-Type\":[\"application/json;charset=utf-8\"],\n \"Transfer-Encoding\":[\"chunked\"],\n \"Connection\":[\"keep-alive\"],\n \"CF-Ray\":[\"9f80deb8db60dc3a-SOF\"],\n \"CF-Cache-Status\":[\"DYNAMIC\"],\n \"Strict-Transport-Security\":[\"max-age=31536000; includeSubDomains; preload\"],\n \"Vary\":[\"origin,\n accept-encoding\"],\n \"access-control-allow-credentials\":[\"false\"],\n \"server-timing\":[\"hcid;desc=\\\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\\\",\n cfr;desc=\\\"9f80deb8e7c6dc3a-IAD\\\"\"],\n \"x-content-type-options\":[\"nosniff\"],\n \"x-hubspot-correlation-id\":[\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\"],\n \"Set-Cookie\":[\"__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-1.0.1.1-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,\n 07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None\"],\n \"Report-To\":[\"{\n\\\"endpoints\\\":[{\n\\\"url\\\":\\\"https:\\\\/\\\\/a.nel.cloudflare.com\\\\/report\\\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\\\"}],\n\\\"group\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"NEL\":[\"{\n\\\"success_fraction\\\":0.01,\n\\\"report_to\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"Server\":[\"cloudflare\"]}} {\n\"correlation_id\":\"95236535-ec98-4541-b92a-adfa73b69eab\",\n\"trace_id\":\"c7ab8365-903f-46d4-9403-0e5b551e3545\"}","depth":4,"bounds":{"left":0.37632978,"top":0.09736632,"width":0.5728058,"height":0.8818835},"on_screen":true,"lines":[{"char_start":207,"char_count":30,"bounds":{"left":0.37632978,"top":0.0,"width":0.07513298,"height":0.014365523}},{"char_start":237,"char_count":36,"bounds":{"left":0.37632978,"top":0.0,"width":0.09075798,"height":0.014365523}},{"char_start":273,"char_count":32,"bounds":{"left":0.37632978,"top":0.0,"width":0.080119684,"height":0.014365523}},{"char_start":305,"char_count":79,"bounds":{"left":0.37632978,"top":0.0,"width":0.20212767,"height":0.014365523}},{"char_start":384,"char_count":18,"bounds":{"left":0.37632978,"top":0.0,"width":0.043882977,"height":0.014365523}},{"char_start":402,"char_count":21,"bounds":{"left":0.37632978,"top":0.0,"width":0.051861703,"height":0.014365523}},{"char_start":423,"char_count":48,"bounds":{"left":0.37632978,"top":0.008778931,"width":0.12167553,"height":0.014365523}},{"char_start":471,"char_count":72,"bounds":{"left":0.37632978,"top":0.026336791,"width":0.18384309,"height":0.014365523}},{"char_start":543,"char_count":40,"bounds":{"left":0.37632978,"top":0.043894652,"width":0.10106383,"height":0.014365523}},{"char_start":583,"char_count":41,"bounds":{"left":0.37632978,"top":0.061452515,"width":0.10372341,"height":0.014365523}},{"char_start":624,"char_count":72,"bounds":{"left":0.37632978,"top":0.079010375,"width":0.18384309,"height":0.014365523}},{"char_start":696,"char_count":219,"bounds":{"left":0.37632978,"top":0.096568234,"width":0.56515956,"height":0.014365523}},{"char_start":915,"char_count":83,"bounds":{"left":0.37632978,"top":0.11412609,"width":0.21243352,"height":0.014365523}},{"char_start":998,"char_count":20,"bounds":{"left":0.37632978,"top":0.13168396,"width":0.04920213,"height":0.014365523}},{"char_start":1018,"char_count":17,"bounds":{"left":0.37632978,"top":0.14924182,"width":0.041223403,"height":0.014365523}},{"char_start":1035,"char_count":203,"bounds":{"left":0.37632978,"top":0.16679968,"width":0.52360374,"height":0.014365523}},{"char_start":1238,"char_count":22,"bounds":{"left":0.37632978,"top":0.18435754,"width":0.05418883,"height":0.014365523}},{"char_start":1260,"char_count":23,"bounds":{"left":0.37632978,"top":0.2019154,"width":0.056848403,"height":0.014365523}},{"char_start":1283,"char_count":10,"bounds":{"left":0.37632978,"top":0.21947326,"width":0.023271276,"height":0.014365523}},{"char_start":1293,"char_count":27,"bounds":{"left":0.37632978,"top":0.23703113,"width":0.06715426,"height":0.014365523}},{"char_start":1320,"char_count":26,"bounds":{"left":0.37632978,"top":0.254589,"width":0.06482713,"height":0.014365523}},{"char_start":1346,"char_count":23,"bounds":{"left":0.37632978,"top":0.27214685,"width":0.056848403,"height":0.014365523}},{"char_start":1369,"char_count":28,"bounds":{"left":0.37632978,"top":0.2897047,"width":0.06981383,"height":0.014365523}},{"char_start":1397,"char_count":57,"bounds":{"left":0.37632978,"top":0.30726257,"width":0.14494681,"height":0.014365523}}],"value":"[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {\n\"headers\":{\n\"Date\":[\"Thu,07 May 2026 14:21:15 GMT\"],\n \"Content-Type\":[\"application/json;charset=utf-8\"],\n \"Transfer-Encoding\":[\"chunked\"],\n \"Connection\":[\"keep-alive\"],\n \"CF-Ray\":[\"9f80deb8db60dc3a-SOF\"],\n \"CF-Cache-Status\":[\"DYNAMIC\"],\n \"Strict-Transport-Security\":[\"max-age=31536000; includeSubDomains; preload\"],\n \"Vary\":[\"origin,\n accept-encoding\"],\n \"access-control-allow-credentials\":[\"false\"],\n \"server-timing\":[\"hcid;desc=\\\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\\\",\n cfr;desc=\\\"9f80deb8e7c6dc3a-IAD\\\"\"],\n \"x-content-type-options\":[\"nosniff\"],\n \"x-hubspot-correlation-id\":[\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\"],\n \"Set-Cookie\":[\"__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-1.0.1.1-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,\n 07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None\"],\n \"Report-To\":[\"{\n\\\"endpoints\\\":[{\n\\\"url\\\":\\\"https:\\\\/\\\\/a.nel.cloudflare.com\\\\/report\\\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\\\"}],\n\\\"group\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"NEL\":[\"{\n\\\"success_fraction\\\":0.01,\n\\\"report_to\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"Server\":[\"cloudflare\"]}} {\n\"correlation_id\":\"95236535-ec98-4541-b92a-adfa73b69eab\",\n\"trace_id\":\"c7ab8365-903f-46d4-9403-0e5b551e3545\"}","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Show Replace Field","depth":4,"bounds":{"left":0.10472074,"top":0.20430966,"width":0.008643617,"height":0.01915403},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Search History","depth":3,"bounds":{"left":0.11735372,"top":0.20351157,"width":0.00731383,"height":0.017557861},"on_screen":true,"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"rateLimiter","depth":4,"bounds":{"left":0.12832446,"top":0.20351157,"width":0.043882977,"height":0.015961692},"on_screen":true,"value":"rateLimiter","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"New Line","depth":3,"bounds":{"left":0.18118352,"top":0.20351157,"width":0.00731383,"height":0.017557861},"on_screen":true,"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Match Case","depth":3,"bounds":{"left":0.19115691,"top":0.20351157,"width":0.00731383,"height":0.017557861},"on_screen":true,"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Words","depth":3,"bounds":{"left":0.19980054,"top":0.20351157,"width":0.00731383,"height":0.017557861},"on_screen":true,"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Regex","depth":3,"bounds":{"left":0.20844415,"top":0.20351157,"width":0.00731383,"height":0.017557861},"on_screen":true,"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Replace History","depth":3,"bounds":{"left":0.27027926,"top":1.0,"width":0.00731383,"height":0.0},"on_screen":false,"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextField","text":"Replace","depth":4,"on_screen":false,"role_description":"text field","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"New Line","depth":3,"bounds":{"left":0.27027926,"top":1.0,"width":0.00731383,"height":0.0},"on_screen":false,"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Preserve case","depth":3,"bounds":{"left":0.27027926,"top":1.0,"width":0.00731383,"height":0.0},"on_screen":false,"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"0 results","depth":4,"bounds":{"left":0.22207446,"top":0.20271349,"width":0.025598405,"height":0.017557861},"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Occurrence","depth":4,"bounds":{"left":0.24767287,"top":0.2019154,"width":0.008643617,"height":0.01915403},"on_screen":true,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Occurrence","depth":4,"bounds":{"left":0.25631648,"top":0.2019154,"width":0.008643617,"height":0.01915403},"on_screen":true,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Filter Search Results","depth":4,"bounds":{"left":0.2649601,"top":0.2019154,"width":0.008643617,"height":0.01915403},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Open in Window, Multiple Cursors","depth":4,"bounds":{"left":0.27360374,"top":0.2019154,"width":0.008643617,"height":0.01915403},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXLink","text":"Click to highlight","depth":4,"on_screen":false,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Close","depth":4,"bounds":{"left":0.34408244,"top":0.2019154,"width":0.008643617,"height":0.01915403},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.042220745,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"2","depth":4,"bounds":{"left":0.30219415,"top":0.2330407,"width":0.007978723,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"64","depth":4,"bounds":{"left":0.31216756,"top":0.2330407,"width":0.010305851,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"1","depth":4,"bounds":{"left":0.32446808,"top":0.2330407,"width":0.00731383,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"1","depth":4,"bounds":{"left":0.3337766,"top":0.2330407,"width":0.00731383,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"bounds":{"left":0.34275267,"top":0.23144454,"width":0.00731383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"bounds":{"left":0.35006648,"top":0.23144454,"width":0.006981383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\ndeclare(strict_types=1);\n\nnamespace Jiminny\\Services\\Crm\\Hubspot;\n\nuse HubSpot\\Client\\Crm\\Deals\\ApiException as DealApiException;\nuse HubSpot\\Client\\Crm\\Contacts\\ApiException as ContactApiException;\nuse HubSpot\\Client\\Crm\\Companies\\ApiException as CompanyApiException;\nuse HubSpot\\Client\\Crm\\Contacts\\Model\\SimplePublicObjectWithAssociations as ContactsWithAssociations;\nuse HubSpot\\Client\\Crm\\Companies\\Model\\SimplePublicObjectWithAssociations as CompaniesWithAssociations;\nuse HubSpot\\Client\\Crm\\Deals\\Model\\SimplePublicObjectWithAssociations as DealWithAssociations;\nuse HubSpot\\Client\\Crm\\Objects\\Model\\SimplePublicObjectInput;\nuse HubSpot\\Client\\Crm\\Objects\\Model\\SimplePublicObjectWithAssociations as ObjectWithAssociations;\nuse HubSpot\\Client\\Crm\\Pipelines\\Model\\Error;\nuse HubSpot\\Client\\Crm\\Pipelines\\Model\\PipelineStage;\nuse HubSpot\\Client\\Crm\\Properties\\Model\\Property;\nuse HubSpot\\Discovery\\Discovery;\nuse Jiminny\\Exceptions\\CrmException;\nuse Jiminny\\Exceptions\\RateLimitException;\nuse Jiminny\\Exceptions\\SocialAccountTokenInvalidException;\nuse Jiminny\\Jobs\\Crm\\NoteObject;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Services\\Crm\\BaseClient;\nuse Jiminny\\Services\\Crm\\Hubspot\\DTO\\Response\\Owner;\nuse Jiminny\\Services\\SocialAccountService;\nuse SevenShores\\Hubspot\\Exceptions\\BadRequest;\nuse SevenShores\\Hubspot\\Exceptions\\HubspotException;\nuse SevenShores\\Hubspot\\Factory;\nuse SevenShores\\Hubspot\\Http\\Response;\nuse Jiminny\\Services\\Crm\\Hubspot\\Pagination\\HubspotPaginationService;\nuse Throwable;\n\n/**\n * @phpstan-type CrmFieldOption array{id:string, label:string, value?:string}\n */\nclass Client extends BaseClient implements HubspotClientInterface\n{\n public const string MIN_API_VERSION = '2';\n\n public const string BASE_URL = 'https://api.hubapi.com';\n\n public const int ASSOCIATIONS_BATCH_SIZE_LIMIT = 1000;\n\n private HubspotPaginationService $paginationService;\n private HubspotTokenManager $tokenManager;\n\n public function __construct(\n SocialAccountService $socialAccountService,\n HubspotPaginationService $paginationService,\n HubspotTokenManager $tokenManager,\n ) {\n parent::__construct($socialAccountService);\n $this->paginationService = $paginationService;\n $this->tokenManager = $tokenManager;\n\n $this->setBaseUrl(self::BASE_URL);\n $this->setVersion(self::MIN_API_VERSION);\n }\n\n /**\n * Reacts to a rate limits (429) from HubSpot by translating it\n * into a RateLimitException carrying retry_after.\n *\n * Wrap any outbound HubSpot call (SDK or raw HTTP) like:\n *\n * $this->executeRequest(fn () => $this->getNewInstance()->crm()->...);\n *\n * @template T\n * @param callable(): T $apiCall\n * @return T\n *\n * @throws RateLimitException\n */\n private function executeRequest(callable $apiCall)\n {\n try {\n return $apiCall();\n } catch (Throwable $e) {\n if ($this->isHubspotRateLimit($e)) {\n $retryAfter = $this->parseRetryAfter($e);\n\n $this->log->warning('[Hubspot] Received 429 from API', [\n 'team_id' => $this->config->team_id,\n 'config_id' => $this->config->getId(),\n 'retry_after' => $retryAfter,\n 'reason' => $e->getMessage(),\n ]);\n\n throw new RateLimitException('Hubspot returned 429', $retryAfter, $e);\n }\n\n throw $e;\n }\n }\n\n public function isHubspotRateLimit(Throwable $e): bool\n {\n if ($e instanceof BadRequest\n || $e instanceof DealApiException\n || $e instanceof ContactApiException\n || $e instanceof CompanyApiException\n || $e instanceof \\GuzzleHttp\\Exception\\RequestException\n ) {\n return (int) $e->getCode() === 429;\n }\n\n return false;\n }\n\n public function parseRetryAfter(Throwable $e): int\n {\n // First try to get Retry-After from response headers\n if (method_exists($e, 'getResponseHeaders')) {\n $headers = $e->getResponseHeaders() ?: [];\n $value = $headers['Retry-After'] ?? $headers['retry-after'] ?? null;\n if (is_array($value)) {\n $value = $value[0] ?? null;\n }\n if (is_numeric($value)) {\n return (int) $value;\n }\n }\n\n if (method_exists($e, 'getResponseBody')) {\n $body = $e->getResponseBody();\n if (is_string($body)) {\n $body = json_decode($body, true) ?? [];\n }\n\n $policyName = $body['policyName'] ?? $body['policy'] ?? $body['context']['policyName'] ?? null;\n\n if ($policyName === 'TEN_SECONDLY_ROLLING' || $policyName === 'ten_secondly_rolling') {\n return 10;\n }\n if ($policyName === 'SECONDLY' || $policyName === 'secondly') {\n return 1;\n }\n }\n\n $this->log->warning('[Hubspot] No retry-after header or policy name found, using default', [\n 'exception_class' => get_class($e),\n ]);\n\n return 10;\n }\n\n public function getMinimumApiVersion(): string\n {\n return self::MIN_API_VERSION;\n }\n\n public function getInstance(): Factory\n {\n return new Factory([\n 'key' => $this->accessToken,\n 'oauth2' => true,\n 'base_url' => $this->baseUrl,\n ]);\n }\n\n public function getNewInstance(): Discovery\n {\n return \\HubSpot\\Factory::createWithAccessToken($this->accessToken);\n }\n\n /**\n * Secondly and daily limits for Hubspot API\n *\n * Product Tier: Free & Starter | Professional & Enterprise | API add-on (any tier)\n * Burst: 100/10 seconds | 150/10 seconds | 200/10 seconds\n * Daily: 250,000 | 500,000 | 1,000,000\n *\n * Official documentation states: The search endpoints are rate limited to five requests per second.\n * Since with 5 RPS were still hitting secondly rate limits we lowered it to 4\n */\n public function getPaginatedData(array $payload, string $type, int $offset = 0): array\n {\n $total = 0;\n $lastId = null;\n $rows = [];\n foreach ($this->getPaginatedDataGenerator($payload, $type, $offset, $total, $lastId) as $row) {\n $rows[] = $row;\n }\n\n return ['results' => $rows, 'total' => $total, 'last_record' => $lastId];\n }\n\n /**\n * @throws HubspotException\n * @throws SocialAccountTokenInvalidException\n * @throws BadRequest\n */\n public function getPaginatedDataGenerator(\n array $payload,\n string $type,\n int $offset = 0,\n int &$total = 0,\n ?string &$lastRecordId = null\n ): \\Generator {\n return $this->paginationService->getPaginatedDataGenerator(\n $this,\n $payload,\n $type,\n $offset,\n $total,\n $lastRecordId\n );\n }\n\n /**\n * Execute a search request against HubSpot CRM objects with rate limiting.\n *\n * @param string $objectType The object type ('deals', 'companies', 'contacts', 'calls')\n * @param array<string, mixed> $payload The search payload with filters, sorts, properties, etc.\n * @return array The search response with 'results', 'total', 'paging' keys\n * @throws RateLimitException When rate limit is hit\n * @throws HubspotException On API errors\n */\n public function search(string $objectType, array $payload): array\n {\n $endpoint = self::BASE_URL . \"/crm/v3/objects/{$objectType}/search\";\n\n return $this->executeRequest(function () use ($endpoint, $payload) {\n $response = $this->getInstance()->getClient()->request('POST', $endpoint, ['json' => $payload]);\n\n return $response->toArray();\n });\n }\n\n /**\n * @throws DealApiException\n * @throws CrmException\n */\n public function getOpportunityById(string $crmId, array $fields): array\n {\n try {\n// $deal = $this->executeRequest(fn () => $this->getNewInstance()->crm()->deals()->basicApi()->getById(\n $deal = $this->getNewInstance()->crm()->deals()->basicApi()->getById(\n $crmId,\n implode(',', $fields),\n 'companies,contacts'\n );\n } catch (DealApiException $e) {\n $this->log->info('[Hubspot] Failed to fetch opportunity', [\n 'crm_id' => $crmId,\n 'reason' => $e->getMessage(),\n ]);\n\n throw $e;\n }\n\n if (! $deal instanceof DealWithAssociations) {\n throw new CrmException('Deal not found');\n }\n\n return [\n 'id' => $deal->getId(),\n 'properties' => $deal->getProperties(),\n 'associations' => $deal->getAssociations(),\n ];\n }\n\n /**\n * Generic batch read method for HubSpot objects\n *\n * @param string $objectType The object type ('deals', 'companies', 'contacts')\n * @param array<string> $crmIds Array of HubSpot object IDs (max 100)\n * @param array<string> $fields Array of property names to fetch\n *\n * @return array<string, array> Array keyed by CRM ID with object data\n */\n private function batchReadObjects(string $objectType, array $crmIds, array $fields): array\n {\n if (empty($crmIds)) {\n return [];\n }\n\n $this->validateBatchSize($objectType, $crmIds);\n $this->ensureValidToken();\n\n try {\n $batchConfig = $this->createBatchConfiguration($objectType);\n $batchReadRequest = $this->prepareBatchRequest($batchConfig, $crmIds, $fields);\n $response = $batchConfig['api']->read($batchReadRequest);\n\n $this->validateApiResponse($response, $objectType);\n\n $results = $this->processApiResults($response);\n $this->logBatchResults($objectType, $crmIds, $results);\n\n return $results;\n } catch (\\Throwable $e) {\n $this->handleBatchError($e, $objectType, $crmIds);\n }\n }\n\n private function validateBatchSize(string $objectType, array $crmIds): void\n {\n if (count($crmIds) > 100) {\n throw new \\InvalidArgumentException(\"Batch size cannot exceed 100 {$objectType}\");\n }\n }\n\n private function createBatchConfiguration(string $objectType): array\n {\n $configurations = [\n 'deals' => [\n 'batchReadRequest' => new \\HubSpot\\Client\\Crm\\Deals\\Model\\BatchReadInputSimplePublicObjectId(),\n 'inputClass' => \\HubSpot\\Client\\Crm\\Deals\\Model\\SimplePublicObjectId::class,\n 'api' => $this->getNewInstance()->crm()->deals()->batchApi(),\n ],\n 'companies' => [\n 'batchReadRequest' => new \\HubSpot\\Client\\Crm\\Companies\\Model\\BatchReadInputSimplePublicObjectId(),\n 'inputClass' => \\HubSpot\\Client\\Crm\\Companies\\Model\\SimplePublicObjectId::class,\n 'api' => $this->getNewInstance()->crm()->companies()->batchApi(),\n ],\n 'contacts' => [\n 'batchReadRequest' => new \\HubSpot\\Client\\Crm\\Contacts\\Model\\BatchReadInputSimplePublicObjectId(),\n 'inputClass' => \\HubSpot\\Client\\Crm\\Contacts\\Model\\SimplePublicObjectId::class,\n 'api' => $this->getNewInstance()->crm()->contacts()->batchApi(),\n ],\n ];\n\n if (! isset($configurations[$objectType])) {\n throw new \\InvalidArgumentException(\"Unsupported object type: {$objectType}\");\n }\n\n return $configurations[$objectType];\n }\n\n private function prepareBatchRequest(array $batchConfig, array $crmIds, array $fields): object\n {\n $batchReadRequest = $batchConfig['batchReadRequest'];\n $inputClass = $batchConfig['inputClass'];\n\n $inputs = array_map(function ($crmId) use ($inputClass) {\n $input = new $inputClass();\n $input->setId($crmId);\n\n return $input;\n }, $crmIds);\n\n $batchReadRequest->setInputs($inputs);\n $batchReadRequest->setProperties($fields);\n\n return $batchReadRequest;\n }\n\n private function validateApiResponse($response, string $objectType): void\n {\n if (! $response) {\n throw new CrmException(\"HubSpot API returned null response for {$objectType} batch read\");\n }\n }\n\n private function processApiResults($response): array\n {\n $results = [];\n $responseResults = $response->getResults();\n\n if ($responseResults) {\n foreach ($responseResults as $object) {\n if ($object && $object->getId()) {\n $results[$object->getId()] = [\n 'id' => $object->getId(),\n 'properties' => $object->getProperties() ?: [],\n ];\n }\n }\n }\n\n return $results;\n }\n\n private function logBatchResults(string $objectType, array $crmIds, array $results): void\n {\n $this->log->info(\"[HubSpot] Batch fetched {$objectType}\", [\n 'requested_count' => count($crmIds),\n 'returned_count' => count($results),\n 'crm_ids' => $crmIds,\n ]);\n }\n\n private function handleBatchError(\\Throwable $e, string $objectType, array $crmIds): void\n {\n $errorMessage = $e->getMessage() ?: 'Unknown error';\n $errorTrace = $e->getTraceAsString() ?: 'No trace available';\n\n $this->log->error(\"[HubSpot] Failed to batch fetch {$objectType}\", [\n 'crm_ids' => $crmIds,\n 'error' => $errorMessage,\n 'trace' => $errorTrace,\n ]);\n\n throw new CrmException(\"Failed to batch fetch {$objectType}: \" . $errorMessage);\n }\n\n /**\n * Batch read multiple opportunities by their CRM IDs\n *\n * @param array<string> $crmIds Array of HubSpot deal IDs (max 100)\n * @param array<string> $fields Array of property names to fetch\n *\n * @return array<string, array> Array keyed by CRM ID with opportunity data\n */\n public function getOpportunitiesByIds(array $crmIds, array $fields): array\n {\n return $this->batchReadObjects('deals', $crmIds, $fields);\n }\n\n /**\n * Batch read multiple companies by their CRM IDs\n *\n * @param array<string> $crmIds Array of HubSpot company IDs (max 100)\n * @param array<string> $fields Array of property names to fetch\n *\n * @return array<string, array> Array keyed by CRM ID with company data\n */\n public function getCompaniesByIds(array $crmIds, array $fields): array\n {\n return $this->batchReadObjects('companies', $crmIds, $fields);\n }\n\n /**\n * Batch read multiple contacts by their CRM IDs\n *\n * @param array<string> $crmIds Array of HubSpot contact IDs (max 100)\n * @param array<string> $fields Array of property names to fetch\n *\n * @return array<string, array> Array keyed by CRM ID with contact data\n */\n public function getContactsByIds(array $crmIds, array $fields): array\n {\n return $this->batchReadObjects('contacts', $crmIds, $fields);\n }\n\n /**\n * @throws CompanyApiException\n * @throws CrmException\n */\n public function getAccountById(string $crmId, array $fields): array\n {\n try {\n $company = $this->getNewInstance()->crm()->companies()->basicApi()->getById(\n $crmId,\n implode(',', $fields),\n );\n } catch (CompanyApiException $e) {\n $this->log->info('[Hubspot] Failed to fetch account', [\n 'crm_id' => $crmId,\n 'reason' => $e->getMessage(),\n ]);\n\n throw $e;\n }\n\n if (! $company instanceof CompaniesWithAssociations) {\n throw new CrmException('Account not found');\n }\n\n return [\n 'id' => $company->getId(),\n 'properties' => $company->getProperties(),\n ];\n }\n\n /**\n * @throws ContactApiException\n * @throws CrmException\n */\n public function getContactById(string $crmId, array $fields): array\n {\n try {\n $contact = $this->getNewInstance()->crm()->contacts()->basicApi()->getById(\n $crmId,\n implode(',', $fields)\n );\n } catch (ContactApiException $e) {\n $this->log->info('[Hubspot] Failed to fetch contact', [\n 'crm_id' => $crmId,\n 'reason' => $e->getMessage(),\n ]);\n\n throw $e;\n }\n\n if (! $contact instanceof ContactsWithAssociations) {\n throw new CrmException('Contact not found');\n }\n\n return [\n 'id' => $contact->getId(),\n 'properties' => $contact->getProperties(),\n ];\n }\n\n /**\n * This is email search request that Hubspot offers as GET (more generous quota)\n */\n public function getContactByEmail(string $email, array $fields = []): array\n {\n try {\n $contact = $this->getNewInstance()->crm()->contacts()->basicApi()->getById(\n $email,\n implode(',', $fields),\n null,\n false,\n 'email'\n );\n\n return [\n 'id' => $contact->getId(),\n 'properties' => $contact->getProperties(),\n ];\n } catch (ContactApiException $e) {\n $this->log->info('[Hubspot] Failed to fetch contact', [\n 'email' => $email,\n 'reason' => $e->getMessage(),\n ]);\n\n return [];\n }\n }\n\n /**\n * @throws CrmException\n */\n public function fetchProperty(string $objectType, string $propertyId): Property\n {\n $result = $this->getNewInstance()->crm()->properties()->coreApi()->getByName($objectType, $propertyId);\n\n if (! $result instanceof Property) {\n $this->log->error('[Hubspot] Failed to fetch property', [\n 'object_type' => $objectType,\n 'property_id' => $propertyId,\n 'reason' => $result->getMessage(),\n ]);\n\n throw new CrmException('Failed to fetch property');\n }\n\n return $result;\n }\n\n /**\n * @return array<CrmFieldOption>\n */\n public function fetchPropertyOptions(string $objectType, string $propertyId): array\n {\n /** @var array<CrmFieldOption> */\n return $this->fetchProperty($objectType, $propertyId)->getOptions();\n }\n\n /**\n * @return array<array{id:string, label:string, deleted:bool}>\n */\n public function fetchCallDispositions(): array\n {\n /** @var Response $response */\n $response = $this->getInstance()->engagements()->getCallDispositions();\n\n /**\n * @var array<array{\n * id:string,\n * label:string,\n * deleted: bool\n * }>\n */\n return $response->toArray();\n }\n\n /**\n * @return array<CrmFieldOption>\n */\n public function fetchOpportunityPipelineStages(): array\n {\n $stages = [];\n $apiResponse = $this->getNewInstance()->crm()->pipelines()->pipelinesApi()->getAll('deals');\n\n if ($apiResponse instanceof Error) {\n $this->log->error('[Hubspot] Failed to fetch opportunity pipelines', [\n 'reason' => $apiResponse->getMessage(),\n ]);\n\n return [];\n }\n\n foreach ($apiResponse->getResults() as $pipeline) {\n $pipelineStages = array_map(\n static function (PipelineStage $stage) {\n return [\n 'id' => $stage->getId(),\n 'label' => $stage->getLabel(),\n ];\n },\n $pipeline->getStages()\n );\n\n $stages = array_merge($stages, $pipelineStages);\n }\n\n return $stages;\n }\n\n public function fetchOpportunityPipelines(): array\n {\n $pipelines = [];\n\n try {\n $apiResponse = $this->makeRequest('/crm/v3/pipelines/deals');\n } catch (\\Exception $e) {\n $this->log->info('[Hubspot] Failed to fetch opportunity pipelines', [\n 'reason' => $e->getMessage(),\n ]);\n\n return [];\n }\n\n $response = $apiResponse->toArray();\n\n foreach ($response['results'] as $pipeline) {\n $pipelines[] = [\n 'id' => $pipeline['id'],\n 'label' => $pipeline['label'],\n ];\n }\n\n return $pipelines;\n }\n\n /**\n * @return array<CrmFieldOption>\n */\n public function fetchMeetingOutcomeFieldOptions(Field $field): array\n {\n return $field->getCrmProviderId() === 'meetingOutcome'\n ? $this->fetchMeetingOutcomeTypes()\n : $this->fetchCallActivityTypes();\n }\n\n public function fetchMeetingOutcomeTypes(): array\n {\n return $this->extractMeetingTypeOptions(\n 'https://api.hubapi.com/crm/v3/properties/meeting/hs_meeting_outcome'\n );\n }\n\n public function fetchCallActivityTypes(): array\n {\n return $this->extractMeetingTypeOptions(\n 'https://api.hubapi.com/crm/v3/properties/call/hs_activity_type'\n );\n }\n\n private function extractMeetingTypeOptions(string $endpoint): array\n {\n /** @var Response $response */\n $response = $this->getInstance()\n ->getClient()\n ->request('GET', $endpoint);\n\n /**\n * @var array<array{\n * value: string,\n * label: string,\n * displayOrder: int\n * }> $optionData\n */\n $optionData = $response->toArray()['options'] ?? [];\n\n $options = [];\n foreach ($optionData as $item) {\n $options[] = [\n 'id' => $item['value'],\n 'value' => $item['value'],\n 'label' => $item['label'],\n 'display_order' => $item['displayOrder'],\n ];\n }\n\n return $options;\n }\n\n /**\n * @return array<CrmFieldOption>\n */\n public function fetchDispositionFieldOptions(): array\n {\n $options = [];\n\n $dispositions = $this->fetchCallDispositions();\n\n foreach ($dispositions as $disposition) {\n if ($disposition['deleted'] !== false) {\n continue;\n }\n\n $option['value'] = $disposition['id'];\n $option['id'] = $disposition['id'];\n $option['label'] = $disposition['label'];\n\n $options[] = $option;\n }\n\n return $options;\n }\n\n /**\n * @return array<CrmFieldOption>\n */\n public function fetchOpportunityFieldOptions(Field $field): array\n {\n if ($field->isStageField()) {\n return $this->fetchOpportunityPipelineStages();\n }\n\n if ($field->isPipelineField()) {\n return $this->fetchOpportunityPipelines();\n }\n\n return $this->fetchPropertyOptions('deals', $field->getCrmProviderId());\n }\n\n /**\n * @throws BadRequest\n * @throws HubspotException\n */\n public function makeRequest(string $endpoint, $method = 'GET', $payload = [], ?string $queryString = null)\n {\n $endpoint = self::BASE_URL . $endpoint;\n\n if ($method === 'GET') {\n $response = $this->getInstance()->getClient()?->request(\n method: $method,\n endpoint: $endpoint,\n query_string: $queryString\n );\n } else {\n $response = $this->getInstance()->getClient()->request($method, $endpoint, [\n 'json' => ($payload),\n ]);\n }\n//\n// $max = $response->getHeaderLine('X-HubSpot-RateLimit-Max'); // \"110\"\n// $remaining = $response->getHeaderLine('X-HubSpot-RateLimit-Remaining'); // \"109\"\n// $interval = $response->getHeaderLine('X-HubSpot-RateLimit-Interval-Milliseconds'); // \"10000\"\n// $body = json_decode((string) $response->getBody(), true);\n//\n// \\Illuminate\\Support\\Facades\\Log::channel('custom_channel')->info('$max ' . PHP_EOL . print_r($max, true));\n// \\Illuminate\\Support\\Facades\\Log::channel('custom_channel')->info('$remaining ' . PHP_EOL . print_r($remaining, true));\n// \\Illuminate\\Support\\Facades\\Log::channel('custom_channel')->info('$interval ' . PHP_EOL . print_r($interval, true));\n// \\Illuminate\\Support\\Facades\\Log::channel('custom_channel')->info('$body ' . PHP_EOL . print_r($body, true));\n\n return $response;\n }\n\n /**\n * @throws BadRequest\n * @throws HubspotException\n */\n public function createMeeting(array $payload): Response\n {\n $endpoint = '/crm/v3/objects/meetings';\n\n return $this->makeRequest($endpoint, 'POST', $payload);\n }\n\n /**\n * @throws BadRequest\n * @throws HubspotException\n */\n public function updateMeeting(string $meetingId, array $payload): Response\n {\n $endpoint = '/crm/v3/objects/meetings/' . $meetingId;\n\n return $this->makeRequest($endpoint, 'PATCH', $payload);\n }\n\n /**\n * @throws \\Exception\n */\n public function createNote(\n string $body,\n string $ownerId,\n int $timestamp,\n string $objectId,\n NoteObject $noteObject\n ): ?string {\n try {\n $noteInput = new SimplePublicObjectInput([\n 'properties' => [\n 'hs_note_body' => $body,\n 'hubspot_owner_id' => $ownerId,\n 'hs_timestamp' => $timestamp,\n ],\n ]);\n\n // Create note\n $note = $this->getNewInstance()->crm()->objects()->basicApi()->create('note', $noteInput);\n\n $this->getNewInstance()->crm()->objects()->associationsApi()->create(\n 'note',\n $note->getId(),\n $this->getNoteObject($noteObject),\n $objectId,\n $this->getNoteAssociationType($noteObject),\n );\n\n return $note->getId();\n } catch (\\Exception $e) {\n $this->log->error('[Hubspot] Failed to create note', [\n 'objectId' => $objectId,\n 'noteObject' => $noteObject->getObjectType(),\n 'reason' => $e->getMessage(),\n ]);\n\n \\Sentry::captureException($e);\n }\n\n return null;\n }\n\n public function updateEngagement(string $objectId, array $engagement, array $metadata): void\n {\n $this->getInstance()->engagements()->update($objectId, $engagement, $metadata);\n }\n\n public function getEngagementData(string $engagementId): array\n {\n $engagement = $this->getInstance()->engagements()->get($engagementId);\n\n return $engagement->toArray();\n }\n\n public function createEngagement(array $engagement, array $associations, array $metadata): Response\n {\n return $this->getInstance()\n ->engagements()\n ->create($engagement, $associations, $metadata);\n }\n\n public function isUnauthorizedException(\\Exception $e): bool\n {\n if ($e instanceof BadRequest\n || $e instanceof DealApiException\n || $e instanceof ContactApiException\n || $e instanceof CompanyApiException\n ) {\n return (int) $e->getCode() === 401;\n }\n\n if ($e instanceof \\GuzzleHttp\\Exception\\RequestException && $e->hasResponse()) {\n return $e->getResponse()?->getStatusCode() === 401;\n }\n\n $message = strtolower($e->getMessage());\n\n return str_contains($message, '401 unauthorized') ||\n str_contains($message, 'http 401') ||\n str_contains($message, 'status code 401') ||\n (preg_match('/\\b401\\b/', $message) === 1 && str_contains($message, 'unauthorized'));\n }\n\n /**\n * Validates and refreshes the access token if needed before API requests.\n * This ensures long-running processes don't fail due to token expiration.\n *\n * @throws SocialAccountTokenInvalidException\n */\n public function ensureValidToken(): void\n {\n if ($this->oauthAccount === null) {\n return;\n }\n\n $newToken = $this->tokenManager->ensureValidToken($this->oauthAccount);\n if ($newToken !== null) {\n $this->accessToken = $newToken;\n }\n }\n\n public function getConfig()\n {\n return $this->config;\n }\n\n // returns only active (archived=false)\n public function getOwners(): array\n {\n return $this->getNewInstance()->crm()->owners()->getAll();\n }\n\n /**\n * @param bool $archived\n *\n * @return array<Owner>|[]\n */\n public function getOwnersArchived(bool $archived = true): array\n {\n $endpoint = '/crm/v3/owners';\n $queryParams = [\n 'archived' => $archived ? 'true' : 'false',\n ];\n $queryString = http_build_query($queryParams);\n\n $owners = [];\n\n try {\n $response = $this->makeRequest(endpoint: $endpoint, queryString: $queryString);\n $responseData = $response?->toArray();\n\n foreach ($responseData['results'] as $result) {\n try {\n $owners[] = Owner::create($result);\n } catch (Throwable $e) {\n $this->log->error('[HubSpot] Failed to process owner data', [\n 'result' => $result,\n 'error' => $e->getMessage(),\n ]);\n\n continue;\n }\n }\n } catch (Throwable $e) {\n $this->log->error('HubSpot] Failed to fetch owners', [\n 'archived' => $archived,\n 'error' => $e->getMessage(),\n ]);\n\n return [];\n }\n\n return $owners;\n }\n\n public function getMeeting(string $engagementId): ObjectWithAssociations\n {\n return $this->getNewInstance()->crm()->objects()->basicApi()\n ->getById('meeting', $engagementId, null, 'contact,company,deal');\n }\n\n public function deleteEngagement(string $engagementId): void\n {\n $this->getInstance()->engagements()->delete((int) $engagementId);\n }\n\n public function getAssociationsData(array $ids, string $fromObject, string $toObject): array\n {\n $associationData = [];\n $idChunks = array_chunk($ids, self::ASSOCIATIONS_BATCH_SIZE_LIMIT);\n\n foreach ($idChunks as $idChunk) {\n try {\n $batchInput = new \\HubSpot\\Client\\Crm\\Associations\\Model\\BatchInputPublicObjectId();\n $batchInput->setInputs(array_map(function ($id) {\n $publicObjectId = new \\HubSpot\\Client\\Crm\\Associations\\Model\\PublicObjectId();\n $publicObjectId->setId($id);\n\n return $publicObjectId;\n }, $idChunk));\n\n $associatedObjectsData = $this\n ->getNewInstance()\n ->crm()\n ->associations()\n ->batchApi()\n ->read($fromObject, $toObject, $batchInput);\n\n if ($associatedObjectsData instanceof \\HubSpot\\Client\\Crm\\Associations\\Model\\BatchResponsePublicAssociationMulti) {\n foreach ($associatedObjectsData->getResults() as $association) {\n $from = $association->getFrom()->getId();\n $toAssociations = $association->getTo();\n\n if (! empty($toAssociations)) {\n $associationData[$from] = array_map(function ($item) {\n return $item->getId();\n }, $toAssociations);\n }\n }\n }\n } catch (RateLimitException $e) {\n throw $e;\n } catch (\\Exception $e) {\n $this->log->error('[Hubspot] Failed to fetch associations', [\n 'from_object' => $fromObject,\n 'to_object' => $toObject,\n 'reason' => $e->getMessage(),\n ]);\n }\n }\n\n return $associationData;\n }\n\n /**\n * @throws \\Exception\n */\n private function getNoteAssociationType(NoteObject $noteObject): string\n {\n return match($noteObject) {\n NoteObject::Opportunity => 'note_to_deal',\n NoteObject::Lead, NoteObject::Contact => 'note_to_contact', // or 'note_to_lead' if your portal supports it\n NoteObject::Account => 'note_to_company',\n NoteObject::Call, NoteObject::Event => throw new \\Exception('Not supported'),\n };\n }\n\n /**\n * @throws \\Exception\n */\n private function getNoteObject(NoteObject $noteObject): string\n {\n return match($noteObject) {\n NoteObject::Opportunity => 'deal',\n NoteObject::Lead, NoteObject::Contact => 'contact',\n NoteObject::Account => 'company',\n NoteObject::Call, NoteObject::Event => throw new \\Exception('Not supported'),\n };\n }\n\n public function addAssociations(string $objectType, string $associationType, array $payload): Response\n {\n $endpoint = \"/crm/v4/associations/$objectType/$associationType/batch/create\";\n\n return $this->makeRequest($endpoint, 'POST', $payload);\n }\n\n public function removeAssociations(string $objectType, string $associationType, array $payload): Response\n {\n $endpoint = \"/crm/v4/associations/$objectType/$associationType/batch/archive\";\n\n return $this->makeRequest($endpoint, 'POST', $payload);\n }\n}","depth":4,"on_screen":true,"value":"<?php\n\ndeclare(strict_types=1);\n\nnamespace Jiminny\\Services\\Crm\\Hubspot;\n\nuse HubSpot\\Client\\Crm\\Deals\\ApiException as DealApiException;\nuse HubSpot\\Client\\Crm\\Contacts\\ApiException as ContactApiException;\nuse HubSpot\\Client\\Crm\\Companies\\ApiException as CompanyApiException;\nuse HubSpot\\Client\\Crm\\Contacts\\Model\\SimplePublicObjectWithAssociations as ContactsWithAssociations;\nuse HubSpot\\Client\\Crm\\Companies\\Model\\SimplePublicObjectWithAssociations as CompaniesWithAssociations;\nuse HubSpot\\Client\\Crm\\Deals\\Model\\SimplePublicObjectWithAssociations as DealWithAssociations;\nuse HubSpot\\Client\\Crm\\Objects\\Model\\SimplePublicObjectInput;\nuse HubSpot\\Client\\Crm\\Objects\\Model\\SimplePublicObjectWithAssociations as ObjectWithAssociations;\nuse HubSpot\\Client\\Crm\\Pipelines\\Model\\Error;\nuse HubSpot\\Client\\Crm\\Pipelines\\Model\\PipelineStage;\nuse HubSpot\\Client\\Crm\\Properties\\Model\\Property;\nuse HubSpot\\Discovery\\Discovery;\nuse Jiminny\\Exceptions\\CrmException;\nuse Jiminny\\Exceptions\\RateLimitException;\nuse Jiminny\\Exceptions\\SocialAccountTokenInvalidException;\nuse Jiminny\\Jobs\\Crm\\NoteObject;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Services\\Crm\\BaseClient;\nuse Jiminny\\Services\\Crm\\Hubspot\\DTO\\Response\\Owner;\nuse Jiminny\\Services\\SocialAccountService;\nuse SevenShores\\Hubspot\\Exceptions\\BadRequest;\nuse SevenShores\\Hubspot\\Exceptions\\HubspotException;\nuse SevenShores\\Hubspot\\Factory;\nuse SevenShores\\Hubspot\\Http\\Response;\nuse Jiminny\\Services\\Crm\\Hubspot\\Pagination\\HubspotPaginationService;\nuse Throwable;\n\n/**\n * @phpstan-type CrmFieldOption array{id:string, label:string, value?:string}\n */\nclass Client extends BaseClient implements HubspotClientInterface\n{\n public const string MIN_API_VERSION = '2';\n\n public const string BASE_URL = 'https://api.hubapi.com';\n\n public const int ASSOCIATIONS_BATCH_SIZE_LIMIT = 1000;\n\n private HubspotPaginationService $paginationService;\n private HubspotTokenManager $tokenManager;\n\n public function __construct(\n SocialAccountService $socialAccountService,\n HubspotPaginationService $paginationService,\n HubspotTokenManager $tokenManager,\n ) {\n parent::__construct($socialAccountService);\n $this->paginationService = $paginationService;\n $this->tokenManager = $tokenManager;\n\n $this->setBaseUrl(self::BASE_URL);\n $this->setVersion(self::MIN_API_VERSION);\n }\n\n /**\n * Reacts to a rate limits (429) from HubSpot by translating it\n * into a RateLimitException carrying retry_after.\n *\n * Wrap any outbound HubSpot call (SDK or raw HTTP) like:\n *\n * $this->executeRequest(fn () => $this->getNewInstance()->crm()->...);\n *\n * @template T\n * @param callable(): T $apiCall\n * @return T\n *\n * @throws RateLimitException\n */\n private function executeRequest(callable $apiCall)\n {\n try {\n return $apiCall();\n } catch (Throwable $e) {\n if ($this->isHubspotRateLimit($e)) {\n $retryAfter = $this->parseRetryAfter($e);\n\n $this->log->warning('[Hubspot] Received 429 from API', [\n 'team_id' => $this->config->team_id,\n 'config_id' => $this->config->getId(),\n 'retry_after' => $retryAfter,\n 'reason' => $e->getMessage(),\n ]);\n\n throw new RateLimitException('Hubspot returned 429', $retryAfter, $e);\n }\n\n throw $e;\n }\n }\n\n public function isHubspotRateLimit(Throwable $e): bool\n {\n if ($e instanceof BadRequest\n || $e instanceof DealApiException\n || $e instanceof ContactApiException\n || $e instanceof CompanyApiException\n || $e instanceof \\GuzzleHttp\\Exception\\RequestException\n ) {\n return (int) $e->getCode() === 429;\n }\n\n return false;\n }\n\n public function parseRetryAfter(Throwable $e): int\n {\n // First try to get Retry-After from response headers\n if (method_exists($e, 'getResponseHeaders')) {\n $headers = $e->getResponseHeaders() ?: [];\n $value = $headers['Retry-After'] ?? $headers['retry-after'] ?? null;\n if (is_array($value)) {\n $value = $value[0] ?? null;\n }\n if (is_numeric($value)) {\n return (int) $value;\n }\n }\n\n if (method_exists($e, 'getResponseBody')) {\n $body = $e->getResponseBody();\n if (is_string($body)) {\n $body = json_decode($body, true) ?? [];\n }\n\n $policyName = $body['policyName'] ?? $body['policy'] ?? $body['context']['policyName'] ?? null;\n\n if ($policyName === 'TEN_SECONDLY_ROLLING' || $policyName === 'ten_secondly_rolling') {\n return 10;\n }\n if ($policyName === 'SECONDLY' || $policyName === 'secondly') {\n return 1;\n }\n }\n\n $this->log->warning('[Hubspot] No retry-after header or policy name found, using default', [\n 'exception_class' => get_class($e),\n ]);\n\n return 10;\n }\n\n public function getMinimumApiVersion(): string\n {\n return self::MIN_API_VERSION;\n }\n\n public function getInstance(): Factory\n {\n return new Factory([\n 'key' => $this->accessToken,\n 'oauth2' => true,\n 'base_url' => $this->baseUrl,\n ]);\n }\n\n public function getNewInstance(): Discovery\n {\n return \\HubSpot\\Factory::createWithAccessToken($this->accessToken);\n }\n\n /**\n * Secondly and daily limits for Hubspot API\n *\n * Product Tier: Free & Starter | Professional & Enterprise | API add-on (any tier)\n * Burst: 100/10 seconds | 150/10 seconds | 200/10 seconds\n * Daily: 250,000 | 500,000 | 1,000,000\n *\n * Official documentation states: The search endpoints are rate limited to five requests per second.\n * Since with 5 RPS were still hitting secondly rate limits we lowered it to 4\n */\n public function getPaginatedData(array $payload, string $type, int $offset = 0): array\n {\n $total = 0;\n $lastId = null;\n $rows = [];\n foreach ($this->getPaginatedDataGenerator($payload, $type, $offset, $total, $lastId) as $row) {\n $rows[] = $row;\n }\n\n return ['results' => $rows, 'total' => $total, 'last_record' => $lastId];\n }\n\n /**\n * @throws HubspotException\n * @throws SocialAccountTokenInvalidException\n * @throws BadRequest\n */\n public function getPaginatedDataGenerator(\n array $payload,\n string $type,\n int $offset = 0,\n int &$total = 0,\n ?string &$lastRecordId = null\n ): \\Generator {\n return $this->paginationService->getPaginatedDataGenerator(\n $this,\n $payload,\n $type,\n $offset,\n $total,\n $lastRecordId\n );\n }\n\n /**\n * Execute a search request against HubSpot CRM objects with rate limiting.\n *\n * @param string $objectType The object type ('deals', 'companies', 'contacts', 'calls')\n * @param array<string, mixed> $payload The search payload with filters, sorts, properties, etc.\n * @return array The search response with 'results', 'total', 'paging' keys\n * @throws RateLimitException When rate limit is hit\n * @throws HubspotException On API errors\n */\n public function search(string $objectType, array $payload): array\n {\n $endpoint = self::BASE_URL . \"/crm/v3/objects/{$objectType}/search\";\n\n return $this->executeRequest(function () use ($endpoint, $payload) {\n $response = $this->getInstance()->getClient()->request('POST', $endpoint, ['json' => $payload]);\n\n return $response->toArray();\n });\n }\n\n /**\n * @throws DealApiException\n * @throws CrmException\n */\n public function getOpportunityById(string $crmId, array $fields): array\n {\n try {\n// $deal = $this->executeRequest(fn () => $this->getNewInstance()->crm()->deals()->basicApi()->getById(\n $deal = $this->getNewInstance()->crm()->deals()->basicApi()->getById(\n $crmId,\n implode(',', $fields),\n 'companies,contacts'\n );\n } catch (DealApiException $e) {\n $this->log->info('[Hubspot] Failed to fetch opportunity', [\n 'crm_id' => $crmId,\n 'reason' => $e->getMessage(),\n ]);\n\n throw $e;\n }\n\n if (! $deal instanceof DealWithAssociations) {\n throw new CrmException('Deal not found');\n }\n\n return [\n 'id' => $deal->getId(),\n 'properties' => $deal->getProperties(),\n 'associations' => $deal->getAssociations(),\n ];\n }\n\n /**\n * Generic batch read method for HubSpot objects\n *\n * @param string $objectType The object type ('deals', 'companies', 'contacts')\n * @param array<string> $crmIds Array of HubSpot object IDs (max 100)\n * @param array<string> $fields Array of property names to fetch\n *\n * @return array<string, array> Array keyed by CRM ID with object data\n */\n private function batchReadObjects(string $objectType, array $crmIds, array $fields): array\n {\n if (empty($crmIds)) {\n return [];\n }\n\n $this->validateBatchSize($objectType, $crmIds);\n $this->ensureValidToken();\n\n try {\n $batchConfig = $this->createBatchConfiguration($objectType);\n $batchReadRequest = $this->prepareBatchRequest($batchConfig, $crmIds, $fields);\n $response = $batchConfig['api']->read($batchReadRequest);\n\n $this->validateApiResponse($response, $objectType);\n\n $results = $this->processApiResults($response);\n $this->logBatchResults($objectType, $crmIds, $results);\n\n return $results;\n } catch (\\Throwable $e) {\n $this->handleBatchError($e, $objectType, $crmIds);\n }\n }\n\n private function validateBatchSize(string $objectType, array $crmIds): void\n {\n if (count($crmIds) > 100) {\n throw new \\InvalidArgumentException(\"Batch size cannot exceed 100 {$objectType}\");\n }\n }\n\n private function createBatchConfiguration(string $objectType): array\n {\n $configurations = [\n 'deals' => [\n 'batchReadRequest' => new \\HubSpot\\Client\\Crm\\Deals\\Model\\BatchReadInputSimplePublicObjectId(),\n 'inputClass' => \\HubSpot\\Client\\Crm\\Deals\\Model\\SimplePublicObjectId::class,\n 'api' => $this->getNewInstance()->crm()->deals()->batchApi(),\n ],\n 'companies' => [\n 'batchReadRequest' => new \\HubSpot\\Client\\Crm\\Companies\\Model\\BatchReadInputSimplePublicObjectId(),\n 'inputClass' => \\HubSpot\\Client\\Crm\\Companies\\Model\\SimplePublicObjectId::class,\n 'api' => $this->getNewInstance()->crm()->companies()->batchApi(),\n ],\n 'contacts' => [\n 'batchReadRequest' => new \\HubSpot\\Client\\Crm\\Contacts\\Model\\BatchReadInputSimplePublicObjectId(),\n 'inputClass' => \\HubSpot\\Client\\Crm\\Contacts\\Model\\SimplePublicObjectId::class,\n 'api' => $this->getNewInstance()->crm()->contacts()->batchApi(),\n ],\n ];\n\n if (! isset($configurations[$objectType])) {\n throw new \\InvalidArgumentException(\"Unsupported object type: {$objectType}\");\n }\n\n return $configurations[$objectType];\n }\n\n private function prepareBatchRequest(array $batchConfig, array $crmIds, array $fields): object\n {\n $batchReadRequest = $batchConfig['batchReadRequest'];\n $inputClass = $batchConfig['inputClass'];\n\n $inputs = array_map(function ($crmId) use ($inputClass) {\n $input = new $inputClass();\n $input->setId($crmId);\n\n return $input;\n }, $crmIds);\n\n $batchReadRequest->setInputs($inputs);\n $batchReadRequest->setProperties($fields);\n\n return $batchReadRequest;\n }\n\n private function validateApiResponse($response, string $objectType): void\n {\n if (! $response) {\n throw new CrmException(\"HubSpot API returned null response for {$objectType} batch read\");\n }\n }\n\n private function processApiResults($response): array\n {\n $results = [];\n $responseResults = $response->getResults();\n\n if ($responseResults) {\n foreach ($responseResults as $object) {\n if ($object && $object->getId()) {\n $results[$object->getId()] = [\n 'id' => $object->getId(),\n 'properties' => $object->getProperties() ?: [],\n ];\n }\n }\n }\n\n return $results;\n }\n\n private function logBatchResults(string $objectType, array $crmIds, array $results): void\n {\n $this->log->info(\"[HubSpot] Batch fetched {$objectType}\", [\n 'requested_count' => count($crmIds),\n 'returned_count' => count($results),\n 'crm_ids' => $crmIds,\n ]);\n }\n\n private function handleBatchError(\\Throwable $e, string $objectType, array $crmIds): void\n {\n $errorMessage = $e->getMessage() ?: 'Unknown error';\n $errorTrace = $e->getTraceAsString() ?: 'No trace available';\n\n $this->log->error(\"[HubSpot] Failed to batch fetch {$objectType}\", [\n 'crm_ids' => $crmIds,\n 'error' => $errorMessage,\n 'trace' => $errorTrace,\n ]);\n\n throw new CrmException(\"Failed to batch fetch {$objectType}: \" . $errorMessage);\n }\n\n /**\n * Batch read multiple opportunities by their CRM IDs\n *\n * @param array<string> $crmIds Array of HubSpot deal IDs (max 100)\n * @param array<string> $fields Array of property names to fetch\n *\n * @return array<string, array> Array keyed by CRM ID with opportunity data\n */\n public function getOpportunitiesByIds(array $crmIds, array $fields): array\n {\n return $this->batchReadObjects('deals', $crmIds, $fields);\n }\n\n /**\n * Batch read multiple companies by their CRM IDs\n *\n * @param array<string> $crmIds Array of HubSpot company IDs (max 100)\n * @param array<string> $fields Array of property names to fetch\n *\n * @return array<string, array> Array keyed by CRM ID with company data\n */\n public function getCompaniesByIds(array $crmIds, array $fields): array\n {\n return $this->batchReadObjects('companies', $crmIds, $fields);\n }\n\n /**\n * Batch read multiple contacts by their CRM IDs\n *\n * @param array<string> $crmIds Array of HubSpot contact IDs (max 100)\n * @param array<string> $fields Array of property names to fetch\n *\n * @return array<string, array> Array keyed by CRM ID with contact data\n */\n public function getContactsByIds(array $crmIds, array $fields): array\n {\n return $this->batchReadObjects('contacts', $crmIds, $fields);\n }\n\n /**\n * @throws CompanyApiException\n * @throws CrmException\n */\n public function getAccountById(string $crmId, array $fields): array\n {\n try {\n $company = $this->getNewInstance()->crm()->companies()->basicApi()->getById(\n $crmId,\n implode(',', $fields),\n );\n } catch (CompanyApiException $e) {\n $this->log->info('[Hubspot] Failed to fetch account', [\n 'crm_id' => $crmId,\n 'reason' => $e->getMessage(),\n ]);\n\n throw $e;\n }\n\n if (! $company instanceof CompaniesWithAssociations) {\n throw new CrmException('Account not found');\n }\n\n return [\n 'id' => $company->getId(),\n 'properties' => $company->getProperties(),\n ];\n }\n\n /**\n * @throws ContactApiException\n * @throws CrmException\n */\n public function getContactById(string $crmId, array $fields): array\n {\n try {\n $contact = $this->getNewInstance()->crm()->contacts()->basicApi()->getById(\n $crmId,\n implode(',', $fields)\n );\n } catch (ContactApiException $e) {\n $this->log->info('[Hubspot] Failed to fetch contact', [\n 'crm_id' => $crmId,\n 'reason' => $e->getMessage(),\n ]);\n\n throw $e;\n }\n\n if (! $contact instanceof ContactsWithAssociations) {\n throw new CrmException('Contact not found');\n }\n\n return [\n 'id' => $contact->getId(),\n 'properties' => $contact->getProperties(),\n ];\n }\n\n /**\n * This is email search request that Hubspot offers as GET (more generous quota)\n */\n public function getContactByEmail(string $email, array $fields = []): array\n {\n try {\n $contact = $this->getNewInstance()->crm()->contacts()->basicApi()->getById(\n $email,\n implode(',', $fields),\n null,\n false,\n 'email'\n );\n\n return [\n 'id' => $contact->getId(),\n 'properties' => $contact->getProperties(),\n ];\n } catch (ContactApiException $e) {\n $this->log->info('[Hubspot] Failed to fetch contact', [\n 'email' => $email,\n 'reason' => $e->getMessage(),\n ]);\n\n return [];\n }\n }\n\n /**\n * @throws CrmException\n */\n public function fetchProperty(string $objectType, string $propertyId): Property\n {\n $result = $this->getNewInstance()->crm()->properties()->coreApi()->getByName($objectType, $propertyId);\n\n if (! $result instanceof Property) {\n $this->log->error('[Hubspot] Failed to fetch property', [\n 'object_type' => $objectType,\n 'property_id' => $propertyId,\n 'reason' => $result->getMessage(),\n ]);\n\n throw new CrmException('Failed to fetch property');\n }\n\n return $result;\n }\n\n /**\n * @return array<CrmFieldOption>\n */\n public function fetchPropertyOptions(string $objectType, string $propertyId): array\n {\n /** @var array<CrmFieldOption> */\n return $this->fetchProperty($objectType, $propertyId)->getOptions();\n }\n\n /**\n * @return array<array{id:string, label:string, deleted:bool}>\n */\n public function fetchCallDispositions(): array\n {\n /** @var Response $response */\n $response = $this->getInstance()->engagements()->getCallDispositions();\n\n /**\n * @var array<array{\n * id:string,\n * label:string,\n * deleted: bool\n * }>\n */\n return $response->toArray();\n }\n\n /**\n * @return array<CrmFieldOption>\n */\n public function fetchOpportunityPipelineStages(): array\n {\n $stages = [];\n $apiResponse = $this->getNewInstance()->crm()->pipelines()->pipelinesApi()->getAll('deals');\n\n if ($apiResponse instanceof Error) {\n $this->log->error('[Hubspot] Failed to fetch opportunity pipelines', [\n 'reason' => $apiResponse->getMessage(),\n ]);\n\n return [];\n }\n\n foreach ($apiResponse->getResults() as $pipeline) {\n $pipelineStages = array_map(\n static function (PipelineStage $stage) {\n return [\n 'id' => $stage->getId(),\n 'label' => $stage->getLabel(),\n ];\n },\n $pipeline->getStages()\n );\n\n $stages = array_merge($stages, $pipelineStages);\n }\n\n return $stages;\n }\n\n public function fetchOpportunityPipelines(): array\n {\n $pipelines = [];\n\n try {\n $apiResponse = $this->makeRequest('/crm/v3/pipelines/deals');\n } catch (\\Exception $e) {\n $this->log->info('[Hubspot] Failed to fetch opportunity pipelines', [\n 'reason' => $e->getMessage(),\n ]);\n\n return [];\n }\n\n $response = $apiResponse->toArray();\n\n foreach ($response['results'] as $pipeline) {\n $pipelines[] = [\n 'id' => $pipeline['id'],\n 'label' => $pipeline['label'],\n ];\n }\n\n return $pipelines;\n }\n\n /**\n * @return array<CrmFieldOption>\n */\n public function fetchMeetingOutcomeFieldOptions(Field $field): array\n {\n return $field->getCrmProviderId() === 'meetingOutcome'\n ? $this->fetchMeetingOutcomeTypes()\n : $this->fetchCallActivityTypes();\n }\n\n public function fetchMeetingOutcomeTypes(): array\n {\n return $this->extractMeetingTypeOptions(\n 'https://api.hubapi.com/crm/v3/properties/meeting/hs_meeting_outcome'\n );\n }\n\n public function fetchCallActivityTypes(): array\n {\n return $this->extractMeetingTypeOptions(\n 'https://api.hubapi.com/crm/v3/properties/call/hs_activity_type'\n );\n }\n\n private function extractMeetingTypeOptions(string $endpoint): array\n {\n /** @var Response $response */\n $response = $this->getInstance()\n ->getClient()\n ->request('GET', $endpoint);\n\n /**\n * @var array<array{\n * value: string,\n * label: string,\n * displayOrder: int\n * }> $optionData\n */\n $optionData = $response->toArray()['options'] ?? [];\n\n $options = [];\n foreach ($optionData as $item) {\n $options[] = [\n 'id' => $item['value'],\n 'value' => $item['value'],\n 'label' => $item['label'],\n 'display_order' => $item['displayOrder'],\n ];\n }\n\n return $options;\n }\n\n /**\n * @return array<CrmFieldOption>\n */\n public function fetchDispositionFieldOptions(): array\n {\n $options = [];\n\n $dispositions = $this->fetchCallDispositions();\n\n foreach ($dispositions as $disposition) {\n if ($disposition['deleted'] !== false) {\n continue;\n }\n\n $option['value'] = $disposition['id'];\n $option['id'] = $disposition['id'];\n $option['label'] = $disposition['label'];\n\n $options[] = $option;\n }\n\n return $options;\n }\n\n /**\n * @return array<CrmFieldOption>\n */\n public function fetchOpportunityFieldOptions(Field $field): array\n {\n if ($field->isStageField()) {\n return $this->fetchOpportunityPipelineStages();\n }\n\n if ($field->isPipelineField()) {\n return $this->fetchOpportunityPipelines();\n }\n\n return $this->fetchPropertyOptions('deals', $field->getCrmProviderId());\n }\n\n /**\n * @throws BadRequest\n * @throws HubspotException\n */\n public function makeRequest(string $endpoint, $method = 'GET', $payload = [], ?string $queryString = null)\n {\n $endpoint = self::BASE_URL . $endpoint;\n\n if ($method === 'GET') {\n $response = $this->getInstance()->getClient()?->request(\n method: $method,\n endpoint: $endpoint,\n query_string: $queryString\n );\n } else {\n $response = $this->getInstance()->getClient()->request($method, $endpoint, [\n 'json' => ($payload),\n ]);\n }\n//\n// $max = $response->getHeaderLine('X-HubSpot-RateLimit-Max'); // \"110\"\n// $remaining = $response->getHeaderLine('X-HubSpot-RateLimit-Remaining'); // \"109\"\n// $interval = $response->getHeaderLine('X-HubSpot-RateLimit-Interval-Milliseconds'); // \"10000\"\n// $body = json_decode((string) $response->getBody(), true);\n//\n// \\Illuminate\\Support\\Facades\\Log::channel('custom_channel')->info('$max ' . PHP_EOL . print_r($max, true));\n// \\Illuminate\\Support\\Facades\\Log::channel('custom_channel')->info('$remaining ' . PHP_EOL . print_r($remaining, true));\n// \\Illuminate\\Support\\Facades\\Log::channel('custom_channel')->info('$interval ' . PHP_EOL . print_r($interval, true));\n// \\Illuminate\\Support\\Facades\\Log::channel('custom_channel')->info('$body ' . PHP_EOL . print_r($body, true));\n\n return $response;\n }\n\n /**\n * @throws BadRequest\n * @throws HubspotException\n */\n public function createMeeting(array $payload): Response\n {\n $endpoint = '/crm/v3/objects/meetings';\n\n return $this->makeRequest($endpoint, 'POST', $payload);\n }\n\n /**\n * @throws BadRequest\n * @throws HubspotException\n */\n public function updateMeeting(string $meetingId, array $payload): Response\n {\n $endpoint = '/crm/v3/objects/meetings/' . $meetingId;\n\n return $this->makeRequest($endpoint, 'PATCH', $payload);\n }\n\n /**\n * @throws \\Exception\n */\n public function createNote(\n string $body,\n string $ownerId,\n int $timestamp,\n string $objectId,\n NoteObject $noteObject\n ): ?string {\n try {\n $noteInput = new SimplePublicObjectInput([\n 'properties' => [\n 'hs_note_body' => $body,\n 'hubspot_owner_id' => $ownerId,\n 'hs_timestamp' => $timestamp,\n ],\n ]);\n\n // Create note\n $note = $this->getNewInstance()->crm()->objects()->basicApi()->create('note', $noteInput);\n\n $this->getNewInstance()->crm()->objects()->associationsApi()->create(\n 'note',\n $note->getId(),\n $this->getNoteObject($noteObject),\n $objectId,\n $this->getNoteAssociationType($noteObject),\n );\n\n return $note->getId();\n } catch (\\Exception $e) {\n $this->log->error('[Hubspot] Failed to create note', [\n 'objectId' => $objectId,\n 'noteObject' => $noteObject->getObjectType(),\n 'reason' => $e->getMessage(),\n ]);\n\n \\Sentry::captureException($e);\n }\n\n return null;\n }\n\n public function updateEngagement(string $objectId, array $engagement, array $metadata): void\n {\n $this->getInstance()->engagements()->update($objectId, $engagement, $metadata);\n }\n\n public function getEngagementData(string $engagementId): array\n {\n $engagement = $this->getInstance()->engagements()->get($engagementId);\n\n return $engagement->toArray();\n }\n\n public function createEngagement(array $engagement, array $associations, array $metadata): Response\n {\n return $this->getInstance()\n ->engagements()\n ->create($engagement, $associations, $metadata);\n }\n\n public function isUnauthorizedException(\\Exception $e): bool\n {\n if ($e instanceof BadRequest\n || $e instanceof DealApiException\n || $e instanceof ContactApiException\n || $e instanceof CompanyApiException\n ) {\n return (int) $e->getCode() === 401;\n }\n\n if ($e instanceof \\GuzzleHttp\\Exception\\RequestException && $e->hasResponse()) {\n return $e->getResponse()?->getStatusCode() === 401;\n }\n\n $message = strtolower($e->getMessage());\n\n return str_contains($message, '401 unauthorized') ||\n str_contains($message, 'http 401') ||\n str_contains($message, 'status code 401') ||\n (preg_match('/\\b401\\b/', $message) === 1 && str_contains($message, 'unauthorized'));\n }\n\n /**\n * Validates and refreshes the access token if needed before API requests.\n * This ensures long-running processes don't fail due to token expiration.\n *\n * @throws SocialAccountTokenInvalidException\n */\n public function ensureValidToken(): void\n {\n if ($this->oauthAccount === null) {\n return;\n }\n\n $newToken = $this->tokenManager->ensureValidToken($this->oauthAccount);\n if ($newToken !== null) {\n $this->accessToken = $newToken;\n }\n }\n\n public function getConfig()\n {\n return $this->config;\n }\n\n // returns only active (archived=false)\n public function getOwners(): array\n {\n return $this->getNewInstance()->crm()->owners()->getAll();\n }\n\n /**\n * @param bool $archived\n *\n * @return array<Owner>|[]\n */\n public function getOwnersArchived(bool $archived = true): array\n {\n $endpoint = '/crm/v3/owners';\n $queryParams = [\n 'archived' => $archived ? 'true' : 'false',\n ];\n $queryString = http_build_query($queryParams);\n\n $owners = [];\n\n try {\n $response = $this->makeRequest(endpoint: $endpoint, queryString: $queryString);\n $responseData = $response?->toArray();\n\n foreach ($responseData['results'] as $result) {\n try {\n $owners[] = Owner::create($result);\n } catch (Throwable $e) {\n $this->log->error('[HubSpot] Failed to process owner data', [\n 'result' => $result,\n 'error' => $e->getMessage(),\n ]);\n\n continue;\n }\n }\n } catch (Throwable $e) {\n $this->log->error('HubSpot] Failed to fetch owners', [\n 'archived' => $archived,\n 'error' => $e->getMessage(),\n ]);\n\n return [];\n }\n\n return $owners;\n }\n\n public function getMeeting(string $engagementId): ObjectWithAssociations\n {\n return $this->getNewInstance()->crm()->objects()->basicApi()\n ->getById('meeting', $engagementId, null, 'contact,company,deal');\n }\n\n public function deleteEngagement(string $engagementId): void\n {\n $this->getInstance()->engagements()->delete((int) $engagementId);\n }\n\n public function getAssociationsData(array $ids, string $fromObject, string $toObject): array\n {\n $associationData = [];\n $idChunks = array_chunk($ids, self::ASSOCIATIONS_BATCH_SIZE_LIMIT);\n\n foreach ($idChunks as $idChunk) {\n try {\n $batchInput = new \\HubSpot\\Client\\Crm\\Associations\\Model\\BatchInputPublicObjectId();\n $batchInput->setInputs(array_map(function ($id) {\n $publicObjectId = new \\HubSpot\\Client\\Crm\\Associations\\Model\\PublicObjectId();\n $publicObjectId->setId($id);\n\n return $publicObjectId;\n }, $idChunk));\n\n $associatedObjectsData = $this\n ->getNewInstance()\n ->crm()\n ->associations()\n ->batchApi()\n ->read($fromObject, $toObject, $batchInput);\n\n if ($associatedObjectsData instanceof \\HubSpot\\Client\\Crm\\Associations\\Model\\BatchResponsePublicAssociationMulti) {\n foreach ($associatedObjectsData->getResults() as $association) {\n $from = $association->getFrom()->getId();\n $toAssociations = $association->getTo();\n\n if (! empty($toAssociations)) {\n $associationData[$from] = array_map(function ($item) {\n return $item->getId();\n }, $toAssociations);\n }\n }\n }\n } catch (RateLimitException $e) {\n throw $e;\n } catch (\\Exception $e) {\n $this->log->error('[Hubspot] Failed to fetch associations', [\n 'from_object' => $fromObject,\n 'to_object' => $toObject,\n 'reason' => $e->getMessage(),\n ]);\n }\n }\n\n return $associationData;\n }\n\n /**\n * @throws \\Exception\n */\n private function getNoteAssociationType(NoteObject $noteObject): string\n {\n return match($noteObject) {\n NoteObject::Opportunity => 'note_to_deal',\n NoteObject::Lead, NoteObject::Contact => 'note_to_contact', // or 'note_to_lead' if your portal supports it\n NoteObject::Account => 'note_to_company',\n NoteObject::Call, NoteObject::Event => throw new \\Exception('Not supported'),\n };\n }\n\n /**\n * @throws \\Exception\n */\n private function getNoteObject(NoteObject $noteObject): string\n {\n return match($noteObject) {\n NoteObject::Opportunity => 'deal',\n NoteObject::Lead, NoteObject::Contact => 'contact',\n NoteObject::Account => 'company',\n NoteObject::Call, NoteObject::Event => throw new \\Exception('Not supported'),\n };\n }\n\n public function addAssociations(string $objectType, string $associationType, array $payload): Response\n {\n $endpoint = \"/crm/v4/associations/$objectType/$associationType/batch/create\";\n\n return $this->makeRequest($endpoint, 'POST', $payload);\n }\n\n public function removeAssociations(string $objectType, string $associationType, array $payload): Response\n {\n $endpoint = \"/crm/v4/associations/$objectType/$associationType/batch/archive\";\n\n return $this->makeRequest($endpoint, 'POST', $payload);\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":true,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Project","depth":3,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Project","depth":3,"bounds":{"left":0.011968086,"top":0.047885075,"width":0.024268618,"height":0.024740623},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"New File or Directory…","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Expand Selected","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Collapse All","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Options","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
7579307838644216966
|
-2772555093692774298
|
visual_change
|
accessibility
|
NULL
|
Project: faVsco.js, menu
JY-20725-handle-HS-search Project: faVsco.js, menu
JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
19
Previous Highlighted Error
Next Highlighted Error
[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {
"headers":{
"Date":["Thu,07 May 2026 14:21:15 GMT"],
"Content-Type":["application/json;charset=utf-8"],
"Transfer-Encoding":["chunked"],
"Connection":["keep-alive"],
"CF-Ray":["9f80deb8db60dc3a-SOF"],
"CF-Cache-Status":["DYNAMIC"],
"Strict-Transport-Security":["max-age=31536000; includeSubDomains; preload"],
"Vary":["origin,
accept-encoding"],
"access-control-allow-credentials":["false"],
"server-timing":["hcid;desc=\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\",
cfr;desc=\"9f80deb8e7c6dc3a-IAD\""],
"x-content-type-options":["nosniff"],
"x-hubspot-correlation-id":["019e02d0-6fd8-7812-bdba-885b7ccb3ee3"],
"Set-Cookie":["__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-[IP_ADDRESS]-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,
07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None"],
"Report-To":["{
\"endpoints\":[{
\"url\":\"https:\\/\\/a.nel.cloudflare.com\\/report\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\"}],
\"group\":\"cf-nel\",
\"max_age\":604800}"],
"NEL":["{
\"success_fraction\":0.01,
\"report_to\":\"cf-nel\",
\"max_age\":604800}"],
"Server":["cloudflare"]}} {
"correlation_id":"95236535-ec98-4541-b92a-adfa73b69eab",
"trace_id":"c7ab8365-903f-46d4-9403-0e5b551e3545"}
Show Replace Field
Search History
rateLimiter
New Line
Match Case
Words
Regex
Replace History
Replace
New Line
Preserve case
0 results
Previous Occurrence
Next Occurrence
Filter Search Results
Open in Window, Multiple Cursors
Click to highlight
Close
Sync Changes
Hide This Notification
Code changed:
Hide
2
64
1
1
Previous Highlighted Error
Next Highlighted Error
<?php
declare(strict_types=1);
namespace Jiminny\Services\Crm\Hubspot;
use HubSpot\Client\Crm\Deals\ApiException as DealApiException;
use HubSpot\Client\Crm\Contacts\ApiException as ContactApiException;
use HubSpot\Client\Crm\Companies\ApiException as CompanyApiException;
use HubSpot\Client\Crm\Contacts\Model\SimplePublicObjectWithAssociations as ContactsWithAssociations;
use HubSpot\Client\Crm\Companies\Model\SimplePublicObjectWithAssociations as CompaniesWithAssociations;
use HubSpot\Client\Crm\Deals\Model\SimplePublicObjectWithAssociations as DealWithAssociations;
use HubSpot\Client\Crm\Objects\Model\SimplePublicObjectInput;
use HubSpot\Client\Crm\Objects\Model\SimplePublicObjectWithAssociations as ObjectWithAssociations;
use HubSpot\Client\Crm\Pipelines\Model\Error;
use HubSpot\Client\Crm\Pipelines\Model\PipelineStage;
use HubSpot\Client\Crm\Properties\Model\Property;
use HubSpot\Discovery\Discovery;
use Jiminny\Exceptions\CrmException;
use Jiminny\Exceptions\RateLimitException;
use Jiminny\Exceptions\SocialAccountTokenInvalidException;
use Jiminny\Jobs\Crm\NoteObject;
use Jiminny\Models\Crm\Field;
use Jiminny\Services\Crm\BaseClient;
use Jiminny\Services\Crm\Hubspot\DTO\Response\Owner;
use Jiminny\Services\SocialAccountService;
use SevenShores\Hubspot\Exceptions\BadRequest;
use SevenShores\Hubspot\Exceptions\HubspotException;
use SevenShores\Hubspot\Factory;
use SevenShores\Hubspot\Http\Response;
use Jiminny\Services\Crm\Hubspot\Pagination\HubspotPaginationService;
use Throwable;
/**
* @phpstan-type CrmFieldOption array{id:string, label:string, value?:string}
*/
class Client extends BaseClient implements HubspotClientInterface
{
public const string MIN_API_VERSION = '2';
public const string BASE_URL = '[URL_WITH_CREDENTIALS] T
* @param callable(): T $apiCall
* @return T
*
* @throws RateLimitException
*/
private function executeRequest(callable $apiCall)
{
try {
return $apiCall();
} catch (Throwable $e) {
if ($this->isHubspotRateLimit($e)) {
$retryAfter = $this->parseRetryAfter($e);
$this->log->warning('[Hubspot] Received 429 from API', [
'team_id' => $this->config->team_id,
'config_id' => $this->config->getId(),
'retry_after' => $retryAfter,
'reason' => $e->getMessage(),
]);
throw new RateLimitException('Hubspot returned 429', $retryAfter, $e);
}
throw $e;
}
}
public function isHubspotRateLimit(Throwable $e): bool
{
if ($e instanceof BadRequest
|| $e instanceof DealApiException
|| $e instanceof ContactApiException
|| $e instanceof CompanyApiException
|| $e instanceof \GuzzleHttp\Exception\RequestException
) {
return (int) $e->getCode() === 429;
}
return false;
}
public function parseRetryAfter(Throwable $e): int
{
// First try to get Retry-After from response headers
if (method_exists($e, 'getResponseHeaders')) {
$headers = $e->getResponseHeaders() ?: [];
$value = $headers['Retry-After'] ?? $headers['retry-after'] ?? null;
if (is_array($value)) {
$value = $value[0] ?? null;
}
if (is_numeric($value)) {
return (int) $value;
}
}
if (method_exists($e, 'getResponseBody')) {
$body = $e->getResponseBody();
if (is_string($body)) {
$body = json_decode($body, true) ?? [];
}
$policyName = $body['policyName'] ?? $body['policy'] ?? $body['context']['policyName'] ?? null;
if ($policyName === 'TEN_SECONDLY_ROLLING' || $policyName === 'ten_secondly_rolling') {
return 10;
}
if ($policyName === 'SECONDLY' || $policyName === 'secondly') {
return 1;
}
}
$this->log->warning('[Hubspot] No retry-after header or policy name found, using default', [
'exception_class' => get_class($e),
]);
return 10;
}
public function getMinimumApiVersion(): string
{
return self::MIN_API_VERSION;
}
public function getInstance(): Factory
{
return new Factory([
'key' => $this->accessToken,
'oauth2' => true,
'base_url' => $this->baseUrl,
]);
}
public function getNewInstance(): Discovery
{
return \HubSpot\Factory::createWithAccessToken($this->accessToken);
}
/**
* Secondly and daily limits for Hubspot API
*
* Product Tier: Free & Starter | Professional & Enterprise | API add-on (any tier)
* Burst: 100/10 seconds | 150/10 seconds | 200/10 seconds
* Daily: 250,000 | 500,000 | 1,000,000
*
* Official documentation states: The search endpoints are rate limited to five requests per second.
* Since with 5 RPS were still hitting secondly rate limits we lowered it to 4
*/
public function getPaginatedData(array $payload, string $type, int $offset = 0): array
{
$total = 0;
$lastId = null;
$rows = [];
foreach ($this->getPaginatedDataGenerator($payload, $type, $offset, $total, $lastId) as $row) {
$rows[] = $row;
}
return ['results' => $rows, 'total' => $total, 'last_record' => $lastId];
}
/**
* @throws HubspotException
* @throws SocialAccountTokenInvalidException
* @throws BadRequest
*/
public function getPaginatedDataGenerator(
array $payload,
string $type,
int $offset = 0,
int &$total = 0,
?string &$lastRecordId = null
): \Generator {
return $this->paginationService->getPaginatedDataGenerator(
$this,
$payload,
$type,
$offset,
$total,
$lastRecordId
);
}
/**
* Execute a search request against HubSpot CRM objects with rate limiting.
*
* @param string $objectType The object type ('deals', 'companies', 'contacts', 'calls')
* @param array<string, mixed> $payload The search payload with filters, sorts, properties, etc.
* @return array The search response with 'results', 'total', 'paging' keys
* @throws RateLimitException When rate limit is hit
* @throws HubspotException On API errors
*/
public function search(string $objectType, array $payload): array
{
$endpoint = self::BASE_URL . "/crm/v3/objects/{$objectType}/search";
return $this->executeRequest(function () use ($endpoint, $payload) {
$response = $this->getInstance()->getClient()->request('POST', $endpoint, ['json' => $payload]);
return $response->toArray();
});
}
/**
* @throws DealApiException
* @throws CrmException
*/
public function getOpportunityById(string $crmId, array $fields): array
{
try {
// $deal = $this->executeRequest(fn () => $this->getNewInstance()->crm()->deals()->basicApi()->getById(
$deal = $this->getNewInstance()->crm()->deals()->basicApi()->getById(
$crmId,
implode(',', $fields),
'companies,contacts'
);
} catch (DealApiException $e) {
$this->log->info('[Hubspot] Failed to fetch opportunity', [
'crm_id' => $crmId,
'reason' => $e->getMessage(),
]);
throw $e;
}
if (! $deal instanceof DealWithAssociations) {
throw new CrmException('Deal not found');
}
return [
'id' => $deal->getId(),
'properties' => $deal->getProperties(),
'associations' => $deal->getAssociations(),
];
}
/**
* Generic batch read method for HubSpot objects
*
* @param string $objectType The object type ('deals', 'companies', 'contacts')
* @param array<string> $crmIds Array of HubSpot object IDs (max 100)
* @param array<string> $fields Array of property names to fetch
*
* @return array<string, array> Array keyed by CRM ID with object data
*/
private function batchReadObjects(string $objectType, array $crmIds, array $fields): array
{
if (empty($crmIds)) {
return [];
}
$this->validateBatchSize($objectType, $crmIds);
$this->ensureValidToken();
try {
$batchConfig = $this->createBatchConfiguration($objectType);
$batchReadRequest = $this->prepareBatchRequest($batchConfig, $crmIds, $fields);
$response = $batchConfig['api']->read($batchReadRequest);
$this->validateApiResponse($response, $objectType);
$results = $this->processApiResults($response);
$this->logBatchResults($objectType, $crmIds, $results);
return $results;
} catch (\Throwable $e) {
$this->handleBatchError($e, $objectType, $crmIds);
}
}
private function validateBatchSize(string $objectType, array $crmIds): void
{
if (count($crmIds) > 100) {
throw new \InvalidArgumentException("Batch size cannot exceed 100 {$objectType}");
}
}
private function createBatchConfiguration(string $objectType): array
{
$configurations = [
'deals' => [
'batchReadRequest' => new \HubSpot\Client\Crm\Deals\Model\BatchReadInputSimplePublicObjectId(),
'inputClass' => \HubSpot\Client\Crm\Deals\Model\SimplePublicObjectId::class,
'api' => $this->getNewInstance()->crm()->deals()->batchApi(),
],
'companies' => [
'batchReadRequest' => new \HubSpot\Client\Crm\Companies\Model\BatchReadInputSimplePublicObjectId(),
'inputClass' => \HubSpot\Client\Crm\Companies\Model\SimplePublicObjectId::class,
'api' => $this->getNewInstance()->crm()->companies()->batchApi(),
],
'contacts' => [
'batchReadRequest' => new \HubSpot\Client\Crm\Contacts\Model\BatchReadInputSimplePublicObjectId(),
'inputClass' => \HubSpot\Client\Crm\Contacts\Model\SimplePublicObjectId::class,
'api' => $this->getNewInstance()->crm()->contacts()->batchApi(),
],
];
if (! isset($configurations[$objectType])) {
throw new \InvalidArgumentException("Unsupported object type: {$objectType}");
}
return $configurations[$objectType];
}
private function prepareBatchRequest(array $batchConfig, array $crmIds, array $fields): object
{
$batchReadRequest = $batchConfig['batchReadRequest'];
$inputClass = $batchConfig['inputClass'];
$inputs = array_map(function ($crmId) use ($inputClass) {
$input = new $inputClass();
$input->setId($crmId);
return $input;
}, $crmIds);
$batchReadRequest->setInputs($inputs);
$batchReadRequest->setProperties($fields);
return $batchReadRequest;
}
private function validateApiResponse($response, string $objectType): void
{
if (! $response) {
throw new CrmException("HubSpot API returned null response for {$objectType} batch read");
}
}
private function processApiResults($response): array
{
$results = [];
$responseResults = $response->getResults();
if ($responseResults) {
foreach ($responseResults as $object) {
if ($object && $object->getId()) {
$results[$object->getId()] = [
'id' => $object->getId(),
'properties' => $object->getProperties() ?: [],
];
}
}
}
return $results;
}
private function logBatchResults(string $objectType, array $crmIds, array $results): void
{
$this->log->info("[HubSpot] Batch fetched {$objectType}", [
'requested_count' => count($crmIds),
'returned_count' => count($results),
'crm_ids' => $crmIds,
]);
}
private function handleBatchError(\Throwable $e, string $objectType, array $crmIds): void
{
$errorMessage = $e->getMessage() ?: 'Unknown error';
$errorTrace = $e->getTraceAsString() ?: 'No trace available';
$this->log->error("[HubSpot] Failed to batch fetch {$objectType}", [
'crm_ids' => $crmIds,
'error' => $errorMessage,
'trace' => $errorTrace,
]);
throw new CrmException("Failed to batch fetch {$objectType}: " . $errorMessage);
}
/**
* Batch read multiple opportunities by their CRM IDs
*
* @param array<string> $crmIds Array of HubSpot deal IDs (max 100)
* @param array<string> $fields Array of property names to fetch
*
* @return array<string, array> Array keyed by CRM ID with opportunity data
*/
public function getOpportunitiesByIds(array $crmIds, array $fields): array
{
return $this->batchReadObjects('deals', $crmIds, $fields);
}
/**
* Batch read multiple companies by their CRM IDs
*
* @param array<string> $crmIds Array of HubSpot company IDs (max 100)
* @param array<string> $fields Array of property names to fetch
*
* @return array<string, array> Array keyed by CRM ID with company data
*/
public function getCompaniesByIds(array $crmIds, array $fields): array
{
return $this->batchReadObjects('companies', $crmIds, $fields);
}
/**
* Batch read multiple contacts by their CRM IDs
*
* @param array<string> $crmIds Array of HubSpot contact IDs (max 100)
* @param array<string> $fields Array of property names to fetch
*
* @return array<string, array> Array keyed by CRM ID with contact data
*/
public function getContactsByIds(array $crmIds, array $fields): array
{
return $this->batchReadObjects('contacts', $crmIds, $fields);
}
/**
* @throws CompanyApiException
* @throws CrmException
*/
public function getAccountById(string $crmId, array $fields): array
{
try {
$company = $this->getNewInstance()->crm()->companies()->basicApi()->getById(
$crmId,
implode(',', $fields),
);
} catch (CompanyApiException $e) {
$this->log->info('[Hubspot] Failed to fetch account', [
'crm_id' => $crmId,
'reason' => $e->getMessage(),
]);
throw $e;
}
if (! $company instanceof CompaniesWithAssociations) {
throw new CrmException('Account not found');
}
return [
'id' => $company->getId(),
'properties' => $company->getProperties(),
];
}
/**
* @throws ContactApiException
* @throws CrmException
*/
public function getContactById(string $crmId, array $fields): array
{
try {
$contact = $this->getNewInstance()->crm()->contacts()->basicApi()->getById(
$crmId,
implode(',', $fields)
);
} catch (ContactApiException $e) {
$this->log->info('[Hubspot] Failed to fetch contact', [
'crm_id' => $crmId,
'reason' => $e->getMessage(),
]);
throw $e;
}
if (! $contact instanceof ContactsWithAssociations) {
throw new CrmException('Contact not found');
}
return [
'id' => $contact->getId(),
'properties' => $contact->getProperties(),
];
}
/**
* This is email search request that Hubspot offers as GET (more generous quota)
*/
public function getContactByEmail(string $email, array $fields = []): array
{
try {
$contact = $this->getNewInstance()->crm()->contacts()->basicApi()->getById(
$email,
implode(',', $fields),
null,
false,
'email'
);
return [
'id' => $contact->getId(),
'properties' => $contact->getProperties(),
];
} catch (ContactApiException $e) {
$this->log->info('[Hubspot] Failed to fetch contact', [
'email' => $email,
'reason' => $e->getMessage(),
]);
return [];
}
}
/**
* @throws CrmException
*/
public function fetchProperty(string $objectType, string $propertyId): Property
{
$result = $this->getNewInstance()->crm()->properties()->coreApi()->getByName($objectType, $propertyId);
if (! $result instanceof Property) {
$this->log->error('[Hubspot] Failed to fetch property', [
'object_type' => $objectType,
'property_id' => $propertyId,
'reason' => $result->getMessage(),
]);
throw new CrmException('Failed to fetch property');
}
return $result;
}
/**
* @return array<CrmFieldOption>
*/
public function fetchPropertyOptions(string $objectType, string $propertyId): array
{
/** @var array<CrmFieldOption> */
return $this->fetchProperty($objectType, $propertyId)->getOptions();
}
/**
* @return array<array{id:string, label:string, deleted:bool}>
*/
public function fetchCallDispositions(): array
{
/** @var Response $response */
$response = $this->getInstance()->engagements()->getCallDispositions();
/**
* @var array<array{
* id:string,
* label:string,
* deleted: bool
* }>
*/
return $response->toArray();
}
/**
* @return array<CrmFieldOption>
*/
public function fetchOpportunityPipelineStages(): array
{
$stages = [];
$apiResponse = $this->getNewInstance()->crm()->pipelines()->pipelinesApi()->getAll('deals');
if ($apiResponse instanceof Error) {
$this->log->error('[Hubspot] Failed to fetch opportunity pipelines', [
'reason' => $apiResponse->getMessage(),
]);
return [];
}
foreach ($apiResponse->getResults() as $pipeline) {
$pipelineStages = array_map(
static function (PipelineStage $stage) {
return [
'id' => $stage->getId(),
'label' => $stage->getLabel(),
];
},
$pipeline->getStages()
);
$stages = array_merge($stages, $pipelineStages);
}
return $stages;
}
public function fetchOpportunityPipelines(): array
{
$pipelines = [];
try {
$apiResponse = $this->makeRequest('/crm/v3/pipelines/deals');
} catch (\Exception $e) {
$this->log->info('[Hubspot] Failed to fetch opportunity pipelines', [
'reason' => $e->getMessage(),
]);
return [];
}
$response = $apiResponse->toArray();
foreach ($response['results'] as $pipeline) {
$pipelines[] = [
'id' => $pipeline['id'],
'label' => $pipeline['label'],
];
}
return $pipelines;
}
/**
* @return array<CrmFieldOption>
*/
public function fetchMeetingOutcomeFieldOptions(Field $field): array
{
return $field->getCrmProviderId() === 'meetingOutcome'
? $this->fetchMeetingOutcomeTypes()
: $this->fetchCallActivityTypes();
}
public function fetchMeetingOutcomeTypes(): array
{
return $this->extractMeetingTypeOptions(
'[URL_WITH_CREDENTIALS] Response $response */
$response = $this->getInstance()
->getClient()
->request('GET', $endpoint);
/**
* @var array<array{
* value: string,
* label: string,
* displayOrder: int
* }> $optionData
*/
$optionData = $response->toArray()['options'] ?? [];
$options = [];
foreach ($optionData as $item) {
$options[] = [
'id' => $item['value'],
'value' => $item['value'],
'label' => $item['label'],
'display_order' => $item['displayOrder'],
];
}
return $options;
}
/**
* @return array<CrmFieldOption>
*/
public function fetchDispositionFieldOptions(): array
{
$options = [];
$dispositions = $this->fetchCallDispositions();
foreach ($dispositions as $disposition) {
if ($disposition['deleted'] !== false) {
continue;
}
$option['value'] = $disposition['id'];
$option['id'] = $disposition['id'];
$option['label'] = $disposition['label'];
$options[] = $option;
}
return $options;
}
/**
* @return array<CrmFieldOption>
*/
public function fetchOpportunityFieldOptions(Field $field): array
{
if ($field->isStageField()) {
return $this->fetchOpportunityPipelineStages();
}
if ($field->isPipelineField()) {
return $this->fetchOpportunityPipelines();
}
return $this->fetchPropertyOptions('deals', $field->getCrmProviderId());
}
/**
* @throws BadRequest
* @throws HubspotException
*/
public function makeRequest(string $endpoint, $method = 'GET', $payload = [], ?string $queryString = null)
{
$endpoint = self::BASE_URL . $endpoint;
if ($method === 'GET') {
$response = $this->getInstance()->getClient()?->request(
method: $method,
endpoint: $endpoint,
query_string: $queryString
);
} else {
$response = $this->getInstance()->getClient()->request($method, $endpoint, [
'json' => ($payload),
]);
}
//
// $max = $response->getHeaderLine('X-HubSpot-RateLimit-Max'); // "110"
// $remaining = $response->getHeaderLine('X-HubSpot-RateLimit-Remaining'); // "109"
// $interval = $response->getHeaderLine('X-HubSpot-RateLimit-Interval-Milliseconds'); // "10000"
// $body = json_decode((string) $response->getBody(), true);
//
// \Illuminate\Support\Facades\Log::channel('custom_channel')->info('$max ' . PHP_EOL . print_r($max, true));
// \Illuminate\Support\Facades\Log::channel('custom_channel')->info('$remaining ' . PHP_EOL . print_r($remaining, true));
// \Illuminate\Support\Facades\Log::channel('custom_channel')->info('$interval ' . PHP_EOL . print_r($interval, true));
// \Illuminate\Support\Facades\Log::channel('custom_channel')->info('$body ' . PHP_EOL . print_r($body, true));
return $response;
}
/**
* @throws BadRequest
* @throws HubspotException
*/
public function createMeeting(array $payload): Response
{
$endpoint = '/crm/v3/objects/meetings';
return $this->makeRequest($endpoint, 'POST', $payload);
}
/**
* @throws BadRequest
* @throws HubspotException
*/
public function updateMeeting(string $meetingId, array $payload): Response
{
$endpoint = '/crm/v3/objects/meetings/' . $meetingId;
return $this->makeRequest($endpoint, 'PATCH', $payload);
}
/**
* @throws \Exception
*/
public function createNote(
string $body,
string $ownerId,
int $timestamp,
string $objectId,
NoteObject $noteObject
): ?string {
try {
$noteInput = new SimplePublicObjectInput([
'properties' => [
'hs_note_body' => $body,
'hubspot_owner_id' => $ownerId,
'hs_timestamp' => $timestamp,
],
]);
// Create note
$note = $this->getNewInstance()->crm()->objects()->basicApi()->create('note', $noteInput);
$this->getNewInstance()->crm()->objects()->associationsApi()->create(
'note',
$note->getId(),
$this->getNoteObject($noteObject),
$objectId,
$this->getNoteAssociationType($noteObject),
);
return $note->getId();
} catch (\Exception $e) {
$this->log->error('[Hubspot] Failed to create note', [
'objectId' => $objectId,
'noteObject' => $noteObject->getObjectType(),
'reason' => $e->getMessage(),
]);
\Sentry::captureException($e);
}
return null;
}
public function updateEngagement(string $objectId, array $engagement, array $metadata): void
{
$this->getInstance()->engagements()->update($objectId, $engagement, $metadata);
}
public function getEngagementData(string $engagementId): array
{
$engagement = $this->getInstance()->engagements()->get($engagementId);
return $engagement->toArray();
}
public function createEngagement(array $engagement, array $associations, array $metadata): Response
{
return $this->getInstance()
->engagements()
->create($engagement, $associations, $metadata);
}
public function isUnauthorizedException(\Exception $e): bool
{
if ($e instanceof BadRequest
|| $e instanceof DealApiException
|| $e instanceof ContactApiException
|| $e instanceof CompanyApiException
) {
return (int) $e->getCode() === 401;
}
if ($e instanceof \GuzzleHttp\Exception\RequestException && $e->hasResponse()) {
return $e->getResponse()?->getStatusCode() === 401;
}
$message = strtolower($e->getMessage());
return str_contains($message, '401 unauthorized') ||
str_contains($message, 'http 401') ||
str_contains($message, 'status code 401') ||
(preg_match('/\b401\b/', $message) === 1 && str_contains($message, 'unauthorized'));
}
/**
* Validates and refreshes the access token if needed before API requests.
* This ensures long-running processes don't fail due to token expiration.
*
* @throws SocialAccountTokenInvalidException
*/
public function ensureValidToken(): void
{
if ($this->oauthAccount === null) {
return;
}
$newToken = $this->tokenManager->ensureValidToken($this->oauthAccount);
if ($newToken !== null) {
$this->accessToken = $newToken;
}
}
public function getConfig()
{
return $this->config;
}
// returns only active (archived=false)
public function getOwners(): array
{
return $this->getNewInstance()->crm()->owners()->getAll();
}
/**
* @param bool $archived
*
* @return array<Owner>|[]
*/
public function getOwnersArchived(bool $archived = true): array
{
$endpoint = '/crm/v3/owners';
$queryParams = [
'archived' => $archived ? 'true' : 'false',
];
$queryString = http_build_query($queryParams);
$owners = [];
try {
$response = $this->makeRequest(endpoint: $endpoint, queryString: $queryString);
$responseData = $response?->toArray();
foreach ($responseData['results'] as $result) {
try {
$owners[] = Owner::create($result);
} catch (Throwable $e) {
$this->log->error('[HubSpot] Failed to process owner data', [
'result' => $result,
'error' => $e->getMessage(),
]);
continue;
}
}
} catch (Throwable $e) {
$this->log->error('HubSpot] Failed to fetch owners', [
'archived' => $archived,
'error' => $e->getMessage(),
]);
return [];
}
return $owners;
}
public function getMeeting(string $engagementId): ObjectWithAssociations
{
return $this->getNewInstance()->crm()->objects()->basicApi()
->getById('meeting', $engagementId, null, 'contact,company,deal');
}
public function deleteEngagement(string $engagementId): void
{
$this->getInstance()->engagements()->delete((int) $engagementId);
}
public function getAssociationsData(array $ids, string $fromObject, string $toObject): array
{
$associationData = [];
$idChunks = array_chunk($ids, self::ASSOCIATIONS_BATCH_SIZE_LIMIT);
foreach ($idChunks as $idChunk) {
try {
$batchInput = new \HubSpot\Client\Crm\Associations\Model\BatchInputPublicObjectId();
$batchInput->setInputs(array_map(function ($id) {
$publicObjectId = new \HubSpot\Client\Crm\Associations\Model\PublicObjectId();
$publicObjectId->setId($id);
return $publicObjectId;
}, $idChunk));
$associatedObjectsData = $this
->getNewInstance()
->crm()
->associations()
->batchApi()
->read($fromObject, $toObject, $batchInput);
if ($associatedObjectsData instanceof \HubSpot\Client\Crm\Associations\Model\BatchResponsePublicAssociationMulti) {
foreach ($associatedObjectsData->getResults() as $association) {
$from = $association->getFrom()->getId();
$toAssociations = $association->getTo();
if (! empty($toAssociations)) {
$associationData[$from] = array_map(function ($item) {
return $item->getId();
}, $toAssociations);
}
}
}
} catch (RateLimitException $e) {
throw $e;
} catch (\Exception $e) {
$this->log->error('[Hubspot] Failed to fetch associations', [
'from_object' => $fromObject,
'to_object' => $toObject,
'reason' => $e->getMessage(),
]);
}
}
return $associationData;
}
/**
* @throws \Exception
*/
private function getNoteAssociationType(NoteObject $noteObject): string
{
return match($noteObject) {
NoteObject::Opportunity => 'note_to_deal',
NoteObject::Lead, NoteObject::Contact => 'note_to_contact', // or 'note_to_lead' if your portal supports it
NoteObject::Account => 'note_to_company',
NoteObject::Call, NoteObject::Event => throw new \Exception('Not supported'),
};
}
/**
* @throws \Exception
*/
private function getNoteObject(NoteObject $noteObject): string
{
return match($noteObject) {
NoteObject::Opportunity => 'deal',
NoteObject::Lead, NoteObject::Contact => 'contact',
NoteObject::Account => 'company',
NoteObject::Call, NoteObject::Event => throw new \Exception('Not supported'),
};
}
public function addAssociations(string $objectType, string $associationType, array $payload): Response
{
$endpoint = "/crm/v4/associations/$objectType/$associationType/batch/create";
return $this->makeRequest($endpoint, 'POST', $payload);
}
public function removeAssociations(string $objectType, string $associationType, array $payload): Response
{
$endpoint = "/crm/v4/associations/$objectType/$associationType/batch/archive";
return $this->makeRequest($endpoint, 'POST', $payload);
}
}
Project
Project
New File or Directory…
Expand Selected
Collapse All
Options
Hide...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
9850
|
446
|
0
|
2026-05-08T13:38:03.199016+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-08/1778 /Users/lukas/.screenpipe/data/data/2026-05-08/1778247483199_m2.jpg...
|
PhpStorm
|
faVsco.js – Client.php
|
1
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
JY-20725-handle-HS-search Project: faVsco.js, menu
JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
19
Previous Highlighted Error
Next Highlighted Error
[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {
"headers":{
"Date":["Thu,07 May 2026 14:21:15 GMT"],
"Content-Type":["application/json;charset=utf-8"],
"Transfer-Encoding":["chunked"],
"Connection":["keep-alive"],
"CF-Ray":["9f80deb8db60dc3a-SOF"],
"CF-Cache-Status":["DYNAMIC"],
"Strict-Transport-Security":["max-age=31536000; includeSubDomains; preload"],
"Vary":["origin,
accept-encoding"],
"access-control-allow-credentials":["false"],
"server-timing":["hcid;desc=\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\",
cfr;desc=\"9f80deb8e7c6dc3a-IAD\""],
"x-content-type-options":["nosniff"],
"x-hubspot-correlation-id":["019e02d0-6fd8-7812-bdba-885b7ccb3ee3"],
"Set-Cookie":["__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-[IP_ADDRESS]-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,
07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None"],
"Report-To":["{
\"endpoints\":[{
\"url\":\"https:\\/\\/a.nel.cloudflare.com\\/report\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\"}],
\"group\":\"cf-nel\",
\"max_age\":604800}"],
"NEL":["{
\"success_fraction\":0.01,
\"report_to\":\"cf-nel\",
\"max_age\":604800}"],
"Server":["cloudflare"]}} {
"correlation_id":"95236535-ec98-4541-b92a-adfa73b69eab",
"trace_id":"c7ab8365-903f-46d4-9403-0e5b551e3545"}
Show Replace Field
Search History
rateLimiter
New Line
Match Case
Words
Regex
Replace History
Replace
New Line
Preserve case
0 results
Previous Occurrence
Next Occurrence...
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"bounds":{"left":0.025930852,"top":0.019952115,"width":0.03856383,"height":0.025538707},"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JY-20725-handle-HS-search-rate-limit, menu","depth":5,"bounds":{"left":0.064494684,"top":0.019952115,"width":0.09541223,"height":0.025538707},"on_screen":true,"help_text":"Git Branch: JY-20725-handle-HS-search-rate-limit","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"bounds":{"left":0.8081782,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"AskJiminnyReportActivityServiceTest","depth":6,"bounds":{"left":0.8234708,"top":0.019952115,"width":0.09208777,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'AskJiminnyReportActivityServiceTest'","depth":6,"bounds":{"left":0.9155585,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'AskJiminnyReportActivityServiceTest'","depth":6,"bounds":{"left":0.9268617,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"bounds":{"left":0.9381649,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"bounds":{"left":0.96609044,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"bounds":{"left":0.9773936,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"IDE and Project Settings","depth":5,"bounds":{"left":0.9886968,"top":0.019952115,"width":0.011303186,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.042220745,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"19","depth":4,"bounds":{"left":0.6615692,"top":0.10055866,"width":0.009640957,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"bounds":{"left":0.67287236,"top":0.09896249,"width":0.00731383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"bounds":{"left":0.68018615,"top":0.09896249,"width":0.006981383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {\n\"headers\":{\n\"Date\":[\"Thu,07 May 2026 14:21:15 GMT\"],\n \"Content-Type\":[\"application/json;charset=utf-8\"],\n \"Transfer-Encoding\":[\"chunked\"],\n \"Connection\":[\"keep-alive\"],\n \"CF-Ray\":[\"9f80deb8db60dc3a-SOF\"],\n \"CF-Cache-Status\":[\"DYNAMIC\"],\n \"Strict-Transport-Security\":[\"max-age=31536000; includeSubDomains; preload\"],\n \"Vary\":[\"origin,\n accept-encoding\"],\n \"access-control-allow-credentials\":[\"false\"],\n \"server-timing\":[\"hcid;desc=\\\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\\\",\n cfr;desc=\\\"9f80deb8e7c6dc3a-IAD\\\"\"],\n \"x-content-type-options\":[\"nosniff\"],\n \"x-hubspot-correlation-id\":[\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\"],\n \"Set-Cookie\":[\"__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-1.0.1.1-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,\n 07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None\"],\n \"Report-To\":[\"{\n\\\"endpoints\\\":[{\n\\\"url\\\":\\\"https:\\\\/\\\\/a.nel.cloudflare.com\\\\/report\\\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\\\"}],\n\\\"group\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"NEL\":[\"{\n\\\"success_fraction\\\":0.01,\n\\\"report_to\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"Server\":[\"cloudflare\"]}} {\n\"correlation_id\":\"95236535-ec98-4541-b92a-adfa73b69eab\",\n\"trace_id\":\"c7ab8365-903f-46d4-9403-0e5b551e3545\"}","depth":4,"bounds":{"left":0.37632978,"top":0.09736632,"width":0.5728058,"height":0.8818835},"on_screen":true,"lines":[{"char_start":207,"char_count":30,"bounds":{"left":0.37632978,"top":0.0,"width":0.07513298,"height":0.014365523}},{"char_start":237,"char_count":36,"bounds":{"left":0.37632978,"top":0.0,"width":0.09075798,"height":0.014365523}},{"char_start":273,"char_count":32,"bounds":{"left":0.37632978,"top":0.0,"width":0.080119684,"height":0.014365523}},{"char_start":305,"char_count":79,"bounds":{"left":0.37632978,"top":0.0,"width":0.20212767,"height":0.014365523}},{"char_start":384,"char_count":18,"bounds":{"left":0.37632978,"top":0.0,"width":0.043882977,"height":0.014365523}},{"char_start":402,"char_count":21,"bounds":{"left":0.37632978,"top":0.0,"width":0.051861703,"height":0.014365523}},{"char_start":423,"char_count":48,"bounds":{"left":0.37632978,"top":0.008778931,"width":0.12167553,"height":0.014365523}},{"char_start":471,"char_count":72,"bounds":{"left":0.37632978,"top":0.026336791,"width":0.18384309,"height":0.014365523}},{"char_start":543,"char_count":40,"bounds":{"left":0.37632978,"top":0.043894652,"width":0.10106383,"height":0.014365523}},{"char_start":583,"char_count":41,"bounds":{"left":0.37632978,"top":0.061452515,"width":0.10372341,"height":0.014365523}},{"char_start":624,"char_count":72,"bounds":{"left":0.37632978,"top":0.079010375,"width":0.18384309,"height":0.014365523}},{"char_start":696,"char_count":219,"bounds":{"left":0.37632978,"top":0.096568234,"width":0.56515956,"height":0.014365523}},{"char_start":915,"char_count":83,"bounds":{"left":0.37632978,"top":0.11412609,"width":0.21243352,"height":0.014365523}},{"char_start":998,"char_count":20,"bounds":{"left":0.37632978,"top":0.13168396,"width":0.04920213,"height":0.014365523}},{"char_start":1018,"char_count":17,"bounds":{"left":0.37632978,"top":0.14924182,"width":0.041223403,"height":0.014365523}},{"char_start":1035,"char_count":203,"bounds":{"left":0.37632978,"top":0.16679968,"width":0.52360374,"height":0.014365523}},{"char_start":1238,"char_count":22,"bounds":{"left":0.37632978,"top":0.18435754,"width":0.05418883,"height":0.014365523}},{"char_start":1260,"char_count":23,"bounds":{"left":0.37632978,"top":0.2019154,"width":0.056848403,"height":0.014365523}},{"char_start":1283,"char_count":10,"bounds":{"left":0.37632978,"top":0.21947326,"width":0.023271276,"height":0.014365523}},{"char_start":1293,"char_count":27,"bounds":{"left":0.37632978,"top":0.23703113,"width":0.06715426,"height":0.014365523}},{"char_start":1320,"char_count":26,"bounds":{"left":0.37632978,"top":0.254589,"width":0.06482713,"height":0.014365523}},{"char_start":1346,"char_count":23,"bounds":{"left":0.37632978,"top":0.27214685,"width":0.056848403,"height":0.014365523}},{"char_start":1369,"char_count":28,"bounds":{"left":0.37632978,"top":0.2897047,"width":0.06981383,"height":0.014365523}},{"char_start":1397,"char_count":57,"bounds":{"left":0.37632978,"top":0.30726257,"width":0.14494681,"height":0.014365523}}],"value":"[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {\n\"headers\":{\n\"Date\":[\"Thu,07 May 2026 14:21:15 GMT\"],\n \"Content-Type\":[\"application/json;charset=utf-8\"],\n \"Transfer-Encoding\":[\"chunked\"],\n \"Connection\":[\"keep-alive\"],\n \"CF-Ray\":[\"9f80deb8db60dc3a-SOF\"],\n \"CF-Cache-Status\":[\"DYNAMIC\"],\n \"Strict-Transport-Security\":[\"max-age=31536000; includeSubDomains; preload\"],\n \"Vary\":[\"origin,\n accept-encoding\"],\n \"access-control-allow-credentials\":[\"false\"],\n \"server-timing\":[\"hcid;desc=\\\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\\\",\n cfr;desc=\\\"9f80deb8e7c6dc3a-IAD\\\"\"],\n \"x-content-type-options\":[\"nosniff\"],\n \"x-hubspot-correlation-id\":[\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\"],\n \"Set-Cookie\":[\"__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-1.0.1.1-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,\n 07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None\"],\n \"Report-To\":[\"{\n\\\"endpoints\\\":[{\n\\\"url\\\":\\\"https:\\\\/\\\\/a.nel.cloudflare.com\\\\/report\\\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\\\"}],\n\\\"group\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"NEL\":[\"{\n\\\"success_fraction\\\":0.01,\n\\\"report_to\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"Server\":[\"cloudflare\"]}} {\n\"correlation_id\":\"95236535-ec98-4541-b92a-adfa73b69eab\",\n\"trace_id\":\"c7ab8365-903f-46d4-9403-0e5b551e3545\"}","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Show Replace Field","depth":4,"bounds":{"left":0.10472074,"top":0.20430966,"width":0.008643617,"height":0.01915403},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Search History","depth":3,"bounds":{"left":0.11735372,"top":0.20351157,"width":0.00731383,"height":0.017557861},"on_screen":true,"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"rateLimiter","depth":4,"bounds":{"left":0.12832446,"top":0.20351157,"width":0.043882977,"height":0.015961692},"on_screen":true,"value":"rateLimiter","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"New Line","depth":3,"bounds":{"left":0.18118352,"top":0.20351157,"width":0.00731383,"height":0.017557861},"on_screen":true,"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Match Case","depth":3,"bounds":{"left":0.19115691,"top":0.20351157,"width":0.00731383,"height":0.017557861},"on_screen":true,"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Words","depth":3,"bounds":{"left":0.19980054,"top":0.20351157,"width":0.00731383,"height":0.017557861},"on_screen":true,"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Regex","depth":3,"bounds":{"left":0.20844415,"top":0.20351157,"width":0.00731383,"height":0.017557861},"on_screen":true,"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Replace History","depth":3,"bounds":{"left":0.27027926,"top":1.0,"width":0.00731383,"height":0.0},"on_screen":false,"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextField","text":"Replace","depth":4,"on_screen":false,"role_description":"text field","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"New Line","depth":3,"bounds":{"left":0.27027926,"top":1.0,"width":0.00731383,"height":0.0},"on_screen":false,"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Preserve case","depth":3,"bounds":{"left":0.27027926,"top":1.0,"width":0.00731383,"height":0.0},"on_screen":false,"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"0 results","depth":4,"bounds":{"left":0.22207446,"top":0.20271349,"width":0.025598405,"height":0.017557861},"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Occurrence","depth":4,"bounds":{"left":0.24767287,"top":0.2019154,"width":0.008643617,"height":0.01915403},"on_screen":true,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Occurrence","depth":4,"bounds":{"left":0.25631648,"top":0.2019154,"width":0.008643617,"height":0.01915403},"on_screen":true,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
6980982724184409425
|
-3048906064421205046
|
click
|
accessibility
|
NULL
|
Project: faVsco.js, menu
JY-20725-handle-HS-search Project: faVsco.js, menu
JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
19
Previous Highlighted Error
Next Highlighted Error
[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {
"headers":{
"Date":["Thu,07 May 2026 14:21:15 GMT"],
"Content-Type":["application/json;charset=utf-8"],
"Transfer-Encoding":["chunked"],
"Connection":["keep-alive"],
"CF-Ray":["9f80deb8db60dc3a-SOF"],
"CF-Cache-Status":["DYNAMIC"],
"Strict-Transport-Security":["max-age=31536000; includeSubDomains; preload"],
"Vary":["origin,
accept-encoding"],
"access-control-allow-credentials":["false"],
"server-timing":["hcid;desc=\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\",
cfr;desc=\"9f80deb8e7c6dc3a-IAD\""],
"x-content-type-options":["nosniff"],
"x-hubspot-correlation-id":["019e02d0-6fd8-7812-bdba-885b7ccb3ee3"],
"Set-Cookie":["__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-[IP_ADDRESS]-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,
07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None"],
"Report-To":["{
\"endpoints\":[{
\"url\":\"https:\\/\\/a.nel.cloudflare.com\\/report\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\"}],
\"group\":\"cf-nel\",
\"max_age\":604800}"],
"NEL":["{
\"success_fraction\":0.01,
\"report_to\":\"cf-nel\",
\"max_age\":604800}"],
"Server":["cloudflare"]}} {
"correlation_id":"95236535-ec98-4541-b92a-adfa73b69eab",
"trace_id":"c7ab8365-903f-46d4-9403-0e5b551e3545"}
Show Replace Field
Search History
rateLimiter
New Line
Match Case
Words
Regex
Replace History
Replace
New Line
Preserve case
0 results
Previous Occurrence
Next Occurrence...
|
9849
|
NULL
|
NULL
|
NULL
|
|
9849
|
NULL
|
0
|
2026-05-08T13:37:46.004923+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-08/1778 /Users/lukas/.screenpipe/data/data/2026-05-08/1778247466004_m2.jpg...
|
PhpStorm
|
faVsco.js – Client.php
|
1
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
JY-20725-handle-HS-search Project: faVsco.js, menu
JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
19
Previous Highlighted Error
Next Highlighted Error
[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {
"headers":{
"Date":["Thu,07 May 2026 14:21:15 GMT"],
"Content-Type":["application/json;charset=utf-8"],
"Transfer-Encoding":["chunked"],
"Connection":["keep-alive"],
"CF-Ray":["9f80deb8db60dc3a-SOF"],
"CF-Cache-Status":["DYNAMIC"],
"Strict-Transport-Security":["max-age=31536000; includeSubDomains; preload"],
"Vary":["origin,
accept-encoding"],
"access-control-allow-credentials":["false"],
"server-timing":["hcid;desc=\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\",
cfr;desc=\"9f80deb8e7c6dc3a-IAD\""],
"x-content-type-options":["nosniff"],
"x-hubspot-correlation-id":["019e02d0-6fd8-7812-bdba-885b7ccb3ee3"],
"Set-Cookie":["__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-[IP_ADDRESS]-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,
07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None"],
"Report-To":["{
\"endpoints\":[{
\"url\":\"https:\\/\\/a.nel.cloudflare.com\\/report\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\"}],
\"group\":\"cf-nel\",
\"max_age\":604800}"],
"NEL":["{
\"success_fraction\":0.01,
\"report_to\":\"cf-nel\",
\"max_age\":604800}"],
"Server":["cloudflare"]}} {
"correlation_id":"95236535-ec98-4541-b92a-adfa73b69eab",
"trace_id":"c7ab8365-903f-46d4-9403-0e5b551e3545"}
Show Replace Field
Search History
rateLimiter
New Line
Match Case
Words
Regex
Replace History
Replace
New Line
Preserve case
0 results
Previous Occurrence
Next Occurrence
Filter Search Results
Open in Window, Multiple Cursors
Click to highlight
Close
Sync Changes
Hide This Notification
Code changed:
Hide
2
64
1
1
Previous Highlighted Error
Next Highlighted Error
<?php
declare(strict_types=1);
namespace Jiminny\Services\Crm\Hubspot;
use HubSpot\Client\Crm\Deals\ApiException as DealApiException;
use HubSpot\Client\Crm\Contacts\ApiException as ContactApiException;
use HubSpot\Client\Crm\Companies\ApiException as CompanyApiException;
use HubSpot\Client\Crm\Contacts\Model\SimplePublicObjectWithAssociations as ContactsWithAssociations;
use HubSpot\Client\Crm\Companies\Model\SimplePublicObjectWithAssociations as CompaniesWithAssociations;
use HubSpot\Client\Crm\Deals\Model\SimplePublicObjectWithAssociations as DealWithAssociations;
use HubSpot\Client\Crm\Objects\Model\SimplePublicObjectInput;
use HubSpot\Client\Crm\Objects\Model\SimplePublicObjectWithAssociations as ObjectWithAssociations;
use HubSpot\Client\Crm\Pipelines\Model\Error;
use HubSpot\Client\Crm\Pipelines\Model\PipelineStage;
use HubSpot\Client\Crm\Properties\Model\Property;
use HubSpot\Discovery\Discovery;
use Jiminny\Exceptions\CrmException;
use Jiminny\Exceptions\RateLimitException;
use Jiminny\Exceptions\SocialAccountTokenInvalidException;
use Jiminny\Jobs\Crm\NoteObject;
use Jiminny\Models\Crm\Field;
use Jiminny\Services\Crm\BaseClient;
use Jiminny\Services\Crm\Hubspot\DTO\Response\Owner;
use Jiminny\Services\SocialAccountService;
use SevenShores\Hubspot\Exceptions\BadRequest;
use SevenShores\Hubspot\Exceptions\HubspotException;
use SevenShores\Hubspot\Factory;
use SevenShores\Hubspot\Http\Response;
use Jiminny\Services\Crm\Hubspot\Pagination\HubspotPaginationService;
use Throwable;
/**
* @phpstan-type CrmFieldOption array{id:string, label:string, value?:string}
*/
class Client extends BaseClient implements HubspotClientInterface
{
public const string MIN_API_VERSION = '2';
public const string BASE_URL = '[URL_WITH_CREDENTIALS] T
* @param callable(): T $apiCall
* @return T
*
* @throws RateLimitException
*/
private function executeRequest(callable $apiCall)
{
try {
return $apiCall();
} catch (Throwable $e) {
if ($this->isHubspotRateLimit($e)) {
$retryAfter = $this->parseRetryAfter($e);
$this->log->warning('[Hubspot] Received 429 from API', [
'team_id' => $this->config->team_id,
'config_id' => $this->config->getId(),
'retry_after' => $retryAfter,
'reason' => $e->getMessage(),
]);
throw new RateLimitException('Hubspot returned 429', $retryAfter, $e);
}
throw $e;
}
}
public function isHubspotRateLimit(Throwable $e): bool
{
if ($e instanceof BadRequest
|| $e instanceof DealApiException
|| $e instanceof ContactApiException
|| $e instanceof CompanyApiException
|| $e instanceof \GuzzleHttp\Exception\RequestException
) {
return (int) $e->getCode() === 429;
}
return false;
}
public function parseRetryAfter(Throwable $e): int
{
// First try to get Retry-After from response headers
if (method_exists($e, 'getResponseHeaders')) {
$headers = $e->getResponseHeaders() ?: [];
$value = $headers['Retry-After'] ?? $headers['retry-after'] ?? null;
if (is_array($value)) {
$value = $value[0] ?? null;
}
if (is_numeric($value)) {
return (int) $value;
}
}
if (method_exists($e, 'getResponseBody')) {
$body = $e->getResponseBody();
if (is_string($body)) {
$body = json_decode($body, true) ?? [];
}
$policyName = $body['policyName'] ?? $body['policy'] ?? $body['context']['policyName'] ?? null;
if ($policyName === 'TEN_SECONDLY_ROLLING' || $policyName === 'ten_secondly_rolling') {
return 10;
}
if ($policyName === 'SECONDLY' || $policyName === 'secondly') {
return 1;
}
}
$this->log->warning('[Hubspot] No retry-after header or policy name found, using default', [
'exception_class' => get_class($e),
]);
return 10;
}
public function getMinimumApiVersion(): string
{
return self::MIN_API_VERSION;
}
public function getInstance(): Factory
{
return new Factory([
'key' => $this->accessToken,
'oauth2' => true,
'base_url' => $this->baseUrl,
]);
}
public function getNewInstance(): Discovery
{
return \HubSpot\Factory::createWithAccessToken($this->accessToken);
}
/**
* Secondly and daily limits for Hubspot API
*
* Product Tier: Free & Starter | Professional & Enterprise | API add-on (any tier)
* Burst: 100/10 seconds | 150/10 seconds | 200/10 seconds
* Daily: 250,000 | 500,000 | 1,000,000
*
* Official documentation states: The search endpoints are rate limited to five requests per second.
* Since with 5 RPS were still hitting secondly rate limits we lowered it to 4
*/
public function getPaginatedData(array $payload, string $type, int $offset = 0): array
{
$total = 0;
$lastId = null;
$rows = [];
foreach ($this->getPaginatedDataGenerator($payload, $type, $offset, $total, $lastId) as $row) {
$rows[] = $row;
}
return ['results' => $rows, 'total' => $total, 'last_record' => $lastId];
}
/**
* @throws HubspotException
* @throws SocialAccountTokenInvalidException
* @throws BadRequest
*/
public function getPaginatedDataGenerator(
array $payload,
string $type,
int $offset = 0,
int &$total = 0,
?string &$lastRecordId = null
): \Generator {
return $this->paginationService->getPaginatedDataGenerator(
$this,
$payload,
$type,
$offset,
$total,
$lastRecordId
);
}
/**
* Execute a search request against HubSpot CRM objects with rate limiting.
*
* @param string $objectType The object type ('deals', 'companies', 'contacts', 'calls')
* @param array<string, mixed> $payload The search payload with filters, sorts, properties, etc.
* @return array The search response with 'results', 'total', 'paging' keys
* @throws RateLimitException When rate limit is hit
* @throws HubspotException On API errors
*/
public function search(string $objectType, array $payload): array
{
$endpoint = self::BASE_URL . "/crm/v3/objects/{$objectType}/search";
return $this->executeRequest(function () use ($endpoint, $payload) {
$response = $this->getInstance()->getClient()->request('POST', $endpoint, ['json' => $payload]);
return $response->toArray();
});
}
/**
* @throws DealApiException
* @throws CrmException
*/
public function getOpportunityById(string $crmId, array $fields): array
{
try {
// $deal = $this->executeRequest(fn () => $this->getNewInstance()->crm()->deals()->basicApi()->getById(
$deal = $this->getNewInstance()->crm()->deals()->basicApi()->getById(
$crmId,
implode(',', $fields),
'companies,contacts'
);
} catch (DealApiException $e) {
$this->log->info('[Hubspot] Failed to fetch opportunity', [
'crm_id' => $crmId,
'reason' => $e->getMessage(),
]);
throw $e;
}
if (! $deal instanceof DealWithAssociations) {
throw new CrmException('Deal not found');
}
return [
'id' => $deal->getId(),
'properties' => $deal->getProperties(),
'associations' => $deal->getAssociations(),
];
}
/**
* Generic batch read method for HubSpot objects
*
* @param string $objectType The object type ('deals', 'companies', 'contacts')
* @param array<string> $crmIds Array of HubSpot object IDs (max 100)
* @param array<string> $fields Array of property names to fetch
*
* @return array<string, array> Array keyed by CRM ID with object data
*/
private function batchReadObjects(string $objectType, array $crmIds, array $fields): array
{
if (empty($crmIds)) {
return [];
}
$this->validateBatchSize($objectType, $crmIds);
$this->ensureValidToken();
try {
$batchConfig = $this->createBatchConfiguration($objectType);
$batchReadRequest = $this->prepareBatchRequest($batchConfig, $crmIds, $fields);
$response = $batchConfig['api']->read($batchReadRequest);
$this->validateApiResponse($response, $objectType);
$results = $this->processApiResults($response);
$this->logBatchResults($objectType, $crmIds, $results);
return $results;
} catch (\Throwable $e) {
$this->handleBatchError($e, $objectType, $crmIds);
}
}
private function validateBatchSize(string $objectType, array $crmIds): void
{
if (count($crmIds) > 100) {
throw new \InvalidArgumentException("Batch size cannot exceed 100 {$objectType}");
}
}
private function createBatchConfiguration(string $objectType): array
{
$configurations = [
'deals' => [
'batchReadRequest' => new \HubSpot\Client\Crm\Deals\Model\BatchReadInputSimplePublicObjectId(),
'inputClass' => \HubSpot\Client\Crm\Deals\Model\SimplePublicObjectId::class,
'api' => $this->getNewInstance()->crm()->deals()->batchApi(),
],
'companies' => [
'batchReadRequest' => new \HubSpot\Client\Crm\Companies\Model\BatchReadInputSimplePublicObjectId(),
'inputClass' => \HubSpot\Client\Crm\Companies\Model\SimplePublicObjectId::class,
'api' => $this->getNewInstance()->crm()->companies()->batchApi(),
],
'contacts' => [
'batchReadRequest' => new \HubSpot\Client\Crm\Contacts\Model\BatchReadInputSimplePublicObjectId(),
'inputClass' => \HubSpot\Client\Crm\Contacts\Model\SimplePublicObjectId::class,
'api' => $this->getNewInstance()->crm()->contacts()->batchApi(),
],
];
if (! isset($configurations[$objectType])) {
throw new \InvalidArgumentException("Unsupported object type: {$objectType}");
}
return $configurations[$objectType];
}
private function prepareBatchRequest(array $batchConfig, array $crmIds, array $fields): object
{
$batchReadRequest = $batchConfig['batchReadRequest'];
$inputClass = $batchConfig['inputClass'];
$inputs = array_map(function ($crmId) use ($inputClass) {
$input = new $inputClass();
$input->setId($crmId);
return $input;
}, $crmIds);
$batchReadRequest->setInputs($inputs);
$batchReadRequest->setProperties($fields);
return $batchReadRequest;
}
private function validateApiResponse($response, string $objectType): void
{
if (! $response) {
throw new CrmException("HubSpot API returned null response for {$objectType} batch read");
}
}
private function processApiResults($response): array
{
$results = [];
$responseResults = $response->getResults();
if ($responseResults) {
foreach ($responseResults as $object) {
if ($object && $object->getId()) {
$results[$object->getId()] = [
'id' => $object->getId(),
'properties' => $object->getProperties() ?: [],
];
}
}
}
return $results;
}
private function logBatchResults(string $objectType, array $crmIds, array $results): void
{
$this->log->info("[HubSpot] Batch fetched {$objectType}", [
'requested_count' => count($crmIds),
'returned_count' => count($results),
'crm_ids' => $crmIds,
]);
}
private function handleBatchError(\Throwable $e, string $objectType, array $crmIds): void
{
$errorMessage = $e->getMessage() ?: 'Unknown error';
$errorTrace = $e->getTraceAsString() ?: 'No trace available';
$this->log->error("[HubSpot] Failed to batch fetch {$objectType}", [
'crm_ids' => $crmIds,
'error' => $errorMessage,
'trace' => $errorTrace,
]);
throw new CrmException("Failed to batch fetch {$objectType}: " . $errorMessage);
}
/**
* Batch read multiple opportunities by their CRM IDs
*
* @param array<string> $crmIds Array of HubSpot deal IDs (max 100)
* @param array<string> $fields Array of property names to fetch
*
* @return array<string, array> Array keyed by CRM ID with opportunity data
*/
public function getOpportunitiesByIds(array $crmIds, array $fields): array
{
return $this->batchReadObjects('deals', $crmIds, $fields);
}
/**
* Batch read multiple companies by their CRM IDs
*
* @param array<string> $crmIds Array of HubSpot company IDs (max 100)
* @param array<string> $fields Array of property names to fetch
*
* @return array<string, array> Array keyed by CRM ID with company data
*/
public function getCompaniesByIds(array $crmIds, array $fields): array
{
return $this->batchReadObjects('companies', $crmIds, $fields);
}
/**
* Batch read multiple contacts by their CRM IDs
*
* @param array<string> $crmIds Array of HubSpot contact IDs (max 100)
* @param array<string> $fields Array of property names to fetch
*
* @return array<string, array> Array keyed by CRM ID with contact data
*/
public function getContactsByIds(array $crmIds, array $fields): array
{
return $this->batchReadObjects('contacts', $crmIds, $fields);
}
/**
* @throws CompanyApiException
* @throws CrmException
*/
public function getAccountById(string $crmId, array $fields): array
{
try {
$company = $this->getNewInstance()->crm()->companies()->basicApi()->getById(
$crmId,
implode(',', $fields),
);
} catch (CompanyApiException $e) {
$this->log->info('[Hubspot] Failed to fetch account', [
'crm_id' => $crmId,
'reason' => $e->getMessage(),
]);
throw $e;
}
if (! $company instanceof CompaniesWithAssociations) {
throw new CrmException('Account not found');
}
return [
'id' => $company->getId(),
'properties' => $company->getProperties(),
];
}
/**
* @throws ContactApiException
* @throws CrmException
*/
public function getContactById(string $crmId, array $fields): array
{
try {
$contact = $this->getNewInstance()->crm()->contacts()->basicApi()->getById(
$crmId,
implode(',', $fields)
);
} catch (ContactApiException $e) {
$this->log->info('[Hubspot] Failed to fetch contact', [
'crm_id' => $crmId,
'reason' => $e->getMessage(),
]);
throw $e;
}
if (! $contact instanceof ContactsWithAssociations) {
throw new CrmException('Contact not found');
}
return [
'id' => $contact->getId(),
'properties' => $contact->getProperties(),
];
}
/**
* This is email search request that Hubspot offers as GET (more generous quota)
*/
public function getContactByEmail(string $email, array $fields = []): array
{
try {
$contact = $this->getNewInstance()->crm()->contacts()->basicApi()->getById(
$email,
implode(',', $fields),
null,
false,
'email'
);
return [
'id' => $contact->getId(),
'properties' => $contact->getProperties(),
];
} catch (ContactApiException $e) {
$this->log->info('[Hubspot] Failed to fetch contact', [
'email' => $email,
'reason' => $e->getMessage(),
]);
return [];
}
}
/**
* @throws CrmException
*/
public function fetchProperty(string $objectType, string $propertyId): Property
{
$result = $this->getNewInstance()->crm()->properties()->coreApi()->getByName($objectType, $propertyId);
if (! $result instanceof Property) {
$this->log->error('[Hubspot] Failed to fetch property', [
'object_type' => $objectType,
'property_id' => $propertyId,
'reason' => $result->getMessage(),
]);
throw new CrmException('Failed to fetch property');
}
return $result;
}
/**
* @return array<CrmFieldOption>
*/
public function fetchPropertyOptions(string $objectType, string $propertyId): array
{
/** @var array<CrmFieldOption> */
return $this->fetchProperty($objectType, $propertyId)->getOptions();
}
/**
* @return array<array{id:string, label:string, deleted:bool}>
*/
public function fetchCallDispositions(): array
{
/** @var Response $response */
$response = $this->getInstance()->engagements()->getCallDispositions();
/**
* @var array<array{
* id:string,
* label:string,
* deleted: bool
* }>
*/
return $response->toArray();
}
/**
* @return array<CrmFieldOption>
*/
public function fetchOpportunityPipelineStages(): array
{
$stages = [];
$apiResponse = $this->getNewInstance()->crm()->pipelines()->pipelinesApi()->getAll('deals');
if ($apiResponse instanceof Error) {
$this->log->error('[Hubspot] Failed to fetch opportunity pipelines', [
'reason' => $apiResponse->getMessage(),
]);
return [];
}
foreach ($apiResponse->getResults() as $pipeline) {
$pipelineStages = array_map(
static function (PipelineStage $stage) {
return [
'id' => $stage->getId(),
'label' => $stage->getLabel(),
];
},
$pipeline->getStages()
);
$stages = array_merge($stages, $pipelineStages);
}
return $stages;
}
public function fetchOpportunityPipelines(): array
{
$pipelines = [];
try {
$apiResponse = $this->makeRequest('/crm/v3/pipelines/deals');
} catch (\Exception $e) {
$this->log->info('[Hubspot] Failed to fetch opportunity pipelines', [
'reason' => $e->getMessage(),
]);
return [];
}
$response = $apiResponse->toArray();
foreach ($response['results'] as $pipeline) {
$pipelines[] = [
'id' => $pipeline['id'],
'label' => $pipeline['label'],
];
}
return $pipelines;
}
/**
* @return array<CrmFieldOption>
*/
public function fetchMeetingOutcomeFieldOptions(Field $field): array
{
return $field->getCrmProviderId() === 'meetingOutcome'
? $this->fetchMeetingOutcomeTypes()
: $this->fetchCallActivityTypes();
}
public function fetchMeetingOutcomeTypes(): array
{
return $this->extractMeetingTypeOptions(
'[URL_WITH_CREDENTIALS] Response $response */
$response = $this->getInstance()
->getClient()
->request('GET', $endpoint);
/**
* @var array<array{
* value: string,
* label: string,
* displayOrder: int
* }> $optionData
*/
$optionData = $response->toArray()['options'] ?? [];
$options = [];
foreach ($optionData as $item) {
$options[] = [
'id' => $item['value'],
'value' => $item['value'],
'label' => $item['label'],
'display_order' => $item['displayOrder'],
];
}
return $options;
}
/**
* @return array<CrmFieldOption>
*/
public function fetchDispositionFieldOptions(): array
{
$options = [];
$dispositions = $this->fetchCallDispositions();
foreach ($dispositions as $disposition) {
if ($disposition['deleted'] !== false) {
continue;
}
$option['value'] = $disposition['id'];
$option['id'] = $disposition['id'];
$option['label'] = $disposition['label'];
$options[] = $option;
}
return $options;
}
/**
* @return array<CrmFieldOption>
*/
public function fetchOpportunityFieldOptions(Field $field): array
{
if ($field->isStageField()) {
return $this->fetchOpportunityPipelineStages();
}
if ($field->isPipelineField()) {
return $this->fetchOpportunityPipelines();
}
return $this->fetchPropertyOptions('deals', $field->getCrmProviderId());
}
/**
* @throws BadRequest
* @throws HubspotException
*/
public function makeRequest(string $endpoint, $method = 'GET', $payload = [], ?string $queryString = null)
{
$endpoint = self::BASE_URL . $endpoint;
if ($method === 'GET') {
$response = $this->getInstance()->getClient()?->request(
method: $method,
endpoint: $endpoint,
query_string: $queryString
);
} else {
$response = $this->getInstance()->getClient()->request($method, $endpoint, [
'json' => ($payload),
]);
}
//
// $max = $response->getHeaderLine('X-HubSpot-RateLimit-Max'); // "110"
// $remaining = $response->getHeaderLine('X-HubSpot-RateLimit-Remaining'); // "109"
// $interval = $response->getHeaderLine('X-HubSpot-RateLimit-Interval-Milliseconds'); // "10000"
// $body = json_decode((string) $response->getBody(), true);
//
// \Illuminate\Support\Facades\Log::channel('custom_channel')->info('$max ' . PHP_EOL . print_r($max, true));
// \Illuminate\Support\Facades\Log::channel('custom_channel')->info('$remaining ' . PHP_EOL . print_r($remaining, true));
// \Illuminate\Support\Facades\Log::channel('custom_channel')->info('$interval ' . PHP_EOL . print_r($interval, true));
// \Illuminate\Support\Facades\Log::channel('custom_channel')->info('$body ' . PHP_EOL . print_r($body, true));
return $response;
}
/**
* @throws BadRequest
* @throws HubspotException
*/
public function createMeeting(array $payload): Response
{
$endpoint = '/crm/v3/objects/meetings';
return $this->makeRequest($endpoint, 'POST', $payload);
}
/**
* @throws BadRequest
* @throws HubspotException
*/
public function updateMeeting(string $meetingId, array $payload): Response
{
$endpoint = '/crm/v3/objects/meetings/' . $meetingId;
return $this->makeRequest($endpoint, 'PATCH', $payload);
}
/**
* @throws \Exception
*/
public function createNote(
string $body,
string $ownerId,
int $timestamp,
string $objectId,
NoteObject $noteObject
): ?string {
try {
$noteInput = new SimplePublicObjectInput([
'properties' => [
'hs_note_body' => $body,
'hubspot_owner_id' => $ownerId,
'hs_timestamp' => $timestamp,
],
]);
// Create note
$note = $this->getNewInstance()->crm()->objects()->basicApi()->create('note', $noteInput);
$this->getNewInstance()->crm()->objects()->associationsApi()->create(
'note',
$note->getId(),
$this->getNoteObject($noteObject),
$objectId,
$this->getNoteAssociationType($noteObject),
);
return $note->getId();
} catch (\Exception $e) {
$this->log->error('[Hubspot] Failed to create note', [
'objectId' => $objectId,
'noteObject' => $noteObject->getObjectType(),
'reason' => $e->getMessage(),
]);
\Sentry::captureException($e);
}
return null;
}
public function updateEngagement(string $objectId, array $engagement, array $metadata): void
{
$this->getInstance()->engagements()->update($objectId, $engagement, $metadata);
}
public function getEngagementData(string $engagementId): array
{
$engagement = $this->getInstance()->engagements()->get($engagementId);
return $engagement->toArray();
}
public function createEngagement(array $engagement, array $associations, array $metadata): Response
{
return $this->getInstance()
->engagements()
->create($engagement, $associations, $metadata);
}
public function isUnauthorizedException(\Exception $e): bool
{
if ($e instanceof BadRequest
|| $e instanceof DealApiException
|| $e instanceof ContactApiException
|| $e instanceof CompanyApiException
) {
return (int) $e->getCode() === 401;
}
if ($e instanceof \GuzzleHttp\Exception\RequestException && $e->hasResponse()) {
return $e->getResponse()?->getStatusCode() === 401;
}
$message = strtolower($e->getMessage());
return str_contains($message, '401 unauthorized') ||
str_contains($message, 'http 401') ||
str_contains($message, 'status code 401') ||
(preg_match('/\b401\b/', $message) === 1 && str_contains($message, 'unauthorized'));
}
/**
* Validates and refreshes the access token if needed before API requests.
* This ensures long-running processes don't fail due to token expiration.
*
* @throws SocialAccountTokenInvalidException
*/
public function ensureValidToken(): void
{
if ($this->oauthAccount === null) {
return;
}
$newToken = $this->tokenManager->ensureValidToken($this->oauthAccount);
if ($newToken !== null) {
$this->accessToken = $newToken;
}
}
public function getConfig()
{
return $this->config;
}
// returns only active (archived=false)
public function getOwners(): array
{
return $this->getNewInstance()->crm()->owners()->getAll();
}
/**
* @param bool $archived
*
* @return array<Owner>|[]
*/
public function getOwnersArchived(bool $archived = true): array
{
$endpoint = '/crm/v3/owners';
$queryParams = [
'archived' => $archived ? 'true' : 'false',
];
$queryString = http_build_query($queryParams);
$owners = [];
try {
$response = $this->makeRequest(endpoint: $endpoint, queryString: $queryString);
$responseData = $response?->toArray();
foreach ($responseData['results'] as $result) {
try {
$owners[] = Owner::create($result);
} catch (Throwable $e) {
$this->log->error('[HubSpot] Failed to process owner data', [
'result' => $result,
'error' => $e->getMessage(),
]);
continue;
}
}
} catch (Throwable $e) {
$this->log->error('HubSpot] Failed to fetch owners', [
'archived' => $archived,
'error' => $e->getMessage(),
]);
return [];
}
return $owners;
}
public function getMeeting(string $engagementId): ObjectWithAssociations
{
return $this->getNewInstance()->crm()->objects()->basicApi()
->getById('meeting', $engagementId, null, 'contact,company,deal');
}
public function deleteEngagement(string $engagementId): void
{
$this->getInstance()->engagements()->delete((int) $engagementId);
}
public function getAssociationsData(array $ids, string $fromObject, string $toObject): array
{
$associationData = [];
$idChunks = array_chunk($ids, self::ASSOCIATIONS_BATCH_SIZE_LIMIT);
foreach ($idChunks as $idChunk) {
try {
$batchInput = new \HubSpot\Client\Crm\Associations\Model\BatchInputPublicObjectId();
$batchInput->setInputs(array_map(function ($id) {
$publicObjectId = new \HubSpot\Client\Crm\Associations\Model\PublicObjectId();
$publicObjectId->setId($id);
return $publicObjectId;
}, $idChunk));
$associatedObjectsData = $this
->getNewInstance()
->crm()
->associations()
->batchApi()
->read($fromObject, $toObject, $batchInput);
if ($associatedObjectsData instanceof \HubSpot\Client\Crm\Associations\Model\BatchResponsePublicAssociationMulti) {
foreach ($associatedObjectsData->getResults() as $association) {
$from = $association->getFrom()->getId();
$toAssociations = $association->getTo();
if (! empty($toAssociations)) {
$associationData[$from] = array_map(function ($item) {
return $item->getId();
}, $toAssociations);
}
}
}
} catch (RateLimitException $e) {
throw $e;
} catch (\Exception $e) {
$this->log->error('[Hubspot] Failed to fetch associations', [
'from_object' => $fromObject,
'to_object' => $toObject,
'reason' => $e->getMessage(),
]);
}
}
return $associationData;
}
/**
* @throws \Exception
*/
private function getNoteAssociationType(NoteObject $noteObject): string
{
return match($noteObject) {
NoteObject::Opportunity => 'note_to_deal',
NoteObject::Lead, NoteObject::Contact => 'note_to_contact', // or 'note_to_lead' if your portal supports it
NoteObject::Account => 'note_to_company',
NoteObject::Call, NoteObject::Event => throw new \Exception('Not supported'),
};
}
/**
* @throws \Exception
*/
private function getNoteObject(NoteObject $noteObject): string
{
return match($noteObject) {
NoteObject::Opportunity => 'deal',
NoteObject::Lead, NoteObject::Contact => 'contact',
NoteObject::Account => 'company',
NoteObject::Call, NoteObject::Event => throw new \Exception('Not supported'),
};
}
public function addAssociations(string $objectType, string $associationType, array $payload): Response
{
$endpoint = "/crm/v4/associations/$objectType/$associationType/batch/create";
return $this->makeRequest($endpoint, 'POST', $payload);
}
public function removeAssociations(string $objectType, string $associationType, array $payload): Response
{
$endpoint = "/crm/v4/associations/$objectType/$associationType/batch/archive";
return $this->makeRequest($endpoint, 'POST', $payload);
}
}
Project
Project
New File or Directory…
Expand Selected
Collapse All
Options
Hide...
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"bounds":{"left":0.025930852,"top":0.019952115,"width":0.03856383,"height":0.025538707},"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JY-20725-handle-HS-search-rate-limit, menu","depth":5,"bounds":{"left":0.064494684,"top":0.019952115,"width":0.09541223,"height":0.025538707},"on_screen":true,"help_text":"Git Branch: JY-20725-handle-HS-search-rate-limit","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"bounds":{"left":0.8081782,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"AskJiminnyReportActivityServiceTest","depth":6,"bounds":{"left":0.8234708,"top":0.019952115,"width":0.09208777,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'AskJiminnyReportActivityServiceTest'","depth":6,"bounds":{"left":0.9155585,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'AskJiminnyReportActivityServiceTest'","depth":6,"bounds":{"left":0.9268617,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"bounds":{"left":0.9381649,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"bounds":{"left":0.96609044,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"bounds":{"left":0.9773936,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"IDE and Project Settings","depth":5,"bounds":{"left":0.9886968,"top":0.019952115,"width":0.011303186,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.042220745,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"19","depth":4,"bounds":{"left":0.6615692,"top":0.10055866,"width":0.009640957,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"bounds":{"left":0.67287236,"top":0.09896249,"width":0.00731383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"bounds":{"left":0.68018615,"top":0.09896249,"width":0.006981383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {\n\"headers\":{\n\"Date\":[\"Thu,07 May 2026 14:21:15 GMT\"],\n \"Content-Type\":[\"application/json;charset=utf-8\"],\n \"Transfer-Encoding\":[\"chunked\"],\n \"Connection\":[\"keep-alive\"],\n \"CF-Ray\":[\"9f80deb8db60dc3a-SOF\"],\n \"CF-Cache-Status\":[\"DYNAMIC\"],\n \"Strict-Transport-Security\":[\"max-age=31536000; includeSubDomains; preload\"],\n \"Vary\":[\"origin,\n accept-encoding\"],\n \"access-control-allow-credentials\":[\"false\"],\n \"server-timing\":[\"hcid;desc=\\\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\\\",\n cfr;desc=\\\"9f80deb8e7c6dc3a-IAD\\\"\"],\n \"x-content-type-options\":[\"nosniff\"],\n \"x-hubspot-correlation-id\":[\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\"],\n \"Set-Cookie\":[\"__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-1.0.1.1-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,\n 07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None\"],\n \"Report-To\":[\"{\n\\\"endpoints\\\":[{\n\\\"url\\\":\\\"https:\\\\/\\\\/a.nel.cloudflare.com\\\\/report\\\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\\\"}],\n\\\"group\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"NEL\":[\"{\n\\\"success_fraction\\\":0.01,\n\\\"report_to\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"Server\":[\"cloudflare\"]}} {\n\"correlation_id\":\"95236535-ec98-4541-b92a-adfa73b69eab\",\n\"trace_id\":\"c7ab8365-903f-46d4-9403-0e5b551e3545\"}","depth":4,"bounds":{"left":0.37632978,"top":0.09736632,"width":0.5728058,"height":0.8818835},"on_screen":true,"lines":[{"char_start":207,"char_count":30,"bounds":{"left":0.37632978,"top":0.0,"width":0.07513298,"height":0.014365523}},{"char_start":237,"char_count":36,"bounds":{"left":0.37632978,"top":0.0,"width":0.09075798,"height":0.014365523}},{"char_start":273,"char_count":32,"bounds":{"left":0.37632978,"top":0.0,"width":0.080119684,"height":0.014365523}},{"char_start":305,"char_count":79,"bounds":{"left":0.37632978,"top":0.0,"width":0.20212767,"height":0.014365523}},{"char_start":384,"char_count":18,"bounds":{"left":0.37632978,"top":0.0,"width":0.043882977,"height":0.014365523}},{"char_start":402,"char_count":21,"bounds":{"left":0.37632978,"top":0.0,"width":0.051861703,"height":0.014365523}},{"char_start":423,"char_count":48,"bounds":{"left":0.37632978,"top":0.008778931,"width":0.12167553,"height":0.014365523}},{"char_start":471,"char_count":72,"bounds":{"left":0.37632978,"top":0.026336791,"width":0.18384309,"height":0.014365523}},{"char_start":543,"char_count":40,"bounds":{"left":0.37632978,"top":0.043894652,"width":0.10106383,"height":0.014365523}},{"char_start":583,"char_count":41,"bounds":{"left":0.37632978,"top":0.061452515,"width":0.10372341,"height":0.014365523}},{"char_start":624,"char_count":72,"bounds":{"left":0.37632978,"top":0.079010375,"width":0.18384309,"height":0.014365523}},{"char_start":696,"char_count":219,"bounds":{"left":0.37632978,"top":0.096568234,"width":0.56515956,"height":0.014365523}},{"char_start":915,"char_count":83,"bounds":{"left":0.37632978,"top":0.11412609,"width":0.21243352,"height":0.014365523}},{"char_start":998,"char_count":20,"bounds":{"left":0.37632978,"top":0.13168396,"width":0.04920213,"height":0.014365523}},{"char_start":1018,"char_count":17,"bounds":{"left":0.37632978,"top":0.14924182,"width":0.041223403,"height":0.014365523}},{"char_start":1035,"char_count":203,"bounds":{"left":0.37632978,"top":0.16679968,"width":0.52360374,"height":0.014365523}},{"char_start":1238,"char_count":22,"bounds":{"left":0.37632978,"top":0.18435754,"width":0.05418883,"height":0.014365523}},{"char_start":1260,"char_count":23,"bounds":{"left":0.37632978,"top":0.2019154,"width":0.056848403,"height":0.014365523}},{"char_start":1283,"char_count":10,"bounds":{"left":0.37632978,"top":0.21947326,"width":0.023271276,"height":0.014365523}},{"char_start":1293,"char_count":27,"bounds":{"left":0.37632978,"top":0.23703113,"width":0.06715426,"height":0.014365523}},{"char_start":1320,"char_count":26,"bounds":{"left":0.37632978,"top":0.254589,"width":0.06482713,"height":0.014365523}},{"char_start":1346,"char_count":23,"bounds":{"left":0.37632978,"top":0.27214685,"width":0.056848403,"height":0.014365523}},{"char_start":1369,"char_count":28,"bounds":{"left":0.37632978,"top":0.2897047,"width":0.06981383,"height":0.014365523}},{"char_start":1397,"char_count":57,"bounds":{"left":0.37632978,"top":0.30726257,"width":0.14494681,"height":0.014365523}}],"value":"[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {\n\"headers\":{\n\"Date\":[\"Thu,07 May 2026 14:21:15 GMT\"],\n \"Content-Type\":[\"application/json;charset=utf-8\"],\n \"Transfer-Encoding\":[\"chunked\"],\n \"Connection\":[\"keep-alive\"],\n \"CF-Ray\":[\"9f80deb8db60dc3a-SOF\"],\n \"CF-Cache-Status\":[\"DYNAMIC\"],\n \"Strict-Transport-Security\":[\"max-age=31536000; includeSubDomains; preload\"],\n \"Vary\":[\"origin,\n accept-encoding\"],\n \"access-control-allow-credentials\":[\"false\"],\n \"server-timing\":[\"hcid;desc=\\\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\\\",\n cfr;desc=\\\"9f80deb8e7c6dc3a-IAD\\\"\"],\n \"x-content-type-options\":[\"nosniff\"],\n \"x-hubspot-correlation-id\":[\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\"],\n \"Set-Cookie\":[\"__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-1.0.1.1-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,\n 07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None\"],\n \"Report-To\":[\"{\n\\\"endpoints\\\":[{\n\\\"url\\\":\\\"https:\\\\/\\\\/a.nel.cloudflare.com\\\\/report\\\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\\\"}],\n\\\"group\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"NEL\":[\"{\n\\\"success_fraction\\\":0.01,\n\\\"report_to\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"Server\":[\"cloudflare\"]}} {\n\"correlation_id\":\"95236535-ec98-4541-b92a-adfa73b69eab\",\n\"trace_id\":\"c7ab8365-903f-46d4-9403-0e5b551e3545\"}","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Show Replace Field","depth":4,"bounds":{"left":0.10472074,"top":0.20430966,"width":0.008643617,"height":0.01915403},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Search History","depth":3,"bounds":{"left":0.11735372,"top":0.20351157,"width":0.00731383,"height":0.017557861},"on_screen":true,"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"rateLimiter","depth":4,"bounds":{"left":0.12832446,"top":0.20351157,"width":0.043882977,"height":0.015961692},"on_screen":true,"value":"rateLimiter","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"New Line","depth":3,"bounds":{"left":0.18118352,"top":0.20351157,"width":0.00731383,"height":0.017557861},"on_screen":true,"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Match Case","depth":3,"bounds":{"left":0.19115691,"top":0.20351157,"width":0.00731383,"height":0.017557861},"on_screen":true,"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Words","depth":3,"bounds":{"left":0.19980054,"top":0.20351157,"width":0.00731383,"height":0.017557861},"on_screen":true,"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Regex","depth":3,"bounds":{"left":0.20844415,"top":0.20351157,"width":0.00731383,"height":0.017557861},"on_screen":true,"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Replace History","depth":3,"bounds":{"left":0.27027926,"top":1.0,"width":0.00731383,"height":0.0},"on_screen":false,"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextField","text":"Replace","depth":4,"on_screen":false,"role_description":"text field","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"New Line","depth":3,"bounds":{"left":0.27027926,"top":1.0,"width":0.00731383,"height":0.0},"on_screen":false,"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Preserve case","depth":3,"bounds":{"left":0.27027926,"top":1.0,"width":0.00731383,"height":0.0},"on_screen":false,"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"0 results","depth":4,"bounds":{"left":0.22207446,"top":0.20271349,"width":0.025598405,"height":0.017557861},"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Occurrence","depth":4,"bounds":{"left":0.24767287,"top":0.2019154,"width":0.008643617,"height":0.01915403},"on_screen":true,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Occurrence","depth":4,"bounds":{"left":0.25631648,"top":0.2019154,"width":0.008643617,"height":0.01915403},"on_screen":true,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Filter Search Results","depth":4,"bounds":{"left":0.2649601,"top":0.2019154,"width":0.008643617,"height":0.01915403},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Open in Window, Multiple Cursors","depth":4,"bounds":{"left":0.27360374,"top":0.2019154,"width":0.008643617,"height":0.01915403},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXLink","text":"Click to highlight","depth":4,"on_screen":false,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Close","depth":4,"bounds":{"left":0.34408244,"top":0.2019154,"width":0.008643617,"height":0.01915403},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.042220745,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"2","depth":4,"bounds":{"left":0.30219415,"top":0.2330407,"width":0.007978723,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"64","depth":4,"bounds":{"left":0.31216756,"top":0.2330407,"width":0.010305851,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"1","depth":4,"bounds":{"left":0.32446808,"top":0.2330407,"width":0.00731383,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"1","depth":4,"bounds":{"left":0.3337766,"top":0.2330407,"width":0.00731383,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"bounds":{"left":0.34275267,"top":0.23144454,"width":0.00731383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"bounds":{"left":0.35006648,"top":0.23144454,"width":0.006981383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\ndeclare(strict_types=1);\n\nnamespace Jiminny\\Services\\Crm\\Hubspot;\n\nuse HubSpot\\Client\\Crm\\Deals\\ApiException as DealApiException;\nuse HubSpot\\Client\\Crm\\Contacts\\ApiException as ContactApiException;\nuse HubSpot\\Client\\Crm\\Companies\\ApiException as CompanyApiException;\nuse HubSpot\\Client\\Crm\\Contacts\\Model\\SimplePublicObjectWithAssociations as ContactsWithAssociations;\nuse HubSpot\\Client\\Crm\\Companies\\Model\\SimplePublicObjectWithAssociations as CompaniesWithAssociations;\nuse HubSpot\\Client\\Crm\\Deals\\Model\\SimplePublicObjectWithAssociations as DealWithAssociations;\nuse HubSpot\\Client\\Crm\\Objects\\Model\\SimplePublicObjectInput;\nuse HubSpot\\Client\\Crm\\Objects\\Model\\SimplePublicObjectWithAssociations as ObjectWithAssociations;\nuse HubSpot\\Client\\Crm\\Pipelines\\Model\\Error;\nuse HubSpot\\Client\\Crm\\Pipelines\\Model\\PipelineStage;\nuse HubSpot\\Client\\Crm\\Properties\\Model\\Property;\nuse HubSpot\\Discovery\\Discovery;\nuse Jiminny\\Exceptions\\CrmException;\nuse Jiminny\\Exceptions\\RateLimitException;\nuse Jiminny\\Exceptions\\SocialAccountTokenInvalidException;\nuse Jiminny\\Jobs\\Crm\\NoteObject;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Services\\Crm\\BaseClient;\nuse Jiminny\\Services\\Crm\\Hubspot\\DTO\\Response\\Owner;\nuse Jiminny\\Services\\SocialAccountService;\nuse SevenShores\\Hubspot\\Exceptions\\BadRequest;\nuse SevenShores\\Hubspot\\Exceptions\\HubspotException;\nuse SevenShores\\Hubspot\\Factory;\nuse SevenShores\\Hubspot\\Http\\Response;\nuse Jiminny\\Services\\Crm\\Hubspot\\Pagination\\HubspotPaginationService;\nuse Throwable;\n\n/**\n * @phpstan-type CrmFieldOption array{id:string, label:string, value?:string}\n */\nclass Client extends BaseClient implements HubspotClientInterface\n{\n public const string MIN_API_VERSION = '2';\n\n public const string BASE_URL = 'https://api.hubapi.com';\n\n public const int ASSOCIATIONS_BATCH_SIZE_LIMIT = 1000;\n\n private HubspotPaginationService $paginationService;\n private HubspotTokenManager $tokenManager;\n\n public function __construct(\n SocialAccountService $socialAccountService,\n HubspotPaginationService $paginationService,\n HubspotTokenManager $tokenManager,\n ) {\n parent::__construct($socialAccountService);\n $this->paginationService = $paginationService;\n $this->tokenManager = $tokenManager;\n\n $this->setBaseUrl(self::BASE_URL);\n $this->setVersion(self::MIN_API_VERSION);\n }\n\n /**\n * Reacts to a rate limits (429) from HubSpot by translating it\n * into a RateLimitException carrying retry_after.\n *\n * Wrap any outbound HubSpot call (SDK or raw HTTP) like:\n *\n * $this->executeRequest(fn () => $this->getNewInstance()->crm()->...);\n *\n * @template T\n * @param callable(): T $apiCall\n * @return T\n *\n * @throws RateLimitException\n */\n private function executeRequest(callable $apiCall)\n {\n try {\n return $apiCall();\n } catch (Throwable $e) {\n if ($this->isHubspotRateLimit($e)) {\n $retryAfter = $this->parseRetryAfter($e);\n\n $this->log->warning('[Hubspot] Received 429 from API', [\n 'team_id' => $this->config->team_id,\n 'config_id' => $this->config->getId(),\n 'retry_after' => $retryAfter,\n 'reason' => $e->getMessage(),\n ]);\n\n throw new RateLimitException('Hubspot returned 429', $retryAfter, $e);\n }\n\n throw $e;\n }\n }\n\n public function isHubspotRateLimit(Throwable $e): bool\n {\n if ($e instanceof BadRequest\n || $e instanceof DealApiException\n || $e instanceof ContactApiException\n || $e instanceof CompanyApiException\n || $e instanceof \\GuzzleHttp\\Exception\\RequestException\n ) {\n return (int) $e->getCode() === 429;\n }\n\n return false;\n }\n\n public function parseRetryAfter(Throwable $e): int\n {\n // First try to get Retry-After from response headers\n if (method_exists($e, 'getResponseHeaders')) {\n $headers = $e->getResponseHeaders() ?: [];\n $value = $headers['Retry-After'] ?? $headers['retry-after'] ?? null;\n if (is_array($value)) {\n $value = $value[0] ?? null;\n }\n if (is_numeric($value)) {\n return (int) $value;\n }\n }\n\n if (method_exists($e, 'getResponseBody')) {\n $body = $e->getResponseBody();\n if (is_string($body)) {\n $body = json_decode($body, true) ?? [];\n }\n\n $policyName = $body['policyName'] ?? $body['policy'] ?? $body['context']['policyName'] ?? null;\n\n if ($policyName === 'TEN_SECONDLY_ROLLING' || $policyName === 'ten_secondly_rolling') {\n return 10;\n }\n if ($policyName === 'SECONDLY' || $policyName === 'secondly') {\n return 1;\n }\n }\n\n $this->log->warning('[Hubspot] No retry-after header or policy name found, using default', [\n 'exception_class' => get_class($e),\n ]);\n\n return 10;\n }\n\n public function getMinimumApiVersion(): string\n {\n return self::MIN_API_VERSION;\n }\n\n public function getInstance(): Factory\n {\n return new Factory([\n 'key' => $this->accessToken,\n 'oauth2' => true,\n 'base_url' => $this->baseUrl,\n ]);\n }\n\n public function getNewInstance(): Discovery\n {\n return \\HubSpot\\Factory::createWithAccessToken($this->accessToken);\n }\n\n /**\n * Secondly and daily limits for Hubspot API\n *\n * Product Tier: Free & Starter | Professional & Enterprise | API add-on (any tier)\n * Burst: 100/10 seconds | 150/10 seconds | 200/10 seconds\n * Daily: 250,000 | 500,000 | 1,000,000\n *\n * Official documentation states: The search endpoints are rate limited to five requests per second.\n * Since with 5 RPS were still hitting secondly rate limits we lowered it to 4\n */\n public function getPaginatedData(array $payload, string $type, int $offset = 0): array\n {\n $total = 0;\n $lastId = null;\n $rows = [];\n foreach ($this->getPaginatedDataGenerator($payload, $type, $offset, $total, $lastId) as $row) {\n $rows[] = $row;\n }\n\n return ['results' => $rows, 'total' => $total, 'last_record' => $lastId];\n }\n\n /**\n * @throws HubspotException\n * @throws SocialAccountTokenInvalidException\n * @throws BadRequest\n */\n public function getPaginatedDataGenerator(\n array $payload,\n string $type,\n int $offset = 0,\n int &$total = 0,\n ?string &$lastRecordId = null\n ): \\Generator {\n return $this->paginationService->getPaginatedDataGenerator(\n $this,\n $payload,\n $type,\n $offset,\n $total,\n $lastRecordId\n );\n }\n\n /**\n * Execute a search request against HubSpot CRM objects with rate limiting.\n *\n * @param string $objectType The object type ('deals', 'companies', 'contacts', 'calls')\n * @param array<string, mixed> $payload The search payload with filters, sorts, properties, etc.\n * @return array The search response with 'results', 'total', 'paging' keys\n * @throws RateLimitException When rate limit is hit\n * @throws HubspotException On API errors\n */\n public function search(string $objectType, array $payload): array\n {\n $endpoint = self::BASE_URL . \"/crm/v3/objects/{$objectType}/search\";\n\n return $this->executeRequest(function () use ($endpoint, $payload) {\n $response = $this->getInstance()->getClient()->request('POST', $endpoint, ['json' => $payload]);\n\n return $response->toArray();\n });\n }\n\n /**\n * @throws DealApiException\n * @throws CrmException\n */\n public function getOpportunityById(string $crmId, array $fields): array\n {\n try {\n// $deal = $this->executeRequest(fn () => $this->getNewInstance()->crm()->deals()->basicApi()->getById(\n $deal = $this->getNewInstance()->crm()->deals()->basicApi()->getById(\n $crmId,\n implode(',', $fields),\n 'companies,contacts'\n );\n } catch (DealApiException $e) {\n $this->log->info('[Hubspot] Failed to fetch opportunity', [\n 'crm_id' => $crmId,\n 'reason' => $e->getMessage(),\n ]);\n\n throw $e;\n }\n\n if (! $deal instanceof DealWithAssociations) {\n throw new CrmException('Deal not found');\n }\n\n return [\n 'id' => $deal->getId(),\n 'properties' => $deal->getProperties(),\n 'associations' => $deal->getAssociations(),\n ];\n }\n\n /**\n * Generic batch read method for HubSpot objects\n *\n * @param string $objectType The object type ('deals', 'companies', 'contacts')\n * @param array<string> $crmIds Array of HubSpot object IDs (max 100)\n * @param array<string> $fields Array of property names to fetch\n *\n * @return array<string, array> Array keyed by CRM ID with object data\n */\n private function batchReadObjects(string $objectType, array $crmIds, array $fields): array\n {\n if (empty($crmIds)) {\n return [];\n }\n\n $this->validateBatchSize($objectType, $crmIds);\n $this->ensureValidToken();\n\n try {\n $batchConfig = $this->createBatchConfiguration($objectType);\n $batchReadRequest = $this->prepareBatchRequest($batchConfig, $crmIds, $fields);\n $response = $batchConfig['api']->read($batchReadRequest);\n\n $this->validateApiResponse($response, $objectType);\n\n $results = $this->processApiResults($response);\n $this->logBatchResults($objectType, $crmIds, $results);\n\n return $results;\n } catch (\\Throwable $e) {\n $this->handleBatchError($e, $objectType, $crmIds);\n }\n }\n\n private function validateBatchSize(string $objectType, array $crmIds): void\n {\n if (count($crmIds) > 100) {\n throw new \\InvalidArgumentException(\"Batch size cannot exceed 100 {$objectType}\");\n }\n }\n\n private function createBatchConfiguration(string $objectType): array\n {\n $configurations = [\n 'deals' => [\n 'batchReadRequest' => new \\HubSpot\\Client\\Crm\\Deals\\Model\\BatchReadInputSimplePublicObjectId(),\n 'inputClass' => \\HubSpot\\Client\\Crm\\Deals\\Model\\SimplePublicObjectId::class,\n 'api' => $this->getNewInstance()->crm()->deals()->batchApi(),\n ],\n 'companies' => [\n 'batchReadRequest' => new \\HubSpot\\Client\\Crm\\Companies\\Model\\BatchReadInputSimplePublicObjectId(),\n 'inputClass' => \\HubSpot\\Client\\Crm\\Companies\\Model\\SimplePublicObjectId::class,\n 'api' => $this->getNewInstance()->crm()->companies()->batchApi(),\n ],\n 'contacts' => [\n 'batchReadRequest' => new \\HubSpot\\Client\\Crm\\Contacts\\Model\\BatchReadInputSimplePublicObjectId(),\n 'inputClass' => \\HubSpot\\Client\\Crm\\Contacts\\Model\\SimplePublicObjectId::class,\n 'api' => $this->getNewInstance()->crm()->contacts()->batchApi(),\n ],\n ];\n\n if (! isset($configurations[$objectType])) {\n throw new \\InvalidArgumentException(\"Unsupported object type: {$objectType}\");\n }\n\n return $configurations[$objectType];\n }\n\n private function prepareBatchRequest(array $batchConfig, array $crmIds, array $fields): object\n {\n $batchReadRequest = $batchConfig['batchReadRequest'];\n $inputClass = $batchConfig['inputClass'];\n\n $inputs = array_map(function ($crmId) use ($inputClass) {\n $input = new $inputClass();\n $input->setId($crmId);\n\n return $input;\n }, $crmIds);\n\n $batchReadRequest->setInputs($inputs);\n $batchReadRequest->setProperties($fields);\n\n return $batchReadRequest;\n }\n\n private function validateApiResponse($response, string $objectType): void\n {\n if (! $response) {\n throw new CrmException(\"HubSpot API returned null response for {$objectType} batch read\");\n }\n }\n\n private function processApiResults($response): array\n {\n $results = [];\n $responseResults = $response->getResults();\n\n if ($responseResults) {\n foreach ($responseResults as $object) {\n if ($object && $object->getId()) {\n $results[$object->getId()] = [\n 'id' => $object->getId(),\n 'properties' => $object->getProperties() ?: [],\n ];\n }\n }\n }\n\n return $results;\n }\n\n private function logBatchResults(string $objectType, array $crmIds, array $results): void\n {\n $this->log->info(\"[HubSpot] Batch fetched {$objectType}\", [\n 'requested_count' => count($crmIds),\n 'returned_count' => count($results),\n 'crm_ids' => $crmIds,\n ]);\n }\n\n private function handleBatchError(\\Throwable $e, string $objectType, array $crmIds): void\n {\n $errorMessage = $e->getMessage() ?: 'Unknown error';\n $errorTrace = $e->getTraceAsString() ?: 'No trace available';\n\n $this->log->error(\"[HubSpot] Failed to batch fetch {$objectType}\", [\n 'crm_ids' => $crmIds,\n 'error' => $errorMessage,\n 'trace' => $errorTrace,\n ]);\n\n throw new CrmException(\"Failed to batch fetch {$objectType}: \" . $errorMessage);\n }\n\n /**\n * Batch read multiple opportunities by their CRM IDs\n *\n * @param array<string> $crmIds Array of HubSpot deal IDs (max 100)\n * @param array<string> $fields Array of property names to fetch\n *\n * @return array<string, array> Array keyed by CRM ID with opportunity data\n */\n public function getOpportunitiesByIds(array $crmIds, array $fields): array\n {\n return $this->batchReadObjects('deals', $crmIds, $fields);\n }\n\n /**\n * Batch read multiple companies by their CRM IDs\n *\n * @param array<string> $crmIds Array of HubSpot company IDs (max 100)\n * @param array<string> $fields Array of property names to fetch\n *\n * @return array<string, array> Array keyed by CRM ID with company data\n */\n public function getCompaniesByIds(array $crmIds, array $fields): array\n {\n return $this->batchReadObjects('companies', $crmIds, $fields);\n }\n\n /**\n * Batch read multiple contacts by their CRM IDs\n *\n * @param array<string> $crmIds Array of HubSpot contact IDs (max 100)\n * @param array<string> $fields Array of property names to fetch\n *\n * @return array<string, array> Array keyed by CRM ID with contact data\n */\n public function getContactsByIds(array $crmIds, array $fields): array\n {\n return $this->batchReadObjects('contacts', $crmIds, $fields);\n }\n\n /**\n * @throws CompanyApiException\n * @throws CrmException\n */\n public function getAccountById(string $crmId, array $fields): array\n {\n try {\n $company = $this->getNewInstance()->crm()->companies()->basicApi()->getById(\n $crmId,\n implode(',', $fields),\n );\n } catch (CompanyApiException $e) {\n $this->log->info('[Hubspot] Failed to fetch account', [\n 'crm_id' => $crmId,\n 'reason' => $e->getMessage(),\n ]);\n\n throw $e;\n }\n\n if (! $company instanceof CompaniesWithAssociations) {\n throw new CrmException('Account not found');\n }\n\n return [\n 'id' => $company->getId(),\n 'properties' => $company->getProperties(),\n ];\n }\n\n /**\n * @throws ContactApiException\n * @throws CrmException\n */\n public function getContactById(string $crmId, array $fields): array\n {\n try {\n $contact = $this->getNewInstance()->crm()->contacts()->basicApi()->getById(\n $crmId,\n implode(',', $fields)\n );\n } catch (ContactApiException $e) {\n $this->log->info('[Hubspot] Failed to fetch contact', [\n 'crm_id' => $crmId,\n 'reason' => $e->getMessage(),\n ]);\n\n throw $e;\n }\n\n if (! $contact instanceof ContactsWithAssociations) {\n throw new CrmException('Contact not found');\n }\n\n return [\n 'id' => $contact->getId(),\n 'properties' => $contact->getProperties(),\n ];\n }\n\n /**\n * This is email search request that Hubspot offers as GET (more generous quota)\n */\n public function getContactByEmail(string $email, array $fields = []): array\n {\n try {\n $contact = $this->getNewInstance()->crm()->contacts()->basicApi()->getById(\n $email,\n implode(',', $fields),\n null,\n false,\n 'email'\n );\n\n return [\n 'id' => $contact->getId(),\n 'properties' => $contact->getProperties(),\n ];\n } catch (ContactApiException $e) {\n $this->log->info('[Hubspot] Failed to fetch contact', [\n 'email' => $email,\n 'reason' => $e->getMessage(),\n ]);\n\n return [];\n }\n }\n\n /**\n * @throws CrmException\n */\n public function fetchProperty(string $objectType, string $propertyId): Property\n {\n $result = $this->getNewInstance()->crm()->properties()->coreApi()->getByName($objectType, $propertyId);\n\n if (! $result instanceof Property) {\n $this->log->error('[Hubspot] Failed to fetch property', [\n 'object_type' => $objectType,\n 'property_id' => $propertyId,\n 'reason' => $result->getMessage(),\n ]);\n\n throw new CrmException('Failed to fetch property');\n }\n\n return $result;\n }\n\n /**\n * @return array<CrmFieldOption>\n */\n public function fetchPropertyOptions(string $objectType, string $propertyId): array\n {\n /** @var array<CrmFieldOption> */\n return $this->fetchProperty($objectType, $propertyId)->getOptions();\n }\n\n /**\n * @return array<array{id:string, label:string, deleted:bool}>\n */\n public function fetchCallDispositions(): array\n {\n /** @var Response $response */\n $response = $this->getInstance()->engagements()->getCallDispositions();\n\n /**\n * @var array<array{\n * id:string,\n * label:string,\n * deleted: bool\n * }>\n */\n return $response->toArray();\n }\n\n /**\n * @return array<CrmFieldOption>\n */\n public function fetchOpportunityPipelineStages(): array\n {\n $stages = [];\n $apiResponse = $this->getNewInstance()->crm()->pipelines()->pipelinesApi()->getAll('deals');\n\n if ($apiResponse instanceof Error) {\n $this->log->error('[Hubspot] Failed to fetch opportunity pipelines', [\n 'reason' => $apiResponse->getMessage(),\n ]);\n\n return [];\n }\n\n foreach ($apiResponse->getResults() as $pipeline) {\n $pipelineStages = array_map(\n static function (PipelineStage $stage) {\n return [\n 'id' => $stage->getId(),\n 'label' => $stage->getLabel(),\n ];\n },\n $pipeline->getStages()\n );\n\n $stages = array_merge($stages, $pipelineStages);\n }\n\n return $stages;\n }\n\n public function fetchOpportunityPipelines(): array\n {\n $pipelines = [];\n\n try {\n $apiResponse = $this->makeRequest('/crm/v3/pipelines/deals');\n } catch (\\Exception $e) {\n $this->log->info('[Hubspot] Failed to fetch opportunity pipelines', [\n 'reason' => $e->getMessage(),\n ]);\n\n return [];\n }\n\n $response = $apiResponse->toArray();\n\n foreach ($response['results'] as $pipeline) {\n $pipelines[] = [\n 'id' => $pipeline['id'],\n 'label' => $pipeline['label'],\n ];\n }\n\n return $pipelines;\n }\n\n /**\n * @return array<CrmFieldOption>\n */\n public function fetchMeetingOutcomeFieldOptions(Field $field): array\n {\n return $field->getCrmProviderId() === 'meetingOutcome'\n ? $this->fetchMeetingOutcomeTypes()\n : $this->fetchCallActivityTypes();\n }\n\n public function fetchMeetingOutcomeTypes(): array\n {\n return $this->extractMeetingTypeOptions(\n 'https://api.hubapi.com/crm/v3/properties/meeting/hs_meeting_outcome'\n );\n }\n\n public function fetchCallActivityTypes(): array\n {\n return $this->extractMeetingTypeOptions(\n 'https://api.hubapi.com/crm/v3/properties/call/hs_activity_type'\n );\n }\n\n private function extractMeetingTypeOptions(string $endpoint): array\n {\n /** @var Response $response */\n $response = $this->getInstance()\n ->getClient()\n ->request('GET', $endpoint);\n\n /**\n * @var array<array{\n * value: string,\n * label: string,\n * displayOrder: int\n * }> $optionData\n */\n $optionData = $response->toArray()['options'] ?? [];\n\n $options = [];\n foreach ($optionData as $item) {\n $options[] = [\n 'id' => $item['value'],\n 'value' => $item['value'],\n 'label' => $item['label'],\n 'display_order' => $item['displayOrder'],\n ];\n }\n\n return $options;\n }\n\n /**\n * @return array<CrmFieldOption>\n */\n public function fetchDispositionFieldOptions(): array\n {\n $options = [];\n\n $dispositions = $this->fetchCallDispositions();\n\n foreach ($dispositions as $disposition) {\n if ($disposition['deleted'] !== false) {\n continue;\n }\n\n $option['value'] = $disposition['id'];\n $option['id'] = $disposition['id'];\n $option['label'] = $disposition['label'];\n\n $options[] = $option;\n }\n\n return $options;\n }\n\n /**\n * @return array<CrmFieldOption>\n */\n public function fetchOpportunityFieldOptions(Field $field): array\n {\n if ($field->isStageField()) {\n return $this->fetchOpportunityPipelineStages();\n }\n\n if ($field->isPipelineField()) {\n return $this->fetchOpportunityPipelines();\n }\n\n return $this->fetchPropertyOptions('deals', $field->getCrmProviderId());\n }\n\n /**\n * @throws BadRequest\n * @throws HubspotException\n */\n public function makeRequest(string $endpoint, $method = 'GET', $payload = [], ?string $queryString = null)\n {\n $endpoint = self::BASE_URL . $endpoint;\n\n if ($method === 'GET') {\n $response = $this->getInstance()->getClient()?->request(\n method: $method,\n endpoint: $endpoint,\n query_string: $queryString\n );\n } else {\n $response = $this->getInstance()->getClient()->request($method, $endpoint, [\n 'json' => ($payload),\n ]);\n }\n//\n// $max = $response->getHeaderLine('X-HubSpot-RateLimit-Max'); // \"110\"\n// $remaining = $response->getHeaderLine('X-HubSpot-RateLimit-Remaining'); // \"109\"\n// $interval = $response->getHeaderLine('X-HubSpot-RateLimit-Interval-Milliseconds'); // \"10000\"\n// $body = json_decode((string) $response->getBody(), true);\n//\n// \\Illuminate\\Support\\Facades\\Log::channel('custom_channel')->info('$max ' . PHP_EOL . print_r($max, true));\n// \\Illuminate\\Support\\Facades\\Log::channel('custom_channel')->info('$remaining ' . PHP_EOL . print_r($remaining, true));\n// \\Illuminate\\Support\\Facades\\Log::channel('custom_channel')->info('$interval ' . PHP_EOL . print_r($interval, true));\n// \\Illuminate\\Support\\Facades\\Log::channel('custom_channel')->info('$body ' . PHP_EOL . print_r($body, true));\n\n return $response;\n }\n\n /**\n * @throws BadRequest\n * @throws HubspotException\n */\n public function createMeeting(array $payload): Response\n {\n $endpoint = '/crm/v3/objects/meetings';\n\n return $this->makeRequest($endpoint, 'POST', $payload);\n }\n\n /**\n * @throws BadRequest\n * @throws HubspotException\n */\n public function updateMeeting(string $meetingId, array $payload): Response\n {\n $endpoint = '/crm/v3/objects/meetings/' . $meetingId;\n\n return $this->makeRequest($endpoint, 'PATCH', $payload);\n }\n\n /**\n * @throws \\Exception\n */\n public function createNote(\n string $body,\n string $ownerId,\n int $timestamp,\n string $objectId,\n NoteObject $noteObject\n ): ?string {\n try {\n $noteInput = new SimplePublicObjectInput([\n 'properties' => [\n 'hs_note_body' => $body,\n 'hubspot_owner_id' => $ownerId,\n 'hs_timestamp' => $timestamp,\n ],\n ]);\n\n // Create note\n $note = $this->getNewInstance()->crm()->objects()->basicApi()->create('note', $noteInput);\n\n $this->getNewInstance()->crm()->objects()->associationsApi()->create(\n 'note',\n $note->getId(),\n $this->getNoteObject($noteObject),\n $objectId,\n $this->getNoteAssociationType($noteObject),\n );\n\n return $note->getId();\n } catch (\\Exception $e) {\n $this->log->error('[Hubspot] Failed to create note', [\n 'objectId' => $objectId,\n 'noteObject' => $noteObject->getObjectType(),\n 'reason' => $e->getMessage(),\n ]);\n\n \\Sentry::captureException($e);\n }\n\n return null;\n }\n\n public function updateEngagement(string $objectId, array $engagement, array $metadata): void\n {\n $this->getInstance()->engagements()->update($objectId, $engagement, $metadata);\n }\n\n public function getEngagementData(string $engagementId): array\n {\n $engagement = $this->getInstance()->engagements()->get($engagementId);\n\n return $engagement->toArray();\n }\n\n public function createEngagement(array $engagement, array $associations, array $metadata): Response\n {\n return $this->getInstance()\n ->engagements()\n ->create($engagement, $associations, $metadata);\n }\n\n public function isUnauthorizedException(\\Exception $e): bool\n {\n if ($e instanceof BadRequest\n || $e instanceof DealApiException\n || $e instanceof ContactApiException\n || $e instanceof CompanyApiException\n ) {\n return (int) $e->getCode() === 401;\n }\n\n if ($e instanceof \\GuzzleHttp\\Exception\\RequestException && $e->hasResponse()) {\n return $e->getResponse()?->getStatusCode() === 401;\n }\n\n $message = strtolower($e->getMessage());\n\n return str_contains($message, '401 unauthorized') ||\n str_contains($message, 'http 401') ||\n str_contains($message, 'status code 401') ||\n (preg_match('/\\b401\\b/', $message) === 1 && str_contains($message, 'unauthorized'));\n }\n\n /**\n * Validates and refreshes the access token if needed before API requests.\n * This ensures long-running processes don't fail due to token expiration.\n *\n * @throws SocialAccountTokenInvalidException\n */\n public function ensureValidToken(): void\n {\n if ($this->oauthAccount === null) {\n return;\n }\n\n $newToken = $this->tokenManager->ensureValidToken($this->oauthAccount);\n if ($newToken !== null) {\n $this->accessToken = $newToken;\n }\n }\n\n public function getConfig()\n {\n return $this->config;\n }\n\n // returns only active (archived=false)\n public function getOwners(): array\n {\n return $this->getNewInstance()->crm()->owners()->getAll();\n }\n\n /**\n * @param bool $archived\n *\n * @return array<Owner>|[]\n */\n public function getOwnersArchived(bool $archived = true): array\n {\n $endpoint = '/crm/v3/owners';\n $queryParams = [\n 'archived' => $archived ? 'true' : 'false',\n ];\n $queryString = http_build_query($queryParams);\n\n $owners = [];\n\n try {\n $response = $this->makeRequest(endpoint: $endpoint, queryString: $queryString);\n $responseData = $response?->toArray();\n\n foreach ($responseData['results'] as $result) {\n try {\n $owners[] = Owner::create($result);\n } catch (Throwable $e) {\n $this->log->error('[HubSpot] Failed to process owner data', [\n 'result' => $result,\n 'error' => $e->getMessage(),\n ]);\n\n continue;\n }\n }\n } catch (Throwable $e) {\n $this->log->error('HubSpot] Failed to fetch owners', [\n 'archived' => $archived,\n 'error' => $e->getMessage(),\n ]);\n\n return [];\n }\n\n return $owners;\n }\n\n public function getMeeting(string $engagementId): ObjectWithAssociations\n {\n return $this->getNewInstance()->crm()->objects()->basicApi()\n ->getById('meeting', $engagementId, null, 'contact,company,deal');\n }\n\n public function deleteEngagement(string $engagementId): void\n {\n $this->getInstance()->engagements()->delete((int) $engagementId);\n }\n\n public function getAssociationsData(array $ids, string $fromObject, string $toObject): array\n {\n $associationData = [];\n $idChunks = array_chunk($ids, self::ASSOCIATIONS_BATCH_SIZE_LIMIT);\n\n foreach ($idChunks as $idChunk) {\n try {\n $batchInput = new \\HubSpot\\Client\\Crm\\Associations\\Model\\BatchInputPublicObjectId();\n $batchInput->setInputs(array_map(function ($id) {\n $publicObjectId = new \\HubSpot\\Client\\Crm\\Associations\\Model\\PublicObjectId();\n $publicObjectId->setId($id);\n\n return $publicObjectId;\n }, $idChunk));\n\n $associatedObjectsData = $this\n ->getNewInstance()\n ->crm()\n ->associations()\n ->batchApi()\n ->read($fromObject, $toObject, $batchInput);\n\n if ($associatedObjectsData instanceof \\HubSpot\\Client\\Crm\\Associations\\Model\\BatchResponsePublicAssociationMulti) {\n foreach ($associatedObjectsData->getResults() as $association) {\n $from = $association->getFrom()->getId();\n $toAssociations = $association->getTo();\n\n if (! empty($toAssociations)) {\n $associationData[$from] = array_map(function ($item) {\n return $item->getId();\n }, $toAssociations);\n }\n }\n }\n } catch (RateLimitException $e) {\n throw $e;\n } catch (\\Exception $e) {\n $this->log->error('[Hubspot] Failed to fetch associations', [\n 'from_object' => $fromObject,\n 'to_object' => $toObject,\n 'reason' => $e->getMessage(),\n ]);\n }\n }\n\n return $associationData;\n }\n\n /**\n * @throws \\Exception\n */\n private function getNoteAssociationType(NoteObject $noteObject): string\n {\n return match($noteObject) {\n NoteObject::Opportunity => 'note_to_deal',\n NoteObject::Lead, NoteObject::Contact => 'note_to_contact', // or 'note_to_lead' if your portal supports it\n NoteObject::Account => 'note_to_company',\n NoteObject::Call, NoteObject::Event => throw new \\Exception('Not supported'),\n };\n }\n\n /**\n * @throws \\Exception\n */\n private function getNoteObject(NoteObject $noteObject): string\n {\n return match($noteObject) {\n NoteObject::Opportunity => 'deal',\n NoteObject::Lead, NoteObject::Contact => 'contact',\n NoteObject::Account => 'company',\n NoteObject::Call, NoteObject::Event => throw new \\Exception('Not supported'),\n };\n }\n\n public function addAssociations(string $objectType, string $associationType, array $payload): Response\n {\n $endpoint = \"/crm/v4/associations/$objectType/$associationType/batch/create\";\n\n return $this->makeRequest($endpoint, 'POST', $payload);\n }\n\n public function removeAssociations(string $objectType, string $associationType, array $payload): Response\n {\n $endpoint = \"/crm/v4/associations/$objectType/$associationType/batch/archive\";\n\n return $this->makeRequest($endpoint, 'POST', $payload);\n }\n}","depth":4,"on_screen":true,"value":"<?php\n\ndeclare(strict_types=1);\n\nnamespace Jiminny\\Services\\Crm\\Hubspot;\n\nuse HubSpot\\Client\\Crm\\Deals\\ApiException as DealApiException;\nuse HubSpot\\Client\\Crm\\Contacts\\ApiException as ContactApiException;\nuse HubSpot\\Client\\Crm\\Companies\\ApiException as CompanyApiException;\nuse HubSpot\\Client\\Crm\\Contacts\\Model\\SimplePublicObjectWithAssociations as ContactsWithAssociations;\nuse HubSpot\\Client\\Crm\\Companies\\Model\\SimplePublicObjectWithAssociations as CompaniesWithAssociations;\nuse HubSpot\\Client\\Crm\\Deals\\Model\\SimplePublicObjectWithAssociations as DealWithAssociations;\nuse HubSpot\\Client\\Crm\\Objects\\Model\\SimplePublicObjectInput;\nuse HubSpot\\Client\\Crm\\Objects\\Model\\SimplePublicObjectWithAssociations as ObjectWithAssociations;\nuse HubSpot\\Client\\Crm\\Pipelines\\Model\\Error;\nuse HubSpot\\Client\\Crm\\Pipelines\\Model\\PipelineStage;\nuse HubSpot\\Client\\Crm\\Properties\\Model\\Property;\nuse HubSpot\\Discovery\\Discovery;\nuse Jiminny\\Exceptions\\CrmException;\nuse Jiminny\\Exceptions\\RateLimitException;\nuse Jiminny\\Exceptions\\SocialAccountTokenInvalidException;\nuse Jiminny\\Jobs\\Crm\\NoteObject;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Services\\Crm\\BaseClient;\nuse Jiminny\\Services\\Crm\\Hubspot\\DTO\\Response\\Owner;\nuse Jiminny\\Services\\SocialAccountService;\nuse SevenShores\\Hubspot\\Exceptions\\BadRequest;\nuse SevenShores\\Hubspot\\Exceptions\\HubspotException;\nuse SevenShores\\Hubspot\\Factory;\nuse SevenShores\\Hubspot\\Http\\Response;\nuse Jiminny\\Services\\Crm\\Hubspot\\Pagination\\HubspotPaginationService;\nuse Throwable;\n\n/**\n * @phpstan-type CrmFieldOption array{id:string, label:string, value?:string}\n */\nclass Client extends BaseClient implements HubspotClientInterface\n{\n public const string MIN_API_VERSION = '2';\n\n public const string BASE_URL = 'https://api.hubapi.com';\n\n public const int ASSOCIATIONS_BATCH_SIZE_LIMIT = 1000;\n\n private HubspotPaginationService $paginationService;\n private HubspotTokenManager $tokenManager;\n\n public function __construct(\n SocialAccountService $socialAccountService,\n HubspotPaginationService $paginationService,\n HubspotTokenManager $tokenManager,\n ) {\n parent::__construct($socialAccountService);\n $this->paginationService = $paginationService;\n $this->tokenManager = $tokenManager;\n\n $this->setBaseUrl(self::BASE_URL);\n $this->setVersion(self::MIN_API_VERSION);\n }\n\n /**\n * Reacts to a rate limits (429) from HubSpot by translating it\n * into a RateLimitException carrying retry_after.\n *\n * Wrap any outbound HubSpot call (SDK or raw HTTP) like:\n *\n * $this->executeRequest(fn () => $this->getNewInstance()->crm()->...);\n *\n * @template T\n * @param callable(): T $apiCall\n * @return T\n *\n * @throws RateLimitException\n */\n private function executeRequest(callable $apiCall)\n {\n try {\n return $apiCall();\n } catch (Throwable $e) {\n if ($this->isHubspotRateLimit($e)) {\n $retryAfter = $this->parseRetryAfter($e);\n\n $this->log->warning('[Hubspot] Received 429 from API', [\n 'team_id' => $this->config->team_id,\n 'config_id' => $this->config->getId(),\n 'retry_after' => $retryAfter,\n 'reason' => $e->getMessage(),\n ]);\n\n throw new RateLimitException('Hubspot returned 429', $retryAfter, $e);\n }\n\n throw $e;\n }\n }\n\n public function isHubspotRateLimit(Throwable $e): bool\n {\n if ($e instanceof BadRequest\n || $e instanceof DealApiException\n || $e instanceof ContactApiException\n || $e instanceof CompanyApiException\n || $e instanceof \\GuzzleHttp\\Exception\\RequestException\n ) {\n return (int) $e->getCode() === 429;\n }\n\n return false;\n }\n\n public function parseRetryAfter(Throwable $e): int\n {\n // First try to get Retry-After from response headers\n if (method_exists($e, 'getResponseHeaders')) {\n $headers = $e->getResponseHeaders() ?: [];\n $value = $headers['Retry-After'] ?? $headers['retry-after'] ?? null;\n if (is_array($value)) {\n $value = $value[0] ?? null;\n }\n if (is_numeric($value)) {\n return (int) $value;\n }\n }\n\n if (method_exists($e, 'getResponseBody')) {\n $body = $e->getResponseBody();\n if (is_string($body)) {\n $body = json_decode($body, true) ?? [];\n }\n\n $policyName = $body['policyName'] ?? $body['policy'] ?? $body['context']['policyName'] ?? null;\n\n if ($policyName === 'TEN_SECONDLY_ROLLING' || $policyName === 'ten_secondly_rolling') {\n return 10;\n }\n if ($policyName === 'SECONDLY' || $policyName === 'secondly') {\n return 1;\n }\n }\n\n $this->log->warning('[Hubspot] No retry-after header or policy name found, using default', [\n 'exception_class' => get_class($e),\n ]);\n\n return 10;\n }\n\n public function getMinimumApiVersion(): string\n {\n return self::MIN_API_VERSION;\n }\n\n public function getInstance(): Factory\n {\n return new Factory([\n 'key' => $this->accessToken,\n 'oauth2' => true,\n 'base_url' => $this->baseUrl,\n ]);\n }\n\n public function getNewInstance(): Discovery\n {\n return \\HubSpot\\Factory::createWithAccessToken($this->accessToken);\n }\n\n /**\n * Secondly and daily limits for Hubspot API\n *\n * Product Tier: Free & Starter | Professional & Enterprise | API add-on (any tier)\n * Burst: 100/10 seconds | 150/10 seconds | 200/10 seconds\n * Daily: 250,000 | 500,000 | 1,000,000\n *\n * Official documentation states: The search endpoints are rate limited to five requests per second.\n * Since with 5 RPS were still hitting secondly rate limits we lowered it to 4\n */\n public function getPaginatedData(array $payload, string $type, int $offset = 0): array\n {\n $total = 0;\n $lastId = null;\n $rows = [];\n foreach ($this->getPaginatedDataGenerator($payload, $type, $offset, $total, $lastId) as $row) {\n $rows[] = $row;\n }\n\n return ['results' => $rows, 'total' => $total, 'last_record' => $lastId];\n }\n\n /**\n * @throws HubspotException\n * @throws SocialAccountTokenInvalidException\n * @throws BadRequest\n */\n public function getPaginatedDataGenerator(\n array $payload,\n string $type,\n int $offset = 0,\n int &$total = 0,\n ?string &$lastRecordId = null\n ): \\Generator {\n return $this->paginationService->getPaginatedDataGenerator(\n $this,\n $payload,\n $type,\n $offset,\n $total,\n $lastRecordId\n );\n }\n\n /**\n * Execute a search request against HubSpot CRM objects with rate limiting.\n *\n * @param string $objectType The object type ('deals', 'companies', 'contacts', 'calls')\n * @param array<string, mixed> $payload The search payload with filters, sorts, properties, etc.\n * @return array The search response with 'results', 'total', 'paging' keys\n * @throws RateLimitException When rate limit is hit\n * @throws HubspotException On API errors\n */\n public function search(string $objectType, array $payload): array\n {\n $endpoint = self::BASE_URL . \"/crm/v3/objects/{$objectType}/search\";\n\n return $this->executeRequest(function () use ($endpoint, $payload) {\n $response = $this->getInstance()->getClient()->request('POST', $endpoint, ['json' => $payload]);\n\n return $response->toArray();\n });\n }\n\n /**\n * @throws DealApiException\n * @throws CrmException\n */\n public function getOpportunityById(string $crmId, array $fields): array\n {\n try {\n// $deal = $this->executeRequest(fn () => $this->getNewInstance()->crm()->deals()->basicApi()->getById(\n $deal = $this->getNewInstance()->crm()->deals()->basicApi()->getById(\n $crmId,\n implode(',', $fields),\n 'companies,contacts'\n );\n } catch (DealApiException $e) {\n $this->log->info('[Hubspot] Failed to fetch opportunity', [\n 'crm_id' => $crmId,\n 'reason' => $e->getMessage(),\n ]);\n\n throw $e;\n }\n\n if (! $deal instanceof DealWithAssociations) {\n throw new CrmException('Deal not found');\n }\n\n return [\n 'id' => $deal->getId(),\n 'properties' => $deal->getProperties(),\n 'associations' => $deal->getAssociations(),\n ];\n }\n\n /**\n * Generic batch read method for HubSpot objects\n *\n * @param string $objectType The object type ('deals', 'companies', 'contacts')\n * @param array<string> $crmIds Array of HubSpot object IDs (max 100)\n * @param array<string> $fields Array of property names to fetch\n *\n * @return array<string, array> Array keyed by CRM ID with object data\n */\n private function batchReadObjects(string $objectType, array $crmIds, array $fields): array\n {\n if (empty($crmIds)) {\n return [];\n }\n\n $this->validateBatchSize($objectType, $crmIds);\n $this->ensureValidToken();\n\n try {\n $batchConfig = $this->createBatchConfiguration($objectType);\n $batchReadRequest = $this->prepareBatchRequest($batchConfig, $crmIds, $fields);\n $response = $batchConfig['api']->read($batchReadRequest);\n\n $this->validateApiResponse($response, $objectType);\n\n $results = $this->processApiResults($response);\n $this->logBatchResults($objectType, $crmIds, $results);\n\n return $results;\n } catch (\\Throwable $e) {\n $this->handleBatchError($e, $objectType, $crmIds);\n }\n }\n\n private function validateBatchSize(string $objectType, array $crmIds): void\n {\n if (count($crmIds) > 100) {\n throw new \\InvalidArgumentException(\"Batch size cannot exceed 100 {$objectType}\");\n }\n }\n\n private function createBatchConfiguration(string $objectType): array\n {\n $configurations = [\n 'deals' => [\n 'batchReadRequest' => new \\HubSpot\\Client\\Crm\\Deals\\Model\\BatchReadInputSimplePublicObjectId(),\n 'inputClass' => \\HubSpot\\Client\\Crm\\Deals\\Model\\SimplePublicObjectId::class,\n 'api' => $this->getNewInstance()->crm()->deals()->batchApi(),\n ],\n 'companies' => [\n 'batchReadRequest' => new \\HubSpot\\Client\\Crm\\Companies\\Model\\BatchReadInputSimplePublicObjectId(),\n 'inputClass' => \\HubSpot\\Client\\Crm\\Companies\\Model\\SimplePublicObjectId::class,\n 'api' => $this->getNewInstance()->crm()->companies()->batchApi(),\n ],\n 'contacts' => [\n 'batchReadRequest' => new \\HubSpot\\Client\\Crm\\Contacts\\Model\\BatchReadInputSimplePublicObjectId(),\n 'inputClass' => \\HubSpot\\Client\\Crm\\Contacts\\Model\\SimplePublicObjectId::class,\n 'api' => $this->getNewInstance()->crm()->contacts()->batchApi(),\n ],\n ];\n\n if (! isset($configurations[$objectType])) {\n throw new \\InvalidArgumentException(\"Unsupported object type: {$objectType}\");\n }\n\n return $configurations[$objectType];\n }\n\n private function prepareBatchRequest(array $batchConfig, array $crmIds, array $fields): object\n {\n $batchReadRequest = $batchConfig['batchReadRequest'];\n $inputClass = $batchConfig['inputClass'];\n\n $inputs = array_map(function ($crmId) use ($inputClass) {\n $input = new $inputClass();\n $input->setId($crmId);\n\n return $input;\n }, $crmIds);\n\n $batchReadRequest->setInputs($inputs);\n $batchReadRequest->setProperties($fields);\n\n return $batchReadRequest;\n }\n\n private function validateApiResponse($response, string $objectType): void\n {\n if (! $response) {\n throw new CrmException(\"HubSpot API returned null response for {$objectType} batch read\");\n }\n }\n\n private function processApiResults($response): array\n {\n $results = [];\n $responseResults = $response->getResults();\n\n if ($responseResults) {\n foreach ($responseResults as $object) {\n if ($object && $object->getId()) {\n $results[$object->getId()] = [\n 'id' => $object->getId(),\n 'properties' => $object->getProperties() ?: [],\n ];\n }\n }\n }\n\n return $results;\n }\n\n private function logBatchResults(string $objectType, array $crmIds, array $results): void\n {\n $this->log->info(\"[HubSpot] Batch fetched {$objectType}\", [\n 'requested_count' => count($crmIds),\n 'returned_count' => count($results),\n 'crm_ids' => $crmIds,\n ]);\n }\n\n private function handleBatchError(\\Throwable $e, string $objectType, array $crmIds): void\n {\n $errorMessage = $e->getMessage() ?: 'Unknown error';\n $errorTrace = $e->getTraceAsString() ?: 'No trace available';\n\n $this->log->error(\"[HubSpot] Failed to batch fetch {$objectType}\", [\n 'crm_ids' => $crmIds,\n 'error' => $errorMessage,\n 'trace' => $errorTrace,\n ]);\n\n throw new CrmException(\"Failed to batch fetch {$objectType}: \" . $errorMessage);\n }\n\n /**\n * Batch read multiple opportunities by their CRM IDs\n *\n * @param array<string> $crmIds Array of HubSpot deal IDs (max 100)\n * @param array<string> $fields Array of property names to fetch\n *\n * @return array<string, array> Array keyed by CRM ID with opportunity data\n */\n public function getOpportunitiesByIds(array $crmIds, array $fields): array\n {\n return $this->batchReadObjects('deals', $crmIds, $fields);\n }\n\n /**\n * Batch read multiple companies by their CRM IDs\n *\n * @param array<string> $crmIds Array of HubSpot company IDs (max 100)\n * @param array<string> $fields Array of property names to fetch\n *\n * @return array<string, array> Array keyed by CRM ID with company data\n */\n public function getCompaniesByIds(array $crmIds, array $fields): array\n {\n return $this->batchReadObjects('companies', $crmIds, $fields);\n }\n\n /**\n * Batch read multiple contacts by their CRM IDs\n *\n * @param array<string> $crmIds Array of HubSpot contact IDs (max 100)\n * @param array<string> $fields Array of property names to fetch\n *\n * @return array<string, array> Array keyed by CRM ID with contact data\n */\n public function getContactsByIds(array $crmIds, array $fields): array\n {\n return $this->batchReadObjects('contacts', $crmIds, $fields);\n }\n\n /**\n * @throws CompanyApiException\n * @throws CrmException\n */\n public function getAccountById(string $crmId, array $fields): array\n {\n try {\n $company = $this->getNewInstance()->crm()->companies()->basicApi()->getById(\n $crmId,\n implode(',', $fields),\n );\n } catch (CompanyApiException $e) {\n $this->log->info('[Hubspot] Failed to fetch account', [\n 'crm_id' => $crmId,\n 'reason' => $e->getMessage(),\n ]);\n\n throw $e;\n }\n\n if (! $company instanceof CompaniesWithAssociations) {\n throw new CrmException('Account not found');\n }\n\n return [\n 'id' => $company->getId(),\n 'properties' => $company->getProperties(),\n ];\n }\n\n /**\n * @throws ContactApiException\n * @throws CrmException\n */\n public function getContactById(string $crmId, array $fields): array\n {\n try {\n $contact = $this->getNewInstance()->crm()->contacts()->basicApi()->getById(\n $crmId,\n implode(',', $fields)\n );\n } catch (ContactApiException $e) {\n $this->log->info('[Hubspot] Failed to fetch contact', [\n 'crm_id' => $crmId,\n 'reason' => $e->getMessage(),\n ]);\n\n throw $e;\n }\n\n if (! $contact instanceof ContactsWithAssociations) {\n throw new CrmException('Contact not found');\n }\n\n return [\n 'id' => $contact->getId(),\n 'properties' => $contact->getProperties(),\n ];\n }\n\n /**\n * This is email search request that Hubspot offers as GET (more generous quota)\n */\n public function getContactByEmail(string $email, array $fields = []): array\n {\n try {\n $contact = $this->getNewInstance()->crm()->contacts()->basicApi()->getById(\n $email,\n implode(',', $fields),\n null,\n false,\n 'email'\n );\n\n return [\n 'id' => $contact->getId(),\n 'properties' => $contact->getProperties(),\n ];\n } catch (ContactApiException $e) {\n $this->log->info('[Hubspot] Failed to fetch contact', [\n 'email' => $email,\n 'reason' => $e->getMessage(),\n ]);\n\n return [];\n }\n }\n\n /**\n * @throws CrmException\n */\n public function fetchProperty(string $objectType, string $propertyId): Property\n {\n $result = $this->getNewInstance()->crm()->properties()->coreApi()->getByName($objectType, $propertyId);\n\n if (! $result instanceof Property) {\n $this->log->error('[Hubspot] Failed to fetch property', [\n 'object_type' => $objectType,\n 'property_id' => $propertyId,\n 'reason' => $result->getMessage(),\n ]);\n\n throw new CrmException('Failed to fetch property');\n }\n\n return $result;\n }\n\n /**\n * @return array<CrmFieldOption>\n */\n public function fetchPropertyOptions(string $objectType, string $propertyId): array\n {\n /** @var array<CrmFieldOption> */\n return $this->fetchProperty($objectType, $propertyId)->getOptions();\n }\n\n /**\n * @return array<array{id:string, label:string, deleted:bool}>\n */\n public function fetchCallDispositions(): array\n {\n /** @var Response $response */\n $response = $this->getInstance()->engagements()->getCallDispositions();\n\n /**\n * @var array<array{\n * id:string,\n * label:string,\n * deleted: bool\n * }>\n */\n return $response->toArray();\n }\n\n /**\n * @return array<CrmFieldOption>\n */\n public function fetchOpportunityPipelineStages(): array\n {\n $stages = [];\n $apiResponse = $this->getNewInstance()->crm()->pipelines()->pipelinesApi()->getAll('deals');\n\n if ($apiResponse instanceof Error) {\n $this->log->error('[Hubspot] Failed to fetch opportunity pipelines', [\n 'reason' => $apiResponse->getMessage(),\n ]);\n\n return [];\n }\n\n foreach ($apiResponse->getResults() as $pipeline) {\n $pipelineStages = array_map(\n static function (PipelineStage $stage) {\n return [\n 'id' => $stage->getId(),\n 'label' => $stage->getLabel(),\n ];\n },\n $pipeline->getStages()\n );\n\n $stages = array_merge($stages, $pipelineStages);\n }\n\n return $stages;\n }\n\n public function fetchOpportunityPipelines(): array\n {\n $pipelines = [];\n\n try {\n $apiResponse = $this->makeRequest('/crm/v3/pipelines/deals');\n } catch (\\Exception $e) {\n $this->log->info('[Hubspot] Failed to fetch opportunity pipelines', [\n 'reason' => $e->getMessage(),\n ]);\n\n return [];\n }\n\n $response = $apiResponse->toArray();\n\n foreach ($response['results'] as $pipeline) {\n $pipelines[] = [\n 'id' => $pipeline['id'],\n 'label' => $pipeline['label'],\n ];\n }\n\n return $pipelines;\n }\n\n /**\n * @return array<CrmFieldOption>\n */\n public function fetchMeetingOutcomeFieldOptions(Field $field): array\n {\n return $field->getCrmProviderId() === 'meetingOutcome'\n ? $this->fetchMeetingOutcomeTypes()\n : $this->fetchCallActivityTypes();\n }\n\n public function fetchMeetingOutcomeTypes(): array\n {\n return $this->extractMeetingTypeOptions(\n 'https://api.hubapi.com/crm/v3/properties/meeting/hs_meeting_outcome'\n );\n }\n\n public function fetchCallActivityTypes(): array\n {\n return $this->extractMeetingTypeOptions(\n 'https://api.hubapi.com/crm/v3/properties/call/hs_activity_type'\n );\n }\n\n private function extractMeetingTypeOptions(string $endpoint): array\n {\n /** @var Response $response */\n $response = $this->getInstance()\n ->getClient()\n ->request('GET', $endpoint);\n\n /**\n * @var array<array{\n * value: string,\n * label: string,\n * displayOrder: int\n * }> $optionData\n */\n $optionData = $response->toArray()['options'] ?? [];\n\n $options = [];\n foreach ($optionData as $item) {\n $options[] = [\n 'id' => $item['value'],\n 'value' => $item['value'],\n 'label' => $item['label'],\n 'display_order' => $item['displayOrder'],\n ];\n }\n\n return $options;\n }\n\n /**\n * @return array<CrmFieldOption>\n */\n public function fetchDispositionFieldOptions(): array\n {\n $options = [];\n\n $dispositions = $this->fetchCallDispositions();\n\n foreach ($dispositions as $disposition) {\n if ($disposition['deleted'] !== false) {\n continue;\n }\n\n $option['value'] = $disposition['id'];\n $option['id'] = $disposition['id'];\n $option['label'] = $disposition['label'];\n\n $options[] = $option;\n }\n\n return $options;\n }\n\n /**\n * @return array<CrmFieldOption>\n */\n public function fetchOpportunityFieldOptions(Field $field): array\n {\n if ($field->isStageField()) {\n return $this->fetchOpportunityPipelineStages();\n }\n\n if ($field->isPipelineField()) {\n return $this->fetchOpportunityPipelines();\n }\n\n return $this->fetchPropertyOptions('deals', $field->getCrmProviderId());\n }\n\n /**\n * @throws BadRequest\n * @throws HubspotException\n */\n public function makeRequest(string $endpoint, $method = 'GET', $payload = [], ?string $queryString = null)\n {\n $endpoint = self::BASE_URL . $endpoint;\n\n if ($method === 'GET') {\n $response = $this->getInstance()->getClient()?->request(\n method: $method,\n endpoint: $endpoint,\n query_string: $queryString\n );\n } else {\n $response = $this->getInstance()->getClient()->request($method, $endpoint, [\n 'json' => ($payload),\n ]);\n }\n//\n// $max = $response->getHeaderLine('X-HubSpot-RateLimit-Max'); // \"110\"\n// $remaining = $response->getHeaderLine('X-HubSpot-RateLimit-Remaining'); // \"109\"\n// $interval = $response->getHeaderLine('X-HubSpot-RateLimit-Interval-Milliseconds'); // \"10000\"\n// $body = json_decode((string) $response->getBody(), true);\n//\n// \\Illuminate\\Support\\Facades\\Log::channel('custom_channel')->info('$max ' . PHP_EOL . print_r($max, true));\n// \\Illuminate\\Support\\Facades\\Log::channel('custom_channel')->info('$remaining ' . PHP_EOL . print_r($remaining, true));\n// \\Illuminate\\Support\\Facades\\Log::channel('custom_channel')->info('$interval ' . PHP_EOL . print_r($interval, true));\n// \\Illuminate\\Support\\Facades\\Log::channel('custom_channel')->info('$body ' . PHP_EOL . print_r($body, true));\n\n return $response;\n }\n\n /**\n * @throws BadRequest\n * @throws HubspotException\n */\n public function createMeeting(array $payload): Response\n {\n $endpoint = '/crm/v3/objects/meetings';\n\n return $this->makeRequest($endpoint, 'POST', $payload);\n }\n\n /**\n * @throws BadRequest\n * @throws HubspotException\n */\n public function updateMeeting(string $meetingId, array $payload): Response\n {\n $endpoint = '/crm/v3/objects/meetings/' . $meetingId;\n\n return $this->makeRequest($endpoint, 'PATCH', $payload);\n }\n\n /**\n * @throws \\Exception\n */\n public function createNote(\n string $body,\n string $ownerId,\n int $timestamp,\n string $objectId,\n NoteObject $noteObject\n ): ?string {\n try {\n $noteInput = new SimplePublicObjectInput([\n 'properties' => [\n 'hs_note_body' => $body,\n 'hubspot_owner_id' => $ownerId,\n 'hs_timestamp' => $timestamp,\n ],\n ]);\n\n // Create note\n $note = $this->getNewInstance()->crm()->objects()->basicApi()->create('note', $noteInput);\n\n $this->getNewInstance()->crm()->objects()->associationsApi()->create(\n 'note',\n $note->getId(),\n $this->getNoteObject($noteObject),\n $objectId,\n $this->getNoteAssociationType($noteObject),\n );\n\n return $note->getId();\n } catch (\\Exception $e) {\n $this->log->error('[Hubspot] Failed to create note', [\n 'objectId' => $objectId,\n 'noteObject' => $noteObject->getObjectType(),\n 'reason' => $e->getMessage(),\n ]);\n\n \\Sentry::captureException($e);\n }\n\n return null;\n }\n\n public function updateEngagement(string $objectId, array $engagement, array $metadata): void\n {\n $this->getInstance()->engagements()->update($objectId, $engagement, $metadata);\n }\n\n public function getEngagementData(string $engagementId): array\n {\n $engagement = $this->getInstance()->engagements()->get($engagementId);\n\n return $engagement->toArray();\n }\n\n public function createEngagement(array $engagement, array $associations, array $metadata): Response\n {\n return $this->getInstance()\n ->engagements()\n ->create($engagement, $associations, $metadata);\n }\n\n public function isUnauthorizedException(\\Exception $e): bool\n {\n if ($e instanceof BadRequest\n || $e instanceof DealApiException\n || $e instanceof ContactApiException\n || $e instanceof CompanyApiException\n ) {\n return (int) $e->getCode() === 401;\n }\n\n if ($e instanceof \\GuzzleHttp\\Exception\\RequestException && $e->hasResponse()) {\n return $e->getResponse()?->getStatusCode() === 401;\n }\n\n $message = strtolower($e->getMessage());\n\n return str_contains($message, '401 unauthorized') ||\n str_contains($message, 'http 401') ||\n str_contains($message, 'status code 401') ||\n (preg_match('/\\b401\\b/', $message) === 1 && str_contains($message, 'unauthorized'));\n }\n\n /**\n * Validates and refreshes the access token if needed before API requests.\n * This ensures long-running processes don't fail due to token expiration.\n *\n * @throws SocialAccountTokenInvalidException\n */\n public function ensureValidToken(): void\n {\n if ($this->oauthAccount === null) {\n return;\n }\n\n $newToken = $this->tokenManager->ensureValidToken($this->oauthAccount);\n if ($newToken !== null) {\n $this->accessToken = $newToken;\n }\n }\n\n public function getConfig()\n {\n return $this->config;\n }\n\n // returns only active (archived=false)\n public function getOwners(): array\n {\n return $this->getNewInstance()->crm()->owners()->getAll();\n }\n\n /**\n * @param bool $archived\n *\n * @return array<Owner>|[]\n */\n public function getOwnersArchived(bool $archived = true): array\n {\n $endpoint = '/crm/v3/owners';\n $queryParams = [\n 'archived' => $archived ? 'true' : 'false',\n ];\n $queryString = http_build_query($queryParams);\n\n $owners = [];\n\n try {\n $response = $this->makeRequest(endpoint: $endpoint, queryString: $queryString);\n $responseData = $response?->toArray();\n\n foreach ($responseData['results'] as $result) {\n try {\n $owners[] = Owner::create($result);\n } catch (Throwable $e) {\n $this->log->error('[HubSpot] Failed to process owner data', [\n 'result' => $result,\n 'error' => $e->getMessage(),\n ]);\n\n continue;\n }\n }\n } catch (Throwable $e) {\n $this->log->error('HubSpot] Failed to fetch owners', [\n 'archived' => $archived,\n 'error' => $e->getMessage(),\n ]);\n\n return [];\n }\n\n return $owners;\n }\n\n public function getMeeting(string $engagementId): ObjectWithAssociations\n {\n return $this->getNewInstance()->crm()->objects()->basicApi()\n ->getById('meeting', $engagementId, null, 'contact,company,deal');\n }\n\n public function deleteEngagement(string $engagementId): void\n {\n $this->getInstance()->engagements()->delete((int) $engagementId);\n }\n\n public function getAssociationsData(array $ids, string $fromObject, string $toObject): array\n {\n $associationData = [];\n $idChunks = array_chunk($ids, self::ASSOCIATIONS_BATCH_SIZE_LIMIT);\n\n foreach ($idChunks as $idChunk) {\n try {\n $batchInput = new \\HubSpot\\Client\\Crm\\Associations\\Model\\BatchInputPublicObjectId();\n $batchInput->setInputs(array_map(function ($id) {\n $publicObjectId = new \\HubSpot\\Client\\Crm\\Associations\\Model\\PublicObjectId();\n $publicObjectId->setId($id);\n\n return $publicObjectId;\n }, $idChunk));\n\n $associatedObjectsData = $this\n ->getNewInstance()\n ->crm()\n ->associations()\n ->batchApi()\n ->read($fromObject, $toObject, $batchInput);\n\n if ($associatedObjectsData instanceof \\HubSpot\\Client\\Crm\\Associations\\Model\\BatchResponsePublicAssociationMulti) {\n foreach ($associatedObjectsData->getResults() as $association) {\n $from = $association->getFrom()->getId();\n $toAssociations = $association->getTo();\n\n if (! empty($toAssociations)) {\n $associationData[$from] = array_map(function ($item) {\n return $item->getId();\n }, $toAssociations);\n }\n }\n }\n } catch (RateLimitException $e) {\n throw $e;\n } catch (\\Exception $e) {\n $this->log->error('[Hubspot] Failed to fetch associations', [\n 'from_object' => $fromObject,\n 'to_object' => $toObject,\n 'reason' => $e->getMessage(),\n ]);\n }\n }\n\n return $associationData;\n }\n\n /**\n * @throws \\Exception\n */\n private function getNoteAssociationType(NoteObject $noteObject): string\n {\n return match($noteObject) {\n NoteObject::Opportunity => 'note_to_deal',\n NoteObject::Lead, NoteObject::Contact => 'note_to_contact', // or 'note_to_lead' if your portal supports it\n NoteObject::Account => 'note_to_company',\n NoteObject::Call, NoteObject::Event => throw new \\Exception('Not supported'),\n };\n }\n\n /**\n * @throws \\Exception\n */\n private function getNoteObject(NoteObject $noteObject): string\n {\n return match($noteObject) {\n NoteObject::Opportunity => 'deal',\n NoteObject::Lead, NoteObject::Contact => 'contact',\n NoteObject::Account => 'company',\n NoteObject::Call, NoteObject::Event => throw new \\Exception('Not supported'),\n };\n }\n\n public function addAssociations(string $objectType, string $associationType, array $payload): Response\n {\n $endpoint = \"/crm/v4/associations/$objectType/$associationType/batch/create\";\n\n return $this->makeRequest($endpoint, 'POST', $payload);\n }\n\n public function removeAssociations(string $objectType, string $associationType, array $payload): Response\n {\n $endpoint = \"/crm/v4/associations/$objectType/$associationType/batch/archive\";\n\n return $this->makeRequest($endpoint, 'POST', $payload);\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":true,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Project","depth":3,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Project","depth":3,"bounds":{"left":0.011968086,"top":0.047885075,"width":0.024268618,"height":0.024740623},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"New File or Directory…","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Expand Selected","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Collapse All","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Options","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
7579307838644216966
|
-2772555093692774298
|
click
|
accessibility
|
NULL
|
Project: faVsco.js, menu
JY-20725-handle-HS-search Project: faVsco.js, menu
JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
19
Previous Highlighted Error
Next Highlighted Error
[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {
"headers":{
"Date":["Thu,07 May 2026 14:21:15 GMT"],
"Content-Type":["application/json;charset=utf-8"],
"Transfer-Encoding":["chunked"],
"Connection":["keep-alive"],
"CF-Ray":["9f80deb8db60dc3a-SOF"],
"CF-Cache-Status":["DYNAMIC"],
"Strict-Transport-Security":["max-age=31536000; includeSubDomains; preload"],
"Vary":["origin,
accept-encoding"],
"access-control-allow-credentials":["false"],
"server-timing":["hcid;desc=\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\",
cfr;desc=\"9f80deb8e7c6dc3a-IAD\""],
"x-content-type-options":["nosniff"],
"x-hubspot-correlation-id":["019e02d0-6fd8-7812-bdba-885b7ccb3ee3"],
"Set-Cookie":["__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-[IP_ADDRESS]-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,
07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None"],
"Report-To":["{
\"endpoints\":[{
\"url\":\"https:\\/\\/a.nel.cloudflare.com\\/report\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\"}],
\"group\":\"cf-nel\",
\"max_age\":604800}"],
"NEL":["{
\"success_fraction\":0.01,
\"report_to\":\"cf-nel\",
\"max_age\":604800}"],
"Server":["cloudflare"]}} {
"correlation_id":"95236535-ec98-4541-b92a-adfa73b69eab",
"trace_id":"c7ab8365-903f-46d4-9403-0e5b551e3545"}
Show Replace Field
Search History
rateLimiter
New Line
Match Case
Words
Regex
Replace History
Replace
New Line
Preserve case
0 results
Previous Occurrence
Next Occurrence
Filter Search Results
Open in Window, Multiple Cursors
Click to highlight
Close
Sync Changes
Hide This Notification
Code changed:
Hide
2
64
1
1
Previous Highlighted Error
Next Highlighted Error
<?php
declare(strict_types=1);
namespace Jiminny\Services\Crm\Hubspot;
use HubSpot\Client\Crm\Deals\ApiException as DealApiException;
use HubSpot\Client\Crm\Contacts\ApiException as ContactApiException;
use HubSpot\Client\Crm\Companies\ApiException as CompanyApiException;
use HubSpot\Client\Crm\Contacts\Model\SimplePublicObjectWithAssociations as ContactsWithAssociations;
use HubSpot\Client\Crm\Companies\Model\SimplePublicObjectWithAssociations as CompaniesWithAssociations;
use HubSpot\Client\Crm\Deals\Model\SimplePublicObjectWithAssociations as DealWithAssociations;
use HubSpot\Client\Crm\Objects\Model\SimplePublicObjectInput;
use HubSpot\Client\Crm\Objects\Model\SimplePublicObjectWithAssociations as ObjectWithAssociations;
use HubSpot\Client\Crm\Pipelines\Model\Error;
use HubSpot\Client\Crm\Pipelines\Model\PipelineStage;
use HubSpot\Client\Crm\Properties\Model\Property;
use HubSpot\Discovery\Discovery;
use Jiminny\Exceptions\CrmException;
use Jiminny\Exceptions\RateLimitException;
use Jiminny\Exceptions\SocialAccountTokenInvalidException;
use Jiminny\Jobs\Crm\NoteObject;
use Jiminny\Models\Crm\Field;
use Jiminny\Services\Crm\BaseClient;
use Jiminny\Services\Crm\Hubspot\DTO\Response\Owner;
use Jiminny\Services\SocialAccountService;
use SevenShores\Hubspot\Exceptions\BadRequest;
use SevenShores\Hubspot\Exceptions\HubspotException;
use SevenShores\Hubspot\Factory;
use SevenShores\Hubspot\Http\Response;
use Jiminny\Services\Crm\Hubspot\Pagination\HubspotPaginationService;
use Throwable;
/**
* @phpstan-type CrmFieldOption array{id:string, label:string, value?:string}
*/
class Client extends BaseClient implements HubspotClientInterface
{
public const string MIN_API_VERSION = '2';
public const string BASE_URL = '[URL_WITH_CREDENTIALS] T
* @param callable(): T $apiCall
* @return T
*
* @throws RateLimitException
*/
private function executeRequest(callable $apiCall)
{
try {
return $apiCall();
} catch (Throwable $e) {
if ($this->isHubspotRateLimit($e)) {
$retryAfter = $this->parseRetryAfter($e);
$this->log->warning('[Hubspot] Received 429 from API', [
'team_id' => $this->config->team_id,
'config_id' => $this->config->getId(),
'retry_after' => $retryAfter,
'reason' => $e->getMessage(),
]);
throw new RateLimitException('Hubspot returned 429', $retryAfter, $e);
}
throw $e;
}
}
public function isHubspotRateLimit(Throwable $e): bool
{
if ($e instanceof BadRequest
|| $e instanceof DealApiException
|| $e instanceof ContactApiException
|| $e instanceof CompanyApiException
|| $e instanceof \GuzzleHttp\Exception\RequestException
) {
return (int) $e->getCode() === 429;
}
return false;
}
public function parseRetryAfter(Throwable $e): int
{
// First try to get Retry-After from response headers
if (method_exists($e, 'getResponseHeaders')) {
$headers = $e->getResponseHeaders() ?: [];
$value = $headers['Retry-After'] ?? $headers['retry-after'] ?? null;
if (is_array($value)) {
$value = $value[0] ?? null;
}
if (is_numeric($value)) {
return (int) $value;
}
}
if (method_exists($e, 'getResponseBody')) {
$body = $e->getResponseBody();
if (is_string($body)) {
$body = json_decode($body, true) ?? [];
}
$policyName = $body['policyName'] ?? $body['policy'] ?? $body['context']['policyName'] ?? null;
if ($policyName === 'TEN_SECONDLY_ROLLING' || $policyName === 'ten_secondly_rolling') {
return 10;
}
if ($policyName === 'SECONDLY' || $policyName === 'secondly') {
return 1;
}
}
$this->log->warning('[Hubspot] No retry-after header or policy name found, using default', [
'exception_class' => get_class($e),
]);
return 10;
}
public function getMinimumApiVersion(): string
{
return self::MIN_API_VERSION;
}
public function getInstance(): Factory
{
return new Factory([
'key' => $this->accessToken,
'oauth2' => true,
'base_url' => $this->baseUrl,
]);
}
public function getNewInstance(): Discovery
{
return \HubSpot\Factory::createWithAccessToken($this->accessToken);
}
/**
* Secondly and daily limits for Hubspot API
*
* Product Tier: Free & Starter | Professional & Enterprise | API add-on (any tier)
* Burst: 100/10 seconds | 150/10 seconds | 200/10 seconds
* Daily: 250,000 | 500,000 | 1,000,000
*
* Official documentation states: The search endpoints are rate limited to five requests per second.
* Since with 5 RPS were still hitting secondly rate limits we lowered it to 4
*/
public function getPaginatedData(array $payload, string $type, int $offset = 0): array
{
$total = 0;
$lastId = null;
$rows = [];
foreach ($this->getPaginatedDataGenerator($payload, $type, $offset, $total, $lastId) as $row) {
$rows[] = $row;
}
return ['results' => $rows, 'total' => $total, 'last_record' => $lastId];
}
/**
* @throws HubspotException
* @throws SocialAccountTokenInvalidException
* @throws BadRequest
*/
public function getPaginatedDataGenerator(
array $payload,
string $type,
int $offset = 0,
int &$total = 0,
?string &$lastRecordId = null
): \Generator {
return $this->paginationService->getPaginatedDataGenerator(
$this,
$payload,
$type,
$offset,
$total,
$lastRecordId
);
}
/**
* Execute a search request against HubSpot CRM objects with rate limiting.
*
* @param string $objectType The object type ('deals', 'companies', 'contacts', 'calls')
* @param array<string, mixed> $payload The search payload with filters, sorts, properties, etc.
* @return array The search response with 'results', 'total', 'paging' keys
* @throws RateLimitException When rate limit is hit
* @throws HubspotException On API errors
*/
public function search(string $objectType, array $payload): array
{
$endpoint = self::BASE_URL . "/crm/v3/objects/{$objectType}/search";
return $this->executeRequest(function () use ($endpoint, $payload) {
$response = $this->getInstance()->getClient()->request('POST', $endpoint, ['json' => $payload]);
return $response->toArray();
});
}
/**
* @throws DealApiException
* @throws CrmException
*/
public function getOpportunityById(string $crmId, array $fields): array
{
try {
// $deal = $this->executeRequest(fn () => $this->getNewInstance()->crm()->deals()->basicApi()->getById(
$deal = $this->getNewInstance()->crm()->deals()->basicApi()->getById(
$crmId,
implode(',', $fields),
'companies,contacts'
);
} catch (DealApiException $e) {
$this->log->info('[Hubspot] Failed to fetch opportunity', [
'crm_id' => $crmId,
'reason' => $e->getMessage(),
]);
throw $e;
}
if (! $deal instanceof DealWithAssociations) {
throw new CrmException('Deal not found');
}
return [
'id' => $deal->getId(),
'properties' => $deal->getProperties(),
'associations' => $deal->getAssociations(),
];
}
/**
* Generic batch read method for HubSpot objects
*
* @param string $objectType The object type ('deals', 'companies', 'contacts')
* @param array<string> $crmIds Array of HubSpot object IDs (max 100)
* @param array<string> $fields Array of property names to fetch
*
* @return array<string, array> Array keyed by CRM ID with object data
*/
private function batchReadObjects(string $objectType, array $crmIds, array $fields): array
{
if (empty($crmIds)) {
return [];
}
$this->validateBatchSize($objectType, $crmIds);
$this->ensureValidToken();
try {
$batchConfig = $this->createBatchConfiguration($objectType);
$batchReadRequest = $this->prepareBatchRequest($batchConfig, $crmIds, $fields);
$response = $batchConfig['api']->read($batchReadRequest);
$this->validateApiResponse($response, $objectType);
$results = $this->processApiResults($response);
$this->logBatchResults($objectType, $crmIds, $results);
return $results;
} catch (\Throwable $e) {
$this->handleBatchError($e, $objectType, $crmIds);
}
}
private function validateBatchSize(string $objectType, array $crmIds): void
{
if (count($crmIds) > 100) {
throw new \InvalidArgumentException("Batch size cannot exceed 100 {$objectType}");
}
}
private function createBatchConfiguration(string $objectType): array
{
$configurations = [
'deals' => [
'batchReadRequest' => new \HubSpot\Client\Crm\Deals\Model\BatchReadInputSimplePublicObjectId(),
'inputClass' => \HubSpot\Client\Crm\Deals\Model\SimplePublicObjectId::class,
'api' => $this->getNewInstance()->crm()->deals()->batchApi(),
],
'companies' => [
'batchReadRequest' => new \HubSpot\Client\Crm\Companies\Model\BatchReadInputSimplePublicObjectId(),
'inputClass' => \HubSpot\Client\Crm\Companies\Model\SimplePublicObjectId::class,
'api' => $this->getNewInstance()->crm()->companies()->batchApi(),
],
'contacts' => [
'batchReadRequest' => new \HubSpot\Client\Crm\Contacts\Model\BatchReadInputSimplePublicObjectId(),
'inputClass' => \HubSpot\Client\Crm\Contacts\Model\SimplePublicObjectId::class,
'api' => $this->getNewInstance()->crm()->contacts()->batchApi(),
],
];
if (! isset($configurations[$objectType])) {
throw new \InvalidArgumentException("Unsupported object type: {$objectType}");
}
return $configurations[$objectType];
}
private function prepareBatchRequest(array $batchConfig, array $crmIds, array $fields): object
{
$batchReadRequest = $batchConfig['batchReadRequest'];
$inputClass = $batchConfig['inputClass'];
$inputs = array_map(function ($crmId) use ($inputClass) {
$input = new $inputClass();
$input->setId($crmId);
return $input;
}, $crmIds);
$batchReadRequest->setInputs($inputs);
$batchReadRequest->setProperties($fields);
return $batchReadRequest;
}
private function validateApiResponse($response, string $objectType): void
{
if (! $response) {
throw new CrmException("HubSpot API returned null response for {$objectType} batch read");
}
}
private function processApiResults($response): array
{
$results = [];
$responseResults = $response->getResults();
if ($responseResults) {
foreach ($responseResults as $object) {
if ($object && $object->getId()) {
$results[$object->getId()] = [
'id' => $object->getId(),
'properties' => $object->getProperties() ?: [],
];
}
}
}
return $results;
}
private function logBatchResults(string $objectType, array $crmIds, array $results): void
{
$this->log->info("[HubSpot] Batch fetched {$objectType}", [
'requested_count' => count($crmIds),
'returned_count' => count($results),
'crm_ids' => $crmIds,
]);
}
private function handleBatchError(\Throwable $e, string $objectType, array $crmIds): void
{
$errorMessage = $e->getMessage() ?: 'Unknown error';
$errorTrace = $e->getTraceAsString() ?: 'No trace available';
$this->log->error("[HubSpot] Failed to batch fetch {$objectType}", [
'crm_ids' => $crmIds,
'error' => $errorMessage,
'trace' => $errorTrace,
]);
throw new CrmException("Failed to batch fetch {$objectType}: " . $errorMessage);
}
/**
* Batch read multiple opportunities by their CRM IDs
*
* @param array<string> $crmIds Array of HubSpot deal IDs (max 100)
* @param array<string> $fields Array of property names to fetch
*
* @return array<string, array> Array keyed by CRM ID with opportunity data
*/
public function getOpportunitiesByIds(array $crmIds, array $fields): array
{
return $this->batchReadObjects('deals', $crmIds, $fields);
}
/**
* Batch read multiple companies by their CRM IDs
*
* @param array<string> $crmIds Array of HubSpot company IDs (max 100)
* @param array<string> $fields Array of property names to fetch
*
* @return array<string, array> Array keyed by CRM ID with company data
*/
public function getCompaniesByIds(array $crmIds, array $fields): array
{
return $this->batchReadObjects('companies', $crmIds, $fields);
}
/**
* Batch read multiple contacts by their CRM IDs
*
* @param array<string> $crmIds Array of HubSpot contact IDs (max 100)
* @param array<string> $fields Array of property names to fetch
*
* @return array<string, array> Array keyed by CRM ID with contact data
*/
public function getContactsByIds(array $crmIds, array $fields): array
{
return $this->batchReadObjects('contacts', $crmIds, $fields);
}
/**
* @throws CompanyApiException
* @throws CrmException
*/
public function getAccountById(string $crmId, array $fields): array
{
try {
$company = $this->getNewInstance()->crm()->companies()->basicApi()->getById(
$crmId,
implode(',', $fields),
);
} catch (CompanyApiException $e) {
$this->log->info('[Hubspot] Failed to fetch account', [
'crm_id' => $crmId,
'reason' => $e->getMessage(),
]);
throw $e;
}
if (! $company instanceof CompaniesWithAssociations) {
throw new CrmException('Account not found');
}
return [
'id' => $company->getId(),
'properties' => $company->getProperties(),
];
}
/**
* @throws ContactApiException
* @throws CrmException
*/
public function getContactById(string $crmId, array $fields): array
{
try {
$contact = $this->getNewInstance()->crm()->contacts()->basicApi()->getById(
$crmId,
implode(',', $fields)
);
} catch (ContactApiException $e) {
$this->log->info('[Hubspot] Failed to fetch contact', [
'crm_id' => $crmId,
'reason' => $e->getMessage(),
]);
throw $e;
}
if (! $contact instanceof ContactsWithAssociations) {
throw new CrmException('Contact not found');
}
return [
'id' => $contact->getId(),
'properties' => $contact->getProperties(),
];
}
/**
* This is email search request that Hubspot offers as GET (more generous quota)
*/
public function getContactByEmail(string $email, array $fields = []): array
{
try {
$contact = $this->getNewInstance()->crm()->contacts()->basicApi()->getById(
$email,
implode(',', $fields),
null,
false,
'email'
);
return [
'id' => $contact->getId(),
'properties' => $contact->getProperties(),
];
} catch (ContactApiException $e) {
$this->log->info('[Hubspot] Failed to fetch contact', [
'email' => $email,
'reason' => $e->getMessage(),
]);
return [];
}
}
/**
* @throws CrmException
*/
public function fetchProperty(string $objectType, string $propertyId): Property
{
$result = $this->getNewInstance()->crm()->properties()->coreApi()->getByName($objectType, $propertyId);
if (! $result instanceof Property) {
$this->log->error('[Hubspot] Failed to fetch property', [
'object_type' => $objectType,
'property_id' => $propertyId,
'reason' => $result->getMessage(),
]);
throw new CrmException('Failed to fetch property');
}
return $result;
}
/**
* @return array<CrmFieldOption>
*/
public function fetchPropertyOptions(string $objectType, string $propertyId): array
{
/** @var array<CrmFieldOption> */
return $this->fetchProperty($objectType, $propertyId)->getOptions();
}
/**
* @return array<array{id:string, label:string, deleted:bool}>
*/
public function fetchCallDispositions(): array
{
/** @var Response $response */
$response = $this->getInstance()->engagements()->getCallDispositions();
/**
* @var array<array{
* id:string,
* label:string,
* deleted: bool
* }>
*/
return $response->toArray();
}
/**
* @return array<CrmFieldOption>
*/
public function fetchOpportunityPipelineStages(): array
{
$stages = [];
$apiResponse = $this->getNewInstance()->crm()->pipelines()->pipelinesApi()->getAll('deals');
if ($apiResponse instanceof Error) {
$this->log->error('[Hubspot] Failed to fetch opportunity pipelines', [
'reason' => $apiResponse->getMessage(),
]);
return [];
}
foreach ($apiResponse->getResults() as $pipeline) {
$pipelineStages = array_map(
static function (PipelineStage $stage) {
return [
'id' => $stage->getId(),
'label' => $stage->getLabel(),
];
},
$pipeline->getStages()
);
$stages = array_merge($stages, $pipelineStages);
}
return $stages;
}
public function fetchOpportunityPipelines(): array
{
$pipelines = [];
try {
$apiResponse = $this->makeRequest('/crm/v3/pipelines/deals');
} catch (\Exception $e) {
$this->log->info('[Hubspot] Failed to fetch opportunity pipelines', [
'reason' => $e->getMessage(),
]);
return [];
}
$response = $apiResponse->toArray();
foreach ($response['results'] as $pipeline) {
$pipelines[] = [
'id' => $pipeline['id'],
'label' => $pipeline['label'],
];
}
return $pipelines;
}
/**
* @return array<CrmFieldOption>
*/
public function fetchMeetingOutcomeFieldOptions(Field $field): array
{
return $field->getCrmProviderId() === 'meetingOutcome'
? $this->fetchMeetingOutcomeTypes()
: $this->fetchCallActivityTypes();
}
public function fetchMeetingOutcomeTypes(): array
{
return $this->extractMeetingTypeOptions(
'[URL_WITH_CREDENTIALS] Response $response */
$response = $this->getInstance()
->getClient()
->request('GET', $endpoint);
/**
* @var array<array{
* value: string,
* label: string,
* displayOrder: int
* }> $optionData
*/
$optionData = $response->toArray()['options'] ?? [];
$options = [];
foreach ($optionData as $item) {
$options[] = [
'id' => $item['value'],
'value' => $item['value'],
'label' => $item['label'],
'display_order' => $item['displayOrder'],
];
}
return $options;
}
/**
* @return array<CrmFieldOption>
*/
public function fetchDispositionFieldOptions(): array
{
$options = [];
$dispositions = $this->fetchCallDispositions();
foreach ($dispositions as $disposition) {
if ($disposition['deleted'] !== false) {
continue;
}
$option['value'] = $disposition['id'];
$option['id'] = $disposition['id'];
$option['label'] = $disposition['label'];
$options[] = $option;
}
return $options;
}
/**
* @return array<CrmFieldOption>
*/
public function fetchOpportunityFieldOptions(Field $field): array
{
if ($field->isStageField()) {
return $this->fetchOpportunityPipelineStages();
}
if ($field->isPipelineField()) {
return $this->fetchOpportunityPipelines();
}
return $this->fetchPropertyOptions('deals', $field->getCrmProviderId());
}
/**
* @throws BadRequest
* @throws HubspotException
*/
public function makeRequest(string $endpoint, $method = 'GET', $payload = [], ?string $queryString = null)
{
$endpoint = self::BASE_URL . $endpoint;
if ($method === 'GET') {
$response = $this->getInstance()->getClient()?->request(
method: $method,
endpoint: $endpoint,
query_string: $queryString
);
} else {
$response = $this->getInstance()->getClient()->request($method, $endpoint, [
'json' => ($payload),
]);
}
//
// $max = $response->getHeaderLine('X-HubSpot-RateLimit-Max'); // "110"
// $remaining = $response->getHeaderLine('X-HubSpot-RateLimit-Remaining'); // "109"
// $interval = $response->getHeaderLine('X-HubSpot-RateLimit-Interval-Milliseconds'); // "10000"
// $body = json_decode((string) $response->getBody(), true);
//
// \Illuminate\Support\Facades\Log::channel('custom_channel')->info('$max ' . PHP_EOL . print_r($max, true));
// \Illuminate\Support\Facades\Log::channel('custom_channel')->info('$remaining ' . PHP_EOL . print_r($remaining, true));
// \Illuminate\Support\Facades\Log::channel('custom_channel')->info('$interval ' . PHP_EOL . print_r($interval, true));
// \Illuminate\Support\Facades\Log::channel('custom_channel')->info('$body ' . PHP_EOL . print_r($body, true));
return $response;
}
/**
* @throws BadRequest
* @throws HubspotException
*/
public function createMeeting(array $payload): Response
{
$endpoint = '/crm/v3/objects/meetings';
return $this->makeRequest($endpoint, 'POST', $payload);
}
/**
* @throws BadRequest
* @throws HubspotException
*/
public function updateMeeting(string $meetingId, array $payload): Response
{
$endpoint = '/crm/v3/objects/meetings/' . $meetingId;
return $this->makeRequest($endpoint, 'PATCH', $payload);
}
/**
* @throws \Exception
*/
public function createNote(
string $body,
string $ownerId,
int $timestamp,
string $objectId,
NoteObject $noteObject
): ?string {
try {
$noteInput = new SimplePublicObjectInput([
'properties' => [
'hs_note_body' => $body,
'hubspot_owner_id' => $ownerId,
'hs_timestamp' => $timestamp,
],
]);
// Create note
$note = $this->getNewInstance()->crm()->objects()->basicApi()->create('note', $noteInput);
$this->getNewInstance()->crm()->objects()->associationsApi()->create(
'note',
$note->getId(),
$this->getNoteObject($noteObject),
$objectId,
$this->getNoteAssociationType($noteObject),
);
return $note->getId();
} catch (\Exception $e) {
$this->log->error('[Hubspot] Failed to create note', [
'objectId' => $objectId,
'noteObject' => $noteObject->getObjectType(),
'reason' => $e->getMessage(),
]);
\Sentry::captureException($e);
}
return null;
}
public function updateEngagement(string $objectId, array $engagement, array $metadata): void
{
$this->getInstance()->engagements()->update($objectId, $engagement, $metadata);
}
public function getEngagementData(string $engagementId): array
{
$engagement = $this->getInstance()->engagements()->get($engagementId);
return $engagement->toArray();
}
public function createEngagement(array $engagement, array $associations, array $metadata): Response
{
return $this->getInstance()
->engagements()
->create($engagement, $associations, $metadata);
}
public function isUnauthorizedException(\Exception $e): bool
{
if ($e instanceof BadRequest
|| $e instanceof DealApiException
|| $e instanceof ContactApiException
|| $e instanceof CompanyApiException
) {
return (int) $e->getCode() === 401;
}
if ($e instanceof \GuzzleHttp\Exception\RequestException && $e->hasResponse()) {
return $e->getResponse()?->getStatusCode() === 401;
}
$message = strtolower($e->getMessage());
return str_contains($message, '401 unauthorized') ||
str_contains($message, 'http 401') ||
str_contains($message, 'status code 401') ||
(preg_match('/\b401\b/', $message) === 1 && str_contains($message, 'unauthorized'));
}
/**
* Validates and refreshes the access token if needed before API requests.
* This ensures long-running processes don't fail due to token expiration.
*
* @throws SocialAccountTokenInvalidException
*/
public function ensureValidToken(): void
{
if ($this->oauthAccount === null) {
return;
}
$newToken = $this->tokenManager->ensureValidToken($this->oauthAccount);
if ($newToken !== null) {
$this->accessToken = $newToken;
}
}
public function getConfig()
{
return $this->config;
}
// returns only active (archived=false)
public function getOwners(): array
{
return $this->getNewInstance()->crm()->owners()->getAll();
}
/**
* @param bool $archived
*
* @return array<Owner>|[]
*/
public function getOwnersArchived(bool $archived = true): array
{
$endpoint = '/crm/v3/owners';
$queryParams = [
'archived' => $archived ? 'true' : 'false',
];
$queryString = http_build_query($queryParams);
$owners = [];
try {
$response = $this->makeRequest(endpoint: $endpoint, queryString: $queryString);
$responseData = $response?->toArray();
foreach ($responseData['results'] as $result) {
try {
$owners[] = Owner::create($result);
} catch (Throwable $e) {
$this->log->error('[HubSpot] Failed to process owner data', [
'result' => $result,
'error' => $e->getMessage(),
]);
continue;
}
}
} catch (Throwable $e) {
$this->log->error('HubSpot] Failed to fetch owners', [
'archived' => $archived,
'error' => $e->getMessage(),
]);
return [];
}
return $owners;
}
public function getMeeting(string $engagementId): ObjectWithAssociations
{
return $this->getNewInstance()->crm()->objects()->basicApi()
->getById('meeting', $engagementId, null, 'contact,company,deal');
}
public function deleteEngagement(string $engagementId): void
{
$this->getInstance()->engagements()->delete((int) $engagementId);
}
public function getAssociationsData(array $ids, string $fromObject, string $toObject): array
{
$associationData = [];
$idChunks = array_chunk($ids, self::ASSOCIATIONS_BATCH_SIZE_LIMIT);
foreach ($idChunks as $idChunk) {
try {
$batchInput = new \HubSpot\Client\Crm\Associations\Model\BatchInputPublicObjectId();
$batchInput->setInputs(array_map(function ($id) {
$publicObjectId = new \HubSpot\Client\Crm\Associations\Model\PublicObjectId();
$publicObjectId->setId($id);
return $publicObjectId;
}, $idChunk));
$associatedObjectsData = $this
->getNewInstance()
->crm()
->associations()
->batchApi()
->read($fromObject, $toObject, $batchInput);
if ($associatedObjectsData instanceof \HubSpot\Client\Crm\Associations\Model\BatchResponsePublicAssociationMulti) {
foreach ($associatedObjectsData->getResults() as $association) {
$from = $association->getFrom()->getId();
$toAssociations = $association->getTo();
if (! empty($toAssociations)) {
$associationData[$from] = array_map(function ($item) {
return $item->getId();
}, $toAssociations);
}
}
}
} catch (RateLimitException $e) {
throw $e;
} catch (\Exception $e) {
$this->log->error('[Hubspot] Failed to fetch associations', [
'from_object' => $fromObject,
'to_object' => $toObject,
'reason' => $e->getMessage(),
]);
}
}
return $associationData;
}
/**
* @throws \Exception
*/
private function getNoteAssociationType(NoteObject $noteObject): string
{
return match($noteObject) {
NoteObject::Opportunity => 'note_to_deal',
NoteObject::Lead, NoteObject::Contact => 'note_to_contact', // or 'note_to_lead' if your portal supports it
NoteObject::Account => 'note_to_company',
NoteObject::Call, NoteObject::Event => throw new \Exception('Not supported'),
};
}
/**
* @throws \Exception
*/
private function getNoteObject(NoteObject $noteObject): string
{
return match($noteObject) {
NoteObject::Opportunity => 'deal',
NoteObject::Lead, NoteObject::Contact => 'contact',
NoteObject::Account => 'company',
NoteObject::Call, NoteObject::Event => throw new \Exception('Not supported'),
};
}
public function addAssociations(string $objectType, string $associationType, array $payload): Response
{
$endpoint = "/crm/v4/associations/$objectType/$associationType/batch/create";
return $this->makeRequest($endpoint, 'POST', $payload);
}
public function removeAssociations(string $objectType, string $associationType, array $payload): Response
{
$endpoint = "/crm/v4/associations/$objectType/$associationType/batch/archive";
return $this->makeRequest($endpoint, 'POST', $payload);
}
}
Project
Project
New File or Directory…
Expand Selected
Collapse All
Options
Hide...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
9848
|
NULL
|
0
|
2026-05-08T13:37:45.930292+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-08/1778 /Users/lukas/.screenpipe/data/data/2026-05-08/1778247465930_m1.jpg...
|
PhpStorm
|
faVsco.js – Client.php
|
1
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
JY-20725-handle-HS-search Project: faVsco.js, menu
JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
19
Previous Highlighted Error
Next Highlighted Error
[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {
"headers":{
"Date":["Thu,07 May 2026 14:21:15 GMT"],
"Content-Type":["application/json;charset=utf-8"],
"Transfer-Encoding":["chunked"],
"Connection":["keep-alive"],
"CF-Ray":["9f80deb8db60dc3a-SOF"],
"CF-Cache-Status":["DYNAMIC"],
"Strict-Transport-Security":["max-age=31536000; includeSubDomains; preload"],
"Vary":["origin,
accept-encoding"],
"access-control-allow-credentials":["false"],
"server-timing":["hcid;desc=\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\",
cfr;desc=\"9f80deb8e7c6dc3a-IAD\""],
"x-content-type-options":["nosniff"],
"x-hubspot-correlation-id":["019e02d0-6fd8-7812-bdba-885b7ccb3ee3"],
"Set-Cookie":["__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-[IP_ADDRESS]-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,
07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None"],
"Report-To":["{
\"endpoints\":[{
\"url\":\"https:\\/\\/a.nel.cloudflare.com\\/report\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\"}],
\"group\":\"cf-nel\",
\"max_age\":604800}"],
"NEL":["{
\"success_fraction\":0.01,
\"report_to\":\"cf-nel\",
\"max_age\":604800}"],
"Server":["cloudflare"]}} {
"correlation_id":"95236535-ec98-4541-b92a-adfa73b69eab",
"trace_id":"c7ab8365-903f-46d4-9403-0e5b551e3545"}
Show Replace Field
Search History
rateLimiter
New Line
Match Case
Words
Regex
Replace History
Replace
New Line
Preserve case
0 results
Previous Occurrence
Next Occurrence
Filter Search Results
Open in Window, Multiple Cursors
Click to highlight
Close
Sync Changes
Hide This Notification
Code changed:
Hide
2
64
1
1
Previous Highlighted Error
Next Highlighted Error
<?php
declare(strict_types=1);
namespace Jiminny\Services\Crm\Hubspot;
use HubSpot\Client\Crm\Deals\ApiException as DealApiException;
use HubSpot\Client\Crm\Contacts\ApiException as ContactApiException;
use HubSpot\Client\Crm\Companies\ApiException as CompanyApiException;
use HubSpot\Client\Crm\Contacts\Model\SimplePublicObjectWithAssociations as ContactsWithAssociations;
use HubSpot\Client\Crm\Companies\Model\SimplePublicObjectWithAssociations as CompaniesWithAssociations;
use HubSpot\Client\Crm\Deals\Model\SimplePublicObjectWithAssociations as DealWithAssociations;
use HubSpot\Client\Crm\Objects\Model\SimplePublicObjectInput;
use HubSpot\Client\Crm\Objects\Model\SimplePublicObjectWithAssociations as ObjectWithAssociations;
use HubSpot\Client\Crm\Pipelines\Model\Error;
use HubSpot\Client\Crm\Pipelines\Model\PipelineStage;
use HubSpot\Client\Crm\Properties\Model\Property;
use HubSpot\Discovery\Discovery;
use Jiminny\Exceptions\CrmException;
use Jiminny\Exceptions\RateLimitException;
use Jiminny\Exceptions\SocialAccountTokenInvalidException;
use Jiminny\Jobs\Crm\NoteObject;
use Jiminny\Models\Crm\Field;
use Jiminny\Services\Crm\BaseClient;
use Jiminny\Services\Crm\Hubspot\DTO\Response\Owner;
use Jiminny\Services\SocialAccountService;
use SevenShores\Hubspot\Exceptions\BadRequest;
use SevenShores\Hubspot\Exceptions\HubspotException;
use SevenShores\Hubspot\Factory;
use SevenShores\Hubspot\Http\Response;
use Jiminny\Services\Crm\Hubspot\Pagination\HubspotPaginationService;
use Throwable;
/**
* @phpstan-type CrmFieldOption array{id:string, label:string, value?:string}
*/
class Client extends BaseClient implements HubspotClientInterface
{
public const string MIN_API_VERSION = '2';
public const string BASE_URL = '[URL_WITH_CREDENTIALS] T
* @param callable(): T $apiCall
* @return T
*
* @throws RateLimitException
*/
private function executeRequest(callable $apiCall)
{
try {
return $apiCall();
} catch (Throwable $e) {
if ($this->isHubspotRateLimit($e)) {
$retryAfter = $this->parseRetryAfter($e);
$this->log->warning('[Hubspot] Received 429 from API', [
'team_id' => $this->config->team_id,
'config_id' => $this->config->getId(),
'retry_after' => $retryAfter,
'reason' => $e->getMessage(),
]);
throw new RateLimitException('Hubspot returned 429', $retryAfter, $e);
}
throw $e;
}
}
public function isHubspotRateLimit(Throwable $e): bool
{
if ($e instanceof BadRequest
|| $e instanceof DealApiException
|| $e instanceof ContactApiException
|| $e instanceof CompanyApiException
|| $e instanceof \GuzzleHttp\Exception\RequestException
) {
return (int) $e->getCode() === 429;
}
return false;
}
public function parseRetryAfter(Throwable $e): int
{
// First try to get Retry-After from response headers
if (method_exists($e, 'getResponseHeaders')) {
$headers = $e->getResponseHeaders() ?: [];
$value = $headers['Retry-After'] ?? $headers['retry-after'] ?? null;
if (is_array($value)) {
$value = $value[0] ?? null;
}
if (is_numeric($value)) {
return (int) $value;
}
}
if (method_exists($e, 'getResponseBody')) {
$body = $e->getResponseBody();
if (is_string($body)) {
$body = json_decode($body, true) ?? [];
}
$policyName = $body['policyName'] ?? $body['policy'] ?? $body['context']['policyName'] ?? null;
if ($policyName === 'TEN_SECONDLY_ROLLING' || $policyName === 'ten_secondly_rolling') {
return 10;
}
if ($policyName === 'SECONDLY' || $policyName === 'secondly') {
return 1;
}
}
$this->log->warning('[Hubspot] No retry-after header or policy name found, using default', [
'exception_class' => get_class($e),
]);
return 10;
}
public function getMinimumApiVersion(): string
{
return self::MIN_API_VERSION;
}
public function getInstance(): Factory
{
return new Factory([
'key' => $this->accessToken,
'oauth2' => true,
'base_url' => $this->baseUrl,
]);
}
public function getNewInstance(): Discovery
{
return \HubSpot\Factory::createWithAccessToken($this->accessToken);
}
/**
* Secondly and daily limits for Hubspot API
*
* Product Tier: Free & Starter | Professional & Enterprise | API add-on (any tier)
* Burst: 100/10 seconds | 150/10 seconds | 200/10 seconds
* Daily: 250,000 | 500,000 | 1,000,000
*
* Official documentation states: The search endpoints are rate limited to five requests per second.
* Since with 5 RPS were still hitting secondly rate limits we lowered it to 4
*/
public function getPaginatedData(array $payload, string $type, int $offset = 0): array
{
$total = 0;
$lastId = null;
$rows = [];
foreach ($this->getPaginatedDataGenerator($payload, $type, $offset, $total, $lastId) as $row) {
$rows[] = $row;
}
return ['results' => $rows, 'total' => $total, 'last_record' => $lastId];
}
/**
* @throws HubspotException
* @throws SocialAccountTokenInvalidException
* @throws BadRequest
*/
public function getPaginatedDataGenerator(
array $payload,
string $type,
int $offset = 0,
int &$total = 0,
?string &$lastRecordId = null
): \Generator {
return $this->paginationService->getPaginatedDataGenerator(
$this,
$payload,
$type,
$offset,
$total,
$lastRecordId
);
}
/**
* Execute a search request against HubSpot CRM objects with rate limiting.
*
* @param string $objectType The object type ('deals', 'companies', 'contacts', 'calls')
* @param array<string, mixed> $payload The search payload with filters, sorts, properties, etc.
* @return array The search response with 'results', 'total', 'paging' keys
* @throws RateLimitException When rate limit is hit
* @throws HubspotException On API errors
*/
public function search(string $objectType, array $payload): array
{
$endpoint = self::BASE_URL . "/crm/v3/objects/{$objectType}/search";
return $this->executeRequest(function () use ($endpoint, $payload) {
$response = $this->getInstance()->getClient()->request('POST', $endpoint, ['json' => $payload]);
return $response->toArray();
});
}
/**
* @throws DealApiException
* @throws CrmException
*/
public function getOpportunityById(string $crmId, array $fields): array
{
try {
// $deal = $this->executeRequest(fn () => $this->getNewInstance()->crm()->deals()->basicApi()->getById(
$deal = $this->getNewInstance()->crm()->deals()->basicApi()->getById(
$crmId,
implode(',', $fields),
'companies,contacts'
);
} catch (DealApiException $e) {
$this->log->info('[Hubspot] Failed to fetch opportunity', [
'crm_id' => $crmId,
'reason' => $e->getMessage(),
]);
throw $e;
}
if (! $deal instanceof DealWithAssociations) {
throw new CrmException('Deal not found');
}
return [
'id' => $deal->getId(),
'properties' => $deal->getProperties(),
'associations' => $deal->getAssociations(),
];
}
/**
* Generic batch read method for HubSpot objects
*
* @param string $objectType The object type ('deals', 'companies', 'contacts')
* @param array<string> $crmIds Array of HubSpot object IDs (max 100)
* @param array<string> $fields Array of property names to fetch
*
* @return array<string, array> Array keyed by CRM ID with object data
*/
private function batchReadObjects(string $objectType, array $crmIds, array $fields): array
{
if (empty($crmIds)) {
return [];
}
$this->validateBatchSize($objectType, $crmIds);
$this->ensureValidToken();
try {
$batchConfig = $this->createBatchConfiguration($objectType);
$batchReadRequest = $this->prepareBatchRequest($batchConfig, $crmIds, $fields);
$response = $batchConfig['api']->read($batchReadRequest);
$this->validateApiResponse($response, $objectType);
$results = $this->processApiResults($response);
$this->logBatchResults($objectType, $crmIds, $results);
return $results;
} catch (\Throwable $e) {
$this->handleBatchError($e, $objectType, $crmIds);
}
}
private function validateBatchSize(string $objectType, array $crmIds): void
{
if (count($crmIds) > 100) {
throw new \InvalidArgumentException("Batch size cannot exceed 100 {$objectType}");
}
}
private function createBatchConfiguration(string $objectType): array
{
$configurations = [
'deals' => [
'batchReadRequest' => new \HubSpot\Client\Crm\Deals\Model\BatchReadInputSimplePublicObjectId(),
'inputClass' => \HubSpot\Client\Crm\Deals\Model\SimplePublicObjectId::class,
'api' => $this->getNewInstance()->crm()->deals()->batchApi(),
],
'companies' => [
'batchReadRequest' => new \HubSpot\Client\Crm\Companies\Model\BatchReadInputSimplePublicObjectId(),
'inputClass' => \HubSpot\Client\Crm\Companies\Model\SimplePublicObjectId::class,
'api' => $this->getNewInstance()->crm()->companies()->batchApi(),
],
'contacts' => [
'batchReadRequest' => new \HubSpot\Client\Crm\Contacts\Model\BatchReadInputSimplePublicObjectId(),
'inputClass' => \HubSpot\Client\Crm\Contacts\Model\SimplePublicObjectId::class,
'api' => $this->getNewInstance()->crm()->contacts()->batchApi(),
],
];
if (! isset($configurations[$objectType])) {
throw new \InvalidArgumentException("Unsupported object type: {$objectType}");
}
return $configurations[$objectType];
}
private function prepareBatchRequest(array $batchConfig, array $crmIds, array $fields): object
{
$batchReadRequest = $batchConfig['batchReadRequest'];
$inputClass = $batchConfig['inputClass'];
$inputs = array_map(function ($crmId) use ($inputClass) {
$input = new $inputClass();
$input->setId($crmId);
return $input;
}, $crmIds);
$batchReadRequest->setInputs($inputs);
$batchReadRequest->setProperties($fields);
return $batchReadRequest;
}
private function validateApiResponse($response, string $objectType): void
{
if (! $response) {
throw new CrmException("HubSpot API returned null response for {$objectType} batch read");
}
}
private function processApiResults($response): array
{
$results = [];
$responseResults = $response->getResults();
if ($responseResults) {
foreach ($responseResults as $object) {
if ($object && $object->getId()) {
$results[$object->getId()] = [
'id' => $object->getId(),
'properties' => $object->getProperties() ?: [],
];
}
}
}
return $results;
}
private function logBatchResults(string $objectType, array $crmIds, array $results): void
{
$this->log->info("[HubSpot] Batch fetched {$objectType}", [
'requested_count' => count($crmIds),
'returned_count' => count($results),
'crm_ids' => $crmIds,
]);
}
private function handleBatchError(\Throwable $e, string $objectType, array $crmIds): void
{
$errorMessage = $e->getMessage() ?: 'Unknown error';
$errorTrace = $e->getTraceAsString() ?: 'No trace available';
$this->log->error("[HubSpot] Failed to batch fetch {$objectType}", [
'crm_ids' => $crmIds,
'error' => $errorMessage,
'trace' => $errorTrace,
]);
throw new CrmException("Failed to batch fetch {$objectType}: " . $errorMessage);
}
/**
* Batch read multiple opportunities by their CRM IDs
*
* @param array<string> $crmIds Array of HubSpot deal IDs (max 100)
* @param array<string> $fields Array of property names to fetch
*
* @return array<string, array> Array keyed by CRM ID with opportunity data
*/
public function getOpportunitiesByIds(array $crmIds, array $fields): array
{
return $this->batchReadObjects('deals', $crmIds, $fields);
}
/**
* Batch read multiple companies by their CRM IDs
*
* @param array<string> $crmIds Array of HubSpot company IDs (max 100)
* @param array<string> $fields Array of property names to fetch
*
* @return array<string, array> Array keyed by CRM ID with company data
*/
public function getCompaniesByIds(array $crmIds, array $fields): array
{
return $this->batchReadObjects('companies', $crmIds, $fields);
}
/**
* Batch read multiple contacts by their CRM IDs
*
* @param array<string> $crmIds Array of HubSpot contact IDs (max 100)
* @param array<string> $fields Array of property names to fetch
*
* @return array<string, array> Array keyed by CRM ID with contact data
*/
public function getContactsByIds(array $crmIds, array $fields): array
{
return $this->batchReadObjects('contacts', $crmIds, $fields);
}
/**
* @throws CompanyApiException
* @throws CrmException
*/
public function getAccountById(string $crmId, array $fields): array
{
try {
$company = $this->getNewInstance()->crm()->companies()->basicApi()->getById(
$crmId,
implode(',', $fields),
);
} catch (CompanyApiException $e) {
$this->log->info('[Hubspot] Failed to fetch account', [
'crm_id' => $crmId,
'reason' => $e->getMessage(),
]);
throw $e;
}
if (! $company instanceof CompaniesWithAssociations) {
throw new CrmException('Account not found');
}
return [
'id' => $company->getId(),
'properties' => $company->getProperties(),
];
}
/**
* @throws ContactApiException
* @throws CrmException
*/
public function getContactById(string $crmId, array $fields): array
{
try {
$contact = $this->getNewInstance()->crm()->contacts()->basicApi()->getById(
$crmId,
implode(',', $fields)
);
} catch (ContactApiException $e) {
$this->log->info('[Hubspot] Failed to fetch contact', [
'crm_id' => $crmId,
'reason' => $e->getMessage(),
]);
throw $e;
}
if (! $contact instanceof ContactsWithAssociations) {
throw new CrmException('Contact not found');
}
return [
'id' => $contact->getId(),
'properties' => $contact->getProperties(),
];
}
/**
* This is email search request that Hubspot offers as GET (more generous quota)
*/
public function getContactByEmail(string $email, array $fields = []): array
{
try {
$contact = $this->getNewInstance()->crm()->contacts()->basicApi()->getById(
$email,
implode(',', $fields),
null,
false,
'email'
);
return [
'id' => $contact->getId(),
'properties' => $contact->getProperties(),
];
} catch (ContactApiException $e) {
$this->log->info('[Hubspot] Failed to fetch contact', [
'email' => $email,
'reason' => $e->getMessage(),
]);
return [];
}
}
/**
* @throws CrmException
*/
public function fetchProperty(string $objectType, string $propertyId): Property
{
$result = $this->getNewInstance()->crm()->properties()->coreApi()->getByName($objectType, $propertyId);
if (! $result instanceof Property) {
$this->log->error('[Hubspot] Failed to fetch property', [
'object_type' => $objectType,
'property_id' => $propertyId,
'reason' => $result->getMessage(),
]);
throw new CrmException('Failed to fetch property');
}
return $result;
}
/**
* @return array<CrmFieldOption>
*/
public function fetchPropertyOptions(string $objectType, string $propertyId): array
{
/** @var array<CrmFieldOption> */
return $this->fetchProperty($objectType, $propertyId)->getOptions();
}
/**
* @return array<array{id:string, label:string, deleted:bool}>
*/
public function fetchCallDispositions(): array
{
/** @var Response $response */
$response = $this->getInstance()->engagements()->getCallDispositions();
/**
* @var array<array{
* id:string,
* label:string,
* deleted: bool
* }>
*/
return $response->toArray();
}
/**
* @return array<CrmFieldOption>
*/
public function fetchOpportunityPipelineStages(): array
{
$stages = [];
$apiResponse = $this->getNewInstance()->crm()->pipelines()->pipelinesApi()->getAll('deals');
if ($apiResponse instanceof Error) {
$this->log->error('[Hubspot] Failed to fetch opportunity pipelines', [
'reason' => $apiResponse->getMessage(),
]);
return [];
}
foreach ($apiResponse->getResults() as $pipeline) {
$pipelineStages = array_map(
static function (PipelineStage $stage) {
return [
'id' => $stage->getId(),
'label' => $stage->getLabel(),
];
},
$pipeline->getStages()
);
$stages = array_merge($stages, $pipelineStages);
}
return $stages;
}
public function fetchOpportunityPipelines(): array
{
$pipelines = [];
try {
$apiResponse = $this->makeRequest('/crm/v3/pipelines/deals');
} catch (\Exception $e) {
$this->log->info('[Hubspot] Failed to fetch opportunity pipelines', [
'reason' => $e->getMessage(),
]);
return [];
}
$response = $apiResponse->toArray();
foreach ($response['results'] as $pipeline) {
$pipelines[] = [
'id' => $pipeline['id'],
'label' => $pipeline['label'],
];
}
return $pipelines;
}
/**
* @return array<CrmFieldOption>
*/
public function fetchMeetingOutcomeFieldOptions(Field $field): array
{
return $field->getCrmProviderId() === 'meetingOutcome'
? $this->fetchMeetingOutcomeTypes()
: $this->fetchCallActivityTypes();
}
public function fetchMeetingOutcomeTypes(): array
{
return $this->extractMeetingTypeOptions(
'[URL_WITH_CREDENTIALS] Response $response */
$response = $this->getInstance()
->getClient()
->request('GET', $endpoint);
/**
* @var array<array{
* value: string,
* label: string,
* displayOrder: int
* }> $optionData
*/
$optionData = $response->toArray()['options'] ?? [];
$options = [];
foreach ($optionData as $item) {
$options[] = [
'id' => $item['value'],
'value' => $item['value'],
'label' => $item['label'],
'display_order' => $item['displayOrder'],
];
}
return $options;
}
/**
* @return array<CrmFieldOption>
*/
public function fetchDispositionFieldOptions(): array
{
$options = [];
$dispositions = $this->fetchCallDispositions();
foreach ($dispositions as $disposition) {
if ($disposition['deleted'] !== false) {
continue;
}
$option['value'] = $disposition['id'];
$option['id'] = $disposition['id'];
$option['label'] = $disposition['label'];
$options[] = $option;
}
return $options;
}
/**
* @return array<CrmFieldOption>
*/
public function fetchOpportunityFieldOptions(Field $field): array
{
if ($field->isStageField()) {
return $this->fetchOpportunityPipelineStages();
}
if ($field->isPipelineField()) {
return $this->fetchOpportunityPipelines();
}
return $this->fetchPropertyOptions('deals', $field->getCrmProviderId());
}
/**
* @throws BadRequest
* @throws HubspotException
*/
public function makeRequest(string $endpoint, $method = 'GET', $payload = [], ?string $queryString = null)
{
$endpoint = self::BASE_URL . $endpoint;
if ($method === 'GET') {
$response = $this->getInstance()->getClient()?->request(
method: $method,
endpoint: $endpoint,
query_string: $queryString
);
} else {
$response = $this->getInstance()->getClient()->request($method, $endpoint, [
'json' => ($payload),
]);
}
//
// $max = $response->getHeaderLine('X-HubSpot-RateLimit-Max'); // "110"
// $remaining = $response->getHeaderLine('X-HubSpot-RateLimit-Remaining'); // "109"
// $interval = $response->getHeaderLine('X-HubSpot-RateLimit-Interval-Milliseconds'); // "10000"
// $body = json_decode((string) $response->getBody(), true);
//
// \Illuminate\Support\Facades\Log::channel('custom_channel')->info('$max ' . PHP_EOL . print_r($max, true));
// \Illuminate\Support\Facades\Log::channel('custom_channel')->info('$remaining ' . PHP_EOL . print_r($remaining, true));
// \Illuminate\Support\Facades\Log::channel('custom_channel')->info('$interval ' . PHP_EOL . print_r($interval, true));
// \Illuminate\Support\Facades\Log::channel('custom_channel')->info('$body ' . PHP_EOL . print_r($body, true));
return $response;
}
/**
* @throws BadRequest
* @throws HubspotException
*/
public function createMeeting(array $payload): Response
{
$endpoint = '/crm/v3/objects/meetings';
return $this->makeRequest($endpoint, 'POST', $payload);
}
/**
* @throws BadRequest
* @throws HubspotException
*/
public function updateMeeting(string $meetingId, array $payload): Response
{
$endpoint = '/crm/v3/objects/meetings/' . $meetingId;
return $this->makeRequest($endpoint, 'PATCH', $payload);
}
/**
* @throws \Exception
*/
public function createNote(
string $body,
string $ownerId,
int $timestamp,
string $objectId,
NoteObject $noteObject
): ?string {
try {
$noteInput = new SimplePublicObjectInput([
'properties' => [
'hs_note_body' => $body,
'hubspot_owner_id' => $ownerId,
'hs_timestamp' => $timestamp,
],
]);
// Create note
$note = $this->getNewInstance()->crm()->objects()->basicApi()->create('note', $noteInput);
$this->getNewInstance()->crm()->objects()->associationsApi()->create(
'note',
$note->getId(),
$this->getNoteObject($noteObject),
$objectId,
$this->getNoteAssociationType($noteObject),
);
return $note->getId();
} catch (\Exception $e) {
$this->log->error('[Hubspot] Failed to create note', [
'objectId' => $objectId,
'noteObject' => $noteObject->getObjectType(),
'reason' => $e->getMessage(),
]);
\Sentry::captureException($e);
}
return null;
}
public function updateEngagement(string $objectId, array $engagement, array $metadata): void
{
$this->getInstance()->engagements()->update($objectId, $engagement, $metadata);
}
public function getEngagementData(string $engagementId): array
{
$engagement = $this->getInstance()->engagements()->get($engagementId);
return $engagement->toArray();
}
public function createEngagement(array $engagement, array $associations, array $metadata): Response
{
return $this->getInstance()
->engagements()
->create($engagement, $associations, $metadata);
}
public function isUnauthorizedException(\Exception $e): bool
{
if ($e instanceof BadRequest
|| $e instanceof DealApiException
|| $e instanceof ContactApiException
|| $e instanceof CompanyApiException
) {
return (int) $e->getCode() === 401;
}
if ($e instanceof \GuzzleHttp\Exception\RequestException && $e->hasResponse()) {
return $e->getResponse()?->getStatusCode() === 401;
}
$message = strtolower($e->getMessage());
return str_contains($message, '401 unauthorized') ||
str_contains($message, 'http 401') ||
str_contains($message, 'status code 401') ||
(preg_match('/\b401\b/', $message) === 1 && str_contains($message, 'unauthorized'));
}
/**
* Validates and refreshes the access token if needed before API requests.
* This ensures long-running processes don't fail due to token expiration.
*
* @throws SocialAccountTokenInvalidException
*/
public function ensureValidToken(): void
{
if ($this->oauthAccount === null) {
return;
}
$newToken = $this->tokenManager->ensureValidToken($this->oauthAccount);
if ($newToken !== null) {
$this->accessToken = $newToken;
}
}
public function getConfig()
{
return $this->config;
}
// returns only active (archived=false)
public function getOwners(): array
{
return $this->getNewInstance()->crm()->owners()->getAll();
}
/**
* @param bool $archived
*
* @return array<Owner>|[]
*/
public function getOwnersArchived(bool $archived = true): array
{
$endpoint = '/crm/v3/owners';
$queryParams = [
'archived' => $archived ? 'true' : 'false',
];
$queryString = http_build_query($queryParams);
$owners = [];
try {
$response = $this->makeRequest(endpoint: $endpoint, queryString: $queryString);
$responseData = $response?->toArray();
foreach ($responseData['results'] as $result) {
try {
$owners[] = Owner::create($result);
} catch (Throwable $e) {
$this->log->error('[HubSpot] Failed to process owner data', [
'result' => $result,
'error' => $e->getMessage(),
]);
continue;
}
}
} catch (Throwable $e) {
$this->log->error('HubSpot] Failed to fetch owners', [
'archived' => $archived,
'error' => $e->getMessage(),
]);
return [];
}
return $owners;
}
public function getMeeting(string $engagementId): ObjectWithAssociations
{
return $this->getNewInstance()->crm()->objects()->basicApi()
->getById('meeting', $engagementId, null, 'contact,company,deal');
}
public function deleteEngagement(string $engagementId): void
{
$this->getInstance()->engagements()->delete((int) $engagementId);
}
public function getAssociationsData(array $ids, string $fromObject, string $toObject): array
{
$associationData = [];
$idChunks = array_chunk($ids, self::ASSOCIATIONS_BATCH_SIZE_LIMIT);
foreach ($idChunks as $idChunk) {
try {
$batchInput = new \HubSpot\Client\Crm\Associations\Model\BatchInputPublicObjectId();
$batchInput->setInputs(array_map(function ($id) {
$publicObjectId = new \HubSpot\Client\Crm\Associations\Model\PublicObjectId();
$publicObjectId->setId($id);
return $publicObjectId;
}, $idChunk));
$associatedObjectsData = $this
->getNewInstance()
->crm()
->associations()
->batchApi()
->read($fromObject, $toObject, $batchInput);
if ($associatedObjectsData instanceof \HubSpot\Client\Crm\Associations\Model\BatchResponsePublicAssociationMulti) {
foreach ($associatedObjectsData->getResults() as $association) {
$from = $association->getFrom()->getId();
$toAssociations = $association->getTo();
if (! empty($toAssociations)) {
$associationData[$from] = array_map(function ($item) {
return $item->getId();
}, $toAssociations);
}
}
}
} catch (RateLimitException $e) {
throw $e;
} catch (\Exception $e) {
$this->log->error('[Hubspot] Failed to fetch associations', [
'from_object' => $fromObject,
'to_object' => $toObject,
'reason' => $e->getMessage(),
]);
}
}
return $associationData;
}
/**
* @throws \Exception
*/
private function getNoteAssociationType(NoteObject $noteObject): string
{
return match($noteObject) {
NoteObject::Opportunity => 'note_to_deal',
NoteObject::Lead, NoteObject::Contact => 'note_to_contact', // or 'note_to_lead' if your portal supports it
NoteObject::Account => 'note_to_company',
NoteObject::Call, NoteObject::Event => throw new \Exception('Not supported'),
};
}
/**
* @throws \Exception
*/
private function getNoteObject(NoteObject $noteObject): string
{
return match($noteObject) {
NoteObject::Opportunity => 'deal',
NoteObject::Lead, NoteObject::Contact => 'contact',
NoteObject::Account => 'company',
NoteObject::Call, NoteObject::Event => throw new \Exception('Not supported'),
};
}
public function addAssociations(string $objectType, string $associationType, array $payload): Response
{
$endpoint = "/crm/v4/associations/$objectType/$associationType/batch/create";
return $this->makeRequest($endpoint, 'POST', $payload);
}
public function removeAssociations(string $objectType, string $associationType, array $payload): Response
{
$endpoint = "/crm/v4/associations/$objectType/$associationType/batch/archive";
return $this->makeRequest($endpoint, 'POST', $payload);
}
}
Project
Project
New File or Directory…
Expand Selected
Collapse All
Options
Hide...
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JY-20725-handle-HS-search-rate-limit, menu","depth":5,"on_screen":true,"help_text":"Git Branch: JY-20725-handle-HS-search-rate-limit","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"AskJiminnyReportActivityServiceTest","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'AskJiminnyReportActivityServiceTest'","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'AskJiminnyReportActivityServiceTest'","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"IDE and Project Settings","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.088194445,"height":0.027777778},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"19","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {\n\"headers\":{\n\"Date\":[\"Thu,07 May 2026 14:21:15 GMT\"],\n \"Content-Type\":[\"application/json;charset=utf-8\"],\n \"Transfer-Encoding\":[\"chunked\"],\n \"Connection\":[\"keep-alive\"],\n \"CF-Ray\":[\"9f80deb8db60dc3a-SOF\"],\n \"CF-Cache-Status\":[\"DYNAMIC\"],\n \"Strict-Transport-Security\":[\"max-age=31536000; includeSubDomains; preload\"],\n \"Vary\":[\"origin,\n accept-encoding\"],\n \"access-control-allow-credentials\":[\"false\"],\n \"server-timing\":[\"hcid;desc=\\\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\\\",\n cfr;desc=\\\"9f80deb8e7c6dc3a-IAD\\\"\"],\n \"x-content-type-options\":[\"nosniff\"],\n \"x-hubspot-correlation-id\":[\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\"],\n \"Set-Cookie\":[\"__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-1.0.1.1-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,\n 07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None\"],\n \"Report-To\":[\"{\n\\\"endpoints\\\":[{\n\\\"url\\\":\\\"https:\\\\/\\\\/a.nel.cloudflare.com\\\\/report\\\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\\\"}],\n\\\"group\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"NEL\":[\"{\n\\\"success_fraction\\\":0.01,\n\\\"report_to\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"Server\":[\"cloudflare\"]}} {\n\"correlation_id\":\"95236535-ec98-4541-b92a-adfa73b69eab\",\n\"trace_id\":\"c7ab8365-903f-46d4-9403-0e5b551e3545\"}","depth":4,"on_screen":true,"value":"[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {\n\"headers\":{\n\"Date\":[\"Thu,07 May 2026 14:21:15 GMT\"],\n \"Content-Type\":[\"application/json;charset=utf-8\"],\n \"Transfer-Encoding\":[\"chunked\"],\n \"Connection\":[\"keep-alive\"],\n \"CF-Ray\":[\"9f80deb8db60dc3a-SOF\"],\n \"CF-Cache-Status\":[\"DYNAMIC\"],\n \"Strict-Transport-Security\":[\"max-age=31536000; includeSubDomains; preload\"],\n \"Vary\":[\"origin,\n accept-encoding\"],\n \"access-control-allow-credentials\":[\"false\"],\n \"server-timing\":[\"hcid;desc=\\\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\\\",\n cfr;desc=\\\"9f80deb8e7c6dc3a-IAD\\\"\"],\n \"x-content-type-options\":[\"nosniff\"],\n \"x-hubspot-correlation-id\":[\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\"],\n \"Set-Cookie\":[\"__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-1.0.1.1-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,\n 07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None\"],\n \"Report-To\":[\"{\n\\\"endpoints\\\":[{\n\\\"url\\\":\\\"https:\\\\/\\\\/a.nel.cloudflare.com\\\\/report\\\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\\\"}],\n\\\"group\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"NEL\":[\"{\n\\\"success_fraction\\\":0.01,\n\\\"report_to\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"Server\":[\"cloudflare\"]}} {\n\"correlation_id\":\"95236535-ec98-4541-b92a-adfa73b69eab\",\n\"trace_id\":\"c7ab8365-903f-46d4-9403-0e5b551e3545\"}","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Show Replace Field","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Search History","depth":3,"on_screen":true,"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"rateLimiter","depth":4,"on_screen":true,"value":"rateLimiter","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"New Line","depth":3,"on_screen":true,"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Match Case","depth":3,"on_screen":true,"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Words","depth":3,"on_screen":true,"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Regex","depth":3,"on_screen":true,"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Replace History","depth":3,"bounds":{"left":0.0,"top":0.0,"width":0.015277778,"height":0.024444444},"on_screen":false,"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextField","text":"Replace","depth":4,"on_screen":false,"role_description":"text field","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"New Line","depth":3,"bounds":{"left":0.0,"top":0.0,"width":0.015277778,"height":0.024444444},"on_screen":false,"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Preserve case","depth":3,"bounds":{"left":0.0,"top":0.0,"width":0.015277778,"height":0.024444444},"on_screen":false,"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"0 results","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Occurrence","depth":4,"on_screen":true,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Occurrence","depth":4,"on_screen":true,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Filter Search Results","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Open in Window, Multiple Cursors","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXLink","text":"Click to highlight","depth":4,"on_screen":false,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Close","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.088194445,"height":0.027777778},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"2","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"64","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"1","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"1","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\ndeclare(strict_types=1);\n\nnamespace Jiminny\\Services\\Crm\\Hubspot;\n\nuse HubSpot\\Client\\Crm\\Deals\\ApiException as DealApiException;\nuse HubSpot\\Client\\Crm\\Contacts\\ApiException as ContactApiException;\nuse HubSpot\\Client\\Crm\\Companies\\ApiException as CompanyApiException;\nuse HubSpot\\Client\\Crm\\Contacts\\Model\\SimplePublicObjectWithAssociations as ContactsWithAssociations;\nuse HubSpot\\Client\\Crm\\Companies\\Model\\SimplePublicObjectWithAssociations as CompaniesWithAssociations;\nuse HubSpot\\Client\\Crm\\Deals\\Model\\SimplePublicObjectWithAssociations as DealWithAssociations;\nuse HubSpot\\Client\\Crm\\Objects\\Model\\SimplePublicObjectInput;\nuse HubSpot\\Client\\Crm\\Objects\\Model\\SimplePublicObjectWithAssociations as ObjectWithAssociations;\nuse HubSpot\\Client\\Crm\\Pipelines\\Model\\Error;\nuse HubSpot\\Client\\Crm\\Pipelines\\Model\\PipelineStage;\nuse HubSpot\\Client\\Crm\\Properties\\Model\\Property;\nuse HubSpot\\Discovery\\Discovery;\nuse Jiminny\\Exceptions\\CrmException;\nuse Jiminny\\Exceptions\\RateLimitException;\nuse Jiminny\\Exceptions\\SocialAccountTokenInvalidException;\nuse Jiminny\\Jobs\\Crm\\NoteObject;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Services\\Crm\\BaseClient;\nuse Jiminny\\Services\\Crm\\Hubspot\\DTO\\Response\\Owner;\nuse Jiminny\\Services\\SocialAccountService;\nuse SevenShores\\Hubspot\\Exceptions\\BadRequest;\nuse SevenShores\\Hubspot\\Exceptions\\HubspotException;\nuse SevenShores\\Hubspot\\Factory;\nuse SevenShores\\Hubspot\\Http\\Response;\nuse Jiminny\\Services\\Crm\\Hubspot\\Pagination\\HubspotPaginationService;\nuse Throwable;\n\n/**\n * @phpstan-type CrmFieldOption array{id:string, label:string, value?:string}\n */\nclass Client extends BaseClient implements HubspotClientInterface\n{\n public const string MIN_API_VERSION = '2';\n\n public const string BASE_URL = 'https://api.hubapi.com';\n\n public const int ASSOCIATIONS_BATCH_SIZE_LIMIT = 1000;\n\n private HubspotPaginationService $paginationService;\n private HubspotTokenManager $tokenManager;\n\n public function __construct(\n SocialAccountService $socialAccountService,\n HubspotPaginationService $paginationService,\n HubspotTokenManager $tokenManager,\n ) {\n parent::__construct($socialAccountService);\n $this->paginationService = $paginationService;\n $this->tokenManager = $tokenManager;\n\n $this->setBaseUrl(self::BASE_URL);\n $this->setVersion(self::MIN_API_VERSION);\n }\n\n /**\n * Reacts to a rate limits (429) from HubSpot by translating it\n * into a RateLimitException carrying retry_after.\n *\n * Wrap any outbound HubSpot call (SDK or raw HTTP) like:\n *\n * $this->executeRequest(fn () => $this->getNewInstance()->crm()->...);\n *\n * @template T\n * @param callable(): T $apiCall\n * @return T\n *\n * @throws RateLimitException\n */\n private function executeRequest(callable $apiCall)\n {\n try {\n return $apiCall();\n } catch (Throwable $e) {\n if ($this->isHubspotRateLimit($e)) {\n $retryAfter = $this->parseRetryAfter($e);\n\n $this->log->warning('[Hubspot] Received 429 from API', [\n 'team_id' => $this->config->team_id,\n 'config_id' => $this->config->getId(),\n 'retry_after' => $retryAfter,\n 'reason' => $e->getMessage(),\n ]);\n\n throw new RateLimitException('Hubspot returned 429', $retryAfter, $e);\n }\n\n throw $e;\n }\n }\n\n public function isHubspotRateLimit(Throwable $e): bool\n {\n if ($e instanceof BadRequest\n || $e instanceof DealApiException\n || $e instanceof ContactApiException\n || $e instanceof CompanyApiException\n || $e instanceof \\GuzzleHttp\\Exception\\RequestException\n ) {\n return (int) $e->getCode() === 429;\n }\n\n return false;\n }\n\n public function parseRetryAfter(Throwable $e): int\n {\n // First try to get Retry-After from response headers\n if (method_exists($e, 'getResponseHeaders')) {\n $headers = $e->getResponseHeaders() ?: [];\n $value = $headers['Retry-After'] ?? $headers['retry-after'] ?? null;\n if (is_array($value)) {\n $value = $value[0] ?? null;\n }\n if (is_numeric($value)) {\n return (int) $value;\n }\n }\n\n if (method_exists($e, 'getResponseBody')) {\n $body = $e->getResponseBody();\n if (is_string($body)) {\n $body = json_decode($body, true) ?? [];\n }\n\n $policyName = $body['policyName'] ?? $body['policy'] ?? $body['context']['policyName'] ?? null;\n\n if ($policyName === 'TEN_SECONDLY_ROLLING' || $policyName === 'ten_secondly_rolling') {\n return 10;\n }\n if ($policyName === 'SECONDLY' || $policyName === 'secondly') {\n return 1;\n }\n }\n\n $this->log->warning('[Hubspot] No retry-after header or policy name found, using default', [\n 'exception_class' => get_class($e),\n ]);\n\n return 10;\n }\n\n public function getMinimumApiVersion(): string\n {\n return self::MIN_API_VERSION;\n }\n\n public function getInstance(): Factory\n {\n return new Factory([\n 'key' => $this->accessToken,\n 'oauth2' => true,\n 'base_url' => $this->baseUrl,\n ]);\n }\n\n public function getNewInstance(): Discovery\n {\n return \\HubSpot\\Factory::createWithAccessToken($this->accessToken);\n }\n\n /**\n * Secondly and daily limits for Hubspot API\n *\n * Product Tier: Free & Starter | Professional & Enterprise | API add-on (any tier)\n * Burst: 100/10 seconds | 150/10 seconds | 200/10 seconds\n * Daily: 250,000 | 500,000 | 1,000,000\n *\n * Official documentation states: The search endpoints are rate limited to five requests per second.\n * Since with 5 RPS were still hitting secondly rate limits we lowered it to 4\n */\n public function getPaginatedData(array $payload, string $type, int $offset = 0): array\n {\n $total = 0;\n $lastId = null;\n $rows = [];\n foreach ($this->getPaginatedDataGenerator($payload, $type, $offset, $total, $lastId) as $row) {\n $rows[] = $row;\n }\n\n return ['results' => $rows, 'total' => $total, 'last_record' => $lastId];\n }\n\n /**\n * @throws HubspotException\n * @throws SocialAccountTokenInvalidException\n * @throws BadRequest\n */\n public function getPaginatedDataGenerator(\n array $payload,\n string $type,\n int $offset = 0,\n int &$total = 0,\n ?string &$lastRecordId = null\n ): \\Generator {\n return $this->paginationService->getPaginatedDataGenerator(\n $this,\n $payload,\n $type,\n $offset,\n $total,\n $lastRecordId\n );\n }\n\n /**\n * Execute a search request against HubSpot CRM objects with rate limiting.\n *\n * @param string $objectType The object type ('deals', 'companies', 'contacts', 'calls')\n * @param array<string, mixed> $payload The search payload with filters, sorts, properties, etc.\n * @return array The search response with 'results', 'total', 'paging' keys\n * @throws RateLimitException When rate limit is hit\n * @throws HubspotException On API errors\n */\n public function search(string $objectType, array $payload): array\n {\n $endpoint = self::BASE_URL . \"/crm/v3/objects/{$objectType}/search\";\n\n return $this->executeRequest(function () use ($endpoint, $payload) {\n $response = $this->getInstance()->getClient()->request('POST', $endpoint, ['json' => $payload]);\n\n return $response->toArray();\n });\n }\n\n /**\n * @throws DealApiException\n * @throws CrmException\n */\n public function getOpportunityById(string $crmId, array $fields): array\n {\n try {\n// $deal = $this->executeRequest(fn () => $this->getNewInstance()->crm()->deals()->basicApi()->getById(\n $deal = $this->getNewInstance()->crm()->deals()->basicApi()->getById(\n $crmId,\n implode(',', $fields),\n 'companies,contacts'\n );\n } catch (DealApiException $e) {\n $this->log->info('[Hubspot] Failed to fetch opportunity', [\n 'crm_id' => $crmId,\n 'reason' => $e->getMessage(),\n ]);\n\n throw $e;\n }\n\n if (! $deal instanceof DealWithAssociations) {\n throw new CrmException('Deal not found');\n }\n\n return [\n 'id' => $deal->getId(),\n 'properties' => $deal->getProperties(),\n 'associations' => $deal->getAssociations(),\n ];\n }\n\n /**\n * Generic batch read method for HubSpot objects\n *\n * @param string $objectType The object type ('deals', 'companies', 'contacts')\n * @param array<string> $crmIds Array of HubSpot object IDs (max 100)\n * @param array<string> $fields Array of property names to fetch\n *\n * @return array<string, array> Array keyed by CRM ID with object data\n */\n private function batchReadObjects(string $objectType, array $crmIds, array $fields): array\n {\n if (empty($crmIds)) {\n return [];\n }\n\n $this->validateBatchSize($objectType, $crmIds);\n $this->ensureValidToken();\n\n try {\n $batchConfig = $this->createBatchConfiguration($objectType);\n $batchReadRequest = $this->prepareBatchRequest($batchConfig, $crmIds, $fields);\n $response = $batchConfig['api']->read($batchReadRequest);\n\n $this->validateApiResponse($response, $objectType);\n\n $results = $this->processApiResults($response);\n $this->logBatchResults($objectType, $crmIds, $results);\n\n return $results;\n } catch (\\Throwable $e) {\n $this->handleBatchError($e, $objectType, $crmIds);\n }\n }\n\n private function validateBatchSize(string $objectType, array $crmIds): void\n {\n if (count($crmIds) > 100) {\n throw new \\InvalidArgumentException(\"Batch size cannot exceed 100 {$objectType}\");\n }\n }\n\n private function createBatchConfiguration(string $objectType): array\n {\n $configurations = [\n 'deals' => [\n 'batchReadRequest' => new \\HubSpot\\Client\\Crm\\Deals\\Model\\BatchReadInputSimplePublicObjectId(),\n 'inputClass' => \\HubSpot\\Client\\Crm\\Deals\\Model\\SimplePublicObjectId::class,\n 'api' => $this->getNewInstance()->crm()->deals()->batchApi(),\n ],\n 'companies' => [\n 'batchReadRequest' => new \\HubSpot\\Client\\Crm\\Companies\\Model\\BatchReadInputSimplePublicObjectId(),\n 'inputClass' => \\HubSpot\\Client\\Crm\\Companies\\Model\\SimplePublicObjectId::class,\n 'api' => $this->getNewInstance()->crm()->companies()->batchApi(),\n ],\n 'contacts' => [\n 'batchReadRequest' => new \\HubSpot\\Client\\Crm\\Contacts\\Model\\BatchReadInputSimplePublicObjectId(),\n 'inputClass' => \\HubSpot\\Client\\Crm\\Contacts\\Model\\SimplePublicObjectId::class,\n 'api' => $this->getNewInstance()->crm()->contacts()->batchApi(),\n ],\n ];\n\n if (! isset($configurations[$objectType])) {\n throw new \\InvalidArgumentException(\"Unsupported object type: {$objectType}\");\n }\n\n return $configurations[$objectType];\n }\n\n private function prepareBatchRequest(array $batchConfig, array $crmIds, array $fields): object\n {\n $batchReadRequest = $batchConfig['batchReadRequest'];\n $inputClass = $batchConfig['inputClass'];\n\n $inputs = array_map(function ($crmId) use ($inputClass) {\n $input = new $inputClass();\n $input->setId($crmId);\n\n return $input;\n }, $crmIds);\n\n $batchReadRequest->setInputs($inputs);\n $batchReadRequest->setProperties($fields);\n\n return $batchReadRequest;\n }\n\n private function validateApiResponse($response, string $objectType): void\n {\n if (! $response) {\n throw new CrmException(\"HubSpot API returned null response for {$objectType} batch read\");\n }\n }\n\n private function processApiResults($response): array\n {\n $results = [];\n $responseResults = $response->getResults();\n\n if ($responseResults) {\n foreach ($responseResults as $object) {\n if ($object && $object->getId()) {\n $results[$object->getId()] = [\n 'id' => $object->getId(),\n 'properties' => $object->getProperties() ?: [],\n ];\n }\n }\n }\n\n return $results;\n }\n\n private function logBatchResults(string $objectType, array $crmIds, array $results): void\n {\n $this->log->info(\"[HubSpot] Batch fetched {$objectType}\", [\n 'requested_count' => count($crmIds),\n 'returned_count' => count($results),\n 'crm_ids' => $crmIds,\n ]);\n }\n\n private function handleBatchError(\\Throwable $e, string $objectType, array $crmIds): void\n {\n $errorMessage = $e->getMessage() ?: 'Unknown error';\n $errorTrace = $e->getTraceAsString() ?: 'No trace available';\n\n $this->log->error(\"[HubSpot] Failed to batch fetch {$objectType}\", [\n 'crm_ids' => $crmIds,\n 'error' => $errorMessage,\n 'trace' => $errorTrace,\n ]);\n\n throw new CrmException(\"Failed to batch fetch {$objectType}: \" . $errorMessage);\n }\n\n /**\n * Batch read multiple opportunities by their CRM IDs\n *\n * @param array<string> $crmIds Array of HubSpot deal IDs (max 100)\n * @param array<string> $fields Array of property names to fetch\n *\n * @return array<string, array> Array keyed by CRM ID with opportunity data\n */\n public function getOpportunitiesByIds(array $crmIds, array $fields): array\n {\n return $this->batchReadObjects('deals', $crmIds, $fields);\n }\n\n /**\n * Batch read multiple companies by their CRM IDs\n *\n * @param array<string> $crmIds Array of HubSpot company IDs (max 100)\n * @param array<string> $fields Array of property names to fetch\n *\n * @return array<string, array> Array keyed by CRM ID with company data\n */\n public function getCompaniesByIds(array $crmIds, array $fields): array\n {\n return $this->batchReadObjects('companies', $crmIds, $fields);\n }\n\n /**\n * Batch read multiple contacts by their CRM IDs\n *\n * @param array<string> $crmIds Array of HubSpot contact IDs (max 100)\n * @param array<string> $fields Array of property names to fetch\n *\n * @return array<string, array> Array keyed by CRM ID with contact data\n */\n public function getContactsByIds(array $crmIds, array $fields): array\n {\n return $this->batchReadObjects('contacts', $crmIds, $fields);\n }\n\n /**\n * @throws CompanyApiException\n * @throws CrmException\n */\n public function getAccountById(string $crmId, array $fields): array\n {\n try {\n $company = $this->getNewInstance()->crm()->companies()->basicApi()->getById(\n $crmId,\n implode(',', $fields),\n );\n } catch (CompanyApiException $e) {\n $this->log->info('[Hubspot] Failed to fetch account', [\n 'crm_id' => $crmId,\n 'reason' => $e->getMessage(),\n ]);\n\n throw $e;\n }\n\n if (! $company instanceof CompaniesWithAssociations) {\n throw new CrmException('Account not found');\n }\n\n return [\n 'id' => $company->getId(),\n 'properties' => $company->getProperties(),\n ];\n }\n\n /**\n * @throws ContactApiException\n * @throws CrmException\n */\n public function getContactById(string $crmId, array $fields): array\n {\n try {\n $contact = $this->getNewInstance()->crm()->contacts()->basicApi()->getById(\n $crmId,\n implode(',', $fields)\n );\n } catch (ContactApiException $e) {\n $this->log->info('[Hubspot] Failed to fetch contact', [\n 'crm_id' => $crmId,\n 'reason' => $e->getMessage(),\n ]);\n\n throw $e;\n }\n\n if (! $contact instanceof ContactsWithAssociations) {\n throw new CrmException('Contact not found');\n }\n\n return [\n 'id' => $contact->getId(),\n 'properties' => $contact->getProperties(),\n ];\n }\n\n /**\n * This is email search request that Hubspot offers as GET (more generous quota)\n */\n public function getContactByEmail(string $email, array $fields = []): array\n {\n try {\n $contact = $this->getNewInstance()->crm()->contacts()->basicApi()->getById(\n $email,\n implode(',', $fields),\n null,\n false,\n 'email'\n );\n\n return [\n 'id' => $contact->getId(),\n 'properties' => $contact->getProperties(),\n ];\n } catch (ContactApiException $e) {\n $this->log->info('[Hubspot] Failed to fetch contact', [\n 'email' => $email,\n 'reason' => $e->getMessage(),\n ]);\n\n return [];\n }\n }\n\n /**\n * @throws CrmException\n */\n public function fetchProperty(string $objectType, string $propertyId): Property\n {\n $result = $this->getNewInstance()->crm()->properties()->coreApi()->getByName($objectType, $propertyId);\n\n if (! $result instanceof Property) {\n $this->log->error('[Hubspot] Failed to fetch property', [\n 'object_type' => $objectType,\n 'property_id' => $propertyId,\n 'reason' => $result->getMessage(),\n ]);\n\n throw new CrmException('Failed to fetch property');\n }\n\n return $result;\n }\n\n /**\n * @return array<CrmFieldOption>\n */\n public function fetchPropertyOptions(string $objectType, string $propertyId): array\n {\n /** @var array<CrmFieldOption> */\n return $this->fetchProperty($objectType, $propertyId)->getOptions();\n }\n\n /**\n * @return array<array{id:string, label:string, deleted:bool}>\n */\n public function fetchCallDispositions(): array\n {\n /** @var Response $response */\n $response = $this->getInstance()->engagements()->getCallDispositions();\n\n /**\n * @var array<array{\n * id:string,\n * label:string,\n * deleted: bool\n * }>\n */\n return $response->toArray();\n }\n\n /**\n * @return array<CrmFieldOption>\n */\n public function fetchOpportunityPipelineStages(): array\n {\n $stages = [];\n $apiResponse = $this->getNewInstance()->crm()->pipelines()->pipelinesApi()->getAll('deals');\n\n if ($apiResponse instanceof Error) {\n $this->log->error('[Hubspot] Failed to fetch opportunity pipelines', [\n 'reason' => $apiResponse->getMessage(),\n ]);\n\n return [];\n }\n\n foreach ($apiResponse->getResults() as $pipeline) {\n $pipelineStages = array_map(\n static function (PipelineStage $stage) {\n return [\n 'id' => $stage->getId(),\n 'label' => $stage->getLabel(),\n ];\n },\n $pipeline->getStages()\n );\n\n $stages = array_merge($stages, $pipelineStages);\n }\n\n return $stages;\n }\n\n public function fetchOpportunityPipelines(): array\n {\n $pipelines = [];\n\n try {\n $apiResponse = $this->makeRequest('/crm/v3/pipelines/deals');\n } catch (\\Exception $e) {\n $this->log->info('[Hubspot] Failed to fetch opportunity pipelines', [\n 'reason' => $e->getMessage(),\n ]);\n\n return [];\n }\n\n $response = $apiResponse->toArray();\n\n foreach ($response['results'] as $pipeline) {\n $pipelines[] = [\n 'id' => $pipeline['id'],\n 'label' => $pipeline['label'],\n ];\n }\n\n return $pipelines;\n }\n\n /**\n * @return array<CrmFieldOption>\n */\n public function fetchMeetingOutcomeFieldOptions(Field $field): array\n {\n return $field->getCrmProviderId() === 'meetingOutcome'\n ? $this->fetchMeetingOutcomeTypes()\n : $this->fetchCallActivityTypes();\n }\n\n public function fetchMeetingOutcomeTypes(): array\n {\n return $this->extractMeetingTypeOptions(\n 'https://api.hubapi.com/crm/v3/properties/meeting/hs_meeting_outcome'\n );\n }\n\n public function fetchCallActivityTypes(): array\n {\n return $this->extractMeetingTypeOptions(\n 'https://api.hubapi.com/crm/v3/properties/call/hs_activity_type'\n );\n }\n\n private function extractMeetingTypeOptions(string $endpoint): array\n {\n /** @var Response $response */\n $response = $this->getInstance()\n ->getClient()\n ->request('GET', $endpoint);\n\n /**\n * @var array<array{\n * value: string,\n * label: string,\n * displayOrder: int\n * }> $optionData\n */\n $optionData = $response->toArray()['options'] ?? [];\n\n $options = [];\n foreach ($optionData as $item) {\n $options[] = [\n 'id' => $item['value'],\n 'value' => $item['value'],\n 'label' => $item['label'],\n 'display_order' => $item['displayOrder'],\n ];\n }\n\n return $options;\n }\n\n /**\n * @return array<CrmFieldOption>\n */\n public function fetchDispositionFieldOptions(): array\n {\n $options = [];\n\n $dispositions = $this->fetchCallDispositions();\n\n foreach ($dispositions as $disposition) {\n if ($disposition['deleted'] !== false) {\n continue;\n }\n\n $option['value'] = $disposition['id'];\n $option['id'] = $disposition['id'];\n $option['label'] = $disposition['label'];\n\n $options[] = $option;\n }\n\n return $options;\n }\n\n /**\n * @return array<CrmFieldOption>\n */\n public function fetchOpportunityFieldOptions(Field $field): array\n {\n if ($field->isStageField()) {\n return $this->fetchOpportunityPipelineStages();\n }\n\n if ($field->isPipelineField()) {\n return $this->fetchOpportunityPipelines();\n }\n\n return $this->fetchPropertyOptions('deals', $field->getCrmProviderId());\n }\n\n /**\n * @throws BadRequest\n * @throws HubspotException\n */\n public function makeRequest(string $endpoint, $method = 'GET', $payload = [], ?string $queryString = null)\n {\n $endpoint = self::BASE_URL . $endpoint;\n\n if ($method === 'GET') {\n $response = $this->getInstance()->getClient()?->request(\n method: $method,\n endpoint: $endpoint,\n query_string: $queryString\n );\n } else {\n $response = $this->getInstance()->getClient()->request($method, $endpoint, [\n 'json' => ($payload),\n ]);\n }\n//\n// $max = $response->getHeaderLine('X-HubSpot-RateLimit-Max'); // \"110\"\n// $remaining = $response->getHeaderLine('X-HubSpot-RateLimit-Remaining'); // \"109\"\n// $interval = $response->getHeaderLine('X-HubSpot-RateLimit-Interval-Milliseconds'); // \"10000\"\n// $body = json_decode((string) $response->getBody(), true);\n//\n// \\Illuminate\\Support\\Facades\\Log::channel('custom_channel')->info('$max ' . PHP_EOL . print_r($max, true));\n// \\Illuminate\\Support\\Facades\\Log::channel('custom_channel')->info('$remaining ' . PHP_EOL . print_r($remaining, true));\n// \\Illuminate\\Support\\Facades\\Log::channel('custom_channel')->info('$interval ' . PHP_EOL . print_r($interval, true));\n// \\Illuminate\\Support\\Facades\\Log::channel('custom_channel')->info('$body ' . PHP_EOL . print_r($body, true));\n\n return $response;\n }\n\n /**\n * @throws BadRequest\n * @throws HubspotException\n */\n public function createMeeting(array $payload): Response\n {\n $endpoint = '/crm/v3/objects/meetings';\n\n return $this->makeRequest($endpoint, 'POST', $payload);\n }\n\n /**\n * @throws BadRequest\n * @throws HubspotException\n */\n public function updateMeeting(string $meetingId, array $payload): Response\n {\n $endpoint = '/crm/v3/objects/meetings/' . $meetingId;\n\n return $this->makeRequest($endpoint, 'PATCH', $payload);\n }\n\n /**\n * @throws \\Exception\n */\n public function createNote(\n string $body,\n string $ownerId,\n int $timestamp,\n string $objectId,\n NoteObject $noteObject\n ): ?string {\n try {\n $noteInput = new SimplePublicObjectInput([\n 'properties' => [\n 'hs_note_body' => $body,\n 'hubspot_owner_id' => $ownerId,\n 'hs_timestamp' => $timestamp,\n ],\n ]);\n\n // Create note\n $note = $this->getNewInstance()->crm()->objects()->basicApi()->create('note', $noteInput);\n\n $this->getNewInstance()->crm()->objects()->associationsApi()->create(\n 'note',\n $note->getId(),\n $this->getNoteObject($noteObject),\n $objectId,\n $this->getNoteAssociationType($noteObject),\n );\n\n return $note->getId();\n } catch (\\Exception $e) {\n $this->log->error('[Hubspot] Failed to create note', [\n 'objectId' => $objectId,\n 'noteObject' => $noteObject->getObjectType(),\n 'reason' => $e->getMessage(),\n ]);\n\n \\Sentry::captureException($e);\n }\n\n return null;\n }\n\n public function updateEngagement(string $objectId, array $engagement, array $metadata): void\n {\n $this->getInstance()->engagements()->update($objectId, $engagement, $metadata);\n }\n\n public function getEngagementData(string $engagementId): array\n {\n $engagement = $this->getInstance()->engagements()->get($engagementId);\n\n return $engagement->toArray();\n }\n\n public function createEngagement(array $engagement, array $associations, array $metadata): Response\n {\n return $this->getInstance()\n ->engagements()\n ->create($engagement, $associations, $metadata);\n }\n\n public function isUnauthorizedException(\\Exception $e): bool\n {\n if ($e instanceof BadRequest\n || $e instanceof DealApiException\n || $e instanceof ContactApiException\n || $e instanceof CompanyApiException\n ) {\n return (int) $e->getCode() === 401;\n }\n\n if ($e instanceof \\GuzzleHttp\\Exception\\RequestException && $e->hasResponse()) {\n return $e->getResponse()?->getStatusCode() === 401;\n }\n\n $message = strtolower($e->getMessage());\n\n return str_contains($message, '401 unauthorized') ||\n str_contains($message, 'http 401') ||\n str_contains($message, 'status code 401') ||\n (preg_match('/\\b401\\b/', $message) === 1 && str_contains($message, 'unauthorized'));\n }\n\n /**\n * Validates and refreshes the access token if needed before API requests.\n * This ensures long-running processes don't fail due to token expiration.\n *\n * @throws SocialAccountTokenInvalidException\n */\n public function ensureValidToken(): void\n {\n if ($this->oauthAccount === null) {\n return;\n }\n\n $newToken = $this->tokenManager->ensureValidToken($this->oauthAccount);\n if ($newToken !== null) {\n $this->accessToken = $newToken;\n }\n }\n\n public function getConfig()\n {\n return $this->config;\n }\n\n // returns only active (archived=false)\n public function getOwners(): array\n {\n return $this->getNewInstance()->crm()->owners()->getAll();\n }\n\n /**\n * @param bool $archived\n *\n * @return array<Owner>|[]\n */\n public function getOwnersArchived(bool $archived = true): array\n {\n $endpoint = '/crm/v3/owners';\n $queryParams = [\n 'archived' => $archived ? 'true' : 'false',\n ];\n $queryString = http_build_query($queryParams);\n\n $owners = [];\n\n try {\n $response = $this->makeRequest(endpoint: $endpoint, queryString: $queryString);\n $responseData = $response?->toArray();\n\n foreach ($responseData['results'] as $result) {\n try {\n $owners[] = Owner::create($result);\n } catch (Throwable $e) {\n $this->log->error('[HubSpot] Failed to process owner data', [\n 'result' => $result,\n 'error' => $e->getMessage(),\n ]);\n\n continue;\n }\n }\n } catch (Throwable $e) {\n $this->log->error('HubSpot] Failed to fetch owners', [\n 'archived' => $archived,\n 'error' => $e->getMessage(),\n ]);\n\n return [];\n }\n\n return $owners;\n }\n\n public function getMeeting(string $engagementId): ObjectWithAssociations\n {\n return $this->getNewInstance()->crm()->objects()->basicApi()\n ->getById('meeting', $engagementId, null, 'contact,company,deal');\n }\n\n public function deleteEngagement(string $engagementId): void\n {\n $this->getInstance()->engagements()->delete((int) $engagementId);\n }\n\n public function getAssociationsData(array $ids, string $fromObject, string $toObject): array\n {\n $associationData = [];\n $idChunks = array_chunk($ids, self::ASSOCIATIONS_BATCH_SIZE_LIMIT);\n\n foreach ($idChunks as $idChunk) {\n try {\n $batchInput = new \\HubSpot\\Client\\Crm\\Associations\\Model\\BatchInputPublicObjectId();\n $batchInput->setInputs(array_map(function ($id) {\n $publicObjectId = new \\HubSpot\\Client\\Crm\\Associations\\Model\\PublicObjectId();\n $publicObjectId->setId($id);\n\n return $publicObjectId;\n }, $idChunk));\n\n $associatedObjectsData = $this\n ->getNewInstance()\n ->crm()\n ->associations()\n ->batchApi()\n ->read($fromObject, $toObject, $batchInput);\n\n if ($associatedObjectsData instanceof \\HubSpot\\Client\\Crm\\Associations\\Model\\BatchResponsePublicAssociationMulti) {\n foreach ($associatedObjectsData->getResults() as $association) {\n $from = $association->getFrom()->getId();\n $toAssociations = $association->getTo();\n\n if (! empty($toAssociations)) {\n $associationData[$from] = array_map(function ($item) {\n return $item->getId();\n }, $toAssociations);\n }\n }\n }\n } catch (RateLimitException $e) {\n throw $e;\n } catch (\\Exception $e) {\n $this->log->error('[Hubspot] Failed to fetch associations', [\n 'from_object' => $fromObject,\n 'to_object' => $toObject,\n 'reason' => $e->getMessage(),\n ]);\n }\n }\n\n return $associationData;\n }\n\n /**\n * @throws \\Exception\n */\n private function getNoteAssociationType(NoteObject $noteObject): string\n {\n return match($noteObject) {\n NoteObject::Opportunity => 'note_to_deal',\n NoteObject::Lead, NoteObject::Contact => 'note_to_contact', // or 'note_to_lead' if your portal supports it\n NoteObject::Account => 'note_to_company',\n NoteObject::Call, NoteObject::Event => throw new \\Exception('Not supported'),\n };\n }\n\n /**\n * @throws \\Exception\n */\n private function getNoteObject(NoteObject $noteObject): string\n {\n return match($noteObject) {\n NoteObject::Opportunity => 'deal',\n NoteObject::Lead, NoteObject::Contact => 'contact',\n NoteObject::Account => 'company',\n NoteObject::Call, NoteObject::Event => throw new \\Exception('Not supported'),\n };\n }\n\n public function addAssociations(string $objectType, string $associationType, array $payload): Response\n {\n $endpoint = \"/crm/v4/associations/$objectType/$associationType/batch/create\";\n\n return $this->makeRequest($endpoint, 'POST', $payload);\n }\n\n public function removeAssociations(string $objectType, string $associationType, array $payload): Response\n {\n $endpoint = \"/crm/v4/associations/$objectType/$associationType/batch/archive\";\n\n return $this->makeRequest($endpoint, 'POST', $payload);\n }\n}","depth":4,"on_screen":true,"value":"<?php\n\ndeclare(strict_types=1);\n\nnamespace Jiminny\\Services\\Crm\\Hubspot;\n\nuse HubSpot\\Client\\Crm\\Deals\\ApiException as DealApiException;\nuse HubSpot\\Client\\Crm\\Contacts\\ApiException as ContactApiException;\nuse HubSpot\\Client\\Crm\\Companies\\ApiException as CompanyApiException;\nuse HubSpot\\Client\\Crm\\Contacts\\Model\\SimplePublicObjectWithAssociations as ContactsWithAssociations;\nuse HubSpot\\Client\\Crm\\Companies\\Model\\SimplePublicObjectWithAssociations as CompaniesWithAssociations;\nuse HubSpot\\Client\\Crm\\Deals\\Model\\SimplePublicObjectWithAssociations as DealWithAssociations;\nuse HubSpot\\Client\\Crm\\Objects\\Model\\SimplePublicObjectInput;\nuse HubSpot\\Client\\Crm\\Objects\\Model\\SimplePublicObjectWithAssociations as ObjectWithAssociations;\nuse HubSpot\\Client\\Crm\\Pipelines\\Model\\Error;\nuse HubSpot\\Client\\Crm\\Pipelines\\Model\\PipelineStage;\nuse HubSpot\\Client\\Crm\\Properties\\Model\\Property;\nuse HubSpot\\Discovery\\Discovery;\nuse Jiminny\\Exceptions\\CrmException;\nuse Jiminny\\Exceptions\\RateLimitException;\nuse Jiminny\\Exceptions\\SocialAccountTokenInvalidException;\nuse Jiminny\\Jobs\\Crm\\NoteObject;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Services\\Crm\\BaseClient;\nuse Jiminny\\Services\\Crm\\Hubspot\\DTO\\Response\\Owner;\nuse Jiminny\\Services\\SocialAccountService;\nuse SevenShores\\Hubspot\\Exceptions\\BadRequest;\nuse SevenShores\\Hubspot\\Exceptions\\HubspotException;\nuse SevenShores\\Hubspot\\Factory;\nuse SevenShores\\Hubspot\\Http\\Response;\nuse Jiminny\\Services\\Crm\\Hubspot\\Pagination\\HubspotPaginationService;\nuse Throwable;\n\n/**\n * @phpstan-type CrmFieldOption array{id:string, label:string, value?:string}\n */\nclass Client extends BaseClient implements HubspotClientInterface\n{\n public const string MIN_API_VERSION = '2';\n\n public const string BASE_URL = 'https://api.hubapi.com';\n\n public const int ASSOCIATIONS_BATCH_SIZE_LIMIT = 1000;\n\n private HubspotPaginationService $paginationService;\n private HubspotTokenManager $tokenManager;\n\n public function __construct(\n SocialAccountService $socialAccountService,\n HubspotPaginationService $paginationService,\n HubspotTokenManager $tokenManager,\n ) {\n parent::__construct($socialAccountService);\n $this->paginationService = $paginationService;\n $this->tokenManager = $tokenManager;\n\n $this->setBaseUrl(self::BASE_URL);\n $this->setVersion(self::MIN_API_VERSION);\n }\n\n /**\n * Reacts to a rate limits (429) from HubSpot by translating it\n * into a RateLimitException carrying retry_after.\n *\n * Wrap any outbound HubSpot call (SDK or raw HTTP) like:\n *\n * $this->executeRequest(fn () => $this->getNewInstance()->crm()->...);\n *\n * @template T\n * @param callable(): T $apiCall\n * @return T\n *\n * @throws RateLimitException\n */\n private function executeRequest(callable $apiCall)\n {\n try {\n return $apiCall();\n } catch (Throwable $e) {\n if ($this->isHubspotRateLimit($e)) {\n $retryAfter = $this->parseRetryAfter($e);\n\n $this->log->warning('[Hubspot] Received 429 from API', [\n 'team_id' => $this->config->team_id,\n 'config_id' => $this->config->getId(),\n 'retry_after' => $retryAfter,\n 'reason' => $e->getMessage(),\n ]);\n\n throw new RateLimitException('Hubspot returned 429', $retryAfter, $e);\n }\n\n throw $e;\n }\n }\n\n public function isHubspotRateLimit(Throwable $e): bool\n {\n if ($e instanceof BadRequest\n || $e instanceof DealApiException\n || $e instanceof ContactApiException\n || $e instanceof CompanyApiException\n || $e instanceof \\GuzzleHttp\\Exception\\RequestException\n ) {\n return (int) $e->getCode() === 429;\n }\n\n return false;\n }\n\n public function parseRetryAfter(Throwable $e): int\n {\n // First try to get Retry-After from response headers\n if (method_exists($e, 'getResponseHeaders')) {\n $headers = $e->getResponseHeaders() ?: [];\n $value = $headers['Retry-After'] ?? $headers['retry-after'] ?? null;\n if (is_array($value)) {\n $value = $value[0] ?? null;\n }\n if (is_numeric($value)) {\n return (int) $value;\n }\n }\n\n if (method_exists($e, 'getResponseBody')) {\n $body = $e->getResponseBody();\n if (is_string($body)) {\n $body = json_decode($body, true) ?? [];\n }\n\n $policyName = $body['policyName'] ?? $body['policy'] ?? $body['context']['policyName'] ?? null;\n\n if ($policyName === 'TEN_SECONDLY_ROLLING' || $policyName === 'ten_secondly_rolling') {\n return 10;\n }\n if ($policyName === 'SECONDLY' || $policyName === 'secondly') {\n return 1;\n }\n }\n\n $this->log->warning('[Hubspot] No retry-after header or policy name found, using default', [\n 'exception_class' => get_class($e),\n ]);\n\n return 10;\n }\n\n public function getMinimumApiVersion(): string\n {\n return self::MIN_API_VERSION;\n }\n\n public function getInstance(): Factory\n {\n return new Factory([\n 'key' => $this->accessToken,\n 'oauth2' => true,\n 'base_url' => $this->baseUrl,\n ]);\n }\n\n public function getNewInstance(): Discovery\n {\n return \\HubSpot\\Factory::createWithAccessToken($this->accessToken);\n }\n\n /**\n * Secondly and daily limits for Hubspot API\n *\n * Product Tier: Free & Starter | Professional & Enterprise | API add-on (any tier)\n * Burst: 100/10 seconds | 150/10 seconds | 200/10 seconds\n * Daily: 250,000 | 500,000 | 1,000,000\n *\n * Official documentation states: The search endpoints are rate limited to five requests per second.\n * Since with 5 RPS were still hitting secondly rate limits we lowered it to 4\n */\n public function getPaginatedData(array $payload, string $type, int $offset = 0): array\n {\n $total = 0;\n $lastId = null;\n $rows = [];\n foreach ($this->getPaginatedDataGenerator($payload, $type, $offset, $total, $lastId) as $row) {\n $rows[] = $row;\n }\n\n return ['results' => $rows, 'total' => $total, 'last_record' => $lastId];\n }\n\n /**\n * @throws HubspotException\n * @throws SocialAccountTokenInvalidException\n * @throws BadRequest\n */\n public function getPaginatedDataGenerator(\n array $payload,\n string $type,\n int $offset = 0,\n int &$total = 0,\n ?string &$lastRecordId = null\n ): \\Generator {\n return $this->paginationService->getPaginatedDataGenerator(\n $this,\n $payload,\n $type,\n $offset,\n $total,\n $lastRecordId\n );\n }\n\n /**\n * Execute a search request against HubSpot CRM objects with rate limiting.\n *\n * @param string $objectType The object type ('deals', 'companies', 'contacts', 'calls')\n * @param array<string, mixed> $payload The search payload with filters, sorts, properties, etc.\n * @return array The search response with 'results', 'total', 'paging' keys\n * @throws RateLimitException When rate limit is hit\n * @throws HubspotException On API errors\n */\n public function search(string $objectType, array $payload): array\n {\n $endpoint = self::BASE_URL . \"/crm/v3/objects/{$objectType}/search\";\n\n return $this->executeRequest(function () use ($endpoint, $payload) {\n $response = $this->getInstance()->getClient()->request('POST', $endpoint, ['json' => $payload]);\n\n return $response->toArray();\n });\n }\n\n /**\n * @throws DealApiException\n * @throws CrmException\n */\n public function getOpportunityById(string $crmId, array $fields): array\n {\n try {\n// $deal = $this->executeRequest(fn () => $this->getNewInstance()->crm()->deals()->basicApi()->getById(\n $deal = $this->getNewInstance()->crm()->deals()->basicApi()->getById(\n $crmId,\n implode(',', $fields),\n 'companies,contacts'\n );\n } catch (DealApiException $e) {\n $this->log->info('[Hubspot] Failed to fetch opportunity', [\n 'crm_id' => $crmId,\n 'reason' => $e->getMessage(),\n ]);\n\n throw $e;\n }\n\n if (! $deal instanceof DealWithAssociations) {\n throw new CrmException('Deal not found');\n }\n\n return [\n 'id' => $deal->getId(),\n 'properties' => $deal->getProperties(),\n 'associations' => $deal->getAssociations(),\n ];\n }\n\n /**\n * Generic batch read method for HubSpot objects\n *\n * @param string $objectType The object type ('deals', 'companies', 'contacts')\n * @param array<string> $crmIds Array of HubSpot object IDs (max 100)\n * @param array<string> $fields Array of property names to fetch\n *\n * @return array<string, array> Array keyed by CRM ID with object data\n */\n private function batchReadObjects(string $objectType, array $crmIds, array $fields): array\n {\n if (empty($crmIds)) {\n return [];\n }\n\n $this->validateBatchSize($objectType, $crmIds);\n $this->ensureValidToken();\n\n try {\n $batchConfig = $this->createBatchConfiguration($objectType);\n $batchReadRequest = $this->prepareBatchRequest($batchConfig, $crmIds, $fields);\n $response = $batchConfig['api']->read($batchReadRequest);\n\n $this->validateApiResponse($response, $objectType);\n\n $results = $this->processApiResults($response);\n $this->logBatchResults($objectType, $crmIds, $results);\n\n return $results;\n } catch (\\Throwable $e) {\n $this->handleBatchError($e, $objectType, $crmIds);\n }\n }\n\n private function validateBatchSize(string $objectType, array $crmIds): void\n {\n if (count($crmIds) > 100) {\n throw new \\InvalidArgumentException(\"Batch size cannot exceed 100 {$objectType}\");\n }\n }\n\n private function createBatchConfiguration(string $objectType): array\n {\n $configurations = [\n 'deals' => [\n 'batchReadRequest' => new \\HubSpot\\Client\\Crm\\Deals\\Model\\BatchReadInputSimplePublicObjectId(),\n 'inputClass' => \\HubSpot\\Client\\Crm\\Deals\\Model\\SimplePublicObjectId::class,\n 'api' => $this->getNewInstance()->crm()->deals()->batchApi(),\n ],\n 'companies' => [\n 'batchReadRequest' => new \\HubSpot\\Client\\Crm\\Companies\\Model\\BatchReadInputSimplePublicObjectId(),\n 'inputClass' => \\HubSpot\\Client\\Crm\\Companies\\Model\\SimplePublicObjectId::class,\n 'api' => $this->getNewInstance()->crm()->companies()->batchApi(),\n ],\n 'contacts' => [\n 'batchReadRequest' => new \\HubSpot\\Client\\Crm\\Contacts\\Model\\BatchReadInputSimplePublicObjectId(),\n 'inputClass' => \\HubSpot\\Client\\Crm\\Contacts\\Model\\SimplePublicObjectId::class,\n 'api' => $this->getNewInstance()->crm()->contacts()->batchApi(),\n ],\n ];\n\n if (! isset($configurations[$objectType])) {\n throw new \\InvalidArgumentException(\"Unsupported object type: {$objectType}\");\n }\n\n return $configurations[$objectType];\n }\n\n private function prepareBatchRequest(array $batchConfig, array $crmIds, array $fields): object\n {\n $batchReadRequest = $batchConfig['batchReadRequest'];\n $inputClass = $batchConfig['inputClass'];\n\n $inputs = array_map(function ($crmId) use ($inputClass) {\n $input = new $inputClass();\n $input->setId($crmId);\n\n return $input;\n }, $crmIds);\n\n $batchReadRequest->setInputs($inputs);\n $batchReadRequest->setProperties($fields);\n\n return $batchReadRequest;\n }\n\n private function validateApiResponse($response, string $objectType): void\n {\n if (! $response) {\n throw new CrmException(\"HubSpot API returned null response for {$objectType} batch read\");\n }\n }\n\n private function processApiResults($response): array\n {\n $results = [];\n $responseResults = $response->getResults();\n\n if ($responseResults) {\n foreach ($responseResults as $object) {\n if ($object && $object->getId()) {\n $results[$object->getId()] = [\n 'id' => $object->getId(),\n 'properties' => $object->getProperties() ?: [],\n ];\n }\n }\n }\n\n return $results;\n }\n\n private function logBatchResults(string $objectType, array $crmIds, array $results): void\n {\n $this->log->info(\"[HubSpot] Batch fetched {$objectType}\", [\n 'requested_count' => count($crmIds),\n 'returned_count' => count($results),\n 'crm_ids' => $crmIds,\n ]);\n }\n\n private function handleBatchError(\\Throwable $e, string $objectType, array $crmIds): void\n {\n $errorMessage = $e->getMessage() ?: 'Unknown error';\n $errorTrace = $e->getTraceAsString() ?: 'No trace available';\n\n $this->log->error(\"[HubSpot] Failed to batch fetch {$objectType}\", [\n 'crm_ids' => $crmIds,\n 'error' => $errorMessage,\n 'trace' => $errorTrace,\n ]);\n\n throw new CrmException(\"Failed to batch fetch {$objectType}: \" . $errorMessage);\n }\n\n /**\n * Batch read multiple opportunities by their CRM IDs\n *\n * @param array<string> $crmIds Array of HubSpot deal IDs (max 100)\n * @param array<string> $fields Array of property names to fetch\n *\n * @return array<string, array> Array keyed by CRM ID with opportunity data\n */\n public function getOpportunitiesByIds(array $crmIds, array $fields): array\n {\n return $this->batchReadObjects('deals', $crmIds, $fields);\n }\n\n /**\n * Batch read multiple companies by their CRM IDs\n *\n * @param array<string> $crmIds Array of HubSpot company IDs (max 100)\n * @param array<string> $fields Array of property names to fetch\n *\n * @return array<string, array> Array keyed by CRM ID with company data\n */\n public function getCompaniesByIds(array $crmIds, array $fields): array\n {\n return $this->batchReadObjects('companies', $crmIds, $fields);\n }\n\n /**\n * Batch read multiple contacts by their CRM IDs\n *\n * @param array<string> $crmIds Array of HubSpot contact IDs (max 100)\n * @param array<string> $fields Array of property names to fetch\n *\n * @return array<string, array> Array keyed by CRM ID with contact data\n */\n public function getContactsByIds(array $crmIds, array $fields): array\n {\n return $this->batchReadObjects('contacts', $crmIds, $fields);\n }\n\n /**\n * @throws CompanyApiException\n * @throws CrmException\n */\n public function getAccountById(string $crmId, array $fields): array\n {\n try {\n $company = $this->getNewInstance()->crm()->companies()->basicApi()->getById(\n $crmId,\n implode(',', $fields),\n );\n } catch (CompanyApiException $e) {\n $this->log->info('[Hubspot] Failed to fetch account', [\n 'crm_id' => $crmId,\n 'reason' => $e->getMessage(),\n ]);\n\n throw $e;\n }\n\n if (! $company instanceof CompaniesWithAssociations) {\n throw new CrmException('Account not found');\n }\n\n return [\n 'id' => $company->getId(),\n 'properties' => $company->getProperties(),\n ];\n }\n\n /**\n * @throws ContactApiException\n * @throws CrmException\n */\n public function getContactById(string $crmId, array $fields): array\n {\n try {\n $contact = $this->getNewInstance()->crm()->contacts()->basicApi()->getById(\n $crmId,\n implode(',', $fields)\n );\n } catch (ContactApiException $e) {\n $this->log->info('[Hubspot] Failed to fetch contact', [\n 'crm_id' => $crmId,\n 'reason' => $e->getMessage(),\n ]);\n\n throw $e;\n }\n\n if (! $contact instanceof ContactsWithAssociations) {\n throw new CrmException('Contact not found');\n }\n\n return [\n 'id' => $contact->getId(),\n 'properties' => $contact->getProperties(),\n ];\n }\n\n /**\n * This is email search request that Hubspot offers as GET (more generous quota)\n */\n public function getContactByEmail(string $email, array $fields = []): array\n {\n try {\n $contact = $this->getNewInstance()->crm()->contacts()->basicApi()->getById(\n $email,\n implode(',', $fields),\n null,\n false,\n 'email'\n );\n\n return [\n 'id' => $contact->getId(),\n 'properties' => $contact->getProperties(),\n ];\n } catch (ContactApiException $e) {\n $this->log->info('[Hubspot] Failed to fetch contact', [\n 'email' => $email,\n 'reason' => $e->getMessage(),\n ]);\n\n return [];\n }\n }\n\n /**\n * @throws CrmException\n */\n public function fetchProperty(string $objectType, string $propertyId): Property\n {\n $result = $this->getNewInstance()->crm()->properties()->coreApi()->getByName($objectType, $propertyId);\n\n if (! $result instanceof Property) {\n $this->log->error('[Hubspot] Failed to fetch property', [\n 'object_type' => $objectType,\n 'property_id' => $propertyId,\n 'reason' => $result->getMessage(),\n ]);\n\n throw new CrmException('Failed to fetch property');\n }\n\n return $result;\n }\n\n /**\n * @return array<CrmFieldOption>\n */\n public function fetchPropertyOptions(string $objectType, string $propertyId): array\n {\n /** @var array<CrmFieldOption> */\n return $this->fetchProperty($objectType, $propertyId)->getOptions();\n }\n\n /**\n * @return array<array{id:string, label:string, deleted:bool}>\n */\n public function fetchCallDispositions(): array\n {\n /** @var Response $response */\n $response = $this->getInstance()->engagements()->getCallDispositions();\n\n /**\n * @var array<array{\n * id:string,\n * label:string,\n * deleted: bool\n * }>\n */\n return $response->toArray();\n }\n\n /**\n * @return array<CrmFieldOption>\n */\n public function fetchOpportunityPipelineStages(): array\n {\n $stages = [];\n $apiResponse = $this->getNewInstance()->crm()->pipelines()->pipelinesApi()->getAll('deals');\n\n if ($apiResponse instanceof Error) {\n $this->log->error('[Hubspot] Failed to fetch opportunity pipelines', [\n 'reason' => $apiResponse->getMessage(),\n ]);\n\n return [];\n }\n\n foreach ($apiResponse->getResults() as $pipeline) {\n $pipelineStages = array_map(\n static function (PipelineStage $stage) {\n return [\n 'id' => $stage->getId(),\n 'label' => $stage->getLabel(),\n ];\n },\n $pipeline->getStages()\n );\n\n $stages = array_merge($stages, $pipelineStages);\n }\n\n return $stages;\n }\n\n public function fetchOpportunityPipelines(): array\n {\n $pipelines = [];\n\n try {\n $apiResponse = $this->makeRequest('/crm/v3/pipelines/deals');\n } catch (\\Exception $e) {\n $this->log->info('[Hubspot] Failed to fetch opportunity pipelines', [\n 'reason' => $e->getMessage(),\n ]);\n\n return [];\n }\n\n $response = $apiResponse->toArray();\n\n foreach ($response['results'] as $pipeline) {\n $pipelines[] = [\n 'id' => $pipeline['id'],\n 'label' => $pipeline['label'],\n ];\n }\n\n return $pipelines;\n }\n\n /**\n * @return array<CrmFieldOption>\n */\n public function fetchMeetingOutcomeFieldOptions(Field $field): array\n {\n return $field->getCrmProviderId() === 'meetingOutcome'\n ? $this->fetchMeetingOutcomeTypes()\n : $this->fetchCallActivityTypes();\n }\n\n public function fetchMeetingOutcomeTypes(): array\n {\n return $this->extractMeetingTypeOptions(\n 'https://api.hubapi.com/crm/v3/properties/meeting/hs_meeting_outcome'\n );\n }\n\n public function fetchCallActivityTypes(): array\n {\n return $this->extractMeetingTypeOptions(\n 'https://api.hubapi.com/crm/v3/properties/call/hs_activity_type'\n );\n }\n\n private function extractMeetingTypeOptions(string $endpoint): array\n {\n /** @var Response $response */\n $response = $this->getInstance()\n ->getClient()\n ->request('GET', $endpoint);\n\n /**\n * @var array<array{\n * value: string,\n * label: string,\n * displayOrder: int\n * }> $optionData\n */\n $optionData = $response->toArray()['options'] ?? [];\n\n $options = [];\n foreach ($optionData as $item) {\n $options[] = [\n 'id' => $item['value'],\n 'value' => $item['value'],\n 'label' => $item['label'],\n 'display_order' => $item['displayOrder'],\n ];\n }\n\n return $options;\n }\n\n /**\n * @return array<CrmFieldOption>\n */\n public function fetchDispositionFieldOptions(): array\n {\n $options = [];\n\n $dispositions = $this->fetchCallDispositions();\n\n foreach ($dispositions as $disposition) {\n if ($disposition['deleted'] !== false) {\n continue;\n }\n\n $option['value'] = $disposition['id'];\n $option['id'] = $disposition['id'];\n $option['label'] = $disposition['label'];\n\n $options[] = $option;\n }\n\n return $options;\n }\n\n /**\n * @return array<CrmFieldOption>\n */\n public function fetchOpportunityFieldOptions(Field $field): array\n {\n if ($field->isStageField()) {\n return $this->fetchOpportunityPipelineStages();\n }\n\n if ($field->isPipelineField()) {\n return $this->fetchOpportunityPipelines();\n }\n\n return $this->fetchPropertyOptions('deals', $field->getCrmProviderId());\n }\n\n /**\n * @throws BadRequest\n * @throws HubspotException\n */\n public function makeRequest(string $endpoint, $method = 'GET', $payload = [], ?string $queryString = null)\n {\n $endpoint = self::BASE_URL . $endpoint;\n\n if ($method === 'GET') {\n $response = $this->getInstance()->getClient()?->request(\n method: $method,\n endpoint: $endpoint,\n query_string: $queryString\n );\n } else {\n $response = $this->getInstance()->getClient()->request($method, $endpoint, [\n 'json' => ($payload),\n ]);\n }\n//\n// $max = $response->getHeaderLine('X-HubSpot-RateLimit-Max'); // \"110\"\n// $remaining = $response->getHeaderLine('X-HubSpot-RateLimit-Remaining'); // \"109\"\n// $interval = $response->getHeaderLine('X-HubSpot-RateLimit-Interval-Milliseconds'); // \"10000\"\n// $body = json_decode((string) $response->getBody(), true);\n//\n// \\Illuminate\\Support\\Facades\\Log::channel('custom_channel')->info('$max ' . PHP_EOL . print_r($max, true));\n// \\Illuminate\\Support\\Facades\\Log::channel('custom_channel')->info('$remaining ' . PHP_EOL . print_r($remaining, true));\n// \\Illuminate\\Support\\Facades\\Log::channel('custom_channel')->info('$interval ' . PHP_EOL . print_r($interval, true));\n// \\Illuminate\\Support\\Facades\\Log::channel('custom_channel')->info('$body ' . PHP_EOL . print_r($body, true));\n\n return $response;\n }\n\n /**\n * @throws BadRequest\n * @throws HubspotException\n */\n public function createMeeting(array $payload): Response\n {\n $endpoint = '/crm/v3/objects/meetings';\n\n return $this->makeRequest($endpoint, 'POST', $payload);\n }\n\n /**\n * @throws BadRequest\n * @throws HubspotException\n */\n public function updateMeeting(string $meetingId, array $payload): Response\n {\n $endpoint = '/crm/v3/objects/meetings/' . $meetingId;\n\n return $this->makeRequest($endpoint, 'PATCH', $payload);\n }\n\n /**\n * @throws \\Exception\n */\n public function createNote(\n string $body,\n string $ownerId,\n int $timestamp,\n string $objectId,\n NoteObject $noteObject\n ): ?string {\n try {\n $noteInput = new SimplePublicObjectInput([\n 'properties' => [\n 'hs_note_body' => $body,\n 'hubspot_owner_id' => $ownerId,\n 'hs_timestamp' => $timestamp,\n ],\n ]);\n\n // Create note\n $note = $this->getNewInstance()->crm()->objects()->basicApi()->create('note', $noteInput);\n\n $this->getNewInstance()->crm()->objects()->associationsApi()->create(\n 'note',\n $note->getId(),\n $this->getNoteObject($noteObject),\n $objectId,\n $this->getNoteAssociationType($noteObject),\n );\n\n return $note->getId();\n } catch (\\Exception $e) {\n $this->log->error('[Hubspot] Failed to create note', [\n 'objectId' => $objectId,\n 'noteObject' => $noteObject->getObjectType(),\n 'reason' => $e->getMessage(),\n ]);\n\n \\Sentry::captureException($e);\n }\n\n return null;\n }\n\n public function updateEngagement(string $objectId, array $engagement, array $metadata): void\n {\n $this->getInstance()->engagements()->update($objectId, $engagement, $metadata);\n }\n\n public function getEngagementData(string $engagementId): array\n {\n $engagement = $this->getInstance()->engagements()->get($engagementId);\n\n return $engagement->toArray();\n }\n\n public function createEngagement(array $engagement, array $associations, array $metadata): Response\n {\n return $this->getInstance()\n ->engagements()\n ->create($engagement, $associations, $metadata);\n }\n\n public function isUnauthorizedException(\\Exception $e): bool\n {\n if ($e instanceof BadRequest\n || $e instanceof DealApiException\n || $e instanceof ContactApiException\n || $e instanceof CompanyApiException\n ) {\n return (int) $e->getCode() === 401;\n }\n\n if ($e instanceof \\GuzzleHttp\\Exception\\RequestException && $e->hasResponse()) {\n return $e->getResponse()?->getStatusCode() === 401;\n }\n\n $message = strtolower($e->getMessage());\n\n return str_contains($message, '401 unauthorized') ||\n str_contains($message, 'http 401') ||\n str_contains($message, 'status code 401') ||\n (preg_match('/\\b401\\b/', $message) === 1 && str_contains($message, 'unauthorized'));\n }\n\n /**\n * Validates and refreshes the access token if needed before API requests.\n * This ensures long-running processes don't fail due to token expiration.\n *\n * @throws SocialAccountTokenInvalidException\n */\n public function ensureValidToken(): void\n {\n if ($this->oauthAccount === null) {\n return;\n }\n\n $newToken = $this->tokenManager->ensureValidToken($this->oauthAccount);\n if ($newToken !== null) {\n $this->accessToken = $newToken;\n }\n }\n\n public function getConfig()\n {\n return $this->config;\n }\n\n // returns only active (archived=false)\n public function getOwners(): array\n {\n return $this->getNewInstance()->crm()->owners()->getAll();\n }\n\n /**\n * @param bool $archived\n *\n * @return array<Owner>|[]\n */\n public function getOwnersArchived(bool $archived = true): array\n {\n $endpoint = '/crm/v3/owners';\n $queryParams = [\n 'archived' => $archived ? 'true' : 'false',\n ];\n $queryString = http_build_query($queryParams);\n\n $owners = [];\n\n try {\n $response = $this->makeRequest(endpoint: $endpoint, queryString: $queryString);\n $responseData = $response?->toArray();\n\n foreach ($responseData['results'] as $result) {\n try {\n $owners[] = Owner::create($result);\n } catch (Throwable $e) {\n $this->log->error('[HubSpot] Failed to process owner data', [\n 'result' => $result,\n 'error' => $e->getMessage(),\n ]);\n\n continue;\n }\n }\n } catch (Throwable $e) {\n $this->log->error('HubSpot] Failed to fetch owners', [\n 'archived' => $archived,\n 'error' => $e->getMessage(),\n ]);\n\n return [];\n }\n\n return $owners;\n }\n\n public function getMeeting(string $engagementId): ObjectWithAssociations\n {\n return $this->getNewInstance()->crm()->objects()->basicApi()\n ->getById('meeting', $engagementId, null, 'contact,company,deal');\n }\n\n public function deleteEngagement(string $engagementId): void\n {\n $this->getInstance()->engagements()->delete((int) $engagementId);\n }\n\n public function getAssociationsData(array $ids, string $fromObject, string $toObject): array\n {\n $associationData = [];\n $idChunks = array_chunk($ids, self::ASSOCIATIONS_BATCH_SIZE_LIMIT);\n\n foreach ($idChunks as $idChunk) {\n try {\n $batchInput = new \\HubSpot\\Client\\Crm\\Associations\\Model\\BatchInputPublicObjectId();\n $batchInput->setInputs(array_map(function ($id) {\n $publicObjectId = new \\HubSpot\\Client\\Crm\\Associations\\Model\\PublicObjectId();\n $publicObjectId->setId($id);\n\n return $publicObjectId;\n }, $idChunk));\n\n $associatedObjectsData = $this\n ->getNewInstance()\n ->crm()\n ->associations()\n ->batchApi()\n ->read($fromObject, $toObject, $batchInput);\n\n if ($associatedObjectsData instanceof \\HubSpot\\Client\\Crm\\Associations\\Model\\BatchResponsePublicAssociationMulti) {\n foreach ($associatedObjectsData->getResults() as $association) {\n $from = $association->getFrom()->getId();\n $toAssociations = $association->getTo();\n\n if (! empty($toAssociations)) {\n $associationData[$from] = array_map(function ($item) {\n return $item->getId();\n }, $toAssociations);\n }\n }\n }\n } catch (RateLimitException $e) {\n throw $e;\n } catch (\\Exception $e) {\n $this->log->error('[Hubspot] Failed to fetch associations', [\n 'from_object' => $fromObject,\n 'to_object' => $toObject,\n 'reason' => $e->getMessage(),\n ]);\n }\n }\n\n return $associationData;\n }\n\n /**\n * @throws \\Exception\n */\n private function getNoteAssociationType(NoteObject $noteObject): string\n {\n return match($noteObject) {\n NoteObject::Opportunity => 'note_to_deal',\n NoteObject::Lead, NoteObject::Contact => 'note_to_contact', // or 'note_to_lead' if your portal supports it\n NoteObject::Account => 'note_to_company',\n NoteObject::Call, NoteObject::Event => throw new \\Exception('Not supported'),\n };\n }\n\n /**\n * @throws \\Exception\n */\n private function getNoteObject(NoteObject $noteObject): string\n {\n return match($noteObject) {\n NoteObject::Opportunity => 'deal',\n NoteObject::Lead, NoteObject::Contact => 'contact',\n NoteObject::Account => 'company',\n NoteObject::Call, NoteObject::Event => throw new \\Exception('Not supported'),\n };\n }\n\n public function addAssociations(string $objectType, string $associationType, array $payload): Response\n {\n $endpoint = \"/crm/v4/associations/$objectType/$associationType/batch/create\";\n\n return $this->makeRequest($endpoint, 'POST', $payload);\n }\n\n public function removeAssociations(string $objectType, string $associationType, array $payload): Response\n {\n $endpoint = \"/crm/v4/associations/$objectType/$associationType/batch/archive\";\n\n return $this->makeRequest($endpoint, 'POST', $payload);\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":true,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Project","depth":3,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Project","depth":3,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"New File or Directory…","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Expand Selected","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Collapse All","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Options","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
7579307838644216966
|
-2772555093692774298
|
click
|
accessibility
|
NULL
|
Project: faVsco.js, menu
JY-20725-handle-HS-search Project: faVsco.js, menu
JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
19
Previous Highlighted Error
Next Highlighted Error
[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {
"headers":{
"Date":["Thu,07 May 2026 14:21:15 GMT"],
"Content-Type":["application/json;charset=utf-8"],
"Transfer-Encoding":["chunked"],
"Connection":["keep-alive"],
"CF-Ray":["9f80deb8db60dc3a-SOF"],
"CF-Cache-Status":["DYNAMIC"],
"Strict-Transport-Security":["max-age=31536000; includeSubDomains; preload"],
"Vary":["origin,
accept-encoding"],
"access-control-allow-credentials":["false"],
"server-timing":["hcid;desc=\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\",
cfr;desc=\"9f80deb8e7c6dc3a-IAD\""],
"x-content-type-options":["nosniff"],
"x-hubspot-correlation-id":["019e02d0-6fd8-7812-bdba-885b7ccb3ee3"],
"Set-Cookie":["__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-[IP_ADDRESS]-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,
07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None"],
"Report-To":["{
\"endpoints\":[{
\"url\":\"https:\\/\\/a.nel.cloudflare.com\\/report\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\"}],
\"group\":\"cf-nel\",
\"max_age\":604800}"],
"NEL":["{
\"success_fraction\":0.01,
\"report_to\":\"cf-nel\",
\"max_age\":604800}"],
"Server":["cloudflare"]}} {
"correlation_id":"95236535-ec98-4541-b92a-adfa73b69eab",
"trace_id":"c7ab8365-903f-46d4-9403-0e5b551e3545"}
Show Replace Field
Search History
rateLimiter
New Line
Match Case
Words
Regex
Replace History
Replace
New Line
Preserve case
0 results
Previous Occurrence
Next Occurrence
Filter Search Results
Open in Window, Multiple Cursors
Click to highlight
Close
Sync Changes
Hide This Notification
Code changed:
Hide
2
64
1
1
Previous Highlighted Error
Next Highlighted Error
<?php
declare(strict_types=1);
namespace Jiminny\Services\Crm\Hubspot;
use HubSpot\Client\Crm\Deals\ApiException as DealApiException;
use HubSpot\Client\Crm\Contacts\ApiException as ContactApiException;
use HubSpot\Client\Crm\Companies\ApiException as CompanyApiException;
use HubSpot\Client\Crm\Contacts\Model\SimplePublicObjectWithAssociations as ContactsWithAssociations;
use HubSpot\Client\Crm\Companies\Model\SimplePublicObjectWithAssociations as CompaniesWithAssociations;
use HubSpot\Client\Crm\Deals\Model\SimplePublicObjectWithAssociations as DealWithAssociations;
use HubSpot\Client\Crm\Objects\Model\SimplePublicObjectInput;
use HubSpot\Client\Crm\Objects\Model\SimplePublicObjectWithAssociations as ObjectWithAssociations;
use HubSpot\Client\Crm\Pipelines\Model\Error;
use HubSpot\Client\Crm\Pipelines\Model\PipelineStage;
use HubSpot\Client\Crm\Properties\Model\Property;
use HubSpot\Discovery\Discovery;
use Jiminny\Exceptions\CrmException;
use Jiminny\Exceptions\RateLimitException;
use Jiminny\Exceptions\SocialAccountTokenInvalidException;
use Jiminny\Jobs\Crm\NoteObject;
use Jiminny\Models\Crm\Field;
use Jiminny\Services\Crm\BaseClient;
use Jiminny\Services\Crm\Hubspot\DTO\Response\Owner;
use Jiminny\Services\SocialAccountService;
use SevenShores\Hubspot\Exceptions\BadRequest;
use SevenShores\Hubspot\Exceptions\HubspotException;
use SevenShores\Hubspot\Factory;
use SevenShores\Hubspot\Http\Response;
use Jiminny\Services\Crm\Hubspot\Pagination\HubspotPaginationService;
use Throwable;
/**
* @phpstan-type CrmFieldOption array{id:string, label:string, value?:string}
*/
class Client extends BaseClient implements HubspotClientInterface
{
public const string MIN_API_VERSION = '2';
public const string BASE_URL = '[URL_WITH_CREDENTIALS] T
* @param callable(): T $apiCall
* @return T
*
* @throws RateLimitException
*/
private function executeRequest(callable $apiCall)
{
try {
return $apiCall();
} catch (Throwable $e) {
if ($this->isHubspotRateLimit($e)) {
$retryAfter = $this->parseRetryAfter($e);
$this->log->warning('[Hubspot] Received 429 from API', [
'team_id' => $this->config->team_id,
'config_id' => $this->config->getId(),
'retry_after' => $retryAfter,
'reason' => $e->getMessage(),
]);
throw new RateLimitException('Hubspot returned 429', $retryAfter, $e);
}
throw $e;
}
}
public function isHubspotRateLimit(Throwable $e): bool
{
if ($e instanceof BadRequest
|| $e instanceof DealApiException
|| $e instanceof ContactApiException
|| $e instanceof CompanyApiException
|| $e instanceof \GuzzleHttp\Exception\RequestException
) {
return (int) $e->getCode() === 429;
}
return false;
}
public function parseRetryAfter(Throwable $e): int
{
// First try to get Retry-After from response headers
if (method_exists($e, 'getResponseHeaders')) {
$headers = $e->getResponseHeaders() ?: [];
$value = $headers['Retry-After'] ?? $headers['retry-after'] ?? null;
if (is_array($value)) {
$value = $value[0] ?? null;
}
if (is_numeric($value)) {
return (int) $value;
}
}
if (method_exists($e, 'getResponseBody')) {
$body = $e->getResponseBody();
if (is_string($body)) {
$body = json_decode($body, true) ?? [];
}
$policyName = $body['policyName'] ?? $body['policy'] ?? $body['context']['policyName'] ?? null;
if ($policyName === 'TEN_SECONDLY_ROLLING' || $policyName === 'ten_secondly_rolling') {
return 10;
}
if ($policyName === 'SECONDLY' || $policyName === 'secondly') {
return 1;
}
}
$this->log->warning('[Hubspot] No retry-after header or policy name found, using default', [
'exception_class' => get_class($e),
]);
return 10;
}
public function getMinimumApiVersion(): string
{
return self::MIN_API_VERSION;
}
public function getInstance(): Factory
{
return new Factory([
'key' => $this->accessToken,
'oauth2' => true,
'base_url' => $this->baseUrl,
]);
}
public function getNewInstance(): Discovery
{
return \HubSpot\Factory::createWithAccessToken($this->accessToken);
}
/**
* Secondly and daily limits for Hubspot API
*
* Product Tier: Free & Starter | Professional & Enterprise | API add-on (any tier)
* Burst: 100/10 seconds | 150/10 seconds | 200/10 seconds
* Daily: 250,000 | 500,000 | 1,000,000
*
* Official documentation states: The search endpoints are rate limited to five requests per second.
* Since with 5 RPS were still hitting secondly rate limits we lowered it to 4
*/
public function getPaginatedData(array $payload, string $type, int $offset = 0): array
{
$total = 0;
$lastId = null;
$rows = [];
foreach ($this->getPaginatedDataGenerator($payload, $type, $offset, $total, $lastId) as $row) {
$rows[] = $row;
}
return ['results' => $rows, 'total' => $total, 'last_record' => $lastId];
}
/**
* @throws HubspotException
* @throws SocialAccountTokenInvalidException
* @throws BadRequest
*/
public function getPaginatedDataGenerator(
array $payload,
string $type,
int $offset = 0,
int &$total = 0,
?string &$lastRecordId = null
): \Generator {
return $this->paginationService->getPaginatedDataGenerator(
$this,
$payload,
$type,
$offset,
$total,
$lastRecordId
);
}
/**
* Execute a search request against HubSpot CRM objects with rate limiting.
*
* @param string $objectType The object type ('deals', 'companies', 'contacts', 'calls')
* @param array<string, mixed> $payload The search payload with filters, sorts, properties, etc.
* @return array The search response with 'results', 'total', 'paging' keys
* @throws RateLimitException When rate limit is hit
* @throws HubspotException On API errors
*/
public function search(string $objectType, array $payload): array
{
$endpoint = self::BASE_URL . "/crm/v3/objects/{$objectType}/search";
return $this->executeRequest(function () use ($endpoint, $payload) {
$response = $this->getInstance()->getClient()->request('POST', $endpoint, ['json' => $payload]);
return $response->toArray();
});
}
/**
* @throws DealApiException
* @throws CrmException
*/
public function getOpportunityById(string $crmId, array $fields): array
{
try {
// $deal = $this->executeRequest(fn () => $this->getNewInstance()->crm()->deals()->basicApi()->getById(
$deal = $this->getNewInstance()->crm()->deals()->basicApi()->getById(
$crmId,
implode(',', $fields),
'companies,contacts'
);
} catch (DealApiException $e) {
$this->log->info('[Hubspot] Failed to fetch opportunity', [
'crm_id' => $crmId,
'reason' => $e->getMessage(),
]);
throw $e;
}
if (! $deal instanceof DealWithAssociations) {
throw new CrmException('Deal not found');
}
return [
'id' => $deal->getId(),
'properties' => $deal->getProperties(),
'associations' => $deal->getAssociations(),
];
}
/**
* Generic batch read method for HubSpot objects
*
* @param string $objectType The object type ('deals', 'companies', 'contacts')
* @param array<string> $crmIds Array of HubSpot object IDs (max 100)
* @param array<string> $fields Array of property names to fetch
*
* @return array<string, array> Array keyed by CRM ID with object data
*/
private function batchReadObjects(string $objectType, array $crmIds, array $fields): array
{
if (empty($crmIds)) {
return [];
}
$this->validateBatchSize($objectType, $crmIds);
$this->ensureValidToken();
try {
$batchConfig = $this->createBatchConfiguration($objectType);
$batchReadRequest = $this->prepareBatchRequest($batchConfig, $crmIds, $fields);
$response = $batchConfig['api']->read($batchReadRequest);
$this->validateApiResponse($response, $objectType);
$results = $this->processApiResults($response);
$this->logBatchResults($objectType, $crmIds, $results);
return $results;
} catch (\Throwable $e) {
$this->handleBatchError($e, $objectType, $crmIds);
}
}
private function validateBatchSize(string $objectType, array $crmIds): void
{
if (count($crmIds) > 100) {
throw new \InvalidArgumentException("Batch size cannot exceed 100 {$objectType}");
}
}
private function createBatchConfiguration(string $objectType): array
{
$configurations = [
'deals' => [
'batchReadRequest' => new \HubSpot\Client\Crm\Deals\Model\BatchReadInputSimplePublicObjectId(),
'inputClass' => \HubSpot\Client\Crm\Deals\Model\SimplePublicObjectId::class,
'api' => $this->getNewInstance()->crm()->deals()->batchApi(),
],
'companies' => [
'batchReadRequest' => new \HubSpot\Client\Crm\Companies\Model\BatchReadInputSimplePublicObjectId(),
'inputClass' => \HubSpot\Client\Crm\Companies\Model\SimplePublicObjectId::class,
'api' => $this->getNewInstance()->crm()->companies()->batchApi(),
],
'contacts' => [
'batchReadRequest' => new \HubSpot\Client\Crm\Contacts\Model\BatchReadInputSimplePublicObjectId(),
'inputClass' => \HubSpot\Client\Crm\Contacts\Model\SimplePublicObjectId::class,
'api' => $this->getNewInstance()->crm()->contacts()->batchApi(),
],
];
if (! isset($configurations[$objectType])) {
throw new \InvalidArgumentException("Unsupported object type: {$objectType}");
}
return $configurations[$objectType];
}
private function prepareBatchRequest(array $batchConfig, array $crmIds, array $fields): object
{
$batchReadRequest = $batchConfig['batchReadRequest'];
$inputClass = $batchConfig['inputClass'];
$inputs = array_map(function ($crmId) use ($inputClass) {
$input = new $inputClass();
$input->setId($crmId);
return $input;
}, $crmIds);
$batchReadRequest->setInputs($inputs);
$batchReadRequest->setProperties($fields);
return $batchReadRequest;
}
private function validateApiResponse($response, string $objectType): void
{
if (! $response) {
throw new CrmException("HubSpot API returned null response for {$objectType} batch read");
}
}
private function processApiResults($response): array
{
$results = [];
$responseResults = $response->getResults();
if ($responseResults) {
foreach ($responseResults as $object) {
if ($object && $object->getId()) {
$results[$object->getId()] = [
'id' => $object->getId(),
'properties' => $object->getProperties() ?: [],
];
}
}
}
return $results;
}
private function logBatchResults(string $objectType, array $crmIds, array $results): void
{
$this->log->info("[HubSpot] Batch fetched {$objectType}", [
'requested_count' => count($crmIds),
'returned_count' => count($results),
'crm_ids' => $crmIds,
]);
}
private function handleBatchError(\Throwable $e, string $objectType, array $crmIds): void
{
$errorMessage = $e->getMessage() ?: 'Unknown error';
$errorTrace = $e->getTraceAsString() ?: 'No trace available';
$this->log->error("[HubSpot] Failed to batch fetch {$objectType}", [
'crm_ids' => $crmIds,
'error' => $errorMessage,
'trace' => $errorTrace,
]);
throw new CrmException("Failed to batch fetch {$objectType}: " . $errorMessage);
}
/**
* Batch read multiple opportunities by their CRM IDs
*
* @param array<string> $crmIds Array of HubSpot deal IDs (max 100)
* @param array<string> $fields Array of property names to fetch
*
* @return array<string, array> Array keyed by CRM ID with opportunity data
*/
public function getOpportunitiesByIds(array $crmIds, array $fields): array
{
return $this->batchReadObjects('deals', $crmIds, $fields);
}
/**
* Batch read multiple companies by their CRM IDs
*
* @param array<string> $crmIds Array of HubSpot company IDs (max 100)
* @param array<string> $fields Array of property names to fetch
*
* @return array<string, array> Array keyed by CRM ID with company data
*/
public function getCompaniesByIds(array $crmIds, array $fields): array
{
return $this->batchReadObjects('companies', $crmIds, $fields);
}
/**
* Batch read multiple contacts by their CRM IDs
*
* @param array<string> $crmIds Array of HubSpot contact IDs (max 100)
* @param array<string> $fields Array of property names to fetch
*
* @return array<string, array> Array keyed by CRM ID with contact data
*/
public function getContactsByIds(array $crmIds, array $fields): array
{
return $this->batchReadObjects('contacts', $crmIds, $fields);
}
/**
* @throws CompanyApiException
* @throws CrmException
*/
public function getAccountById(string $crmId, array $fields): array
{
try {
$company = $this->getNewInstance()->crm()->companies()->basicApi()->getById(
$crmId,
implode(',', $fields),
);
} catch (CompanyApiException $e) {
$this->log->info('[Hubspot] Failed to fetch account', [
'crm_id' => $crmId,
'reason' => $e->getMessage(),
]);
throw $e;
}
if (! $company instanceof CompaniesWithAssociations) {
throw new CrmException('Account not found');
}
return [
'id' => $company->getId(),
'properties' => $company->getProperties(),
];
}
/**
* @throws ContactApiException
* @throws CrmException
*/
public function getContactById(string $crmId, array $fields): array
{
try {
$contact = $this->getNewInstance()->crm()->contacts()->basicApi()->getById(
$crmId,
implode(',', $fields)
);
} catch (ContactApiException $e) {
$this->log->info('[Hubspot] Failed to fetch contact', [
'crm_id' => $crmId,
'reason' => $e->getMessage(),
]);
throw $e;
}
if (! $contact instanceof ContactsWithAssociations) {
throw new CrmException('Contact not found');
}
return [
'id' => $contact->getId(),
'properties' => $contact->getProperties(),
];
}
/**
* This is email search request that Hubspot offers as GET (more generous quota)
*/
public function getContactByEmail(string $email, array $fields = []): array
{
try {
$contact = $this->getNewInstance()->crm()->contacts()->basicApi()->getById(
$email,
implode(',', $fields),
null,
false,
'email'
);
return [
'id' => $contact->getId(),
'properties' => $contact->getProperties(),
];
} catch (ContactApiException $e) {
$this->log->info('[Hubspot] Failed to fetch contact', [
'email' => $email,
'reason' => $e->getMessage(),
]);
return [];
}
}
/**
* @throws CrmException
*/
public function fetchProperty(string $objectType, string $propertyId): Property
{
$result = $this->getNewInstance()->crm()->properties()->coreApi()->getByName($objectType, $propertyId);
if (! $result instanceof Property) {
$this->log->error('[Hubspot] Failed to fetch property', [
'object_type' => $objectType,
'property_id' => $propertyId,
'reason' => $result->getMessage(),
]);
throw new CrmException('Failed to fetch property');
}
return $result;
}
/**
* @return array<CrmFieldOption>
*/
public function fetchPropertyOptions(string $objectType, string $propertyId): array
{
/** @var array<CrmFieldOption> */
return $this->fetchProperty($objectType, $propertyId)->getOptions();
}
/**
* @return array<array{id:string, label:string, deleted:bool}>
*/
public function fetchCallDispositions(): array
{
/** @var Response $response */
$response = $this->getInstance()->engagements()->getCallDispositions();
/**
* @var array<array{
* id:string,
* label:string,
* deleted: bool
* }>
*/
return $response->toArray();
}
/**
* @return array<CrmFieldOption>
*/
public function fetchOpportunityPipelineStages(): array
{
$stages = [];
$apiResponse = $this->getNewInstance()->crm()->pipelines()->pipelinesApi()->getAll('deals');
if ($apiResponse instanceof Error) {
$this->log->error('[Hubspot] Failed to fetch opportunity pipelines', [
'reason' => $apiResponse->getMessage(),
]);
return [];
}
foreach ($apiResponse->getResults() as $pipeline) {
$pipelineStages = array_map(
static function (PipelineStage $stage) {
return [
'id' => $stage->getId(),
'label' => $stage->getLabel(),
];
},
$pipeline->getStages()
);
$stages = array_merge($stages, $pipelineStages);
}
return $stages;
}
public function fetchOpportunityPipelines(): array
{
$pipelines = [];
try {
$apiResponse = $this->makeRequest('/crm/v3/pipelines/deals');
} catch (\Exception $e) {
$this->log->info('[Hubspot] Failed to fetch opportunity pipelines', [
'reason' => $e->getMessage(),
]);
return [];
}
$response = $apiResponse->toArray();
foreach ($response['results'] as $pipeline) {
$pipelines[] = [
'id' => $pipeline['id'],
'label' => $pipeline['label'],
];
}
return $pipelines;
}
/**
* @return array<CrmFieldOption>
*/
public function fetchMeetingOutcomeFieldOptions(Field $field): array
{
return $field->getCrmProviderId() === 'meetingOutcome'
? $this->fetchMeetingOutcomeTypes()
: $this->fetchCallActivityTypes();
}
public function fetchMeetingOutcomeTypes(): array
{
return $this->extractMeetingTypeOptions(
'[URL_WITH_CREDENTIALS] Response $response */
$response = $this->getInstance()
->getClient()
->request('GET', $endpoint);
/**
* @var array<array{
* value: string,
* label: string,
* displayOrder: int
* }> $optionData
*/
$optionData = $response->toArray()['options'] ?? [];
$options = [];
foreach ($optionData as $item) {
$options[] = [
'id' => $item['value'],
'value' => $item['value'],
'label' => $item['label'],
'display_order' => $item['displayOrder'],
];
}
return $options;
}
/**
* @return array<CrmFieldOption>
*/
public function fetchDispositionFieldOptions(): array
{
$options = [];
$dispositions = $this->fetchCallDispositions();
foreach ($dispositions as $disposition) {
if ($disposition['deleted'] !== false) {
continue;
}
$option['value'] = $disposition['id'];
$option['id'] = $disposition['id'];
$option['label'] = $disposition['label'];
$options[] = $option;
}
return $options;
}
/**
* @return array<CrmFieldOption>
*/
public function fetchOpportunityFieldOptions(Field $field): array
{
if ($field->isStageField()) {
return $this->fetchOpportunityPipelineStages();
}
if ($field->isPipelineField()) {
return $this->fetchOpportunityPipelines();
}
return $this->fetchPropertyOptions('deals', $field->getCrmProviderId());
}
/**
* @throws BadRequest
* @throws HubspotException
*/
public function makeRequest(string $endpoint, $method = 'GET', $payload = [], ?string $queryString = null)
{
$endpoint = self::BASE_URL . $endpoint;
if ($method === 'GET') {
$response = $this->getInstance()->getClient()?->request(
method: $method,
endpoint: $endpoint,
query_string: $queryString
);
} else {
$response = $this->getInstance()->getClient()->request($method, $endpoint, [
'json' => ($payload),
]);
}
//
// $max = $response->getHeaderLine('X-HubSpot-RateLimit-Max'); // "110"
// $remaining = $response->getHeaderLine('X-HubSpot-RateLimit-Remaining'); // "109"
// $interval = $response->getHeaderLine('X-HubSpot-RateLimit-Interval-Milliseconds'); // "10000"
// $body = json_decode((string) $response->getBody(), true);
//
// \Illuminate\Support\Facades\Log::channel('custom_channel')->info('$max ' . PHP_EOL . print_r($max, true));
// \Illuminate\Support\Facades\Log::channel('custom_channel')->info('$remaining ' . PHP_EOL . print_r($remaining, true));
// \Illuminate\Support\Facades\Log::channel('custom_channel')->info('$interval ' . PHP_EOL . print_r($interval, true));
// \Illuminate\Support\Facades\Log::channel('custom_channel')->info('$body ' . PHP_EOL . print_r($body, true));
return $response;
}
/**
* @throws BadRequest
* @throws HubspotException
*/
public function createMeeting(array $payload): Response
{
$endpoint = '/crm/v3/objects/meetings';
return $this->makeRequest($endpoint, 'POST', $payload);
}
/**
* @throws BadRequest
* @throws HubspotException
*/
public function updateMeeting(string $meetingId, array $payload): Response
{
$endpoint = '/crm/v3/objects/meetings/' . $meetingId;
return $this->makeRequest($endpoint, 'PATCH', $payload);
}
/**
* @throws \Exception
*/
public function createNote(
string $body,
string $ownerId,
int $timestamp,
string $objectId,
NoteObject $noteObject
): ?string {
try {
$noteInput = new SimplePublicObjectInput([
'properties' => [
'hs_note_body' => $body,
'hubspot_owner_id' => $ownerId,
'hs_timestamp' => $timestamp,
],
]);
// Create note
$note = $this->getNewInstance()->crm()->objects()->basicApi()->create('note', $noteInput);
$this->getNewInstance()->crm()->objects()->associationsApi()->create(
'note',
$note->getId(),
$this->getNoteObject($noteObject),
$objectId,
$this->getNoteAssociationType($noteObject),
);
return $note->getId();
} catch (\Exception $e) {
$this->log->error('[Hubspot] Failed to create note', [
'objectId' => $objectId,
'noteObject' => $noteObject->getObjectType(),
'reason' => $e->getMessage(),
]);
\Sentry::captureException($e);
}
return null;
}
public function updateEngagement(string $objectId, array $engagement, array $metadata): void
{
$this->getInstance()->engagements()->update($objectId, $engagement, $metadata);
}
public function getEngagementData(string $engagementId): array
{
$engagement = $this->getInstance()->engagements()->get($engagementId);
return $engagement->toArray();
}
public function createEngagement(array $engagement, array $associations, array $metadata): Response
{
return $this->getInstance()
->engagements()
->create($engagement, $associations, $metadata);
}
public function isUnauthorizedException(\Exception $e): bool
{
if ($e instanceof BadRequest
|| $e instanceof DealApiException
|| $e instanceof ContactApiException
|| $e instanceof CompanyApiException
) {
return (int) $e->getCode() === 401;
}
if ($e instanceof \GuzzleHttp\Exception\RequestException && $e->hasResponse()) {
return $e->getResponse()?->getStatusCode() === 401;
}
$message = strtolower($e->getMessage());
return str_contains($message, '401 unauthorized') ||
str_contains($message, 'http 401') ||
str_contains($message, 'status code 401') ||
(preg_match('/\b401\b/', $message) === 1 && str_contains($message, 'unauthorized'));
}
/**
* Validates and refreshes the access token if needed before API requests.
* This ensures long-running processes don't fail due to token expiration.
*
* @throws SocialAccountTokenInvalidException
*/
public function ensureValidToken(): void
{
if ($this->oauthAccount === null) {
return;
}
$newToken = $this->tokenManager->ensureValidToken($this->oauthAccount);
if ($newToken !== null) {
$this->accessToken = $newToken;
}
}
public function getConfig()
{
return $this->config;
}
// returns only active (archived=false)
public function getOwners(): array
{
return $this->getNewInstance()->crm()->owners()->getAll();
}
/**
* @param bool $archived
*
* @return array<Owner>|[]
*/
public function getOwnersArchived(bool $archived = true): array
{
$endpoint = '/crm/v3/owners';
$queryParams = [
'archived' => $archived ? 'true' : 'false',
];
$queryString = http_build_query($queryParams);
$owners = [];
try {
$response = $this->makeRequest(endpoint: $endpoint, queryString: $queryString);
$responseData = $response?->toArray();
foreach ($responseData['results'] as $result) {
try {
$owners[] = Owner::create($result);
} catch (Throwable $e) {
$this->log->error('[HubSpot] Failed to process owner data', [
'result' => $result,
'error' => $e->getMessage(),
]);
continue;
}
}
} catch (Throwable $e) {
$this->log->error('HubSpot] Failed to fetch owners', [
'archived' => $archived,
'error' => $e->getMessage(),
]);
return [];
}
return $owners;
}
public function getMeeting(string $engagementId): ObjectWithAssociations
{
return $this->getNewInstance()->crm()->objects()->basicApi()
->getById('meeting', $engagementId, null, 'contact,company,deal');
}
public function deleteEngagement(string $engagementId): void
{
$this->getInstance()->engagements()->delete((int) $engagementId);
}
public function getAssociationsData(array $ids, string $fromObject, string $toObject): array
{
$associationData = [];
$idChunks = array_chunk($ids, self::ASSOCIATIONS_BATCH_SIZE_LIMIT);
foreach ($idChunks as $idChunk) {
try {
$batchInput = new \HubSpot\Client\Crm\Associations\Model\BatchInputPublicObjectId();
$batchInput->setInputs(array_map(function ($id) {
$publicObjectId = new \HubSpot\Client\Crm\Associations\Model\PublicObjectId();
$publicObjectId->setId($id);
return $publicObjectId;
}, $idChunk));
$associatedObjectsData = $this
->getNewInstance()
->crm()
->associations()
->batchApi()
->read($fromObject, $toObject, $batchInput);
if ($associatedObjectsData instanceof \HubSpot\Client\Crm\Associations\Model\BatchResponsePublicAssociationMulti) {
foreach ($associatedObjectsData->getResults() as $association) {
$from = $association->getFrom()->getId();
$toAssociations = $association->getTo();
if (! empty($toAssociations)) {
$associationData[$from] = array_map(function ($item) {
return $item->getId();
}, $toAssociations);
}
}
}
} catch (RateLimitException $e) {
throw $e;
} catch (\Exception $e) {
$this->log->error('[Hubspot] Failed to fetch associations', [
'from_object' => $fromObject,
'to_object' => $toObject,
'reason' => $e->getMessage(),
]);
}
}
return $associationData;
}
/**
* @throws \Exception
*/
private function getNoteAssociationType(NoteObject $noteObject): string
{
return match($noteObject) {
NoteObject::Opportunity => 'note_to_deal',
NoteObject::Lead, NoteObject::Contact => 'note_to_contact', // or 'note_to_lead' if your portal supports it
NoteObject::Account => 'note_to_company',
NoteObject::Call, NoteObject::Event => throw new \Exception('Not supported'),
};
}
/**
* @throws \Exception
*/
private function getNoteObject(NoteObject $noteObject): string
{
return match($noteObject) {
NoteObject::Opportunity => 'deal',
NoteObject::Lead, NoteObject::Contact => 'contact',
NoteObject::Account => 'company',
NoteObject::Call, NoteObject::Event => throw new \Exception('Not supported'),
};
}
public function addAssociations(string $objectType, string $associationType, array $payload): Response
{
$endpoint = "/crm/v4/associations/$objectType/$associationType/batch/create";
return $this->makeRequest($endpoint, 'POST', $payload);
}
public function removeAssociations(string $objectType, string $associationType, array $payload): Response
{
$endpoint = "/crm/v4/associations/$objectType/$associationType/batch/archive";
return $this->makeRequest($endpoint, 'POST', $payload);
}
}
Project
Project
New File or Directory…
Expand Selected
Collapse All
Options
Hide...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
9847
|
443
|
24
|
2026-05-08T13:37:41.500471+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-08/1778 /Users/lukas/.screenpipe/data/data/2026-05-08/1778247461500_m1.jpg...
|
PhpStorm
|
faVsco.js – HubspotSyncStrategyBase.php
|
1
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
JY-20725-handle-HS-search Project: faVsco.js, menu
JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
19
Previous Highlighted Error
Next Highlighted Error
[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {
"headers":{
"Date":["Thu,07 May 2026 14:21:15 GMT"],
"Content-Type":["application/json;charset=utf-8"],
"Transfer-Encoding":["chunked"],
"Connection":["keep-alive"],
"CF-Ray":["9f80deb8db60dc3a-SOF"],
"CF-Cache-Status":["DYNAMIC"],
"Strict-Transport-Security":["max-age=31536000; includeSubDomains; preload"],
"Vary":["origin,
accept-encoding"],
"access-control-allow-credentials":["false"],
"server-timing":["hcid;desc=\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\",
cfr;desc=\"9f80deb8e7c6dc3a-IAD\""],
"x-content-type-options":["nosniff"],
"x-hubspot-correlation-id":["019e02d0-6fd8-7812-bdba-885b7ccb3ee3"],
"Set-Cookie":["__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-[IP_ADDRESS]-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,
07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None"],
"Report-To":["{
\"endpoints\":[{
\"url\":\"https:\\/\\/a.nel.cloudflare.com\\/report\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\"}],
\"group\":\"cf-nel\",
\"max_age\":604800}"],
"NEL":["{
\"success_fraction\":0.01,
\"report_to\":\"cf-nel\",
\"max_age\":604800}"],
"Server":["cloudflare"]}} {
"correlation_id":"95236535-ec98-4541-b92a-adfa73b69eab",
"trace_id":"c7ab8365-903f-46d4-9403-0e5b551e3545"}
Code changed:
Hide
Sync Changes
Hide This Notification
2
Previous Highlighted Error
Next Highlighted Error
<?php
declare(strict_types=1);
namespace Jiminny\Services\Crm\Hubspot\OpportunitySyncStrategy;
use Jiminny\Exceptions\Crm\InvalidSyncParametersException;
use Jiminny\Exceptions\SocialAccountTokenInvalidException;
use Jiminny\Models\Crm\Configuration;
use Jiminny\Services\Crm\CrmConfigurationSettingsService;
use Jiminny\Services\Crm\Hubspot\Client;
use Jiminny\Services\Crm\Hubspot\ClosedDealStagesService;
use Jiminny\Services\Crm\Hubspot\DealFieldsService;
use Jiminny\Services\Crm\Hubspot\PayloadBuilder;
use Jiminny\Services\Crm\OpportunitySyncStrategyInterface;
use Psr\Log\LoggerInterface;
use SevenShores\Hubspot\Exceptions\BadRequest;
use SevenShores\Hubspot\Exceptions\HubspotException;
abstract class HubspotSyncStrategyBase implements OpportunitySyncStrategyInterface
{
protected Client $client;
protected PayloadBuilder $payloadBuilder;
public function __construct(Client $client)
{
$this->client = $client;
$this->payloadBuilder = app(PayloadBuilder::class);
}
/**
* @throws SocialAccountTokenInvalidException
* @throws BadRequest
* @throws HubspotException
*/
public function fetchOpportunities(
array $params,
int &$total = 0,
?string &$lastRecordId = null
): \Generator {
try {
$this->validateParameters($params);
} catch (InvalidSyncParametersException $e) {
$logger = app(LoggerInterface::class);
$logger->error('Invalid sync parameters: ' . $e->getMessage());
// Return empty generator for invalid parameters
return;
}
$fields = $this->getOpportunityFields($params['config']);
$payload = $this->buildQuery($params, $fields);
$offset = $params['offset'] ?? 0;
yield from $this->client->getPaginatedDataGenerator(
$payload,
'deals',
$offset,
$total,
$lastRecordId
);
}
protected function getOpportunityFields(Configuration $config): array
{
return $this->getDealFieldsService()->getFieldsForConfiguration($config);
}
protected function buildQuery(array $params, array $fields): array
{
return [];
}
protected function getDealFieldsService(): DealFieldsService
{
return app(DealFieldsService::class);
}
protected function getCrmConfigurationSettingsService(): CrmConfigurationSettingsService
{
return app(CrmConfigurationSettingsService::class);
}
protected function getClosedDealStagesService(): ClosedDealStagesService
{
return app(ClosedDealStagesService::class);
}
}
Project
Project
New File or Directory…
Expand Selected
Collapse All
Options
Hide
app ~/jiminny/app, folder
.circleci, folder
.cursor, folder
.github
.sonarlint, folder
.vscode, folder
.windsurf, folder
app, sources root
Actions, folder
Component, folder
Acl, folder
ActionItems, folder
Activity, folder
ActivityAnalytics, folder
ActivitySearch, folder
AiActivityType, folder
AiAutomation, folder
AiCallScoring, folder
AskAnything, folder
Dtos, folder
Events, folder
AskAnythingPromptService.php, class
HistoryService.php, class
AskJiminnyAi, folder
AWS, folder
BillingManagement, folder
Cache, folder
CoachingFeedback, folder
Country, folder
CustomerApi, folder
Database, folder
Datadog, folder
DateTime, folder
DealInsights, folder
DealRisks, folder
ElasticSearch, folder
Eloquent, folder
Encoding, folder
Encryption, folder
ES, folder
Faker, folder
FeatureFlags, folder
FFMpeg, folder
FileSystem, folder
Gecko, folder
Gong, folder
GuzzleHttp, folder
KeyPoints, folder
Kiosk, folder
LanguageDetection, folder
LiveFeed, folder
Locks, folder
Math, folder
MediaPipeline, folder
MeetingBot, folder
MobileSettings, folder
Model, folder
Notification, folder
Nudge, folder
ParagraphBreaker, folder
ParticipantSpeech, folder
PartitionedCookie, folder
PlaybackPage, folder
Playlist, folder
Prophet, folder
ProphetAi, folder
ProsperWorks, folder
Queue, folder
Router, folder
Saml2, folder
SCIM, folder
Seeder, folder
Sentry, folder
Serializer, folder
Settings, folder
Sidekick, folder
Slack, folder
TeamInsights, folder
TimeMemoryMapper, folder
Transcription, folder
TranscriptionSummary, folder
Twilio, folder
Uploader, folder
UrlGenerator, folder
Utility, folder
Exceptions, folder
Service, folder
BaseRateLimiter.php, class
EfficientJsonParser.php, class
ProviderRateLimiter.php, class
RateLimiterInstance.php, class
Uuid
Waveform
Webhooks
Workflow
Configuration
Console
Commands
Activities
Analytics
Calendars
Crm
Hubspot
IntegrationApp
Traits
AddLayoutEntities.php, class
AutologDelayedCommand.php, class
BullhornCommandAbstract.php, abstract class
BullhornPingCommand.php, class
BullhornSearchCommand.php, class
BullhornSessionCommand.php, class
CheckActivityLoggableCommand.php, final class
CleanDuplicateFieldDataCommand.php, class
FullSyncOpportunityCommand.php, class
LogActivitiesCommand.php, final class
ManageSyncStrategyCommand.php, class
MatchCrmObjectsCommand.php, class
MatchOpportunityActivitiesCommand.php, class
MigrateProvider.php, class
ProcessHubspotObjectsSyncBatches.php, class
PurgeDeletedOpportunitiesCommand.php, class
ResetGovernorLimits.php, class
SendNotLogged.php, class
SetupActivityTypeForFollowUp.php, final class
SetupCloseCrm.php, class
SetupCopperCrm.php, class
SetupCrmCommand.php, abstract class
SetupLayouts.php, class
SyncAccount.php, class
SyncContact.php, class
SyncFieldMetadata.php, class
SyncHubspotActiveDeals.php, class
SyncHubspotObjects.php, class
SyncLead.php, class
SyncObjects.php, class
SyncOpportunitiesMissingFieldDataCommand.php, class
SyncOpportunity.php, class
SyncProfileMetadata.php, class
SyncTeamMetadata.php, class
UpdateOpportunitySpecifications.php, class
DealInsights, folder
Dev, folder
Dialers, folder
DTOs, folder
Elasticsearch, folder
EngagementStats, folder
GeckoExport, folder
Livestream, folder
Mailboxes, folder
Migrate, folder
PlaybackThemes, folder
Playbooks, folder
Playlists, folder
Postmark, folder
ProphetAi, folder
Reports, folder
AutomatedReportsCommand.php, class
AutomatedReportsRetentionPolicyCommand.php, class
AutomatedReportsSendCommand.php, class
CreateMockAskJiminnyReportResultCommand.php, class
DeleteReportCommand.php, class
GenerateMarketingReport.php, class
Team.php, class
Usage.php, class
Slack, folder
Teams, folder
Tracks, folder
Transcription, folder
Twilio, folder
Users, folder
Vocabulary, folder
Zoom, folder
Command.php, class
CreateDatabaseUsers.php, class
DatabaseTableCount.php, class
DeleteOldAiCrmNotesCommand.php, class
DeleteS3LeftoversCommand.php, class
DevPostmanCommand.php, final class
DiarizeViaAiParticipantIdentificationCommand.php, class
EncryptTokensCommand.php, class
EngagementStatsRegenerateCommand.php, class
FeatureFlagsHelper.php
FixCrossTenantIssues.php, class
FlushRolesPermissionsCache.php, class
GenerateInternalWebhookToken.php, class
GroupSetDefaultLanguageCommand.php, final class
HelperTruncateCoachingTables.php, class
HubspotJournalPollingCommand.php, class
HubspotWebhookServiceCommand.php, class
ImportRecording.php, class
ImportUsersFromCsvFile.php, final class
IterateUsersCommand.php, abstract class
JiminnyCacheClearCommand.php, class
JiminnyDebugCommand.php, class
JiminnySetEncryptedTokenManagerModeCommand.php, class
JiminnyTokenInfoCommand.php, class
MakeSlackLiveCoachingChatNotesOn.php, class
ManageScimForTeam.php, class
MarkBranchForEnvironmentPipelineCommand.php, class
MuteOrganizerChannel.php, class
PhpApm.php, class
PropagateCoachingFeedbackCreatedAtToSectionFeedbacks.php, class
PurgeConferences.php, class
PurgeSoftDeletedOpportunitiesCommand.php, class
PurgeSyncBatchesCommand.php, class
RecalculateDealRisksCommand.php, class
RemoveDeleteMarkersCommand.php, class
RemoveExpiredNudgesCommand.php, class
RemoveUnusedParticipantSpeechesCommand.php, class
ResetElasticSearch.php, class
RestoreActivityCrmProviderIdCommand.php, class
RestoreActivityTypeCommand.php, class
RunAiCallScoringForUntypedActivitiesCommand.php, class
SeedActivities.php, class
SendNudgeExpirationWarningsCommand.php, class
SyncActivity.php, class...
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JY-20725-handle-HS-search-rate-limit, menu","depth":5,"on_screen":true,"help_text":"Git Branch: JY-20725-handle-HS-search-rate-limit","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"AskJiminnyReportActivityServiceTest","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'AskJiminnyReportActivityServiceTest'","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'AskJiminnyReportActivityServiceTest'","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"IDE and Project Settings","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.088194445,"height":0.027777778},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"19","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {\n\"headers\":{\n\"Date\":[\"Thu,07 May 2026 14:21:15 GMT\"],\n \"Content-Type\":[\"application/json;charset=utf-8\"],\n \"Transfer-Encoding\":[\"chunked\"],\n \"Connection\":[\"keep-alive\"],\n \"CF-Ray\":[\"9f80deb8db60dc3a-SOF\"],\n \"CF-Cache-Status\":[\"DYNAMIC\"],\n \"Strict-Transport-Security\":[\"max-age=31536000; includeSubDomains; preload\"],\n \"Vary\":[\"origin,\n accept-encoding\"],\n \"access-control-allow-credentials\":[\"false\"],\n \"server-timing\":[\"hcid;desc=\\\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\\\",\n cfr;desc=\\\"9f80deb8e7c6dc3a-IAD\\\"\"],\n \"x-content-type-options\":[\"nosniff\"],\n \"x-hubspot-correlation-id\":[\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\"],\n \"Set-Cookie\":[\"__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-1.0.1.1-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,\n 07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None\"],\n \"Report-To\":[\"{\n\\\"endpoints\\\":[{\n\\\"url\\\":\\\"https:\\\\/\\\\/a.nel.cloudflare.com\\\\/report\\\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\\\"}],\n\\\"group\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"NEL\":[\"{\n\\\"success_fraction\\\":0.01,\n\\\"report_to\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"Server\":[\"cloudflare\"]}} {\n\"correlation_id\":\"95236535-ec98-4541-b92a-adfa73b69eab\",\n\"trace_id\":\"c7ab8365-903f-46d4-9403-0e5b551e3545\"}","depth":4,"on_screen":true,"value":"[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {\n\"headers\":{\n\"Date\":[\"Thu,07 May 2026 14:21:15 GMT\"],\n \"Content-Type\":[\"application/json;charset=utf-8\"],\n \"Transfer-Encoding\":[\"chunked\"],\n \"Connection\":[\"keep-alive\"],\n \"CF-Ray\":[\"9f80deb8db60dc3a-SOF\"],\n \"CF-Cache-Status\":[\"DYNAMIC\"],\n \"Strict-Transport-Security\":[\"max-age=31536000; includeSubDomains; preload\"],\n \"Vary\":[\"origin,\n accept-encoding\"],\n \"access-control-allow-credentials\":[\"false\"],\n \"server-timing\":[\"hcid;desc=\\\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\\\",\n cfr;desc=\\\"9f80deb8e7c6dc3a-IAD\\\"\"],\n \"x-content-type-options\":[\"nosniff\"],\n \"x-hubspot-correlation-id\":[\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\"],\n \"Set-Cookie\":[\"__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-1.0.1.1-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,\n 07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None\"],\n \"Report-To\":[\"{\n\\\"endpoints\\\":[{\n\\\"url\\\":\\\"https:\\\\/\\\\/a.nel.cloudflare.com\\\\/report\\\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\\\"}],\n\\\"group\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"NEL\":[\"{\n\\\"success_fraction\\\":0.01,\n\\\"report_to\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"Server\":[\"cloudflare\"]}} {\n\"correlation_id\":\"95236535-ec98-4541-b92a-adfa73b69eab\",\n\"trace_id\":\"c7ab8365-903f-46d4-9403-0e5b551e3545\"}","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.088194445,"height":0.027777778},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"2","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\ndeclare(strict_types=1);\n\nnamespace Jiminny\\Services\\Crm\\Hubspot\\OpportunitySyncStrategy;\n\nuse Jiminny\\Exceptions\\Crm\\InvalidSyncParametersException;\nuse Jiminny\\Exceptions\\SocialAccountTokenInvalidException;\nuse Jiminny\\Models\\Crm\\Configuration;\nuse Jiminny\\Services\\Crm\\CrmConfigurationSettingsService;\nuse Jiminny\\Services\\Crm\\Hubspot\\Client;\nuse Jiminny\\Services\\Crm\\Hubspot\\ClosedDealStagesService;\nuse Jiminny\\Services\\Crm\\Hubspot\\DealFieldsService;\nuse Jiminny\\Services\\Crm\\Hubspot\\PayloadBuilder;\nuse Jiminny\\Services\\Crm\\OpportunitySyncStrategyInterface;\nuse Psr\\Log\\LoggerInterface;\nuse SevenShores\\Hubspot\\Exceptions\\BadRequest;\nuse SevenShores\\Hubspot\\Exceptions\\HubspotException;\n\nabstract class HubspotSyncStrategyBase implements OpportunitySyncStrategyInterface\n{\n protected Client $client;\n protected PayloadBuilder $payloadBuilder;\n\n public function __construct(Client $client)\n {\n $this->client = $client;\n $this->payloadBuilder = app(PayloadBuilder::class);\n }\n\n /**\n * @throws SocialAccountTokenInvalidException\n * @throws BadRequest\n * @throws HubspotException\n */\n public function fetchOpportunities(\n array $params,\n int &$total = 0,\n ?string &$lastRecordId = null\n ): \\Generator {\n try {\n $this->validateParameters($params);\n } catch (InvalidSyncParametersException $e) {\n $logger = app(LoggerInterface::class);\n $logger->error('Invalid sync parameters: ' . $e->getMessage());\n\n // Return empty generator for invalid parameters\n return;\n }\n\n $fields = $this->getOpportunityFields($params['config']);\n $payload = $this->buildQuery($params, $fields);\n $offset = $params['offset'] ?? 0;\n\n yield from $this->client->getPaginatedDataGenerator(\n $payload,\n 'deals',\n $offset,\n $total,\n $lastRecordId\n );\n }\n\n protected function getOpportunityFields(Configuration $config): array\n {\n return $this->getDealFieldsService()->getFieldsForConfiguration($config);\n }\n\n protected function buildQuery(array $params, array $fields): array\n {\n return [];\n }\n\n protected function getDealFieldsService(): DealFieldsService\n {\n return app(DealFieldsService::class);\n }\n\n protected function getCrmConfigurationSettingsService(): CrmConfigurationSettingsService\n {\n return app(CrmConfigurationSettingsService::class);\n }\n\n protected function getClosedDealStagesService(): ClosedDealStagesService\n {\n return app(ClosedDealStagesService::class);\n }\n}","depth":4,"on_screen":true,"value":"<?php\n\ndeclare(strict_types=1);\n\nnamespace Jiminny\\Services\\Crm\\Hubspot\\OpportunitySyncStrategy;\n\nuse Jiminny\\Exceptions\\Crm\\InvalidSyncParametersException;\nuse Jiminny\\Exceptions\\SocialAccountTokenInvalidException;\nuse Jiminny\\Models\\Crm\\Configuration;\nuse Jiminny\\Services\\Crm\\CrmConfigurationSettingsService;\nuse Jiminny\\Services\\Crm\\Hubspot\\Client;\nuse Jiminny\\Services\\Crm\\Hubspot\\ClosedDealStagesService;\nuse Jiminny\\Services\\Crm\\Hubspot\\DealFieldsService;\nuse Jiminny\\Services\\Crm\\Hubspot\\PayloadBuilder;\nuse Jiminny\\Services\\Crm\\OpportunitySyncStrategyInterface;\nuse Psr\\Log\\LoggerInterface;\nuse SevenShores\\Hubspot\\Exceptions\\BadRequest;\nuse SevenShores\\Hubspot\\Exceptions\\HubspotException;\n\nabstract class HubspotSyncStrategyBase implements OpportunitySyncStrategyInterface\n{\n protected Client $client;\n protected PayloadBuilder $payloadBuilder;\n\n public function __construct(Client $client)\n {\n $this->client = $client;\n $this->payloadBuilder = app(PayloadBuilder::class);\n }\n\n /**\n * @throws SocialAccountTokenInvalidException\n * @throws BadRequest\n * @throws HubspotException\n */\n public function fetchOpportunities(\n array $params,\n int &$total = 0,\n ?string &$lastRecordId = null\n ): \\Generator {\n try {\n $this->validateParameters($params);\n } catch (InvalidSyncParametersException $e) {\n $logger = app(LoggerInterface::class);\n $logger->error('Invalid sync parameters: ' . $e->getMessage());\n\n // Return empty generator for invalid parameters\n return;\n }\n\n $fields = $this->getOpportunityFields($params['config']);\n $payload = $this->buildQuery($params, $fields);\n $offset = $params['offset'] ?? 0;\n\n yield from $this->client->getPaginatedDataGenerator(\n $payload,\n 'deals',\n $offset,\n $total,\n $lastRecordId\n );\n }\n\n protected function getOpportunityFields(Configuration $config): array\n {\n return $this->getDealFieldsService()->getFieldsForConfiguration($config);\n }\n\n protected function buildQuery(array $params, array $fields): array\n {\n return [];\n }\n\n protected function getDealFieldsService(): DealFieldsService\n {\n return app(DealFieldsService::class);\n }\n\n protected function getCrmConfigurationSettingsService(): CrmConfigurationSettingsService\n {\n return app(CrmConfigurationSettingsService::class);\n }\n\n protected function getClosedDealStagesService(): ClosedDealStagesService\n {\n return app(ClosedDealStagesService::class);\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":true,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Project","depth":3,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Project","depth":3,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"New File or Directory…","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Expand Selected","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Collapse All","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Options","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"app ~/jiminny/app, folder","depth":6,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":".circleci, folder","depth":7,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":".cursor, folder","depth":7,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":".github","depth":7,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":".sonarlint, folder","depth":7,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":".vscode, folder","depth":7,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":".windsurf, folder","depth":7,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"app, sources root","depth":7,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Actions, folder","depth":8,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Component, folder","depth":8,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Acl, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"ActionItems, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Activity, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"ActivityAnalytics, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"ActivitySearch, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"AiActivityType, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"AiAutomation, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"AiCallScoring, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"AskAnything, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Dtos, folder","depth":10,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Events, folder","depth":10,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"AskAnythingPromptService.php, class","depth":10,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"HistoryService.php, class","depth":10,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"AskJiminnyAi, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"AWS, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"BillingManagement, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Cache, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"CoachingFeedback, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Country, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"CustomerApi, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Database, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Datadog, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"DateTime, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"DealInsights, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"DealRisks, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"ElasticSearch, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Eloquent, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Encoding, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Encryption, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"ES, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Faker, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"FeatureFlags, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"FFMpeg, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"FileSystem, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Gecko, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Gong, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"GuzzleHttp, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"KeyPoints, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Kiosk, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"LanguageDetection, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"LiveFeed, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Locks, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Math, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"MediaPipeline, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"MeetingBot, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"MobileSettings, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Model, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Notification, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Nudge, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"ParagraphBreaker, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"ParticipantSpeech, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"PartitionedCookie, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"PlaybackPage, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Playlist, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Prophet, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"ProphetAi, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"ProsperWorks, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Queue, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Router, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Saml2, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"SCIM, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Seeder, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Sentry, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Serializer, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Settings, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Sidekick, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Slack, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"TeamInsights, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"TimeMemoryMapper, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Transcription, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"TranscriptionSummary, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Twilio, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Uploader, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"UrlGenerator, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Utility, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Exceptions, folder","depth":10,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Service, folder","depth":10,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"BaseRateLimiter.php, class","depth":11,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"EfficientJsonParser.php, class","depth":11,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"ProviderRateLimiter.php, class","depth":11,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"RateLimiterInstance.php, class","depth":11,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Uuid","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Waveform","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Webhooks","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Workflow","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Configuration","depth":8,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Console","depth":8,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Commands","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Activities","depth":10,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Analytics","depth":10,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Calendars","depth":10,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Crm","depth":10,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Hubspot","depth":11,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"IntegrationApp","depth":11,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Traits","depth":11,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"AddLayoutEntities.php, class","depth":11,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"AutologDelayedCommand.php, class","depth":11,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"BullhornCommandAbstract.php, abstract class","depth":11,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"BullhornPingCommand.php, class","depth":11,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"BullhornSearchCommand.php, class","depth":11,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"BullhornSessionCommand.php, class","depth":11,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"CheckActivityLoggableCommand.php, final class","depth":11,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"CleanDuplicateFieldDataCommand.php, class","depth":11,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"FullSyncOpportunityCommand.php, class","depth":11,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"LogActivitiesCommand.php, final class","depth":11,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"ManageSyncStrategyCommand.php, class","depth":11,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"MatchCrmObjectsCommand.php, class","depth":11,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"MatchOpportunityActivitiesCommand.php, class","depth":11,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"MigrateProvider.php, class","depth":11,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"ProcessHubspotObjectsSyncBatches.php, class","depth":11,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"PurgeDeletedOpportunitiesCommand.php, class","depth":11,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"ResetGovernorLimits.php, class","depth":11,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"SendNotLogged.php, class","depth":11,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"SetupActivityTypeForFollowUp.php, final class","depth":11,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"SetupCloseCrm.php, class","depth":11,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"SetupCopperCrm.php, class","depth":11,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"SetupCrmCommand.php, abstract class","depth":11,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"SetupLayouts.php, class","depth":11,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"SyncAccount.php, class","depth":11,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"SyncContact.php, class","depth":11,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"SyncFieldMetadata.php, class","depth":11,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"SyncHubspotActiveDeals.php, class","depth":11,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"SyncHubspotObjects.php, class","depth":11,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"SyncLead.php, class","depth":11,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"SyncObjects.php, class","depth":11,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"SyncOpportunitiesMissingFieldDataCommand.php, class","depth":11,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"SyncOpportunity.php, class","depth":11,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"SyncProfileMetadata.php, class","depth":11,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"SyncTeamMetadata.php, class","depth":11,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"UpdateOpportunitySpecifications.php, class","depth":11,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"DealInsights, folder","depth":10,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Dev, folder","depth":10,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Dialers, folder","depth":10,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"DTOs, folder","depth":10,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Elasticsearch, folder","depth":10,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"EngagementStats, folder","depth":10,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"GeckoExport, folder","depth":10,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Livestream, folder","depth":10,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Mailboxes, folder","depth":10,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Migrate, folder","depth":10,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"PlaybackThemes, folder","depth":10,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Playbooks, folder","depth":10,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Playlists, folder","depth":10,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Postmark, folder","depth":10,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"ProphetAi, folder","depth":10,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Reports, folder","depth":10,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"AutomatedReportsCommand.php, class","depth":11,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"AutomatedReportsRetentionPolicyCommand.php, class","depth":11,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"AutomatedReportsSendCommand.php, class","depth":11,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"CreateMockAskJiminnyReportResultCommand.php, class","depth":11,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"DeleteReportCommand.php, class","depth":11,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"GenerateMarketingReport.php, class","depth":11,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Team.php, class","depth":11,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Usage.php, class","depth":11,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Slack, folder","depth":10,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Teams, folder","depth":10,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Tracks, folder","depth":10,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Transcription, folder","depth":10,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Twilio, folder","depth":10,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Users, folder","depth":10,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Vocabulary, folder","depth":10,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Zoom, folder","depth":10,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Command.php, class","depth":10,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"CreateDatabaseUsers.php, class","depth":10,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"DatabaseTableCount.php, class","depth":10,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"DeleteOldAiCrmNotesCommand.php, class","depth":10,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"DeleteS3LeftoversCommand.php, class","depth":10,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"DevPostmanCommand.php, final class","depth":10,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"DiarizeViaAiParticipantIdentificationCommand.php, class","depth":10,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"EncryptTokensCommand.php, class","depth":10,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"EngagementStatsRegenerateCommand.php, class","depth":10,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"FeatureFlagsHelper.php","depth":10,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"FixCrossTenantIssues.php, class","depth":10,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"FlushRolesPermissionsCache.php, class","depth":10,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"GenerateInternalWebhookToken.php, class","depth":10,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"GroupSetDefaultLanguageCommand.php, final class","depth":10,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"HelperTruncateCoachingTables.php, class","depth":10,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"HubspotJournalPollingCommand.php, class","depth":10,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"HubspotWebhookServiceCommand.php, class","depth":10,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"ImportRecording.php, class","depth":10,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"ImportUsersFromCsvFile.php, final class","depth":10,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"IterateUsersCommand.php, abstract class","depth":10,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"JiminnyCacheClearCommand.php, class","depth":10,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"JiminnyDebugCommand.php, class","depth":10,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"JiminnySetEncryptedTokenManagerModeCommand.php, class","depth":10,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"JiminnyTokenInfoCommand.php, class","depth":10,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"MakeSlackLiveCoachingChatNotesOn.php, class","depth":10,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"ManageScimForTeam.php, class","depth":10,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"MarkBranchForEnvironmentPipelineCommand.php, class","depth":10,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"MuteOrganizerChannel.php, class","depth":10,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"PhpApm.php, class","depth":10,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"PropagateCoachingFeedbackCreatedAtToSectionFeedbacks.php, class","depth":10,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"PurgeConferences.php, class","depth":10,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"PurgeSoftDeletedOpportunitiesCommand.php, class","depth":10,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"PurgeSyncBatchesCommand.php, class","depth":10,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"RecalculateDealRisksCommand.php, class","depth":10,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"RemoveDeleteMarkersCommand.php, class","depth":10,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"RemoveExpiredNudgesCommand.php, class","depth":10,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"RemoveUnusedParticipantSpeechesCommand.php, class","depth":10,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"ResetElasticSearch.php, class","depth":10,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"RestoreActivityCrmProviderIdCommand.php, class","depth":10,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"RestoreActivityTypeCommand.php, class","depth":10,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"RunAiCallScoringForUntypedActivitiesCommand.php, class","depth":10,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"SeedActivities.php, class","depth":10,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"SendNudgeExpirationWarningsCommand.php, class","depth":10,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"SyncActivity.php, class","depth":10,"on_screen":false,"role_description":"text"}]...
|
-1382112507344856741
|
5036252807440759108
|
click
|
accessibility
|
NULL
|
Project: faVsco.js, menu
JY-20725-handle-HS-search Project: faVsco.js, menu
JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
19
Previous Highlighted Error
Next Highlighted Error
[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {
"headers":{
"Date":["Thu,07 May 2026 14:21:15 GMT"],
"Content-Type":["application/json;charset=utf-8"],
"Transfer-Encoding":["chunked"],
"Connection":["keep-alive"],
"CF-Ray":["9f80deb8db60dc3a-SOF"],
"CF-Cache-Status":["DYNAMIC"],
"Strict-Transport-Security":["max-age=31536000; includeSubDomains; preload"],
"Vary":["origin,
accept-encoding"],
"access-control-allow-credentials":["false"],
"server-timing":["hcid;desc=\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\",
cfr;desc=\"9f80deb8e7c6dc3a-IAD\""],
"x-content-type-options":["nosniff"],
"x-hubspot-correlation-id":["019e02d0-6fd8-7812-bdba-885b7ccb3ee3"],
"Set-Cookie":["__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-[IP_ADDRESS]-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,
07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None"],
"Report-To":["{
\"endpoints\":[{
\"url\":\"https:\\/\\/a.nel.cloudflare.com\\/report\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\"}],
\"group\":\"cf-nel\",
\"max_age\":604800}"],
"NEL":["{
\"success_fraction\":0.01,
\"report_to\":\"cf-nel\",
\"max_age\":604800}"],
"Server":["cloudflare"]}} {
"correlation_id":"95236535-ec98-4541-b92a-adfa73b69eab",
"trace_id":"c7ab8365-903f-46d4-9403-0e5b551e3545"}
Code changed:
Hide
Sync Changes
Hide This Notification
2
Previous Highlighted Error
Next Highlighted Error
<?php
declare(strict_types=1);
namespace Jiminny\Services\Crm\Hubspot\OpportunitySyncStrategy;
use Jiminny\Exceptions\Crm\InvalidSyncParametersException;
use Jiminny\Exceptions\SocialAccountTokenInvalidException;
use Jiminny\Models\Crm\Configuration;
use Jiminny\Services\Crm\CrmConfigurationSettingsService;
use Jiminny\Services\Crm\Hubspot\Client;
use Jiminny\Services\Crm\Hubspot\ClosedDealStagesService;
use Jiminny\Services\Crm\Hubspot\DealFieldsService;
use Jiminny\Services\Crm\Hubspot\PayloadBuilder;
use Jiminny\Services\Crm\OpportunitySyncStrategyInterface;
use Psr\Log\LoggerInterface;
use SevenShores\Hubspot\Exceptions\BadRequest;
use SevenShores\Hubspot\Exceptions\HubspotException;
abstract class HubspotSyncStrategyBase implements OpportunitySyncStrategyInterface
{
protected Client $client;
protected PayloadBuilder $payloadBuilder;
public function __construct(Client $client)
{
$this->client = $client;
$this->payloadBuilder = app(PayloadBuilder::class);
}
/**
* @throws SocialAccountTokenInvalidException
* @throws BadRequest
* @throws HubspotException
*/
public function fetchOpportunities(
array $params,
int &$total = 0,
?string &$lastRecordId = null
): \Generator {
try {
$this->validateParameters($params);
} catch (InvalidSyncParametersException $e) {
$logger = app(LoggerInterface::class);
$logger->error('Invalid sync parameters: ' . $e->getMessage());
// Return empty generator for invalid parameters
return;
}
$fields = $this->getOpportunityFields($params['config']);
$payload = $this->buildQuery($params, $fields);
$offset = $params['offset'] ?? 0;
yield from $this->client->getPaginatedDataGenerator(
$payload,
'deals',
$offset,
$total,
$lastRecordId
);
}
protected function getOpportunityFields(Configuration $config): array
{
return $this->getDealFieldsService()->getFieldsForConfiguration($config);
}
protected function buildQuery(array $params, array $fields): array
{
return [];
}
protected function getDealFieldsService(): DealFieldsService
{
return app(DealFieldsService::class);
}
protected function getCrmConfigurationSettingsService(): CrmConfigurationSettingsService
{
return app(CrmConfigurationSettingsService::class);
}
protected function getClosedDealStagesService(): ClosedDealStagesService
{
return app(ClosedDealStagesService::class);
}
}
Project
Project
New File or Directory…
Expand Selected
Collapse All
Options
Hide
app ~/jiminny/app, folder
.circleci, folder
.cursor, folder
.github
.sonarlint, folder
.vscode, folder
.windsurf, folder
app, sources root
Actions, folder
Component, folder
Acl, folder
ActionItems, folder
Activity, folder
ActivityAnalytics, folder
ActivitySearch, folder
AiActivityType, folder
AiAutomation, folder
AiCallScoring, folder
AskAnything, folder
Dtos, folder
Events, folder
AskAnythingPromptService.php, class
HistoryService.php, class
AskJiminnyAi, folder
AWS, folder
BillingManagement, folder
Cache, folder
CoachingFeedback, folder
Country, folder
CustomerApi, folder
Database, folder
Datadog, folder
DateTime, folder
DealInsights, folder
DealRisks, folder
ElasticSearch, folder
Eloquent, folder
Encoding, folder
Encryption, folder
ES, folder
Faker, folder
FeatureFlags, folder
FFMpeg, folder
FileSystem, folder
Gecko, folder
Gong, folder
GuzzleHttp, folder
KeyPoints, folder
Kiosk, folder
LanguageDetection, folder
LiveFeed, folder
Locks, folder
Math, folder
MediaPipeline, folder
MeetingBot, folder
MobileSettings, folder
Model, folder
Notification, folder
Nudge, folder
ParagraphBreaker, folder
ParticipantSpeech, folder
PartitionedCookie, folder
PlaybackPage, folder
Playlist, folder
Prophet, folder
ProphetAi, folder
ProsperWorks, folder
Queue, folder
Router, folder
Saml2, folder
SCIM, folder
Seeder, folder
Sentry, folder
Serializer, folder
Settings, folder
Sidekick, folder
Slack, folder
TeamInsights, folder
TimeMemoryMapper, folder
Transcription, folder
TranscriptionSummary, folder
Twilio, folder
Uploader, folder
UrlGenerator, folder
Utility, folder
Exceptions, folder
Service, folder
BaseRateLimiter.php, class
EfficientJsonParser.php, class
ProviderRateLimiter.php, class
RateLimiterInstance.php, class
Uuid
Waveform
Webhooks
Workflow
Configuration
Console
Commands
Activities
Analytics
Calendars
Crm
Hubspot
IntegrationApp
Traits
AddLayoutEntities.php, class
AutologDelayedCommand.php, class
BullhornCommandAbstract.php, abstract class
BullhornPingCommand.php, class
BullhornSearchCommand.php, class
BullhornSessionCommand.php, class
CheckActivityLoggableCommand.php, final class
CleanDuplicateFieldDataCommand.php, class
FullSyncOpportunityCommand.php, class
LogActivitiesCommand.php, final class
ManageSyncStrategyCommand.php, class
MatchCrmObjectsCommand.php, class
MatchOpportunityActivitiesCommand.php, class
MigrateProvider.php, class
ProcessHubspotObjectsSyncBatches.php, class
PurgeDeletedOpportunitiesCommand.php, class
ResetGovernorLimits.php, class
SendNotLogged.php, class
SetupActivityTypeForFollowUp.php, final class
SetupCloseCrm.php, class
SetupCopperCrm.php, class
SetupCrmCommand.php, abstract class
SetupLayouts.php, class
SyncAccount.php, class
SyncContact.php, class
SyncFieldMetadata.php, class
SyncHubspotActiveDeals.php, class
SyncHubspotObjects.php, class
SyncLead.php, class
SyncObjects.php, class
SyncOpportunitiesMissingFieldDataCommand.php, class
SyncOpportunity.php, class
SyncProfileMetadata.php, class
SyncTeamMetadata.php, class
UpdateOpportunitySpecifications.php, class
DealInsights, folder
Dev, folder
Dialers, folder
DTOs, folder
Elasticsearch, folder
EngagementStats, folder
GeckoExport, folder
Livestream, folder
Mailboxes, folder
Migrate, folder
PlaybackThemes, folder
Playbooks, folder
Playlists, folder
Postmark, folder
ProphetAi, folder
Reports, folder
AutomatedReportsCommand.php, class
AutomatedReportsRetentionPolicyCommand.php, class
AutomatedReportsSendCommand.php, class
CreateMockAskJiminnyReportResultCommand.php, class
DeleteReportCommand.php, class
GenerateMarketingReport.php, class
Team.php, class
Usage.php, class
Slack, folder
Teams, folder
Tracks, folder
Transcription, folder
Twilio, folder
Users, folder
Vocabulary, folder
Zoom, folder
Command.php, class
CreateDatabaseUsers.php, class
DatabaseTableCount.php, class
DeleteOldAiCrmNotesCommand.php, class
DeleteS3LeftoversCommand.php, class
DevPostmanCommand.php, final class
DiarizeViaAiParticipantIdentificationCommand.php, class
EncryptTokensCommand.php, class
EngagementStatsRegenerateCommand.php, class
FeatureFlagsHelper.php
FixCrossTenantIssues.php, class
FlushRolesPermissionsCache.php, class
GenerateInternalWebhookToken.php, class
GroupSetDefaultLanguageCommand.php, final class
HelperTruncateCoachingTables.php, class
HubspotJournalPollingCommand.php, class
HubspotWebhookServiceCommand.php, class
ImportRecording.php, class
ImportUsersFromCsvFile.php, final class
IterateUsersCommand.php, abstract class
JiminnyCacheClearCommand.php, class
JiminnyDebugCommand.php, class
JiminnySetEncryptedTokenManagerModeCommand.php, class
JiminnyTokenInfoCommand.php, class
MakeSlackLiveCoachingChatNotesOn.php, class
ManageScimForTeam.php, class
MarkBranchForEnvironmentPipelineCommand.php, class
MuteOrganizerChannel.php, class
PhpApm.php, class
PropagateCoachingFeedbackCreatedAtToSectionFeedbacks.php, class
PurgeConferences.php, class
PurgeSoftDeletedOpportunitiesCommand.php, class
PurgeSyncBatchesCommand.php, class
RecalculateDealRisksCommand.php, class
RemoveDeleteMarkersCommand.php, class
RemoveExpiredNudgesCommand.php, class
RemoveUnusedParticipantSpeechesCommand.php, class
ResetElasticSearch.php, class
RestoreActivityCrmProviderIdCommand.php, class
RestoreActivityTypeCommand.php, class
RunAiCallScoringForUntypedActivitiesCommand.php, class
SeedActivities.php, class
SendNudgeExpirationWarningsCommand.php, class
SyncActivity.php, class...
|
9845
|
NULL
|
NULL
|
NULL
|
|
9846
|
444
|
29
|
2026-05-08T13:37:40.456619+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-08/1778 /Users/lukas/.screenpipe/data/data/2026-05-08/1778247460456_m2.jpg...
|
PhpStorm
|
faVsco.js – HubspotSyncStrategyBase.php
|
1
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
JY-20725-handle-HS-search Project: faVsco.js, menu
JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
19
Previous Highlighted Error
Next Highlighted Error
[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {
"headers":{
"Date":["Thu,07 May 2026 14:21:15 GMT"],
"Content-Type":["application/json;charset=utf-8"],
"Transfer-Encoding":["chunked"],
"Connection":["keep-alive"],
"CF-Ray":["9f80deb8db60dc3a-SOF"],
"CF-Cache-Status":["DYNAMIC"],
"Strict-Transport-Security":["max-age=31536000; includeSubDomains; preload"],
"Vary":["origin,
accept-encoding"],
"access-control-allow-credentials":["false"],
"server-timing":["hcid;desc=\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\",
cfr;desc=\"9f80deb8e7c6dc3a-IAD\""],
"x-content-type-options":["nosniff"],
"x-hubspot-correlation-id":["019e02d0-6fd8-7812-bdba-885b7ccb3ee3"],
"Set-Cookie":["__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-[IP_ADDRESS]-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,
07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None"],
"Report-To":["{
\"endpoints\":[{
\"url\":\"https:\\/\\/a.nel.cloudflare.com\\/report\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\"}],
\"group\":\"cf-nel\",
\"max_age\":604800}"],
"NEL":["{
\"success_fraction\":0.01,
\"report_to\":\"cf-nel\",
\"max_age\":604800}"],
"Server":["cloudflare"]}} {
"correlation_id":"95236535-ec98-4541-b92a-adfa73b69eab",
"trace_id":"c7ab8365-903f-46d4-9403-0e5b551e3545"}
Code changed:
Hide
Sync Changes
Hide This Notification
2
Previous Highlighted Error
Next Highlighted Error
<?php
declare(strict_types=1);
namespace Jiminny\Services\Crm\Hubspot\OpportunitySyncStrategy;
use Jiminny\Exceptions\Crm\InvalidSyncParametersException;
use Jiminny\Exceptions\SocialAccountTokenInvalidException;
use Jiminny\Models\Crm\Configuration;
use Jiminny\Services\Crm\CrmConfigurationSettingsService;
use Jiminny\Services\Crm\Hubspot\Client;
use Jiminny\Services\Crm\Hubspot\ClosedDealStagesService;
use Jiminny\Services\Crm\Hubspot\DealFieldsService;
use Jiminny\Services\Crm\Hubspot\PayloadBuilder;
use Jiminny\Services\Crm\OpportunitySyncStrategyInterface;
use Psr\Log\LoggerInterface;
use SevenShores\Hubspot\Exceptions\BadRequest;
use SevenShores\Hubspot\Exceptions\HubspotException;
abstract class HubspotSyncStrategyBase implements OpportunitySyncStrategyInterface
{
protected Client $client;
protected PayloadBuilder $payloadBuilder;
public function __construct(Client $client)
{
$this->client = $client;
$this->payloadBuilder = app(PayloadBuilder::class);
}
/**
* @throws SocialAccountTokenInvalidException
* @throws BadRequest
* @throws HubspotException
*/
public function fetchOpportunities(
array $params,
int &$total = 0,
?string &$lastRecordId = null
): \Generator {
try {
$this->validateParameters($params);
} catch (InvalidSyncParametersException $e) {
$logger = app(LoggerInterface::class);
$logger->error('Invalid sync parameters: ' . $e->getMessage());
// Return empty generator for invalid parameters
return;
}
$fields = $this->getOpportunityFields($params['config']);
$payload = $this->buildQuery($params, $fields);
$offset = $params['offset'] ?? 0;
yield from $this->client->getPaginatedDataGenerator(
$payload,
'deals',
$offset,
$total,
$lastRecordId
);
}
protected function getOpportunityFields(Configuration $config): array
{
return $this->getDealFieldsService()->getFieldsForConfiguration($config);
}
protected function buildQuery(array $params, array $fields): array
{
return [];
}
protected function getDealFieldsService(): DealFieldsService
{
return app(DealFieldsService::class);
}
protected function getCrmConfigurationSettingsService(): CrmConfigurationSettingsService
{
return app(CrmConfigurationSettingsService::class);
}
protected function getClosedDealStagesService(): ClosedDealStagesService
{
return app(ClosedDealStagesService::class);
}
}
Project
Project
New File or Directory…
Expand Selected
Collapse All
Options
Hide
app ~/jiminny/app, folder
.circleci, folder
.cursor, folder
.github
.sonarlint, folder
.vscode, folder
.windsurf, folder
app, sources root
Actions, folder
Component, folder
Acl, folder
ActionItems, folder
Activity, folder
ActivityAnalytics, folder
ActivitySearch, folder
AiActivityType, folder
AiAutomation, folder
AiCallScoring, folder
AskAnything, folder
Dtos, folder
Events, folder
AskAnythingPromptService.php, class
HistoryService.php, class
AskJiminnyAi, folder
AWS, folder
BillingManagement, folder
Cache, folder
CoachingFeedback, folder
Country, folder
CustomerApi, folder
Database, folder
Datadog, folder
DateTime, folder
DealInsights, folder
DealRisks, folder
ElasticSearch, folder
Eloquent, folder
Encoding, folder
Encryption, folder
ES, folder
Faker, folder
FeatureFlags, folder
FFMpeg, folder
FileSystem, folder
Gecko, folder
Gong, folder
GuzzleHttp, folder
KeyPoints, folder
Kiosk, folder
LanguageDetection, folder
LiveFeed, folder
Locks, folder
Math, folder
MediaPipeline, folder
MeetingBot, folder
MobileSettings, folder
Model, folder
Notification, folder
Nudge, folder
ParagraphBreaker, folder
ParticipantSpeech, folder
PartitionedCookie, folder
PlaybackPage, folder
Playlist, folder
Prophet, folder
ProphetAi, folder
ProsperWorks, folder
Queue, folder
Router, folder...
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"bounds":{"left":0.025930852,"top":0.019952115,"width":0.03856383,"height":0.025538707},"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JY-20725-handle-HS-search-rate-limit, menu","depth":5,"bounds":{"left":0.064494684,"top":0.019952115,"width":0.09541223,"height":0.025538707},"on_screen":true,"help_text":"Git Branch: JY-20725-handle-HS-search-rate-limit","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"bounds":{"left":0.8081782,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"AskJiminnyReportActivityServiceTest","depth":6,"bounds":{"left":0.8234708,"top":0.019952115,"width":0.09208777,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'AskJiminnyReportActivityServiceTest'","depth":6,"bounds":{"left":0.9155585,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'AskJiminnyReportActivityServiceTest'","depth":6,"bounds":{"left":0.9268617,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"bounds":{"left":0.9381649,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"bounds":{"left":0.96609044,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"bounds":{"left":0.9773936,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"IDE and Project Settings","depth":5,"bounds":{"left":0.9886968,"top":0.019952115,"width":0.011303186,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.042220745,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"19","depth":4,"bounds":{"left":0.6615692,"top":0.10055866,"width":0.009640957,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"bounds":{"left":0.67287236,"top":0.09896249,"width":0.00731383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"bounds":{"left":0.68018615,"top":0.09896249,"width":0.006981383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {\n\"headers\":{\n\"Date\":[\"Thu,07 May 2026 14:21:15 GMT\"],\n \"Content-Type\":[\"application/json;charset=utf-8\"],\n \"Transfer-Encoding\":[\"chunked\"],\n \"Connection\":[\"keep-alive\"],\n \"CF-Ray\":[\"9f80deb8db60dc3a-SOF\"],\n \"CF-Cache-Status\":[\"DYNAMIC\"],\n \"Strict-Transport-Security\":[\"max-age=31536000; includeSubDomains; preload\"],\n \"Vary\":[\"origin,\n accept-encoding\"],\n \"access-control-allow-credentials\":[\"false\"],\n \"server-timing\":[\"hcid;desc=\\\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\\\",\n cfr;desc=\\\"9f80deb8e7c6dc3a-IAD\\\"\"],\n \"x-content-type-options\":[\"nosniff\"],\n \"x-hubspot-correlation-id\":[\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\"],\n \"Set-Cookie\":[\"__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-1.0.1.1-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,\n 07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None\"],\n \"Report-To\":[\"{\n\\\"endpoints\\\":[{\n\\\"url\\\":\\\"https:\\\\/\\\\/a.nel.cloudflare.com\\\\/report\\\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\\\"}],\n\\\"group\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"NEL\":[\"{\n\\\"success_fraction\\\":0.01,\n\\\"report_to\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"Server\":[\"cloudflare\"]}} {\n\"correlation_id\":\"95236535-ec98-4541-b92a-adfa73b69eab\",\n\"trace_id\":\"c7ab8365-903f-46d4-9403-0e5b551e3545\"}","depth":4,"bounds":{"left":0.37632978,"top":0.09736632,"width":0.5728058,"height":0.8818835},"on_screen":true,"lines":[{"char_start":207,"char_count":30,"bounds":{"left":0.37632978,"top":0.0,"width":0.07513298,"height":0.014365523}},{"char_start":237,"char_count":36,"bounds":{"left":0.37632978,"top":0.0,"width":0.09075798,"height":0.014365523}},{"char_start":273,"char_count":32,"bounds":{"left":0.37632978,"top":0.0,"width":0.080119684,"height":0.014365523}},{"char_start":305,"char_count":79,"bounds":{"left":0.37632978,"top":0.0,"width":0.20212767,"height":0.014365523}},{"char_start":384,"char_count":18,"bounds":{"left":0.37632978,"top":0.0,"width":0.043882977,"height":0.014365523}},{"char_start":402,"char_count":21,"bounds":{"left":0.37632978,"top":0.0,"width":0.051861703,"height":0.014365523}},{"char_start":423,"char_count":48,"bounds":{"left":0.37632978,"top":0.008778931,"width":0.12167553,"height":0.014365523}},{"char_start":471,"char_count":72,"bounds":{"left":0.37632978,"top":0.026336791,"width":0.18384309,"height":0.014365523}},{"char_start":543,"char_count":40,"bounds":{"left":0.37632978,"top":0.043894652,"width":0.10106383,"height":0.014365523}},{"char_start":583,"char_count":41,"bounds":{"left":0.37632978,"top":0.061452515,"width":0.10372341,"height":0.014365523}},{"char_start":624,"char_count":72,"bounds":{"left":0.37632978,"top":0.079010375,"width":0.18384309,"height":0.014365523}},{"char_start":696,"char_count":219,"bounds":{"left":0.37632978,"top":0.096568234,"width":0.56515956,"height":0.014365523}},{"char_start":915,"char_count":83,"bounds":{"left":0.37632978,"top":0.11412609,"width":0.21243352,"height":0.014365523}},{"char_start":998,"char_count":20,"bounds":{"left":0.37632978,"top":0.13168396,"width":0.04920213,"height":0.014365523}},{"char_start":1018,"char_count":17,"bounds":{"left":0.37632978,"top":0.14924182,"width":0.041223403,"height":0.014365523}},{"char_start":1035,"char_count":203,"bounds":{"left":0.37632978,"top":0.16679968,"width":0.52360374,"height":0.014365523}},{"char_start":1238,"char_count":22,"bounds":{"left":0.37632978,"top":0.18435754,"width":0.05418883,"height":0.014365523}},{"char_start":1260,"char_count":23,"bounds":{"left":0.37632978,"top":0.2019154,"width":0.056848403,"height":0.014365523}},{"char_start":1283,"char_count":10,"bounds":{"left":0.37632978,"top":0.21947326,"width":0.023271276,"height":0.014365523}},{"char_start":1293,"char_count":27,"bounds":{"left":0.37632978,"top":0.23703113,"width":0.06715426,"height":0.014365523}},{"char_start":1320,"char_count":26,"bounds":{"left":0.37632978,"top":0.254589,"width":0.06482713,"height":0.014365523}},{"char_start":1346,"char_count":23,"bounds":{"left":0.37632978,"top":0.27214685,"width":0.056848403,"height":0.014365523}},{"char_start":1369,"char_count":28,"bounds":{"left":0.37632978,"top":0.2897047,"width":0.06981383,"height":0.014365523}},{"char_start":1397,"char_count":57,"bounds":{"left":0.37632978,"top":0.30726257,"width":0.14494681,"height":0.014365523}}],"value":"[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {\n\"headers\":{\n\"Date\":[\"Thu,07 May 2026 14:21:15 GMT\"],\n \"Content-Type\":[\"application/json;charset=utf-8\"],\n \"Transfer-Encoding\":[\"chunked\"],\n \"Connection\":[\"keep-alive\"],\n \"CF-Ray\":[\"9f80deb8db60dc3a-SOF\"],\n \"CF-Cache-Status\":[\"DYNAMIC\"],\n \"Strict-Transport-Security\":[\"max-age=31536000; includeSubDomains; preload\"],\n \"Vary\":[\"origin,\n accept-encoding\"],\n \"access-control-allow-credentials\":[\"false\"],\n \"server-timing\":[\"hcid;desc=\\\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\\\",\n cfr;desc=\\\"9f80deb8e7c6dc3a-IAD\\\"\"],\n \"x-content-type-options\":[\"nosniff\"],\n \"x-hubspot-correlation-id\":[\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\"],\n \"Set-Cookie\":[\"__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-1.0.1.1-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,\n 07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None\"],\n \"Report-To\":[\"{\n\\\"endpoints\\\":[{\n\\\"url\\\":\\\"https:\\\\/\\\\/a.nel.cloudflare.com\\\\/report\\\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\\\"}],\n\\\"group\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"NEL\":[\"{\n\\\"success_fraction\\\":0.01,\n\\\"report_to\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"Server\":[\"cloudflare\"]}} {\n\"correlation_id\":\"95236535-ec98-4541-b92a-adfa73b69eab\",\n\"trace_id\":\"c7ab8365-903f-46d4-9403-0e5b551e3545\"}","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.042220745,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"2","depth":4,"bounds":{"left":0.3331117,"top":0.19952115,"width":0.007978723,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"bounds":{"left":0.34275267,"top":0.19792499,"width":0.00731383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"bounds":{"left":0.35006648,"top":0.19792499,"width":0.006981383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\ndeclare(strict_types=1);\n\nnamespace Jiminny\\Services\\Crm\\Hubspot\\OpportunitySyncStrategy;\n\nuse Jiminny\\Exceptions\\Crm\\InvalidSyncParametersException;\nuse Jiminny\\Exceptions\\SocialAccountTokenInvalidException;\nuse Jiminny\\Models\\Crm\\Configuration;\nuse Jiminny\\Services\\Crm\\CrmConfigurationSettingsService;\nuse Jiminny\\Services\\Crm\\Hubspot\\Client;\nuse Jiminny\\Services\\Crm\\Hubspot\\ClosedDealStagesService;\nuse Jiminny\\Services\\Crm\\Hubspot\\DealFieldsService;\nuse Jiminny\\Services\\Crm\\Hubspot\\PayloadBuilder;\nuse Jiminny\\Services\\Crm\\OpportunitySyncStrategyInterface;\nuse Psr\\Log\\LoggerInterface;\nuse SevenShores\\Hubspot\\Exceptions\\BadRequest;\nuse SevenShores\\Hubspot\\Exceptions\\HubspotException;\n\nabstract class HubspotSyncStrategyBase implements OpportunitySyncStrategyInterface\n{\n protected Client $client;\n protected PayloadBuilder $payloadBuilder;\n\n public function __construct(Client $client)\n {\n $this->client = $client;\n $this->payloadBuilder = app(PayloadBuilder::class);\n }\n\n /**\n * @throws SocialAccountTokenInvalidException\n * @throws BadRequest\n * @throws HubspotException\n */\n public function fetchOpportunities(\n array $params,\n int &$total = 0,\n ?string &$lastRecordId = null\n ): \\Generator {\n try {\n $this->validateParameters($params);\n } catch (InvalidSyncParametersException $e) {\n $logger = app(LoggerInterface::class);\n $logger->error('Invalid sync parameters: ' . $e->getMessage());\n\n // Return empty generator for invalid parameters\n return;\n }\n\n $fields = $this->getOpportunityFields($params['config']);\n $payload = $this->buildQuery($params, $fields);\n $offset = $params['offset'] ?? 0;\n\n yield from $this->client->getPaginatedDataGenerator(\n $payload,\n 'deals',\n $offset,\n $total,\n $lastRecordId\n );\n }\n\n protected function getOpportunityFields(Configuration $config): array\n {\n return $this->getDealFieldsService()->getFieldsForConfiguration($config);\n }\n\n protected function buildQuery(array $params, array $fields): array\n {\n return [];\n }\n\n protected function getDealFieldsService(): DealFieldsService\n {\n return app(DealFieldsService::class);\n }\n\n protected function getCrmConfigurationSettingsService(): CrmConfigurationSettingsService\n {\n return app(CrmConfigurationSettingsService::class);\n }\n\n protected function getClosedDealStagesService(): ClosedDealStagesService\n {\n return app(ClosedDealStagesService::class);\n }\n}","depth":4,"bounds":{"left":0.125,"top":0.0031923384,"width":0.25897607,"height":0.99680763},"on_screen":true,"value":"<?php\n\ndeclare(strict_types=1);\n\nnamespace Jiminny\\Services\\Crm\\Hubspot\\OpportunitySyncStrategy;\n\nuse Jiminny\\Exceptions\\Crm\\InvalidSyncParametersException;\nuse Jiminny\\Exceptions\\SocialAccountTokenInvalidException;\nuse Jiminny\\Models\\Crm\\Configuration;\nuse Jiminny\\Services\\Crm\\CrmConfigurationSettingsService;\nuse Jiminny\\Services\\Crm\\Hubspot\\Client;\nuse Jiminny\\Services\\Crm\\Hubspot\\ClosedDealStagesService;\nuse Jiminny\\Services\\Crm\\Hubspot\\DealFieldsService;\nuse Jiminny\\Services\\Crm\\Hubspot\\PayloadBuilder;\nuse Jiminny\\Services\\Crm\\OpportunitySyncStrategyInterface;\nuse Psr\\Log\\LoggerInterface;\nuse SevenShores\\Hubspot\\Exceptions\\BadRequest;\nuse SevenShores\\Hubspot\\Exceptions\\HubspotException;\n\nabstract class HubspotSyncStrategyBase implements OpportunitySyncStrategyInterface\n{\n protected Client $client;\n protected PayloadBuilder $payloadBuilder;\n\n public function __construct(Client $client)\n {\n $this->client = $client;\n $this->payloadBuilder = app(PayloadBuilder::class);\n }\n\n /**\n * @throws SocialAccountTokenInvalidException\n * @throws BadRequest\n * @throws HubspotException\n */\n public function fetchOpportunities(\n array $params,\n int &$total = 0,\n ?string &$lastRecordId = null\n ): \\Generator {\n try {\n $this->validateParameters($params);\n } catch (InvalidSyncParametersException $e) {\n $logger = app(LoggerInterface::class);\n $logger->error('Invalid sync parameters: ' . $e->getMessage());\n\n // Return empty generator for invalid parameters\n return;\n }\n\n $fields = $this->getOpportunityFields($params['config']);\n $payload = $this->buildQuery($params, $fields);\n $offset = $params['offset'] ?? 0;\n\n yield from $this->client->getPaginatedDataGenerator(\n $payload,\n 'deals',\n $offset,\n $total,\n $lastRecordId\n );\n }\n\n protected function getOpportunityFields(Configuration $config): array\n {\n return $this->getDealFieldsService()->getFieldsForConfiguration($config);\n }\n\n protected function buildQuery(array $params, array $fields): array\n {\n return [];\n }\n\n protected function getDealFieldsService(): DealFieldsService\n {\n return app(DealFieldsService::class);\n }\n\n protected function getCrmConfigurationSettingsService(): CrmConfigurationSettingsService\n {\n return app(CrmConfigurationSettingsService::class);\n }\n\n protected function getClosedDealStagesService(): ClosedDealStagesService\n {\n return app(ClosedDealStagesService::class);\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":true,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Project","depth":3,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Project","depth":3,"bounds":{"left":0.011968086,"top":0.047885075,"width":0.024268618,"height":0.024740623},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"New File or Directory…","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Expand Selected","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Collapse All","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Options","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"app ~/jiminny/app, folder","depth":6,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":".circleci, folder","depth":7,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":".cursor, folder","depth":7,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":".github","depth":7,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":".sonarlint, folder","depth":7,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":".vscode, folder","depth":7,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":".windsurf, folder","depth":7,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"app, sources root","depth":7,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Actions, folder","depth":8,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Component, folder","depth":8,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Acl, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"ActionItems, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Activity, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"ActivityAnalytics, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"ActivitySearch, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"AiActivityType, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"AiAutomation, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"AiCallScoring, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"AskAnything, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Dtos, folder","depth":10,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Events, folder","depth":10,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"AskAnythingPromptService.php, class","depth":10,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"HistoryService.php, class","depth":10,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"AskJiminnyAi, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"AWS, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"BillingManagement, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Cache, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"CoachingFeedback, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Country, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"CustomerApi, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Database, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Datadog, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"DateTime, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"DealInsights, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"DealRisks, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"ElasticSearch, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Eloquent, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Encoding, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Encryption, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"ES, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Faker, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"FeatureFlags, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"FFMpeg, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"FileSystem, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Gecko, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Gong, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"GuzzleHttp, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"KeyPoints, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Kiosk, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"LanguageDetection, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"LiveFeed, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Locks, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Math, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"MediaPipeline, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"MeetingBot, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"MobileSettings, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Model, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Notification, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Nudge, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"ParagraphBreaker, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"ParticipantSpeech, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"PartitionedCookie, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"PlaybackPage, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Playlist, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Prophet, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"ProphetAi, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"ProsperWorks, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Queue, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Router, folder","depth":9,"on_screen":false,"role_description":"text"}]...
|
706356509084272538
|
-3610448469919958580
|
click
|
accessibility
|
NULL
|
Project: faVsco.js, menu
JY-20725-handle-HS-search Project: faVsco.js, menu
JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
19
Previous Highlighted Error
Next Highlighted Error
[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {
"headers":{
"Date":["Thu,07 May 2026 14:21:15 GMT"],
"Content-Type":["application/json;charset=utf-8"],
"Transfer-Encoding":["chunked"],
"Connection":["keep-alive"],
"CF-Ray":["9f80deb8db60dc3a-SOF"],
"CF-Cache-Status":["DYNAMIC"],
"Strict-Transport-Security":["max-age=31536000; includeSubDomains; preload"],
"Vary":["origin,
accept-encoding"],
"access-control-allow-credentials":["false"],
"server-timing":["hcid;desc=\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\",
cfr;desc=\"9f80deb8e7c6dc3a-IAD\""],
"x-content-type-options":["nosniff"],
"x-hubspot-correlation-id":["019e02d0-6fd8-7812-bdba-885b7ccb3ee3"],
"Set-Cookie":["__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-[IP_ADDRESS]-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,
07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None"],
"Report-To":["{
\"endpoints\":[{
\"url\":\"https:\\/\\/a.nel.cloudflare.com\\/report\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\"}],
\"group\":\"cf-nel\",
\"max_age\":604800}"],
"NEL":["{
\"success_fraction\":0.01,
\"report_to\":\"cf-nel\",
\"max_age\":604800}"],
"Server":["cloudflare"]}} {
"correlation_id":"95236535-ec98-4541-b92a-adfa73b69eab",
"trace_id":"c7ab8365-903f-46d4-9403-0e5b551e3545"}
Code changed:
Hide
Sync Changes
Hide This Notification
2
Previous Highlighted Error
Next Highlighted Error
<?php
declare(strict_types=1);
namespace Jiminny\Services\Crm\Hubspot\OpportunitySyncStrategy;
use Jiminny\Exceptions\Crm\InvalidSyncParametersException;
use Jiminny\Exceptions\SocialAccountTokenInvalidException;
use Jiminny\Models\Crm\Configuration;
use Jiminny\Services\Crm\CrmConfigurationSettingsService;
use Jiminny\Services\Crm\Hubspot\Client;
use Jiminny\Services\Crm\Hubspot\ClosedDealStagesService;
use Jiminny\Services\Crm\Hubspot\DealFieldsService;
use Jiminny\Services\Crm\Hubspot\PayloadBuilder;
use Jiminny\Services\Crm\OpportunitySyncStrategyInterface;
use Psr\Log\LoggerInterface;
use SevenShores\Hubspot\Exceptions\BadRequest;
use SevenShores\Hubspot\Exceptions\HubspotException;
abstract class HubspotSyncStrategyBase implements OpportunitySyncStrategyInterface
{
protected Client $client;
protected PayloadBuilder $payloadBuilder;
public function __construct(Client $client)
{
$this->client = $client;
$this->payloadBuilder = app(PayloadBuilder::class);
}
/**
* @throws SocialAccountTokenInvalidException
* @throws BadRequest
* @throws HubspotException
*/
public function fetchOpportunities(
array $params,
int &$total = 0,
?string &$lastRecordId = null
): \Generator {
try {
$this->validateParameters($params);
} catch (InvalidSyncParametersException $e) {
$logger = app(LoggerInterface::class);
$logger->error('Invalid sync parameters: ' . $e->getMessage());
// Return empty generator for invalid parameters
return;
}
$fields = $this->getOpportunityFields($params['config']);
$payload = $this->buildQuery($params, $fields);
$offset = $params['offset'] ?? 0;
yield from $this->client->getPaginatedDataGenerator(
$payload,
'deals',
$offset,
$total,
$lastRecordId
);
}
protected function getOpportunityFields(Configuration $config): array
{
return $this->getDealFieldsService()->getFieldsForConfiguration($config);
}
protected function buildQuery(array $params, array $fields): array
{
return [];
}
protected function getDealFieldsService(): DealFieldsService
{
return app(DealFieldsService::class);
}
protected function getCrmConfigurationSettingsService(): CrmConfigurationSettingsService
{
return app(CrmConfigurationSettingsService::class);
}
protected function getClosedDealStagesService(): ClosedDealStagesService
{
return app(ClosedDealStagesService::class);
}
}
Project
Project
New File or Directory…
Expand Selected
Collapse All
Options
Hide
app ~/jiminny/app, folder
.circleci, folder
.cursor, folder
.github
.sonarlint, folder
.vscode, folder
.windsurf, folder
app, sources root
Actions, folder
Component, folder
Acl, folder
ActionItems, folder
Activity, folder
ActivityAnalytics, folder
ActivitySearch, folder
AiActivityType, folder
AiAutomation, folder
AiCallScoring, folder
AskAnything, folder
Dtos, folder
Events, folder
AskAnythingPromptService.php, class
HistoryService.php, class
AskJiminnyAi, folder
AWS, folder
BillingManagement, folder
Cache, folder
CoachingFeedback, folder
Country, folder
CustomerApi, folder
Database, folder
Datadog, folder
DateTime, folder
DealInsights, folder
DealRisks, folder
ElasticSearch, folder
Eloquent, folder
Encoding, folder
Encryption, folder
ES, folder
Faker, folder
FeatureFlags, folder
FFMpeg, folder
FileSystem, folder
Gecko, folder
Gong, folder
GuzzleHttp, folder
KeyPoints, folder
Kiosk, folder
LanguageDetection, folder
LiveFeed, folder
Locks, folder
Math, folder
MediaPipeline, folder
MeetingBot, folder
MobileSettings, folder
Model, folder
Notification, folder
Nudge, folder
ParagraphBreaker, folder
ParticipantSpeech, folder
PartitionedCookie, folder
PlaybackPage, folder
Playlist, folder
Prophet, folder
ProphetAi, folder
ProsperWorks, folder
Queue, folder
Router, folder...
|
9844
|
NULL
|
NULL
|
NULL
|
|
9845
|
443
|
23
|
2026-05-08T13:37:39.929725+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-08/1778 /Users/lukas/.screenpipe/data/data/2026-05-08/1778247459929_m1.jpg...
|
PhpStorm
|
faVsco.js – HubspotSyncStrategyBase.php
|
1
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
JY-20725-handle-HS-search Project: faVsco.js, menu
JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
19
Previous Highlighted Error
Next Highlighted Error
[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {
"headers":{
"Date":["Thu,07 May 2026 14:21:15 GMT"],
"Content-Type":["application/json;charset=utf-8"],
"Transfer-Encoding":["chunked"],
"Connection":["keep-alive"],
"CF-Ray":["9f80deb8db60dc3a-SOF"],
"CF-Cache-Status":["DYNAMIC"],
"Strict-Transport-Security":["max-age=31536000; includeSubDomains; preload"],
"Vary":["origin,
accept-encoding"],
"access-control-allow-credentials":["false"],
"server-timing":["hcid;desc=\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\",
cfr;desc=\"9f80deb8e7c6dc3a-IAD\""],
"x-content-type-options":["nosniff"],
"x-hubspot-correlation-id":["019e02d0-6fd8-7812-bdba-885b7ccb3ee3"],
"Set-Cookie":["__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-[IP_ADDRESS]-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,
07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None"],
"Report-To":["{
\"endpoints\":[{
\"url\":\"https:\\/\\/a.nel.cloudflare.com\\/report\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\"}],
\"group\":\"cf-nel\",
\"max_age\":604800}"],
"NEL":["{
\"success_fraction\":0.01,
\"report_to\":\"cf-nel\",
\"max_age\":604800}"],
"Server":["cloudflare"]}} {
"correlation_id":"95236535-ec98-4541-b92a-adfa73b69eab",
"trace_id":"c7ab8365-903f-46d4-9403-0e5b551e3545"}
Code changed:
Hide
Sync Changes
Hide This Notification
2
Previous Highlighted Error
Next Highlighted Error
<?php
declare(strict_types=1);
namespace Jiminny\Services\Crm\Hubspot\OpportunitySyncStrategy;
use Jiminny\Exceptions\Crm\InvalidSyncParametersException;
use Jiminny\Exceptions\SocialAccountTokenInvalidException;
use Jiminny\Models\Crm\Configuration;
use Jiminny\Services\Crm\CrmConfigurationSettingsService;
use Jiminny\Services\Crm\Hubspot\Client;
use Jiminny\Services\Crm\Hubspot\ClosedDealStagesService;
use Jiminny\Services\Crm\Hubspot\DealFieldsService;
use Jiminny\Services\Crm\Hubspot\PayloadBuilder;
use Jiminny\Services\Crm\OpportunitySyncStrategyInterface;
use Psr\Log\LoggerInterface;
use SevenShores\Hubspot\Exceptions\BadRequest;
use SevenShores\Hubspot\Exceptions\HubspotException;
abstract class HubspotSyncStrategyBase implements OpportunitySyncStrategyInterface
{
protected Client $client;
protected PayloadBuilder $payloadBuilder;
public function __construct(Client $client)
{
$this->client = $client;
$this->payloadBuilder = app(PayloadBuilder::class);
}
/**
* @throws SocialAccountTokenInvalidException
* @throws BadRequest
* @throws HubspotException
*/
public function fetchOpportunities(
array $params,
int &$total = 0,
?string &$lastRecordId = null
): \Generator {
try {
$this->validateParameters($params);
} catch (InvalidSyncParametersException $e) {
$logger = app(LoggerInterface::class);
$logger->error('Invalid sync parameters: ' . $e->getMessage());
// Return empty generator for invalid parameters
return;
}
$fields = $this->getOpportunityFields($params['config']);
$payload = $this->buildQuery($params, $fields);
$offset = $params['offset'] ?? 0;
yield from $this->client->getPaginatedDataGenerator(
$payload,
'deals',
$offset,
$total,
$lastRecordId
);
}
protected function getOpportunityFields(Configuration $config): array
{
return $this->getDealFieldsService()->getFieldsForConfiguration($config);
}
protected function buildQuery(array $params, array $fields): array
{
return [];
}
protected function getDealFieldsService(): DealFieldsService
{
return app(DealFieldsService::class);
}
protected function getCrmConfigurationSettingsService(): CrmConfigurationSettingsService
{
return app(CrmConfigurationSettingsService::class);
}
protected function getClosedDealStagesService(): ClosedDealStagesService
{
return app(ClosedDealStagesService::class);
}
}
Project
Project
New File or Directory…
Expand Selected
Collapse All
Options
Hide
app ~/jiminny/app, folder
.circleci, folder
.cursor, folder
.github
.sonarlint, folder
.vscode, folder
.windsurf, folder
app, sources root
Actions, folder
Component, folder
Acl, folder
ActionItems, folder
Activity, folder
ActivityAnalytics, folder
ActivitySearch, folder
AiActivityType, folder
AiAutomation, folder
AiCallScoring, folder
AskAnything, folder
Dtos, folder
Events, folder
AskAnythingPromptService.php, class
HistoryService.php, class
AskJiminnyAi, folder
AWS, folder
BillingManagement, folder
Cache, folder
CoachingFeedback, folder
Country, folder
CustomerApi, folder
Database, folder
Datadog, folder
DateTime, folder
DealInsights, folder
DealRisks, folder
ElasticSearch, folder
Eloquent, folder
Encoding, folder
Encryption, folder
ES, folder
Faker, folder
FeatureFlags, folder
FFMpeg, folder
FileSystem, folder
Gecko, folder
Gong, folder
GuzzleHttp, folder
KeyPoints, folder
Kiosk, folder
LanguageDetection, folder
LiveFeed, folder
Locks, folder
Math, folder
MediaPipeline, folder
MeetingBot, folder
MobileSettings, folder
Model, folder
Notification, folder
Nudge, folder
ParagraphBreaker, folder
ParticipantSpeech, folder
PartitionedCookie, folder
PlaybackPage, folder
Playlist, folder
Prophet, folder
ProphetAi, folder
ProsperWorks, folder
Queue, folder
Router, folder
Saml2, folder
SCIM, folder
Seeder, folder
Sentry, folder
Serializer, folder
Settings, folder
Sidekick, folder
Slack, folder
TeamInsights, folder
TimeMemoryMapper, folder
Transcription, folder
TranscriptionSummary, folder
Twilio, folder
Uploader, folder
UrlGenerator, folder
Utility, folder
Exceptions, folder
Service, folder
BaseRateLimiter.php, class
EfficientJsonParser.php, class
ProviderRateLimiter.php, class
RateLimiterInstance.php, class
Uuid
Waveform
Webhooks
Workflow
Configuration
Console
Commands
Activities
Analytics
Calendars
Crm...
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JY-20725-handle-HS-search-rate-limit, menu","depth":5,"on_screen":true,"help_text":"Git Branch: JY-20725-handle-HS-search-rate-limit","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"AskJiminnyReportActivityServiceTest","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'AskJiminnyReportActivityServiceTest'","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'AskJiminnyReportActivityServiceTest'","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"IDE and Project Settings","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.088194445,"height":0.027777778},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"19","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {\n\"headers\":{\n\"Date\":[\"Thu,07 May 2026 14:21:15 GMT\"],\n \"Content-Type\":[\"application/json;charset=utf-8\"],\n \"Transfer-Encoding\":[\"chunked\"],\n \"Connection\":[\"keep-alive\"],\n \"CF-Ray\":[\"9f80deb8db60dc3a-SOF\"],\n \"CF-Cache-Status\":[\"DYNAMIC\"],\n \"Strict-Transport-Security\":[\"max-age=31536000; includeSubDomains; preload\"],\n \"Vary\":[\"origin,\n accept-encoding\"],\n \"access-control-allow-credentials\":[\"false\"],\n \"server-timing\":[\"hcid;desc=\\\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\\\",\n cfr;desc=\\\"9f80deb8e7c6dc3a-IAD\\\"\"],\n \"x-content-type-options\":[\"nosniff\"],\n \"x-hubspot-correlation-id\":[\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\"],\n \"Set-Cookie\":[\"__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-1.0.1.1-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,\n 07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None\"],\n \"Report-To\":[\"{\n\\\"endpoints\\\":[{\n\\\"url\\\":\\\"https:\\\\/\\\\/a.nel.cloudflare.com\\\\/report\\\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\\\"}],\n\\\"group\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"NEL\":[\"{\n\\\"success_fraction\\\":0.01,\n\\\"report_to\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"Server\":[\"cloudflare\"]}} {\n\"correlation_id\":\"95236535-ec98-4541-b92a-adfa73b69eab\",\n\"trace_id\":\"c7ab8365-903f-46d4-9403-0e5b551e3545\"}","depth":4,"on_screen":true,"value":"[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {\n\"headers\":{\n\"Date\":[\"Thu,07 May 2026 14:21:15 GMT\"],\n \"Content-Type\":[\"application/json;charset=utf-8\"],\n \"Transfer-Encoding\":[\"chunked\"],\n \"Connection\":[\"keep-alive\"],\n \"CF-Ray\":[\"9f80deb8db60dc3a-SOF\"],\n \"CF-Cache-Status\":[\"DYNAMIC\"],\n \"Strict-Transport-Security\":[\"max-age=31536000; includeSubDomains; preload\"],\n \"Vary\":[\"origin,\n accept-encoding\"],\n \"access-control-allow-credentials\":[\"false\"],\n \"server-timing\":[\"hcid;desc=\\\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\\\",\n cfr;desc=\\\"9f80deb8e7c6dc3a-IAD\\\"\"],\n \"x-content-type-options\":[\"nosniff\"],\n \"x-hubspot-correlation-id\":[\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\"],\n \"Set-Cookie\":[\"__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-1.0.1.1-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,\n 07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None\"],\n \"Report-To\":[\"{\n\\\"endpoints\\\":[{\n\\\"url\\\":\\\"https:\\\\/\\\\/a.nel.cloudflare.com\\\\/report\\\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\\\"}],\n\\\"group\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"NEL\":[\"{\n\\\"success_fraction\\\":0.01,\n\\\"report_to\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"Server\":[\"cloudflare\"]}} {\n\"correlation_id\":\"95236535-ec98-4541-b92a-adfa73b69eab\",\n\"trace_id\":\"c7ab8365-903f-46d4-9403-0e5b551e3545\"}","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.088194445,"height":0.027777778},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"2","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\ndeclare(strict_types=1);\n\nnamespace Jiminny\\Services\\Crm\\Hubspot\\OpportunitySyncStrategy;\n\nuse Jiminny\\Exceptions\\Crm\\InvalidSyncParametersException;\nuse Jiminny\\Exceptions\\SocialAccountTokenInvalidException;\nuse Jiminny\\Models\\Crm\\Configuration;\nuse Jiminny\\Services\\Crm\\CrmConfigurationSettingsService;\nuse Jiminny\\Services\\Crm\\Hubspot\\Client;\nuse Jiminny\\Services\\Crm\\Hubspot\\ClosedDealStagesService;\nuse Jiminny\\Services\\Crm\\Hubspot\\DealFieldsService;\nuse Jiminny\\Services\\Crm\\Hubspot\\PayloadBuilder;\nuse Jiminny\\Services\\Crm\\OpportunitySyncStrategyInterface;\nuse Psr\\Log\\LoggerInterface;\nuse SevenShores\\Hubspot\\Exceptions\\BadRequest;\nuse SevenShores\\Hubspot\\Exceptions\\HubspotException;\n\nabstract class HubspotSyncStrategyBase implements OpportunitySyncStrategyInterface\n{\n protected Client $client;\n protected PayloadBuilder $payloadBuilder;\n\n public function __construct(Client $client)\n {\n $this->client = $client;\n $this->payloadBuilder = app(PayloadBuilder::class);\n }\n\n /**\n * @throws SocialAccountTokenInvalidException\n * @throws BadRequest\n * @throws HubspotException\n */\n public function fetchOpportunities(\n array $params,\n int &$total = 0,\n ?string &$lastRecordId = null\n ): \\Generator {\n try {\n $this->validateParameters($params);\n } catch (InvalidSyncParametersException $e) {\n $logger = app(LoggerInterface::class);\n $logger->error('Invalid sync parameters: ' . $e->getMessage());\n\n // Return empty generator for invalid parameters\n return;\n }\n\n $fields = $this->getOpportunityFields($params['config']);\n $payload = $this->buildQuery($params, $fields);\n $offset = $params['offset'] ?? 0;\n\n yield from $this->client->getPaginatedDataGenerator(\n $payload,\n 'deals',\n $offset,\n $total,\n $lastRecordId\n );\n }\n\n protected function getOpportunityFields(Configuration $config): array\n {\n return $this->getDealFieldsService()->getFieldsForConfiguration($config);\n }\n\n protected function buildQuery(array $params, array $fields): array\n {\n return [];\n }\n\n protected function getDealFieldsService(): DealFieldsService\n {\n return app(DealFieldsService::class);\n }\n\n protected function getCrmConfigurationSettingsService(): CrmConfigurationSettingsService\n {\n return app(CrmConfigurationSettingsService::class);\n }\n\n protected function getClosedDealStagesService(): ClosedDealStagesService\n {\n return app(ClosedDealStagesService::class);\n }\n}","depth":4,"on_screen":true,"value":"<?php\n\ndeclare(strict_types=1);\n\nnamespace Jiminny\\Services\\Crm\\Hubspot\\OpportunitySyncStrategy;\n\nuse Jiminny\\Exceptions\\Crm\\InvalidSyncParametersException;\nuse Jiminny\\Exceptions\\SocialAccountTokenInvalidException;\nuse Jiminny\\Models\\Crm\\Configuration;\nuse Jiminny\\Services\\Crm\\CrmConfigurationSettingsService;\nuse Jiminny\\Services\\Crm\\Hubspot\\Client;\nuse Jiminny\\Services\\Crm\\Hubspot\\ClosedDealStagesService;\nuse Jiminny\\Services\\Crm\\Hubspot\\DealFieldsService;\nuse Jiminny\\Services\\Crm\\Hubspot\\PayloadBuilder;\nuse Jiminny\\Services\\Crm\\OpportunitySyncStrategyInterface;\nuse Psr\\Log\\LoggerInterface;\nuse SevenShores\\Hubspot\\Exceptions\\BadRequest;\nuse SevenShores\\Hubspot\\Exceptions\\HubspotException;\n\nabstract class HubspotSyncStrategyBase implements OpportunitySyncStrategyInterface\n{\n protected Client $client;\n protected PayloadBuilder $payloadBuilder;\n\n public function __construct(Client $client)\n {\n $this->client = $client;\n $this->payloadBuilder = app(PayloadBuilder::class);\n }\n\n /**\n * @throws SocialAccountTokenInvalidException\n * @throws BadRequest\n * @throws HubspotException\n */\n public function fetchOpportunities(\n array $params,\n int &$total = 0,\n ?string &$lastRecordId = null\n ): \\Generator {\n try {\n $this->validateParameters($params);\n } catch (InvalidSyncParametersException $e) {\n $logger = app(LoggerInterface::class);\n $logger->error('Invalid sync parameters: ' . $e->getMessage());\n\n // Return empty generator for invalid parameters\n return;\n }\n\n $fields = $this->getOpportunityFields($params['config']);\n $payload = $this->buildQuery($params, $fields);\n $offset = $params['offset'] ?? 0;\n\n yield from $this->client->getPaginatedDataGenerator(\n $payload,\n 'deals',\n $offset,\n $total,\n $lastRecordId\n );\n }\n\n protected function getOpportunityFields(Configuration $config): array\n {\n return $this->getDealFieldsService()->getFieldsForConfiguration($config);\n }\n\n protected function buildQuery(array $params, array $fields): array\n {\n return [];\n }\n\n protected function getDealFieldsService(): DealFieldsService\n {\n return app(DealFieldsService::class);\n }\n\n protected function getCrmConfigurationSettingsService(): CrmConfigurationSettingsService\n {\n return app(CrmConfigurationSettingsService::class);\n }\n\n protected function getClosedDealStagesService(): ClosedDealStagesService\n {\n return app(ClosedDealStagesService::class);\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":true,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Project","depth":3,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Project","depth":3,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"New File or Directory…","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Expand Selected","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Collapse All","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Options","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"app ~/jiminny/app, folder","depth":6,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":".circleci, folder","depth":7,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":".cursor, folder","depth":7,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":".github","depth":7,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":".sonarlint, folder","depth":7,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":".vscode, folder","depth":7,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":".windsurf, folder","depth":7,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"app, sources root","depth":7,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Actions, folder","depth":8,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Component, folder","depth":8,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Acl, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"ActionItems, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Activity, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"ActivityAnalytics, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"ActivitySearch, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"AiActivityType, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"AiAutomation, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"AiCallScoring, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"AskAnything, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Dtos, folder","depth":10,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Events, folder","depth":10,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"AskAnythingPromptService.php, class","depth":10,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"HistoryService.php, class","depth":10,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"AskJiminnyAi, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"AWS, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"BillingManagement, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Cache, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"CoachingFeedback, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Country, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"CustomerApi, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Database, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Datadog, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"DateTime, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"DealInsights, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"DealRisks, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"ElasticSearch, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Eloquent, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Encoding, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Encryption, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"ES, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Faker, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"FeatureFlags, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"FFMpeg, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"FileSystem, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Gecko, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Gong, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"GuzzleHttp, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"KeyPoints, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Kiosk, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"LanguageDetection, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"LiveFeed, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Locks, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Math, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"MediaPipeline, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"MeetingBot, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"MobileSettings, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Model, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Notification, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Nudge, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"ParagraphBreaker, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"ParticipantSpeech, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"PartitionedCookie, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"PlaybackPage, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Playlist, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Prophet, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"ProphetAi, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"ProsperWorks, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Queue, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Router, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Saml2, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"SCIM, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Seeder, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Sentry, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Serializer, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Settings, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Sidekick, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Slack, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"TeamInsights, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"TimeMemoryMapper, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Transcription, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"TranscriptionSummary, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Twilio, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Uploader, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"UrlGenerator, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Utility, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Exceptions, folder","depth":10,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Service, folder","depth":10,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"BaseRateLimiter.php, class","depth":11,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"EfficientJsonParser.php, class","depth":11,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"ProviderRateLimiter.php, class","depth":11,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"RateLimiterInstance.php, class","depth":11,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Uuid","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Waveform","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Webhooks","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Workflow","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Configuration","depth":8,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Console","depth":8,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Commands","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Activities","depth":10,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Analytics","depth":10,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Calendars","depth":10,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Crm","depth":10,"on_screen":false,"role_description":"text"}]...
|
-7993828380324121508
|
-3610448465692067380
|
click
|
accessibility
|
NULL
|
Project: faVsco.js, menu
JY-20725-handle-HS-search Project: faVsco.js, menu
JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
19
Previous Highlighted Error
Next Highlighted Error
[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {
"headers":{
"Date":["Thu,07 May 2026 14:21:15 GMT"],
"Content-Type":["application/json;charset=utf-8"],
"Transfer-Encoding":["chunked"],
"Connection":["keep-alive"],
"CF-Ray":["9f80deb8db60dc3a-SOF"],
"CF-Cache-Status":["DYNAMIC"],
"Strict-Transport-Security":["max-age=31536000; includeSubDomains; preload"],
"Vary":["origin,
accept-encoding"],
"access-control-allow-credentials":["false"],
"server-timing":["hcid;desc=\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\",
cfr;desc=\"9f80deb8e7c6dc3a-IAD\""],
"x-content-type-options":["nosniff"],
"x-hubspot-correlation-id":["019e02d0-6fd8-7812-bdba-885b7ccb3ee3"],
"Set-Cookie":["__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-[IP_ADDRESS]-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,
07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None"],
"Report-To":["{
\"endpoints\":[{
\"url\":\"https:\\/\\/a.nel.cloudflare.com\\/report\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\"}],
\"group\":\"cf-nel\",
\"max_age\":604800}"],
"NEL":["{
\"success_fraction\":0.01,
\"report_to\":\"cf-nel\",
\"max_age\":604800}"],
"Server":["cloudflare"]}} {
"correlation_id":"95236535-ec98-4541-b92a-adfa73b69eab",
"trace_id":"c7ab8365-903f-46d4-9403-0e5b551e3545"}
Code changed:
Hide
Sync Changes
Hide This Notification
2
Previous Highlighted Error
Next Highlighted Error
<?php
declare(strict_types=1);
namespace Jiminny\Services\Crm\Hubspot\OpportunitySyncStrategy;
use Jiminny\Exceptions\Crm\InvalidSyncParametersException;
use Jiminny\Exceptions\SocialAccountTokenInvalidException;
use Jiminny\Models\Crm\Configuration;
use Jiminny\Services\Crm\CrmConfigurationSettingsService;
use Jiminny\Services\Crm\Hubspot\Client;
use Jiminny\Services\Crm\Hubspot\ClosedDealStagesService;
use Jiminny\Services\Crm\Hubspot\DealFieldsService;
use Jiminny\Services\Crm\Hubspot\PayloadBuilder;
use Jiminny\Services\Crm\OpportunitySyncStrategyInterface;
use Psr\Log\LoggerInterface;
use SevenShores\Hubspot\Exceptions\BadRequest;
use SevenShores\Hubspot\Exceptions\HubspotException;
abstract class HubspotSyncStrategyBase implements OpportunitySyncStrategyInterface
{
protected Client $client;
protected PayloadBuilder $payloadBuilder;
public function __construct(Client $client)
{
$this->client = $client;
$this->payloadBuilder = app(PayloadBuilder::class);
}
/**
* @throws SocialAccountTokenInvalidException
* @throws BadRequest
* @throws HubspotException
*/
public function fetchOpportunities(
array $params,
int &$total = 0,
?string &$lastRecordId = null
): \Generator {
try {
$this->validateParameters($params);
} catch (InvalidSyncParametersException $e) {
$logger = app(LoggerInterface::class);
$logger->error('Invalid sync parameters: ' . $e->getMessage());
// Return empty generator for invalid parameters
return;
}
$fields = $this->getOpportunityFields($params['config']);
$payload = $this->buildQuery($params, $fields);
$offset = $params['offset'] ?? 0;
yield from $this->client->getPaginatedDataGenerator(
$payload,
'deals',
$offset,
$total,
$lastRecordId
);
}
protected function getOpportunityFields(Configuration $config): array
{
return $this->getDealFieldsService()->getFieldsForConfiguration($config);
}
protected function buildQuery(array $params, array $fields): array
{
return [];
}
protected function getDealFieldsService(): DealFieldsService
{
return app(DealFieldsService::class);
}
protected function getCrmConfigurationSettingsService(): CrmConfigurationSettingsService
{
return app(CrmConfigurationSettingsService::class);
}
protected function getClosedDealStagesService(): ClosedDealStagesService
{
return app(ClosedDealStagesService::class);
}
}
Project
Project
New File or Directory…
Expand Selected
Collapse All
Options
Hide
app ~/jiminny/app, folder
.circleci, folder
.cursor, folder
.github
.sonarlint, folder
.vscode, folder
.windsurf, folder
app, sources root
Actions, folder
Component, folder
Acl, folder
ActionItems, folder
Activity, folder
ActivityAnalytics, folder
ActivitySearch, folder
AiActivityType, folder
AiAutomation, folder
AiCallScoring, folder
AskAnything, folder
Dtos, folder
Events, folder
AskAnythingPromptService.php, class
HistoryService.php, class
AskJiminnyAi, folder
AWS, folder
BillingManagement, folder
Cache, folder
CoachingFeedback, folder
Country, folder
CustomerApi, folder
Database, folder
Datadog, folder
DateTime, folder
DealInsights, folder
DealRisks, folder
ElasticSearch, folder
Eloquent, folder
Encoding, folder
Encryption, folder
ES, folder
Faker, folder
FeatureFlags, folder
FFMpeg, folder
FileSystem, folder
Gecko, folder
Gong, folder
GuzzleHttp, folder
KeyPoints, folder
Kiosk, folder
LanguageDetection, folder
LiveFeed, folder
Locks, folder
Math, folder
MediaPipeline, folder
MeetingBot, folder
MobileSettings, folder
Model, folder
Notification, folder
Nudge, folder
ParagraphBreaker, folder
ParticipantSpeech, folder
PartitionedCookie, folder
PlaybackPage, folder
Playlist, folder
Prophet, folder
ProphetAi, folder
ProsperWorks, folder
Queue, folder
Router, folder
Saml2, folder
SCIM, folder
Seeder, folder
Sentry, folder
Serializer, folder
Settings, folder
Sidekick, folder
Slack, folder
TeamInsights, folder
TimeMemoryMapper, folder
Transcription, folder
TranscriptionSummary, folder
Twilio, folder
Uploader, folder
UrlGenerator, folder
Utility, folder
Exceptions, folder
Service, folder
BaseRateLimiter.php, class
EfficientJsonParser.php, class
ProviderRateLimiter.php, class
RateLimiterInstance.php, class
Uuid
Waveform
Webhooks
Workflow
Configuration
Console
Commands
Activities
Analytics
Calendars
Crm...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
9844
|
444
|
28
|
2026-05-08T13:37:36.884238+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-08/1778 /Users/lukas/.screenpipe/data/data/2026-05-08/1778247456884_m2.jpg...
|
PhpStorm
|
faVsco.js – HubspotSyncStrategyBase.php
|
1
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
JY-20725-handle-HS-search Project: faVsco.js, menu
JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
19
Previous Highlighted Error
Next Highlighted Error
[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {
"headers":{
"Date":["Thu,07 May 2026 14:21:15 GMT"],
"Content-Type":["application/json;charset=utf-8"],
"Transfer-Encoding":["chunked"],
"Connection":["keep-alive"],
"CF-Ray":["9f80deb8db60dc3a-SOF"],
"CF-Cache-Status":["DYNAMIC"],
"Strict-Transport-Security":["max-age=31536000; includeSubDomains; preload"],
"Vary":["origin,
accept-encoding"],
"access-control-allow-credentials":["false"],
"server-timing":["hcid;desc=\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\",
cfr;desc=\"9f80deb8e7c6dc3a-IAD\""],
"x-content-type-options":["nosniff"],
"x-hubspot-correlation-id":["019e02d0-6fd8-7812-bdba-885b7ccb3ee3"],
"Set-Cookie":["__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-[IP_ADDRESS]-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,
07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None"],
"Report-To":["{
\"endpoints\":[{
\"url\":\"https:\\/\\/a.nel.cloudflare.com\\/report\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\"}],
\"group\":\"cf-nel\",
\"max_age\":604800}"],
"NEL":["{
\"success_fraction\":0.01,
\"report_to\":\"cf-nel\",
\"max_age\":604800}"],
"Server":["cloudflare"]}} {
"correlation_id":"95236535-ec98-4541-b92a-adfa73b69eab",
"trace_id":"c7ab8365-903f-46d4-9403-0e5b551e3545"}
Code changed:
Hide...
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"bounds":{"left":0.025930852,"top":0.019952115,"width":0.03856383,"height":0.025538707},"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JY-20725-handle-HS-search-rate-limit, menu","depth":5,"bounds":{"left":0.064494684,"top":0.019952115,"width":0.09541223,"height":0.025538707},"on_screen":true,"help_text":"Git Branch: JY-20725-handle-HS-search-rate-limit","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"bounds":{"left":0.8081782,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"AskJiminnyReportActivityServiceTest","depth":6,"bounds":{"left":0.8234708,"top":0.019952115,"width":0.09208777,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'AskJiminnyReportActivityServiceTest'","depth":6,"bounds":{"left":0.9155585,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'AskJiminnyReportActivityServiceTest'","depth":6,"bounds":{"left":0.9268617,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"bounds":{"left":0.9381649,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"bounds":{"left":0.96609044,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"bounds":{"left":0.9773936,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"IDE and Project Settings","depth":5,"bounds":{"left":0.9886968,"top":0.019952115,"width":0.011303186,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.042220745,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"19","depth":4,"bounds":{"left":0.6615692,"top":0.10055866,"width":0.009640957,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"bounds":{"left":0.67287236,"top":0.09896249,"width":0.00731383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"bounds":{"left":0.68018615,"top":0.09896249,"width":0.006981383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {\n\"headers\":{\n\"Date\":[\"Thu,07 May 2026 14:21:15 GMT\"],\n \"Content-Type\":[\"application/json;charset=utf-8\"],\n \"Transfer-Encoding\":[\"chunked\"],\n \"Connection\":[\"keep-alive\"],\n \"CF-Ray\":[\"9f80deb8db60dc3a-SOF\"],\n \"CF-Cache-Status\":[\"DYNAMIC\"],\n \"Strict-Transport-Security\":[\"max-age=31536000; includeSubDomains; preload\"],\n \"Vary\":[\"origin,\n accept-encoding\"],\n \"access-control-allow-credentials\":[\"false\"],\n \"server-timing\":[\"hcid;desc=\\\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\\\",\n cfr;desc=\\\"9f80deb8e7c6dc3a-IAD\\\"\"],\n \"x-content-type-options\":[\"nosniff\"],\n \"x-hubspot-correlation-id\":[\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\"],\n \"Set-Cookie\":[\"__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-1.0.1.1-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,\n 07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None\"],\n \"Report-To\":[\"{\n\\\"endpoints\\\":[{\n\\\"url\\\":\\\"https:\\\\/\\\\/a.nel.cloudflare.com\\\\/report\\\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\\\"}],\n\\\"group\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"NEL\":[\"{\n\\\"success_fraction\\\":0.01,\n\\\"report_to\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"Server\":[\"cloudflare\"]}} {\n\"correlation_id\":\"95236535-ec98-4541-b92a-adfa73b69eab\",\n\"trace_id\":\"c7ab8365-903f-46d4-9403-0e5b551e3545\"}","depth":4,"bounds":{"left":0.37632978,"top":0.09736632,"width":0.5728058,"height":0.8818835},"on_screen":true,"lines":[{"char_start":207,"char_count":30,"bounds":{"left":0.37632978,"top":0.0,"width":0.07513298,"height":0.014365523}},{"char_start":237,"char_count":36,"bounds":{"left":0.37632978,"top":0.0,"width":0.09075798,"height":0.014365523}},{"char_start":273,"char_count":32,"bounds":{"left":0.37632978,"top":0.0,"width":0.080119684,"height":0.014365523}},{"char_start":305,"char_count":79,"bounds":{"left":0.37632978,"top":0.0,"width":0.20212767,"height":0.014365523}},{"char_start":384,"char_count":18,"bounds":{"left":0.37632978,"top":0.0,"width":0.043882977,"height":0.014365523}},{"char_start":402,"char_count":21,"bounds":{"left":0.37632978,"top":0.0,"width":0.051861703,"height":0.014365523}},{"char_start":423,"char_count":48,"bounds":{"left":0.37632978,"top":0.008778931,"width":0.12167553,"height":0.014365523}},{"char_start":471,"char_count":72,"bounds":{"left":0.37632978,"top":0.026336791,"width":0.18384309,"height":0.014365523}},{"char_start":543,"char_count":40,"bounds":{"left":0.37632978,"top":0.043894652,"width":0.10106383,"height":0.014365523}},{"char_start":583,"char_count":41,"bounds":{"left":0.37632978,"top":0.061452515,"width":0.10372341,"height":0.014365523}},{"char_start":624,"char_count":72,"bounds":{"left":0.37632978,"top":0.079010375,"width":0.18384309,"height":0.014365523}},{"char_start":696,"char_count":219,"bounds":{"left":0.37632978,"top":0.096568234,"width":0.56515956,"height":0.014365523}},{"char_start":915,"char_count":83,"bounds":{"left":0.37632978,"top":0.11412609,"width":0.21243352,"height":0.014365523}},{"char_start":998,"char_count":20,"bounds":{"left":0.37632978,"top":0.13168396,"width":0.04920213,"height":0.014365523}},{"char_start":1018,"char_count":17,"bounds":{"left":0.37632978,"top":0.14924182,"width":0.041223403,"height":0.014365523}},{"char_start":1035,"char_count":203,"bounds":{"left":0.37632978,"top":0.16679968,"width":0.52360374,"height":0.014365523}},{"char_start":1238,"char_count":22,"bounds":{"left":0.37632978,"top":0.18435754,"width":0.05418883,"height":0.014365523}},{"char_start":1260,"char_count":23,"bounds":{"left":0.37632978,"top":0.2019154,"width":0.056848403,"height":0.014365523}},{"char_start":1283,"char_count":10,"bounds":{"left":0.37632978,"top":0.21947326,"width":0.023271276,"height":0.014365523}},{"char_start":1293,"char_count":27,"bounds":{"left":0.37632978,"top":0.23703113,"width":0.06715426,"height":0.014365523}},{"char_start":1320,"char_count":26,"bounds":{"left":0.37632978,"top":0.254589,"width":0.06482713,"height":0.014365523}},{"char_start":1346,"char_count":23,"bounds":{"left":0.37632978,"top":0.27214685,"width":0.056848403,"height":0.014365523}},{"char_start":1369,"char_count":28,"bounds":{"left":0.37632978,"top":0.2897047,"width":0.06981383,"height":0.014365523}},{"char_start":1397,"char_count":57,"bounds":{"left":0.37632978,"top":0.30726257,"width":0.14494681,"height":0.014365523}}],"value":"[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {\n\"headers\":{\n\"Date\":[\"Thu,07 May 2026 14:21:15 GMT\"],\n \"Content-Type\":[\"application/json;charset=utf-8\"],\n \"Transfer-Encoding\":[\"chunked\"],\n \"Connection\":[\"keep-alive\"],\n \"CF-Ray\":[\"9f80deb8db60dc3a-SOF\"],\n \"CF-Cache-Status\":[\"DYNAMIC\"],\n \"Strict-Transport-Security\":[\"max-age=31536000; includeSubDomains; preload\"],\n \"Vary\":[\"origin,\n accept-encoding\"],\n \"access-control-allow-credentials\":[\"false\"],\n \"server-timing\":[\"hcid;desc=\\\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\\\",\n cfr;desc=\\\"9f80deb8e7c6dc3a-IAD\\\"\"],\n \"x-content-type-options\":[\"nosniff\"],\n \"x-hubspot-correlation-id\":[\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\"],\n \"Set-Cookie\":[\"__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-1.0.1.1-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,\n 07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None\"],\n \"Report-To\":[\"{\n\\\"endpoints\\\":[{\n\\\"url\\\":\\\"https:\\\\/\\\\/a.nel.cloudflare.com\\\\/report\\\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\\\"}],\n\\\"group\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"NEL\":[\"{\n\\\"success_fraction\\\":0.01,\n\\\"report_to\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"Server\":[\"cloudflare\"]}} {\n\"correlation_id\":\"95236535-ec98-4541-b92a-adfa73b69eab\",\n\"trace_id\":\"c7ab8365-903f-46d4-9403-0e5b551e3545\"}","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.042220745,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
6441614775373765850
|
-3048904861664425526
|
click
|
accessibility
|
NULL
|
Project: faVsco.js, menu
JY-20725-handle-HS-search Project: faVsco.js, menu
JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
19
Previous Highlighted Error
Next Highlighted Error
[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {
"headers":{
"Date":["Thu,07 May 2026 14:21:15 GMT"],
"Content-Type":["application/json;charset=utf-8"],
"Transfer-Encoding":["chunked"],
"Connection":["keep-alive"],
"CF-Ray":["9f80deb8db60dc3a-SOF"],
"CF-Cache-Status":["DYNAMIC"],
"Strict-Transport-Security":["max-age=31536000; includeSubDomains; preload"],
"Vary":["origin,
accept-encoding"],
"access-control-allow-credentials":["false"],
"server-timing":["hcid;desc=\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\",
cfr;desc=\"9f80deb8e7c6dc3a-IAD\""],
"x-content-type-options":["nosniff"],
"x-hubspot-correlation-id":["019e02d0-6fd8-7812-bdba-885b7ccb3ee3"],
"Set-Cookie":["__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-[IP_ADDRESS]-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,
07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None"],
"Report-To":["{
\"endpoints\":[{
\"url\":\"https:\\/\\/a.nel.cloudflare.com\\/report\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\"}],
\"group\":\"cf-nel\",
\"max_age\":604800}"],
"NEL":["{
\"success_fraction\":0.01,
\"report_to\":\"cf-nel\",
\"max_age\":604800}"],
"Server":["cloudflare"]}} {
"correlation_id":"95236535-ec98-4541-b92a-adfa73b69eab",
"trace_id":"c7ab8365-903f-46d4-9403-0e5b551e3545"}
Code changed:
Hide...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
9843
|
443
|
22
|
2026-05-08T13:37:36.800360+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-08/1778 /Users/lukas/.screenpipe/data/data/2026-05-08/1778247456800_m1.jpg...
|
PhpStorm
|
faVsco.js – HubspotSyncStrategyBase.php
|
1
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
JY-20725-handle-HS-search Project: faVsco.js, menu
JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
19
Previous Highlighted Error
Next Highlighted Error
[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {
"headers":{
"Date":["Thu,07 May 2026 14:21:15 GMT"],
"Content-Type":["application/json;charset=utf-8"],
"Transfer-Encoding":["chunked"],
"Connection":["keep-alive"],
"CF-Ray":["9f80deb8db60dc3a-SOF"],
"CF-Cache-Status":["DYNAMIC"],
"Strict-Transport-Security":["max-age=31536000; includeSubDomains; preload"],
"Vary":["origin,
accept-encoding"],
"access-control-allow-credentials":["false"],
"server-timing":["hcid;desc=\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\",
cfr;desc=\"9f80deb8e7c6dc3a-IAD\""],
"x-content-type-options":["nosniff"],
"x-hubspot-correlation-id":["019e02d0-6fd8-7812-bdba-885b7ccb3ee3"],
"Set-Cookie":["__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-[IP_ADDRESS]-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,
07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None"],
"Report-To":["{
\"endpoints\":[{
\"url\":\"https:\\/\\/a.nel.cloudflare.com\\/report\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\"}],
\"group\":\"cf-nel\",
\"max_age\":604800}"],
"NEL":["{
\"success_fraction\":0.01,
\"report_to\":\"cf-nel\",
\"max_age\":604800}"],
"Server":["cloudflare"]}} {
"correlation_id":"95236535-ec98-4541-b92a-adfa73b69eab",
"trace_id":"c7ab8365-903f-46d4-9403-0e5b551e3545"}
Code changed:
Hide
Sync Changes
Hide This Notification
2
Previous Highlighted Error
Next Highlighted Error
<?php
declare(strict_types=1);
namespace Jiminny\Services\Crm\Hubspot\OpportunitySyncStrategy;
use Jiminny\Exceptions\Crm\InvalidSyncParametersException;
use Jiminny\Exceptions\SocialAccountTokenInvalidException;
use Jiminny\Models\Crm\Configuration;
use Jiminny\Services\Crm\CrmConfigurationSettingsService;
use Jiminny\Services\Crm\Hubspot\Client;
use Jiminny\Services\Crm\Hubspot\ClosedDealStagesService;
use Jiminny\Services\Crm\Hubspot\DealFieldsService;
use Jiminny\Services\Crm\Hubspot\PayloadBuilder;
use Jiminny\Services\Crm\OpportunitySyncStrategyInterface;
use Psr\Log\LoggerInterface;
use SevenShores\Hubspot\Exceptions\BadRequest;
use SevenShores\Hubspot\Exceptions\HubspotException;
abstract class HubspotSyncStrategyBase implements OpportunitySyncStrategyInterface
{
protected Client $client;
protected PayloadBuilder $payloadBuilder;
public function __construct(Client $client)
{
$this->client = $client;
$this->payloadBuilder = app(PayloadBuilder::class);
}
/**
* @throws SocialAccountTokenInvalidException
* @throws BadRequest
* @throws HubspotException
*/
public function fetchOpportunities(
array $params,
int &$total = 0,
?string &$lastRecordId = null
): \Generator {
try {
$this->validateParameters($params);
} catch (InvalidSyncParametersException $e) {
$logger = app(LoggerInterface::class);
$logger->error('Invalid sync parameters: ' . $e->getMessage());
// Return empty generator for invalid parameters
return;
}
$fields = $this->getOpportunityFields($params['config']);
$payload = $this->buildQuery($params, $fields);
$offset = $params['offset'] ?? 0;
yield from $this->client->getPaginatedDataGenerator(
$payload,
'deals',
$offset,
$total,
$lastRecordId
);
}
protected function getOpportunityFields(Configuration $config): array
{
return $this->getDealFieldsService()->getFieldsForConfiguration($config);
}
protected function buildQuery(array $params, array $fields): array
{
return [];
}
protected function getDealFieldsService(): DealFieldsService
{
return app(DealFieldsService::class);
}
protected function getCrmConfigurationSettingsService(): CrmConfigurationSettingsService
{
return app(CrmConfigurationSettingsService::class);
}
protected function getClosedDealStagesService(): ClosedDealStagesService
{
return app(ClosedDealStagesService::class);
}
}
Project
Project
New File or Directory…
Expand Selected
Collapse All
Options
Hide
app ~/jiminny/app, folder
.circleci, folder
.cursor, folder
.github
.sonarlint, folder
.vscode, folder
.windsurf, folder
app, sources root
Actions, folder
Component, folder
Acl, folder
ActionItems, folder
Activity, folder
ActivityAnalytics, folder
ActivitySearch, folder
AiActivityType, folder
AiAutomation, folder
AiCallScoring, folder
AskAnything, folder
Dtos, folder
Events, folder
AskAnythingPromptService.php, class
HistoryService.php, class
AskJiminnyAi, folder
AWS, folder
BillingManagement, folder
Cache, folder
CoachingFeedback, folder
Country, folder
CustomerApi, folder
Database, folder
Datadog, folder
DateTime, folder
DealInsights, folder
DealRisks, folder
ElasticSearch, folder
Eloquent, folder
Encoding, folder
Encryption, folder...
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JY-20725-handle-HS-search-rate-limit, menu","depth":5,"on_screen":true,"help_text":"Git Branch: JY-20725-handle-HS-search-rate-limit","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"AskJiminnyReportActivityServiceTest","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'AskJiminnyReportActivityServiceTest'","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'AskJiminnyReportActivityServiceTest'","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"IDE and Project Settings","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.088194445,"height":0.027777778},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"19","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {\n\"headers\":{\n\"Date\":[\"Thu,07 May 2026 14:21:15 GMT\"],\n \"Content-Type\":[\"application/json;charset=utf-8\"],\n \"Transfer-Encoding\":[\"chunked\"],\n \"Connection\":[\"keep-alive\"],\n \"CF-Ray\":[\"9f80deb8db60dc3a-SOF\"],\n \"CF-Cache-Status\":[\"DYNAMIC\"],\n \"Strict-Transport-Security\":[\"max-age=31536000; includeSubDomains; preload\"],\n \"Vary\":[\"origin,\n accept-encoding\"],\n \"access-control-allow-credentials\":[\"false\"],\n \"server-timing\":[\"hcid;desc=\\\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\\\",\n cfr;desc=\\\"9f80deb8e7c6dc3a-IAD\\\"\"],\n \"x-content-type-options\":[\"nosniff\"],\n \"x-hubspot-correlation-id\":[\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\"],\n \"Set-Cookie\":[\"__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-1.0.1.1-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,\n 07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None\"],\n \"Report-To\":[\"{\n\\\"endpoints\\\":[{\n\\\"url\\\":\\\"https:\\\\/\\\\/a.nel.cloudflare.com\\\\/report\\\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\\\"}],\n\\\"group\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"NEL\":[\"{\n\\\"success_fraction\\\":0.01,\n\\\"report_to\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"Server\":[\"cloudflare\"]}} {\n\"correlation_id\":\"95236535-ec98-4541-b92a-adfa73b69eab\",\n\"trace_id\":\"c7ab8365-903f-46d4-9403-0e5b551e3545\"}","depth":4,"on_screen":true,"value":"[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {\n\"headers\":{\n\"Date\":[\"Thu,07 May 2026 14:21:15 GMT\"],\n \"Content-Type\":[\"application/json;charset=utf-8\"],\n \"Transfer-Encoding\":[\"chunked\"],\n \"Connection\":[\"keep-alive\"],\n \"CF-Ray\":[\"9f80deb8db60dc3a-SOF\"],\n \"CF-Cache-Status\":[\"DYNAMIC\"],\n \"Strict-Transport-Security\":[\"max-age=31536000; includeSubDomains; preload\"],\n \"Vary\":[\"origin,\n accept-encoding\"],\n \"access-control-allow-credentials\":[\"false\"],\n \"server-timing\":[\"hcid;desc=\\\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\\\",\n cfr;desc=\\\"9f80deb8e7c6dc3a-IAD\\\"\"],\n \"x-content-type-options\":[\"nosniff\"],\n \"x-hubspot-correlation-id\":[\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\"],\n \"Set-Cookie\":[\"__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-1.0.1.1-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,\n 07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None\"],\n \"Report-To\":[\"{\n\\\"endpoints\\\":[{\n\\\"url\\\":\\\"https:\\\\/\\\\/a.nel.cloudflare.com\\\\/report\\\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\\\"}],\n\\\"group\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"NEL\":[\"{\n\\\"success_fraction\\\":0.01,\n\\\"report_to\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"Server\":[\"cloudflare\"]}} {\n\"correlation_id\":\"95236535-ec98-4541-b92a-adfa73b69eab\",\n\"trace_id\":\"c7ab8365-903f-46d4-9403-0e5b551e3545\"}","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.088194445,"height":0.027777778},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"2","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\ndeclare(strict_types=1);\n\nnamespace Jiminny\\Services\\Crm\\Hubspot\\OpportunitySyncStrategy;\n\nuse Jiminny\\Exceptions\\Crm\\InvalidSyncParametersException;\nuse Jiminny\\Exceptions\\SocialAccountTokenInvalidException;\nuse Jiminny\\Models\\Crm\\Configuration;\nuse Jiminny\\Services\\Crm\\CrmConfigurationSettingsService;\nuse Jiminny\\Services\\Crm\\Hubspot\\Client;\nuse Jiminny\\Services\\Crm\\Hubspot\\ClosedDealStagesService;\nuse Jiminny\\Services\\Crm\\Hubspot\\DealFieldsService;\nuse Jiminny\\Services\\Crm\\Hubspot\\PayloadBuilder;\nuse Jiminny\\Services\\Crm\\OpportunitySyncStrategyInterface;\nuse Psr\\Log\\LoggerInterface;\nuse SevenShores\\Hubspot\\Exceptions\\BadRequest;\nuse SevenShores\\Hubspot\\Exceptions\\HubspotException;\n\nabstract class HubspotSyncStrategyBase implements OpportunitySyncStrategyInterface\n{\n protected Client $client;\n protected PayloadBuilder $payloadBuilder;\n\n public function __construct(Client $client)\n {\n $this->client = $client;\n $this->payloadBuilder = app(PayloadBuilder::class);\n }\n\n /**\n * @throws SocialAccountTokenInvalidException\n * @throws BadRequest\n * @throws HubspotException\n */\n public function fetchOpportunities(\n array $params,\n int &$total = 0,\n ?string &$lastRecordId = null\n ): \\Generator {\n try {\n $this->validateParameters($params);\n } catch (InvalidSyncParametersException $e) {\n $logger = app(LoggerInterface::class);\n $logger->error('Invalid sync parameters: ' . $e->getMessage());\n\n // Return empty generator for invalid parameters\n return;\n }\n\n $fields = $this->getOpportunityFields($params['config']);\n $payload = $this->buildQuery($params, $fields);\n $offset = $params['offset'] ?? 0;\n\n yield from $this->client->getPaginatedDataGenerator(\n $payload,\n 'deals',\n $offset,\n $total,\n $lastRecordId\n );\n }\n\n protected function getOpportunityFields(Configuration $config): array\n {\n return $this->getDealFieldsService()->getFieldsForConfiguration($config);\n }\n\n protected function buildQuery(array $params, array $fields): array\n {\n return [];\n }\n\n protected function getDealFieldsService(): DealFieldsService\n {\n return app(DealFieldsService::class);\n }\n\n protected function getCrmConfigurationSettingsService(): CrmConfigurationSettingsService\n {\n return app(CrmConfigurationSettingsService::class);\n }\n\n protected function getClosedDealStagesService(): ClosedDealStagesService\n {\n return app(ClosedDealStagesService::class);\n }\n}","depth":4,"on_screen":true,"value":"<?php\n\ndeclare(strict_types=1);\n\nnamespace Jiminny\\Services\\Crm\\Hubspot\\OpportunitySyncStrategy;\n\nuse Jiminny\\Exceptions\\Crm\\InvalidSyncParametersException;\nuse Jiminny\\Exceptions\\SocialAccountTokenInvalidException;\nuse Jiminny\\Models\\Crm\\Configuration;\nuse Jiminny\\Services\\Crm\\CrmConfigurationSettingsService;\nuse Jiminny\\Services\\Crm\\Hubspot\\Client;\nuse Jiminny\\Services\\Crm\\Hubspot\\ClosedDealStagesService;\nuse Jiminny\\Services\\Crm\\Hubspot\\DealFieldsService;\nuse Jiminny\\Services\\Crm\\Hubspot\\PayloadBuilder;\nuse Jiminny\\Services\\Crm\\OpportunitySyncStrategyInterface;\nuse Psr\\Log\\LoggerInterface;\nuse SevenShores\\Hubspot\\Exceptions\\BadRequest;\nuse SevenShores\\Hubspot\\Exceptions\\HubspotException;\n\nabstract class HubspotSyncStrategyBase implements OpportunitySyncStrategyInterface\n{\n protected Client $client;\n protected PayloadBuilder $payloadBuilder;\n\n public function __construct(Client $client)\n {\n $this->client = $client;\n $this->payloadBuilder = app(PayloadBuilder::class);\n }\n\n /**\n * @throws SocialAccountTokenInvalidException\n * @throws BadRequest\n * @throws HubspotException\n */\n public function fetchOpportunities(\n array $params,\n int &$total = 0,\n ?string &$lastRecordId = null\n ): \\Generator {\n try {\n $this->validateParameters($params);\n } catch (InvalidSyncParametersException $e) {\n $logger = app(LoggerInterface::class);\n $logger->error('Invalid sync parameters: ' . $e->getMessage());\n\n // Return empty generator for invalid parameters\n return;\n }\n\n $fields = $this->getOpportunityFields($params['config']);\n $payload = $this->buildQuery($params, $fields);\n $offset = $params['offset'] ?? 0;\n\n yield from $this->client->getPaginatedDataGenerator(\n $payload,\n 'deals',\n $offset,\n $total,\n $lastRecordId\n );\n }\n\n protected function getOpportunityFields(Configuration $config): array\n {\n return $this->getDealFieldsService()->getFieldsForConfiguration($config);\n }\n\n protected function buildQuery(array $params, array $fields): array\n {\n return [];\n }\n\n protected function getDealFieldsService(): DealFieldsService\n {\n return app(DealFieldsService::class);\n }\n\n protected function getCrmConfigurationSettingsService(): CrmConfigurationSettingsService\n {\n return app(CrmConfigurationSettingsService::class);\n }\n\n protected function getClosedDealStagesService(): ClosedDealStagesService\n {\n return app(ClosedDealStagesService::class);\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":true,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Project","depth":3,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Project","depth":3,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"New File or Directory…","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Expand Selected","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Collapse All","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Options","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"app ~/jiminny/app, folder","depth":6,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":".circleci, folder","depth":7,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":".cursor, folder","depth":7,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":".github","depth":7,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":".sonarlint, folder","depth":7,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":".vscode, folder","depth":7,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":".windsurf, folder","depth":7,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"app, sources root","depth":7,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Actions, folder","depth":8,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Component, folder","depth":8,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Acl, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"ActionItems, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Activity, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"ActivityAnalytics, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"ActivitySearch, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"AiActivityType, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"AiAutomation, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"AiCallScoring, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"AskAnything, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Dtos, folder","depth":10,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Events, folder","depth":10,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"AskAnythingPromptService.php, class","depth":10,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"HistoryService.php, class","depth":10,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"AskJiminnyAi, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"AWS, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"BillingManagement, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Cache, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"CoachingFeedback, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Country, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"CustomerApi, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Database, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Datadog, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"DateTime, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"DealInsights, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"DealRisks, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"ElasticSearch, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Eloquent, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Encoding, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Encryption, folder","depth":9,"on_screen":false,"role_description":"text"}]...
|
-2554573100487986256
|
-3610448470047883828
|
click
|
accessibility
|
NULL
|
Project: faVsco.js, menu
JY-20725-handle-HS-search Project: faVsco.js, menu
JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
19
Previous Highlighted Error
Next Highlighted Error
[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {
"headers":{
"Date":["Thu,07 May 2026 14:21:15 GMT"],
"Content-Type":["application/json;charset=utf-8"],
"Transfer-Encoding":["chunked"],
"Connection":["keep-alive"],
"CF-Ray":["9f80deb8db60dc3a-SOF"],
"CF-Cache-Status":["DYNAMIC"],
"Strict-Transport-Security":["max-age=31536000; includeSubDomains; preload"],
"Vary":["origin,
accept-encoding"],
"access-control-allow-credentials":["false"],
"server-timing":["hcid;desc=\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\",
cfr;desc=\"9f80deb8e7c6dc3a-IAD\""],
"x-content-type-options":["nosniff"],
"x-hubspot-correlation-id":["019e02d0-6fd8-7812-bdba-885b7ccb3ee3"],
"Set-Cookie":["__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-[IP_ADDRESS]-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,
07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None"],
"Report-To":["{
\"endpoints\":[{
\"url\":\"https:\\/\\/a.nel.cloudflare.com\\/report\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\"}],
\"group\":\"cf-nel\",
\"max_age\":604800}"],
"NEL":["{
\"success_fraction\":0.01,
\"report_to\":\"cf-nel\",
\"max_age\":604800}"],
"Server":["cloudflare"]}} {
"correlation_id":"95236535-ec98-4541-b92a-adfa73b69eab",
"trace_id":"c7ab8365-903f-46d4-9403-0e5b551e3545"}
Code changed:
Hide
Sync Changes
Hide This Notification
2
Previous Highlighted Error
Next Highlighted Error
<?php
declare(strict_types=1);
namespace Jiminny\Services\Crm\Hubspot\OpportunitySyncStrategy;
use Jiminny\Exceptions\Crm\InvalidSyncParametersException;
use Jiminny\Exceptions\SocialAccountTokenInvalidException;
use Jiminny\Models\Crm\Configuration;
use Jiminny\Services\Crm\CrmConfigurationSettingsService;
use Jiminny\Services\Crm\Hubspot\Client;
use Jiminny\Services\Crm\Hubspot\ClosedDealStagesService;
use Jiminny\Services\Crm\Hubspot\DealFieldsService;
use Jiminny\Services\Crm\Hubspot\PayloadBuilder;
use Jiminny\Services\Crm\OpportunitySyncStrategyInterface;
use Psr\Log\LoggerInterface;
use SevenShores\Hubspot\Exceptions\BadRequest;
use SevenShores\Hubspot\Exceptions\HubspotException;
abstract class HubspotSyncStrategyBase implements OpportunitySyncStrategyInterface
{
protected Client $client;
protected PayloadBuilder $payloadBuilder;
public function __construct(Client $client)
{
$this->client = $client;
$this->payloadBuilder = app(PayloadBuilder::class);
}
/**
* @throws SocialAccountTokenInvalidException
* @throws BadRequest
* @throws HubspotException
*/
public function fetchOpportunities(
array $params,
int &$total = 0,
?string &$lastRecordId = null
): \Generator {
try {
$this->validateParameters($params);
} catch (InvalidSyncParametersException $e) {
$logger = app(LoggerInterface::class);
$logger->error('Invalid sync parameters: ' . $e->getMessage());
// Return empty generator for invalid parameters
return;
}
$fields = $this->getOpportunityFields($params['config']);
$payload = $this->buildQuery($params, $fields);
$offset = $params['offset'] ?? 0;
yield from $this->client->getPaginatedDataGenerator(
$payload,
'deals',
$offset,
$total,
$lastRecordId
);
}
protected function getOpportunityFields(Configuration $config): array
{
return $this->getDealFieldsService()->getFieldsForConfiguration($config);
}
protected function buildQuery(array $params, array $fields): array
{
return [];
}
protected function getDealFieldsService(): DealFieldsService
{
return app(DealFieldsService::class);
}
protected function getCrmConfigurationSettingsService(): CrmConfigurationSettingsService
{
return app(CrmConfigurationSettingsService::class);
}
protected function getClosedDealStagesService(): ClosedDealStagesService
{
return app(ClosedDealStagesService::class);
}
}
Project
Project
New File or Directory…
Expand Selected
Collapse All
Options
Hide
app ~/jiminny/app, folder
.circleci, folder
.cursor, folder
.github
.sonarlint, folder
.vscode, folder
.windsurf, folder
app, sources root
Actions, folder
Component, folder
Acl, folder
ActionItems, folder
Activity, folder
ActivityAnalytics, folder
ActivitySearch, folder
AiActivityType, folder
AiAutomation, folder
AiCallScoring, folder
AskAnything, folder
Dtos, folder
Events, folder
AskAnythingPromptService.php, class
HistoryService.php, class
AskJiminnyAi, folder
AWS, folder
BillingManagement, folder
Cache, folder
CoachingFeedback, folder
Country, folder
CustomerApi, folder
Database, folder
Datadog, folder
DateTime, folder
DealInsights, folder
DealRisks, folder
ElasticSearch, folder
Eloquent, folder
Encoding, folder
Encryption, folder...
|
9841
|
NULL
|
NULL
|
NULL
|
|
9842
|
444
|
27
|
2026-05-08T13:37:35.336926+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-08/1778 /Users/lukas/.screenpipe/data/data/2026-05-08/1778247455336_m2.jpg...
|
PhpStorm
|
faVsco.js – Client.php
|
1
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
JY-20725-handle-HS-search Project: faVsco.js, menu
JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
19
Previous Highlighted Error
Next Highlighted Error
[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {
"headers":{
"Date":["Thu,07 May 2026 14:21:15 GMT"],
"Content-Type":["application/json;charset=utf-8"],
"Transfer-Encoding":["chunked"],
"Connection":["keep-alive"],
"CF-Ray":["9f80deb8db60dc3a-SOF"],
"CF-Cache-Status":["DYNAMIC"],
"Strict-Transport-Security":["max-age=31536000; includeSubDomains; preload"],
"Vary":["origin,
accept-encoding"],
"access-control-allow-credentials":["false"],
"server-timing":["hcid;desc=\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\",
cfr;desc=\"9f80deb8e7c6dc3a-IAD\""],
"x-content-type-options":["nosniff"],
"x-hubspot-correlation-id":["019e02d0-6fd8-7812-bdba-885b7ccb3ee3"],
"Set-Cookie":["__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-[IP_ADDRESS]-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,
07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None"],
"Report-To":["{
\"endpoints\":[{
\"url\":\"https:\\/\\/a.nel.cloudflare.com\\/report\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\"}],
\"group\":\"cf-nel\",
\"max_age\":604800}"],
"NEL":["{
\"success_fraction\":0.01,
\"report_to\":\"cf-nel\",
\"max_age\":604800}"],
"Server":["cloudflare"]}} {
"correlation_id":"95236535-ec98-4541-b92a-adfa73b69eab",
"trace_id":"c7ab8365-903f-46d4-9403-0e5b551e3545"}
Show Replace Field
Search History
rateLimiter
New Line
Match Case
Words
Regex
Replace History
Replace
New Line
Preserve case
0 results
Previous Occurrence
Next Occurrence
Filter Search Results
Open in Window, Multiple Cursors
Click to highlight
Close
Sync Changes
Hide This Notification
Code changed:
Hide
2
64
1
1
Previous Highlighted Error
Next Highlighted Error
<?php
declare(strict_types=1);
namespace Jiminny\Services\Crm\Hubspot;
use HubSpot\Client\Crm\Deals\ApiException as DealApiException;
use HubSpot\Client\Crm\Contacts\ApiException as ContactApiException;
use HubSpot\Client\Crm\Companies\ApiException as CompanyApiException;
use HubSpot\Client\Crm\Contacts\Model\SimplePublicObjectWithAssociations as ContactsWithAssociations;
use HubSpot\Client\Crm\Companies\Model\SimplePublicObjectWithAssociations as CompaniesWithAssociations;
use HubSpot\Client\Crm\Deals\Model\SimplePublicObjectWithAssociations as DealWithAssociations;
use HubSpot\Client\Crm\Objects\Model\SimplePublicObjectInput;
use HubSpot\Client\Crm\Objects\Model\SimplePublicObjectWithAssociations as ObjectWithAssociations;
use HubSpot\Client\Crm\Pipelines\Model\Error;
use HubSpot\Client\Crm\Pipelines\Model\PipelineStage;
use HubSpot\Client\Crm\Properties\Model\Property;
use HubSpot\Discovery\Discovery;
use Jiminny\Exceptions\CrmException;
use Jiminny\Exceptions\RateLimitException;
use Jiminny\Exceptions\SocialAccountTokenInvalidException;
use Jiminny\Jobs\Crm\NoteObject;
use Jiminny\Models\Crm\Field;
use Jiminny\Services\Crm\BaseClient;
use Jiminny\Services\Crm\Hubspot\DTO\Response\Owner;
use Jiminny\Services\SocialAccountService;
use SevenShores\Hubspot\Exceptions\BadRequest;
use SevenShores\Hubspot\Exceptions\HubspotException;
use SevenShores\Hubspot\Factory;
use SevenShores\Hubspot\Http\Response;
use Jiminny\Services\Crm\Hubspot\Pagination\HubspotPaginationService;
use Throwable;
/**
* @phpstan-type CrmFieldOption array{id:string, label:string, value?:string}
*/
class Client extends BaseClient implements HubspotClientInterface
{
public const string MIN_API_VERSION = '2';
public const string BASE_URL = '[URL_WITH_CREDENTIALS] T
* @param callable(): T $apiCall
* @return T
*
* @throws RateLimitException
*/
private function executeRequest(callable $apiCall)
{
try {
return $apiCall();
} catch (Throwable $e) {
if ($this->isHubspotRateLimit($e)) {
$retryAfter = $this->parseRetryAfter($e);
$this->log->warning('[Hubspot] Received 429 from API', [
'team_id' => $this->config->team_id,
'config_id' => $this->config->getId(),
'retry_after' => $retryAfter,
'reason' => $e->getMessage(),
]);
throw new RateLimitException('Hubspot returned 429', $retryAfter, $e);
}
throw $e;
}
}
public function isHubspotRateLimit(Throwable $e): bool
{
if ($e instanceof BadRequest
|| $e instanceof DealApiException
|| $e instanceof ContactApiException
|| $e instanceof CompanyApiException
|| $e instanceof \GuzzleHttp\Exception\RequestException
) {
return (int) $e->getCode() === 429;
}
return false;
}
public function parseRetryAfter(Throwable $e): int
{
// First try to get Retry-After from response headers
if (method_exists($e, 'getResponseHeaders')) {
$headers = $e->getResponseHeaders() ?: [];
$value = $headers['Retry-After'] ?? $headers['retry-after'] ?? null;
if (is_array($value)) {
$value = $value[0] ?? null;
}
if (is_numeric($value)) {
return (int) $value;
}
}
if (method_exists($e, 'getResponseBody')) {
$body = $e->getResponseBody();
if (is_string($body)) {
$body = json_decode($body, true) ?? [];
}
$policyName = $body['policyName'] ?? $body['policy'] ?? $body['context']['policyName'] ?? null;
if ($policyName === 'TEN_SECONDLY_ROLLING' || $policyName === 'ten_secondly_rolling') {
return 10;
}
if ($policyName === 'SECONDLY' || $policyName === 'secondly') {
return 1;
}
}
$this->log->warning('[Hubspot] No retry-after header or policy name found, using default', [
'exception_class' => get_class($e),
]);
return 10;
}
public function getMinimumApiVersion(): string
{
return self::MIN_API_VERSION;
}
public function getInstance(): Factory
{
return new Factory([
'key' => $this->accessToken,
'oauth2' => true,
'base_url' => $this->baseUrl,
]);
}
public function getNewInstance(): Discovery
{
return \HubSpot\Factory::createWithAccessToken($this->accessToken);
}
/**
* Secondly and daily limits for Hubspot API
*
* Product Tier: Free & Starter | Professional & Enterprise | API add-on (any tier)
* Burst: 100/10 seconds | 150/10 seconds | 200/10 seconds
* Daily: 250,000 | 500,000 | 1,000,000
*
* Official documentation states: The search endpoints are rate limited to five requests per second.
* Since with 5 RPS were still hitting secondly rate limits we lowered it to 4
*/
public function getPaginatedData(array $payload, string $type, int $offset = 0): array
{
$total = 0;
$lastId = null;
$rows = [];
foreach ($this->getPaginatedDataGenerator($payload, $type, $offset, $total, $lastId) as $row) {
$rows[] = $row;
}
return ['results' => $rows, 'total' => $total, 'last_record' => $lastId];
}
/**
* @throws HubspotException
* @throws SocialAccountTokenInvalidException
* @throws BadRequest
*/
public function getPaginatedDataGenerator(
array $payload,
string $type,
int $offset = 0,
int &$total = 0,
?string &$lastRecordId = null
): \Generator {
return $this->paginationService->getPaginatedDataGenerator(
$this,
$payload,
$type,
$offset,
$total,
$lastRecordId
);
}
/**
* Execute a search request against HubSpot CRM objects with rate limiting.
*
* @param string $objectType The object type ('deals', 'companies', 'contacts', 'calls')
* @param array<string, mixed> $payload The search payload with filters, sorts, properties, etc.
* @return array The search response with 'results', 'total', 'paging' keys
* @throws RateLimitException When rate limit is hit
* @throws HubspotException On API errors
*/
public function search(string $objectType, array $payload): array
{
$endpoint = self::BASE_URL . "/crm/v3/objects/{$objectType}/search";
return $this->executeRequest(function () use ($endpoint, $payload) {
$response = $this->getInstance()->getClient()->request('POST', $endpoint, ['json' => $payload]);
return $response->toArray();
});
}
/**
* @throws DealApiException
* @throws CrmException
*/
public function getOpportunityById(string $crmId, array $fields): array
{
try {
// $deal = $this->executeRequest(fn () => $this->getNewInstance()->crm()->deals()->basicApi()->getById(
$deal = $this->getNewInstance()->crm()->deals()->basicApi()->getById(
$crmId,
implode(',', $fields),
'companies,contacts'
);
} catch (DealApiException $e) {
$this->log->info('[Hubspot] Failed to fetch opportunity', [
'crm_id' => $crmId,
'reason' => $e->getMessage(),
]);
throw $e;
}
if (! $deal instanceof DealWithAssociations) {
throw new CrmException('Deal not found');
}
return [
'id' => $deal->getId(),
'properties' => $deal->getProperties(),
'associations' => $deal->getAssociations(),
];
}
/**
* Generic batch read method for HubSpot objects
*
* @param string $objectType The object type ('deals', 'companies', 'contacts')
* @param array<string> $crmIds Array of HubSpot object IDs (max 100)
* @param array<string> $fields Array of property names to fetch
*
* @return array<string, array> Array keyed by CRM ID with object data
*/
private function batchReadObjects(string $objectType, array $crmIds, array $fields): array
{
if (empty($crmIds)) {
return [];
}
$this->validateBatchSize($objectType, $crmIds);
$this->ensureValidToken();
try {
$batchConfig = $this->createBatchConfiguration($objectType);
$batchReadRequest = $this->prepareBatchRequest($batchConfig, $crmIds, $fields);
$response = $batchConfig['api']->read($batchReadRequest);
$this->validateApiResponse($response, $objectType);
$results = $this->processApiResults($response);
$this->logBatchResults($objectType, $crmIds, $results);
return $results;
} catch (\Throwable $e) {
$this->handleBatchError($e, $objectType, $crmIds);
}
}
private function validateBatchSize(string $objectType, array $crmIds): void
{
if (count($crmIds) > 100) {
throw new \InvalidArgumentException("Batch size cannot exceed 100 {$objectType}");
}
}
private function createBatchConfiguration(string $objectType): array
{
$configurations = [
'deals' => [
'batchReadRequest' => new \HubSpot\Client\Crm\Deals\Model\BatchReadInputSimplePublicObjectId(),
'inputClass' => \HubSpot\Client\Crm\Deals\Model\SimplePublicObjectId::class,
'api' => $this->getNewInstance()->crm()->deals()->batchApi(),
],
'companies' => [
'batchReadRequest' => new \HubSpot\Client\Crm\Companies\Model\BatchReadInputSimplePublicObjectId(),
'inputClass' => \HubSpot\Client\Crm\Companies\Model\SimplePublicObjectId::class,
'api' => $this->getNewInstance()->crm()->companies()->batchApi(),
],
'contacts' => [
'batchReadRequest' => new \HubSpot\Client\Crm\Contacts\Model\BatchReadInputSimplePublicObjectId(),
'inputClass' => \HubSpot\Client\Crm\Contacts\Model\SimplePublicObjectId::class,
'api' => $this->getNewInstance()->crm()->contacts()->batchApi(),
],
];
if (! isset($configurations[$objectType])) {
throw new \InvalidArgumentException("Unsupported object type: {$objectType}");
}
return $configurations[$objectType];
}
private function prepareBatchRequest(array $batchConfig, array $crmIds, array $fields): object
{
$batchReadRequest = $batchConfig['batchReadRequest'];
$inputClass = $batchConfig['inputClass'];
$inputs = array_map(function ($crmId) use ($inputClass) {
$input = new $inputClass();
$input->setId($crmId);
return $input;
}, $crmIds);
$batchReadRequest->setInputs($inputs);
$batchReadRequest->setProperties($fields);
return $batchReadRequest;
}
private function validateApiResponse($response, string $objectType): void
{
if (! $response) {
throw new CrmException("HubSpot API returned null response for {$objectType} batch read");
}
}
private function processApiResults($response): array
{
$results = [];
$responseResults = $response->getResults();
if ($responseResults) {
foreach ($responseResults as $object) {
if ($object && $object->getId()) {
$results[$object->getId()] = [
'id' => $object->getId(),
'properties' => $object->getProperties() ?: [],
];
}
}
}
return $results;
}
private function logBatchResults(string $objectType, array $crmIds, array $results): void
{
$this->log->info("[HubSpot] Batch fetched {$objectType}", [
'requested_count' => count($crmIds),
'returned_count' => count($results),
'crm_ids' => $crmIds,
]);
}
private function handleBatchError(\Throwable $e, string $objectType, array $crmIds): void
{
$errorMessage = $e->getMessage() ?: 'Unknown error';
$errorTrace = $e->getTraceAsString() ?: 'No trace available';
$this->log->error("[HubSpot] Failed to batch fetch {$objectType}", [
'crm_ids' => $crmIds,
'error' => $errorMessage,
'trace' => $errorTrace,
]);
throw new CrmException("Failed to batch fetch {$objectType}: " . $errorMessage);
}
/**
* Batch read multiple opportunities by their CRM IDs
*
* @param array<string> $crmIds Array of HubSpot deal IDs (max 100)
* @param array<string> $fields Array of property names to fetch
*
* @return array<string, array> Array keyed by CRM ID with opportunity data
*/
public function getOpportunitiesByIds(array $crmIds, array $fields): array
{
return $this->batchReadObjects('deals', $crmIds, $fields);
}
/**
* Batch read multiple companies by their CRM IDs
*
* @param array<string> $crmIds Array of HubSpot company IDs (max 100)
* @param array<string> $fields Array of property names to fetch
*
* @return array<string, array> Array keyed by CRM ID with company data
*/
public function getCompaniesByIds(array $crmIds, array $fields): array
{
return $this->batchReadObjects('companies', $crmIds, $fields);
}
/**
* Batch read multiple contacts by their CRM IDs
*
* @param array<string> $crmIds Array of HubSpot contact IDs (max 100)
* @param array<string> $fields Array of property names to fetch
*
* @return array<string, array> Array keyed by CRM ID with contact data
*/
public function getContactsByIds(array $crmIds, array $fields): array
{
return $this->batchReadObjects('contacts', $crmIds, $fields);
}
/**
* @throws CompanyApiException
* @throws CrmException
*/
public function getAccountById(string $crmId, array $fields): array
{
try {
$company = $this->getNewInstance()->crm()->companies()->basicApi()->getById(
$crmId,
implode(',', $fields),
);
} catch (CompanyApiException $e) {
$this->log->info('[Hubspot] Failed to fetch account', [
'crm_id' => $crmId,
'reason' => $e->getMessage(),
]);
throw $e;
}
if (! $company instanceof CompaniesWithAssociations) {
throw new CrmException('Account not found');
}
return [
'id' => $company->getId(),
'properties' => $company->getProperties(),
];
}
/**
* @throws ContactApiException
* @throws CrmException
*/
public function getContactById(string $crmId, array $fields): array
{
try {
$contact = $this->getNewInstance()->crm()->contacts()->basicApi()->getById(
$crmId,
implode(',', $fields)
);
} catch (ContactApiException $e) {
$this->log->info('[Hubspot] Failed to fetch contact', [
'crm_id' => $crmId,
'reason' => $e->getMessage(),
]);
throw $e;
}
if (! $contact instanceof ContactsWithAssociations) {
throw new CrmException('Contact not found');
}
return [
'id' => $contact->getId(),
'properties' => $contact->getProperties(),
];
}
/**
* This is email search request that Hubspot offers as GET (more generous quota)
*/
public function getContactByEmail(string $email, array $fields = []): array
{
try {
$contact = $this->getNewInstance()->crm()->contacts()->basicApi()->getById(
$email,
implode(',', $fields),
null,
false,
'email'
);
return [
'id' => $contact->getId(),
'properties' => $contact->getProperties(),
];
} catch (ContactApiException $e) {
$this->log->info('[Hubspot] Failed to fetch contact', [
'email' => $email,
'reason' => $e->getMessage(),
]);
return [];
}
}
/**
* @throws CrmException
*/
public function fetchProperty(string $objectType, string $propertyId): Property
{
$result = $this->getNewInstance()->crm()->properties()->coreApi()->getByName($objectType, $propertyId);
if (! $result instanceof Property) {
$this->log->error('[Hubspot] Failed to fetch property', [
'object_type' => $objectType,
'property_id' => $propertyId,
'reason' => $result->getMessage(),
]);
throw new CrmException('Failed to fetch property');
}
return $result;
}
/**
* @return array<CrmFieldOption>
*/
public function fetchPropertyOptions(string $objectType, string $propertyId): array
{
/** @var array<CrmFieldOption> */
return $this->fetchProperty($objectType, $propertyId)->getOptions();
}
/**
* @return array<array{id:string, label:string, deleted:bool}>
*/
public function fetchCallDispositions(): array
{
/** @var Response $response */
$response = $this->getInstance()->engagements()->getCallDispositions();
/**
* @var array<array{
* id:string,
* label:string,
* deleted: bool
* }>
*/
return $response->toArray();
}
/**
* @return array<CrmFieldOption>
*/
public function fetchOpportunityPipelineStages(): array
{
$stages = [];
$apiResponse = $this->getNewInstance()->crm()->pipelines()->pipelinesApi()->getAll('deals');
if ($apiResponse instanceof Error) {
$this->log->error('[Hubspot] Failed to fetch opportunity pipelines', [
'reason' => $apiResponse->getMessage(),
]);
return [];
}
foreach ($apiResponse->getResults() as $pipeline) {
$pipelineStages = array_map(
static function (PipelineStage $stage) {
return [
'id' => $stage->getId(),
'label' => $stage->getLabel(),
];
},
$pipeline->getStages()
);
$stages = array_merge($stages, $pipelineStages);
}
return $stages;
}
public function fetchOpportunityPipelines(): array
{
$pipelines = [];
try {
$apiResponse = $this->makeRequest('/crm/v3/pipelines/deals');
} catch (\Exception $e) {
$this->log->info('[Hubspot] Failed to fetch opportunity pipelines', [
'reason' => $e->getMessage(),
]);
return [];
}
$response = $apiResponse->toArray();
foreach ($response['results'] as $pipeline) {
$pipelines[] = [
'id' => $pipeline['id'],
'label' => $pipeline['label'],
];
}
return $pipelines;
}
/**
* @return array<CrmFieldOption>
*/
public function fetchMeetingOutcomeFieldOptions(Field $field): array
{
return $field->getCrmProviderId() === 'meetingOutcome'
? $this->fetchMeetingOutcomeTypes()
: $this->fetchCallActivityTypes();
}
public function fetchMeetingOutcomeTypes(): array
{
return $this->extractMeetingTypeOptions(
'[URL_WITH_CREDENTIALS] Response $response */
$response = $this->getInstance()
->getClient()
->request('GET', $endpoint);
/**
* @var array<array{
* value: string,
* label: string,
* displayOrder: int
* }> $optionData
*/
$optionData = $response->toArray()['options'] ?? [];
$options = [];
foreach ($optionData as $item) {
$options[] = [
'id' => $item['value'],
'value' => $item['value'],
'label' => $item['label'],
'display_order' => $item['displayOrder'],
];
}
return $options;
}
/**
* @return array<CrmFieldOption>
*/
public function fetchDispositionFieldOptions(): array
{
$options = [];
$dispositions = $this->fetchCallDispositions();
foreach ($dispositions as $disposition) {
if ($disposition['deleted'] !== false) {
continue;
}
$option['value'] = $disposition['id'];
$option['id'] = $disposition['id'];
$option['label'] = $disposition['label'];
$options[] = $option;
}
return $options;
}
/**
* @return array<CrmFieldOption>
*/
public function fetchOpportunityFieldOptions(Field $field): array
{
if ($field->isStageField()) {
return $this->fetchOpportunityPipelineStages();
}
if ($field->isPipelineField()) {
return $this->fetchOpportunityPipelines();
}
return $this->fetchPropertyOptions('deals', $field->getCrmProviderId());
}
/**
* @throws BadRequest
* @throws HubspotException
*/
public function makeRequest(string $endpoint, $method = 'GET', $payload = [], ?string $queryString = null)
{
$endpoint = self::BASE_URL . $endpoint;
if ($method === 'GET') {
$response = $this->getInstance()->getClient()?->request(
method: $method,
endpoint: $endpoint,
query_string: $queryString
);
} else {
$response = $this->getInstance()->getClient()->request($method, $endpoint, [
'json' => ($payload),
]);
}
//
// $max = $response->getHeaderLine('X-HubSpot-RateLimit-Max'); // "110"
// $remaining = $response->getHeaderLine('X-HubSpot-RateLimit-Remaining'); // "109"
// $interval = $response->getHeaderLine('X-HubSpot-RateLimit-Interval-Milliseconds'); // "10000"
// $body = json_decode((string) $response->getBody(), true);
//
// \Illuminate\Support\Facades\Log::channel('custom_channel')->info('$max ' . PHP_EOL . print_r($max, true));
// \Illuminate\Support\Facades\Log::channel('custom_channel')->info('$remaining ' . PHP_EOL . print_r($remaining, true));
// \Illuminate\Support\Facades\Log::channel('custom_channel')->info('$interval ' . PHP_EOL . print_r($interval, true));
// \Illuminate\Support\Facades\Log::channel('custom_channel')->info('$body ' . PHP_EOL . print_r($body, true));
return $response;
}
/**
* @throws BadRequest
* @throws HubspotException
*/
public function createMeeting(array $payload): Response
{
$endpoint = '/crm/v3/objects/meetings';
return $this->makeRequest($endpoint, 'POST', $payload);
}
/**
* @throws BadRequest
* @throws HubspotException
*/
public function updateMeeting(string $meetingId, array $payload): Response
{
$endpoint = '/crm/v3/objects/meetings/' . $meetingId;
return $this->makeRequest($endpoint, 'PATCH', $payload);
}
/**
* @throws \Exception
*/
public function createNote(
string $body,
string $ownerId,
int $timestamp,
string $objectId,
NoteObject $noteObject
): ?string {
try {
$noteInput = new SimplePublicObjectInput([
'properties' => [
'hs_note_body' => $body,
'hubspot_owner_id' => $ownerId,
'hs_timestamp' => $timestamp,
],
]);
// Create note
$note = $this->getNewInstance()->crm()->objects()->basicApi()->create('note', $noteInput);
$this->getNewInstance()->crm()->objects()->associationsApi()->create(
'note',
$note->getId(),
$this->getNoteObject($noteObject),
$objectId,
$this->getNoteAssociationType($noteObject),
);
return $note->getId();
} catch (\Exception $e) {
$this->log->error('[Hubspot] Failed to create note', [
'objectId' => $objectId,
'noteObject' => $noteObject->getObjectType(),
'reason' => $e->getMessage(),
]);
\Sentry::captureException($e);
}
return null;
}
public function updateEngagement(string $objectId, array $engagement, array $metadata): void
{
$this->getInstance()->engagements()->update($objectId, $engagement, $metadata);
}
public function getEngagementData(string $engagementId): array
{
$engagement = $this->getInstance()->engagements()->get($engagementId);
return $engagement->toArray();
}
public function createEngagement(array $engagement, array $associations, array $metadata): Response
{
return $this->getInstance()
->engagements()
->create($engagement, $associations, $metadata);
}
public function isUnauthorizedException(\Exception $e): bool
{
if ($e instanceof BadRequest
|| $e instanceof DealApiException
|| $e instanceof ContactApiException
|| $e instanceof CompanyApiException
) {
return (int) $e->getCode() === 401;
}
if ($e instanceof \GuzzleHttp\Exception\RequestException && $e->hasResponse()) {
return $e->getResponse()?->getStatusCode() === 401;
}
$message = strtolower($e->getMessage());
return str_contains($message, '401 unauthorized') ||
str_contains($message, 'http 401') ||
str_contains($message, 'status code 401') ||
(preg_match('/\b401\b/', $message) === 1 && str_contains($message, 'unauthorized'));
}
/**
* Validates and refreshes the access token if needed before API requests.
* This ensures long-running processes don't fail due to token expiration.
*
* @throws SocialAccountTokenInvalidException
*/
public function ensureValidToken(): void
{
if ($this->oauthAccount === null) {
return;
}
$newToken = $this->tokenManager->ensureValidToken($this->oauthAccount);
if ($newToken !== null) {
$this->accessToken = $newToken;
}
}
public function getConfig()
{
return $this->config;
}
// returns only active (archived=false)
public function getOwners(): array
{
return $this->getNewInstance()->crm()->owners()->getAll();
}
/**
* @param bool $archived
*
* @return array<Owner>|[]
*/
public function getOwnersArchived(bool $archived = true): array
{
$endpoint = '/crm/v3/owners';
$queryParams = [
'archived' => $archived ? 'true' : 'false',
];
$queryString = http_build_query($queryParams);
$owners = [];
try {
$response = $this->makeRequest(endpoint: $endpoint, queryString: $queryString);
$responseData = $response?->toArray();
foreach ($responseData['results'] as $result) {
try {
$owners[] = Owner::create($result);
} catch (Throwable $e) {
$this->log->error('[HubSpot] Failed to process owner data', [
'result' => $result,
'error' => $e->getMessage(),
]);
continue;
}
}
} catch (Throwable $e) {
$this->log->error('HubSpot] Failed to fetch owners', [
'archived' => $archived,
'error' => $e->getMessage(),
]);
return [];
}
return $owners;
}
public function getMeeting(string $engagementId): ObjectWithAssociations
{
return $this->getNewInstance()->crm()->objects()->basicApi()
->getById('meeting', $engagementId, null, 'contact,company,deal');
}
public function deleteEngagement(string $engagementId): void
{
$this->getInstance()->engagements()->delete((int) $engagementId);
}
public function getAssociationsData(array $ids, string $fromObject, string $toObject): array
{
$associationData = [];
$idChunks = array_chunk($ids, self::ASSOCIATIONS_BATCH_SIZE_LIMIT);
foreach ($idChunks as $idChunk) {
try {
$batchInput = new \HubSpot\Client\Crm\Associations\Model\BatchInputPublicObjectId();
$batchInput->setInputs(array_map(function ($id) {
$publicObjectId = new \HubSpot\Client\Crm\Associations\Model\PublicObjectId();
$publicObjectId->setId($id);
return $publicObjectId;
}, $idChunk));
$associatedObjectsData = $this
->getNewInstance()
->crm()
->associations()
->batchApi()
->read($fromObject, $toObject, $batchInput);
if ($associatedObjectsData instanceof \HubSpot\Client\Crm\Associations\Model\BatchResponsePublicAssociationMulti) {
foreach ($associatedObjectsData->getResults() as $association) {
$from = $association->getFrom()->getId();
$toAssociations = $association->getTo();
if (! empty($toAssociations)) {
$associationData[$from] = array_map(function ($item) {
return $item->getId();
}, $toAssociations);
}
}
}
} catch (RateLimitException $e) {
throw $e;
} catch (\Exception $e) {
$this->log->error('[Hubspot] Failed to fetch associations', [
'from_object' => $fromObject,
'to_object' => $toObject,
'reason' => $e->getMessage(),
]);
}
}
return $associationData;
}
/**
* @throws \Exception
*/
private function getNoteAssociationType(NoteObject $noteObject): string
{
return match($noteObject) {
NoteObject::Opportunity => 'note_to_deal',
NoteObject::Lead, NoteObject::Contact => 'note_to_contact', // or 'note_to_lead' if your portal supports it
NoteObject::Account => 'note_to_company',
NoteObject::Call, NoteObject::Event => throw new \Exception('Not supported'),
};
}
/**
* @throws \Exception
*/
private function getNoteObject(NoteObject $noteObject): string
{
return match($noteObject) {
NoteObject::Opportunity => 'deal',
NoteObject::Lead, NoteObject::Contact => 'contact',
NoteObject::Account => 'company',
NoteObject::Call, NoteObject::Event => throw new \Exception('Not supported'),
};
}
public function addAssociations(string $objectType, string $associationType, array $payload): Response
{
$endpoint = "/crm/v4/associations/$objectType/$associationType/batch/create";
return $this->makeRequest($endpoint, 'POST', $payload);
}
public function removeAssociations(string $objectType, string $associationType, array $payload): Response
{
$endpoint = "/crm/v4/associations/$objectType/$associationType/batch/archive";
return $this->makeRequest($endpoint, 'POST', $payload);
}
}
Project
Project
New File or Directory…
Expand Selected
Collapse All
Options
Hide...
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"bounds":{"left":0.025930852,"top":0.019952115,"width":0.03856383,"height":0.025538707},"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JY-20725-handle-HS-search-rate-limit, menu","depth":5,"bounds":{"left":0.064494684,"top":0.019952115,"width":0.09541223,"height":0.025538707},"on_screen":true,"help_text":"Git Branch: JY-20725-handle-HS-search-rate-limit","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"bounds":{"left":0.8081782,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"AskJiminnyReportActivityServiceTest","depth":6,"bounds":{"left":0.8234708,"top":0.019952115,"width":0.09208777,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'AskJiminnyReportActivityServiceTest'","depth":6,"bounds":{"left":0.9155585,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'AskJiminnyReportActivityServiceTest'","depth":6,"bounds":{"left":0.9268617,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"bounds":{"left":0.9381649,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"bounds":{"left":0.96609044,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"bounds":{"left":0.9773936,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"IDE and Project Settings","depth":5,"bounds":{"left":0.9886968,"top":0.019952115,"width":0.011303186,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.042220745,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"19","depth":4,"bounds":{"left":0.6615692,"top":0.10055866,"width":0.009640957,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"bounds":{"left":0.67287236,"top":0.09896249,"width":0.00731383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"bounds":{"left":0.68018615,"top":0.09896249,"width":0.006981383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {\n\"headers\":{\n\"Date\":[\"Thu,07 May 2026 14:21:15 GMT\"],\n \"Content-Type\":[\"application/json;charset=utf-8\"],\n \"Transfer-Encoding\":[\"chunked\"],\n \"Connection\":[\"keep-alive\"],\n \"CF-Ray\":[\"9f80deb8db60dc3a-SOF\"],\n \"CF-Cache-Status\":[\"DYNAMIC\"],\n \"Strict-Transport-Security\":[\"max-age=31536000; includeSubDomains; preload\"],\n \"Vary\":[\"origin,\n accept-encoding\"],\n \"access-control-allow-credentials\":[\"false\"],\n \"server-timing\":[\"hcid;desc=\\\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\\\",\n cfr;desc=\\\"9f80deb8e7c6dc3a-IAD\\\"\"],\n \"x-content-type-options\":[\"nosniff\"],\n \"x-hubspot-correlation-id\":[\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\"],\n \"Set-Cookie\":[\"__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-1.0.1.1-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,\n 07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None\"],\n \"Report-To\":[\"{\n\\\"endpoints\\\":[{\n\\\"url\\\":\\\"https:\\\\/\\\\/a.nel.cloudflare.com\\\\/report\\\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\\\"}],\n\\\"group\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"NEL\":[\"{\n\\\"success_fraction\\\":0.01,\n\\\"report_to\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"Server\":[\"cloudflare\"]}} {\n\"correlation_id\":\"95236535-ec98-4541-b92a-adfa73b69eab\",\n\"trace_id\":\"c7ab8365-903f-46d4-9403-0e5b551e3545\"}","depth":4,"bounds":{"left":0.37632978,"top":0.09736632,"width":0.5728058,"height":0.8818835},"on_screen":true,"lines":[{"char_start":207,"char_count":30,"bounds":{"left":0.37632978,"top":0.0,"width":0.07513298,"height":0.014365523}},{"char_start":237,"char_count":36,"bounds":{"left":0.37632978,"top":0.0,"width":0.09075798,"height":0.014365523}},{"char_start":273,"char_count":32,"bounds":{"left":0.37632978,"top":0.0,"width":0.080119684,"height":0.014365523}},{"char_start":305,"char_count":79,"bounds":{"left":0.37632978,"top":0.0,"width":0.20212767,"height":0.014365523}},{"char_start":384,"char_count":18,"bounds":{"left":0.37632978,"top":0.0,"width":0.043882977,"height":0.014365523}},{"char_start":402,"char_count":21,"bounds":{"left":0.37632978,"top":0.0,"width":0.051861703,"height":0.014365523}},{"char_start":423,"char_count":48,"bounds":{"left":0.37632978,"top":0.008778931,"width":0.12167553,"height":0.014365523}},{"char_start":471,"char_count":72,"bounds":{"left":0.37632978,"top":0.026336791,"width":0.18384309,"height":0.014365523}},{"char_start":543,"char_count":40,"bounds":{"left":0.37632978,"top":0.043894652,"width":0.10106383,"height":0.014365523}},{"char_start":583,"char_count":41,"bounds":{"left":0.37632978,"top":0.061452515,"width":0.10372341,"height":0.014365523}},{"char_start":624,"char_count":72,"bounds":{"left":0.37632978,"top":0.079010375,"width":0.18384309,"height":0.014365523}},{"char_start":696,"char_count":219,"bounds":{"left":0.37632978,"top":0.096568234,"width":0.56515956,"height":0.014365523}},{"char_start":915,"char_count":83,"bounds":{"left":0.37632978,"top":0.11412609,"width":0.21243352,"height":0.014365523}},{"char_start":998,"char_count":20,"bounds":{"left":0.37632978,"top":0.13168396,"width":0.04920213,"height":0.014365523}},{"char_start":1018,"char_count":17,"bounds":{"left":0.37632978,"top":0.14924182,"width":0.041223403,"height":0.014365523}},{"char_start":1035,"char_count":203,"bounds":{"left":0.37632978,"top":0.16679968,"width":0.52360374,"height":0.014365523}},{"char_start":1238,"char_count":22,"bounds":{"left":0.37632978,"top":0.18435754,"width":0.05418883,"height":0.014365523}},{"char_start":1260,"char_count":23,"bounds":{"left":0.37632978,"top":0.2019154,"width":0.056848403,"height":0.014365523}},{"char_start":1283,"char_count":10,"bounds":{"left":0.37632978,"top":0.21947326,"width":0.023271276,"height":0.014365523}},{"char_start":1293,"char_count":27,"bounds":{"left":0.37632978,"top":0.23703113,"width":0.06715426,"height":0.014365523}},{"char_start":1320,"char_count":26,"bounds":{"left":0.37632978,"top":0.254589,"width":0.06482713,"height":0.014365523}},{"char_start":1346,"char_count":23,"bounds":{"left":0.37632978,"top":0.27214685,"width":0.056848403,"height":0.014365523}},{"char_start":1369,"char_count":28,"bounds":{"left":0.37632978,"top":0.2897047,"width":0.06981383,"height":0.014365523}},{"char_start":1397,"char_count":57,"bounds":{"left":0.37632978,"top":0.30726257,"width":0.14494681,"height":0.014365523}}],"value":"[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {\n\"headers\":{\n\"Date\":[\"Thu,07 May 2026 14:21:15 GMT\"],\n \"Content-Type\":[\"application/json;charset=utf-8\"],\n \"Transfer-Encoding\":[\"chunked\"],\n \"Connection\":[\"keep-alive\"],\n \"CF-Ray\":[\"9f80deb8db60dc3a-SOF\"],\n \"CF-Cache-Status\":[\"DYNAMIC\"],\n \"Strict-Transport-Security\":[\"max-age=31536000; includeSubDomains; preload\"],\n \"Vary\":[\"origin,\n accept-encoding\"],\n \"access-control-allow-credentials\":[\"false\"],\n \"server-timing\":[\"hcid;desc=\\\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\\\",\n cfr;desc=\\\"9f80deb8e7c6dc3a-IAD\\\"\"],\n \"x-content-type-options\":[\"nosniff\"],\n \"x-hubspot-correlation-id\":[\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\"],\n \"Set-Cookie\":[\"__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-1.0.1.1-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,\n 07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None\"],\n \"Report-To\":[\"{\n\\\"endpoints\\\":[{\n\\\"url\\\":\\\"https:\\\\/\\\\/a.nel.cloudflare.com\\\\/report\\\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\\\"}],\n\\\"group\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"NEL\":[\"{\n\\\"success_fraction\\\":0.01,\n\\\"report_to\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"Server\":[\"cloudflare\"]}} {\n\"correlation_id\":\"95236535-ec98-4541-b92a-adfa73b69eab\",\n\"trace_id\":\"c7ab8365-903f-46d4-9403-0e5b551e3545\"}","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Show Replace Field","depth":4,"bounds":{"left":0.10472074,"top":0.20430966,"width":0.008643617,"height":0.01915403},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Search History","depth":3,"bounds":{"left":0.11735372,"top":0.20351157,"width":0.00731383,"height":0.017557861},"on_screen":true,"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"rateLimiter","depth":4,"bounds":{"left":0.12832446,"top":0.20351157,"width":0.043882977,"height":0.015961692},"on_screen":true,"value":"rateLimiter","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"New Line","depth":3,"bounds":{"left":0.18118352,"top":0.20351157,"width":0.00731383,"height":0.017557861},"on_screen":true,"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Match Case","depth":3,"bounds":{"left":0.19115691,"top":0.20351157,"width":0.00731383,"height":0.017557861},"on_screen":true,"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Words","depth":3,"bounds":{"left":0.19980054,"top":0.20351157,"width":0.00731383,"height":0.017557861},"on_screen":true,"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Regex","depth":3,"bounds":{"left":0.20844415,"top":0.20351157,"width":0.00731383,"height":0.017557861},"on_screen":true,"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Replace History","depth":3,"bounds":{"left":0.27027926,"top":1.0,"width":0.00731383,"height":0.0},"on_screen":false,"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextField","text":"Replace","depth":4,"on_screen":false,"role_description":"text field","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"New Line","depth":3,"bounds":{"left":0.27027926,"top":1.0,"width":0.00731383,"height":0.0},"on_screen":false,"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Preserve case","depth":3,"bounds":{"left":0.27027926,"top":1.0,"width":0.00731383,"height":0.0},"on_screen":false,"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"0 results","depth":4,"bounds":{"left":0.22207446,"top":0.20271349,"width":0.025598405,"height":0.017557861},"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Occurrence","depth":4,"bounds":{"left":0.24767287,"top":0.2019154,"width":0.008643617,"height":0.01915403},"on_screen":true,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Occurrence","depth":4,"bounds":{"left":0.25631648,"top":0.2019154,"width":0.008643617,"height":0.01915403},"on_screen":true,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Filter Search Results","depth":4,"bounds":{"left":0.2649601,"top":0.2019154,"width":0.008643617,"height":0.01915403},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Open in Window, Multiple Cursors","depth":4,"bounds":{"left":0.27360374,"top":0.2019154,"width":0.008643617,"height":0.01915403},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXLink","text":"Click to highlight","depth":4,"on_screen":false,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Close","depth":4,"bounds":{"left":0.34408244,"top":0.2019154,"width":0.008643617,"height":0.01915403},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.042220745,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"2","depth":4,"bounds":{"left":0.30219415,"top":0.2330407,"width":0.007978723,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"64","depth":4,"bounds":{"left":0.31216756,"top":0.2330407,"width":0.010305851,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"1","depth":4,"bounds":{"left":0.32446808,"top":0.2330407,"width":0.00731383,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"1","depth":4,"bounds":{"left":0.3337766,"top":0.2330407,"width":0.00731383,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"bounds":{"left":0.34275267,"top":0.23144454,"width":0.00731383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"bounds":{"left":0.35006648,"top":0.23144454,"width":0.006981383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\ndeclare(strict_types=1);\n\nnamespace Jiminny\\Services\\Crm\\Hubspot;\n\nuse HubSpot\\Client\\Crm\\Deals\\ApiException as DealApiException;\nuse HubSpot\\Client\\Crm\\Contacts\\ApiException as ContactApiException;\nuse HubSpot\\Client\\Crm\\Companies\\ApiException as CompanyApiException;\nuse HubSpot\\Client\\Crm\\Contacts\\Model\\SimplePublicObjectWithAssociations as ContactsWithAssociations;\nuse HubSpot\\Client\\Crm\\Companies\\Model\\SimplePublicObjectWithAssociations as CompaniesWithAssociations;\nuse HubSpot\\Client\\Crm\\Deals\\Model\\SimplePublicObjectWithAssociations as DealWithAssociations;\nuse HubSpot\\Client\\Crm\\Objects\\Model\\SimplePublicObjectInput;\nuse HubSpot\\Client\\Crm\\Objects\\Model\\SimplePublicObjectWithAssociations as ObjectWithAssociations;\nuse HubSpot\\Client\\Crm\\Pipelines\\Model\\Error;\nuse HubSpot\\Client\\Crm\\Pipelines\\Model\\PipelineStage;\nuse HubSpot\\Client\\Crm\\Properties\\Model\\Property;\nuse HubSpot\\Discovery\\Discovery;\nuse Jiminny\\Exceptions\\CrmException;\nuse Jiminny\\Exceptions\\RateLimitException;\nuse Jiminny\\Exceptions\\SocialAccountTokenInvalidException;\nuse Jiminny\\Jobs\\Crm\\NoteObject;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Services\\Crm\\BaseClient;\nuse Jiminny\\Services\\Crm\\Hubspot\\DTO\\Response\\Owner;\nuse Jiminny\\Services\\SocialAccountService;\nuse SevenShores\\Hubspot\\Exceptions\\BadRequest;\nuse SevenShores\\Hubspot\\Exceptions\\HubspotException;\nuse SevenShores\\Hubspot\\Factory;\nuse SevenShores\\Hubspot\\Http\\Response;\nuse Jiminny\\Services\\Crm\\Hubspot\\Pagination\\HubspotPaginationService;\nuse Throwable;\n\n/**\n * @phpstan-type CrmFieldOption array{id:string, label:string, value?:string}\n */\nclass Client extends BaseClient implements HubspotClientInterface\n{\n public const string MIN_API_VERSION = '2';\n\n public const string BASE_URL = 'https://api.hubapi.com';\n\n public const int ASSOCIATIONS_BATCH_SIZE_LIMIT = 1000;\n\n private HubspotPaginationService $paginationService;\n private HubspotTokenManager $tokenManager;\n\n public function __construct(\n SocialAccountService $socialAccountService,\n HubspotPaginationService $paginationService,\n HubspotTokenManager $tokenManager,\n ) {\n parent::__construct($socialAccountService);\n $this->paginationService = $paginationService;\n $this->tokenManager = $tokenManager;\n\n $this->setBaseUrl(self::BASE_URL);\n $this->setVersion(self::MIN_API_VERSION);\n }\n\n /**\n * Reacts to a rate limits (429) from HubSpot by translating it\n * into a RateLimitException carrying retry_after.\n *\n * Wrap any outbound HubSpot call (SDK or raw HTTP) like:\n *\n * $this->executeRequest(fn () => $this->getNewInstance()->crm()->...);\n *\n * @template T\n * @param callable(): T $apiCall\n * @return T\n *\n * @throws RateLimitException\n */\n private function executeRequest(callable $apiCall)\n {\n try {\n return $apiCall();\n } catch (Throwable $e) {\n if ($this->isHubspotRateLimit($e)) {\n $retryAfter = $this->parseRetryAfter($e);\n\n $this->log->warning('[Hubspot] Received 429 from API', [\n 'team_id' => $this->config->team_id,\n 'config_id' => $this->config->getId(),\n 'retry_after' => $retryAfter,\n 'reason' => $e->getMessage(),\n ]);\n\n throw new RateLimitException('Hubspot returned 429', $retryAfter, $e);\n }\n\n throw $e;\n }\n }\n\n public function isHubspotRateLimit(Throwable $e): bool\n {\n if ($e instanceof BadRequest\n || $e instanceof DealApiException\n || $e instanceof ContactApiException\n || $e instanceof CompanyApiException\n || $e instanceof \\GuzzleHttp\\Exception\\RequestException\n ) {\n return (int) $e->getCode() === 429;\n }\n\n return false;\n }\n\n public function parseRetryAfter(Throwable $e): int\n {\n // First try to get Retry-After from response headers\n if (method_exists($e, 'getResponseHeaders')) {\n $headers = $e->getResponseHeaders() ?: [];\n $value = $headers['Retry-After'] ?? $headers['retry-after'] ?? null;\n if (is_array($value)) {\n $value = $value[0] ?? null;\n }\n if (is_numeric($value)) {\n return (int) $value;\n }\n }\n\n if (method_exists($e, 'getResponseBody')) {\n $body = $e->getResponseBody();\n if (is_string($body)) {\n $body = json_decode($body, true) ?? [];\n }\n\n $policyName = $body['policyName'] ?? $body['policy'] ?? $body['context']['policyName'] ?? null;\n\n if ($policyName === 'TEN_SECONDLY_ROLLING' || $policyName === 'ten_secondly_rolling') {\n return 10;\n }\n if ($policyName === 'SECONDLY' || $policyName === 'secondly') {\n return 1;\n }\n }\n\n $this->log->warning('[Hubspot] No retry-after header or policy name found, using default', [\n 'exception_class' => get_class($e),\n ]);\n\n return 10;\n }\n\n public function getMinimumApiVersion(): string\n {\n return self::MIN_API_VERSION;\n }\n\n public function getInstance(): Factory\n {\n return new Factory([\n 'key' => $this->accessToken,\n 'oauth2' => true,\n 'base_url' => $this->baseUrl,\n ]);\n }\n\n public function getNewInstance(): Discovery\n {\n return \\HubSpot\\Factory::createWithAccessToken($this->accessToken);\n }\n\n /**\n * Secondly and daily limits for Hubspot API\n *\n * Product Tier: Free & Starter | Professional & Enterprise | API add-on (any tier)\n * Burst: 100/10 seconds | 150/10 seconds | 200/10 seconds\n * Daily: 250,000 | 500,000 | 1,000,000\n *\n * Official documentation states: The search endpoints are rate limited to five requests per second.\n * Since with 5 RPS were still hitting secondly rate limits we lowered it to 4\n */\n public function getPaginatedData(array $payload, string $type, int $offset = 0): array\n {\n $total = 0;\n $lastId = null;\n $rows = [];\n foreach ($this->getPaginatedDataGenerator($payload, $type, $offset, $total, $lastId) as $row) {\n $rows[] = $row;\n }\n\n return ['results' => $rows, 'total' => $total, 'last_record' => $lastId];\n }\n\n /**\n * @throws HubspotException\n * @throws SocialAccountTokenInvalidException\n * @throws BadRequest\n */\n public function getPaginatedDataGenerator(\n array $payload,\n string $type,\n int $offset = 0,\n int &$total = 0,\n ?string &$lastRecordId = null\n ): \\Generator {\n return $this->paginationService->getPaginatedDataGenerator(\n $this,\n $payload,\n $type,\n $offset,\n $total,\n $lastRecordId\n );\n }\n\n /**\n * Execute a search request against HubSpot CRM objects with rate limiting.\n *\n * @param string $objectType The object type ('deals', 'companies', 'contacts', 'calls')\n * @param array<string, mixed> $payload The search payload with filters, sorts, properties, etc.\n * @return array The search response with 'results', 'total', 'paging' keys\n * @throws RateLimitException When rate limit is hit\n * @throws HubspotException On API errors\n */\n public function search(string $objectType, array $payload): array\n {\n $endpoint = self::BASE_URL . \"/crm/v3/objects/{$objectType}/search\";\n\n return $this->executeRequest(function () use ($endpoint, $payload) {\n $response = $this->getInstance()->getClient()->request('POST', $endpoint, ['json' => $payload]);\n\n return $response->toArray();\n });\n }\n\n /**\n * @throws DealApiException\n * @throws CrmException\n */\n public function getOpportunityById(string $crmId, array $fields): array\n {\n try {\n// $deal = $this->executeRequest(fn () => $this->getNewInstance()->crm()->deals()->basicApi()->getById(\n $deal = $this->getNewInstance()->crm()->deals()->basicApi()->getById(\n $crmId,\n implode(',', $fields),\n 'companies,contacts'\n );\n } catch (DealApiException $e) {\n $this->log->info('[Hubspot] Failed to fetch opportunity', [\n 'crm_id' => $crmId,\n 'reason' => $e->getMessage(),\n ]);\n\n throw $e;\n }\n\n if (! $deal instanceof DealWithAssociations) {\n throw new CrmException('Deal not found');\n }\n\n return [\n 'id' => $deal->getId(),\n 'properties' => $deal->getProperties(),\n 'associations' => $deal->getAssociations(),\n ];\n }\n\n /**\n * Generic batch read method for HubSpot objects\n *\n * @param string $objectType The object type ('deals', 'companies', 'contacts')\n * @param array<string> $crmIds Array of HubSpot object IDs (max 100)\n * @param array<string> $fields Array of property names to fetch\n *\n * @return array<string, array> Array keyed by CRM ID with object data\n */\n private function batchReadObjects(string $objectType, array $crmIds, array $fields): array\n {\n if (empty($crmIds)) {\n return [];\n }\n\n $this->validateBatchSize($objectType, $crmIds);\n $this->ensureValidToken();\n\n try {\n $batchConfig = $this->createBatchConfiguration($objectType);\n $batchReadRequest = $this->prepareBatchRequest($batchConfig, $crmIds, $fields);\n $response = $batchConfig['api']->read($batchReadRequest);\n\n $this->validateApiResponse($response, $objectType);\n\n $results = $this->processApiResults($response);\n $this->logBatchResults($objectType, $crmIds, $results);\n\n return $results;\n } catch (\\Throwable $e) {\n $this->handleBatchError($e, $objectType, $crmIds);\n }\n }\n\n private function validateBatchSize(string $objectType, array $crmIds): void\n {\n if (count($crmIds) > 100) {\n throw new \\InvalidArgumentException(\"Batch size cannot exceed 100 {$objectType}\");\n }\n }\n\n private function createBatchConfiguration(string $objectType): array\n {\n $configurations = [\n 'deals' => [\n 'batchReadRequest' => new \\HubSpot\\Client\\Crm\\Deals\\Model\\BatchReadInputSimplePublicObjectId(),\n 'inputClass' => \\HubSpot\\Client\\Crm\\Deals\\Model\\SimplePublicObjectId::class,\n 'api' => $this->getNewInstance()->crm()->deals()->batchApi(),\n ],\n 'companies' => [\n 'batchReadRequest' => new \\HubSpot\\Client\\Crm\\Companies\\Model\\BatchReadInputSimplePublicObjectId(),\n 'inputClass' => \\HubSpot\\Client\\Crm\\Companies\\Model\\SimplePublicObjectId::class,\n 'api' => $this->getNewInstance()->crm()->companies()->batchApi(),\n ],\n 'contacts' => [\n 'batchReadRequest' => new \\HubSpot\\Client\\Crm\\Contacts\\Model\\BatchReadInputSimplePublicObjectId(),\n 'inputClass' => \\HubSpot\\Client\\Crm\\Contacts\\Model\\SimplePublicObjectId::class,\n 'api' => $this->getNewInstance()->crm()->contacts()->batchApi(),\n ],\n ];\n\n if (! isset($configurations[$objectType])) {\n throw new \\InvalidArgumentException(\"Unsupported object type: {$objectType}\");\n }\n\n return $configurations[$objectType];\n }\n\n private function prepareBatchRequest(array $batchConfig, array $crmIds, array $fields): object\n {\n $batchReadRequest = $batchConfig['batchReadRequest'];\n $inputClass = $batchConfig['inputClass'];\n\n $inputs = array_map(function ($crmId) use ($inputClass) {\n $input = new $inputClass();\n $input->setId($crmId);\n\n return $input;\n }, $crmIds);\n\n $batchReadRequest->setInputs($inputs);\n $batchReadRequest->setProperties($fields);\n\n return $batchReadRequest;\n }\n\n private function validateApiResponse($response, string $objectType): void\n {\n if (! $response) {\n throw new CrmException(\"HubSpot API returned null response for {$objectType} batch read\");\n }\n }\n\n private function processApiResults($response): array\n {\n $results = [];\n $responseResults = $response->getResults();\n\n if ($responseResults) {\n foreach ($responseResults as $object) {\n if ($object && $object->getId()) {\n $results[$object->getId()] = [\n 'id' => $object->getId(),\n 'properties' => $object->getProperties() ?: [],\n ];\n }\n }\n }\n\n return $results;\n }\n\n private function logBatchResults(string $objectType, array $crmIds, array $results): void\n {\n $this->log->info(\"[HubSpot] Batch fetched {$objectType}\", [\n 'requested_count' => count($crmIds),\n 'returned_count' => count($results),\n 'crm_ids' => $crmIds,\n ]);\n }\n\n private function handleBatchError(\\Throwable $e, string $objectType, array $crmIds): void\n {\n $errorMessage = $e->getMessage() ?: 'Unknown error';\n $errorTrace = $e->getTraceAsString() ?: 'No trace available';\n\n $this->log->error(\"[HubSpot] Failed to batch fetch {$objectType}\", [\n 'crm_ids' => $crmIds,\n 'error' => $errorMessage,\n 'trace' => $errorTrace,\n ]);\n\n throw new CrmException(\"Failed to batch fetch {$objectType}: \" . $errorMessage);\n }\n\n /**\n * Batch read multiple opportunities by their CRM IDs\n *\n * @param array<string> $crmIds Array of HubSpot deal IDs (max 100)\n * @param array<string> $fields Array of property names to fetch\n *\n * @return array<string, array> Array keyed by CRM ID with opportunity data\n */\n public function getOpportunitiesByIds(array $crmIds, array $fields): array\n {\n return $this->batchReadObjects('deals', $crmIds, $fields);\n }\n\n /**\n * Batch read multiple companies by their CRM IDs\n *\n * @param array<string> $crmIds Array of HubSpot company IDs (max 100)\n * @param array<string> $fields Array of property names to fetch\n *\n * @return array<string, array> Array keyed by CRM ID with company data\n */\n public function getCompaniesByIds(array $crmIds, array $fields): array\n {\n return $this->batchReadObjects('companies', $crmIds, $fields);\n }\n\n /**\n * Batch read multiple contacts by their CRM IDs\n *\n * @param array<string> $crmIds Array of HubSpot contact IDs (max 100)\n * @param array<string> $fields Array of property names to fetch\n *\n * @return array<string, array> Array keyed by CRM ID with contact data\n */\n public function getContactsByIds(array $crmIds, array $fields): array\n {\n return $this->batchReadObjects('contacts', $crmIds, $fields);\n }\n\n /**\n * @throws CompanyApiException\n * @throws CrmException\n */\n public function getAccountById(string $crmId, array $fields): array\n {\n try {\n $company = $this->getNewInstance()->crm()->companies()->basicApi()->getById(\n $crmId,\n implode(',', $fields),\n );\n } catch (CompanyApiException $e) {\n $this->log->info('[Hubspot] Failed to fetch account', [\n 'crm_id' => $crmId,\n 'reason' => $e->getMessage(),\n ]);\n\n throw $e;\n }\n\n if (! $company instanceof CompaniesWithAssociations) {\n throw new CrmException('Account not found');\n }\n\n return [\n 'id' => $company->getId(),\n 'properties' => $company->getProperties(),\n ];\n }\n\n /**\n * @throws ContactApiException\n * @throws CrmException\n */\n public function getContactById(string $crmId, array $fields): array\n {\n try {\n $contact = $this->getNewInstance()->crm()->contacts()->basicApi()->getById(\n $crmId,\n implode(',', $fields)\n );\n } catch (ContactApiException $e) {\n $this->log->info('[Hubspot] Failed to fetch contact', [\n 'crm_id' => $crmId,\n 'reason' => $e->getMessage(),\n ]);\n\n throw $e;\n }\n\n if (! $contact instanceof ContactsWithAssociations) {\n throw new CrmException('Contact not found');\n }\n\n return [\n 'id' => $contact->getId(),\n 'properties' => $contact->getProperties(),\n ];\n }\n\n /**\n * This is email search request that Hubspot offers as GET (more generous quota)\n */\n public function getContactByEmail(string $email, array $fields = []): array\n {\n try {\n $contact = $this->getNewInstance()->crm()->contacts()->basicApi()->getById(\n $email,\n implode(',', $fields),\n null,\n false,\n 'email'\n );\n\n return [\n 'id' => $contact->getId(),\n 'properties' => $contact->getProperties(),\n ];\n } catch (ContactApiException $e) {\n $this->log->info('[Hubspot] Failed to fetch contact', [\n 'email' => $email,\n 'reason' => $e->getMessage(),\n ]);\n\n return [];\n }\n }\n\n /**\n * @throws CrmException\n */\n public function fetchProperty(string $objectType, string $propertyId): Property\n {\n $result = $this->getNewInstance()->crm()->properties()->coreApi()->getByName($objectType, $propertyId);\n\n if (! $result instanceof Property) {\n $this->log->error('[Hubspot] Failed to fetch property', [\n 'object_type' => $objectType,\n 'property_id' => $propertyId,\n 'reason' => $result->getMessage(),\n ]);\n\n throw new CrmException('Failed to fetch property');\n }\n\n return $result;\n }\n\n /**\n * @return array<CrmFieldOption>\n */\n public function fetchPropertyOptions(string $objectType, string $propertyId): array\n {\n /** @var array<CrmFieldOption> */\n return $this->fetchProperty($objectType, $propertyId)->getOptions();\n }\n\n /**\n * @return array<array{id:string, label:string, deleted:bool}>\n */\n public function fetchCallDispositions(): array\n {\n /** @var Response $response */\n $response = $this->getInstance()->engagements()->getCallDispositions();\n\n /**\n * @var array<array{\n * id:string,\n * label:string,\n * deleted: bool\n * }>\n */\n return $response->toArray();\n }\n\n /**\n * @return array<CrmFieldOption>\n */\n public function fetchOpportunityPipelineStages(): array\n {\n $stages = [];\n $apiResponse = $this->getNewInstance()->crm()->pipelines()->pipelinesApi()->getAll('deals');\n\n if ($apiResponse instanceof Error) {\n $this->log->error('[Hubspot] Failed to fetch opportunity pipelines', [\n 'reason' => $apiResponse->getMessage(),\n ]);\n\n return [];\n }\n\n foreach ($apiResponse->getResults() as $pipeline) {\n $pipelineStages = array_map(\n static function (PipelineStage $stage) {\n return [\n 'id' => $stage->getId(),\n 'label' => $stage->getLabel(),\n ];\n },\n $pipeline->getStages()\n );\n\n $stages = array_merge($stages, $pipelineStages);\n }\n\n return $stages;\n }\n\n public function fetchOpportunityPipelines(): array\n {\n $pipelines = [];\n\n try {\n $apiResponse = $this->makeRequest('/crm/v3/pipelines/deals');\n } catch (\\Exception $e) {\n $this->log->info('[Hubspot] Failed to fetch opportunity pipelines', [\n 'reason' => $e->getMessage(),\n ]);\n\n return [];\n }\n\n $response = $apiResponse->toArray();\n\n foreach ($response['results'] as $pipeline) {\n $pipelines[] = [\n 'id' => $pipeline['id'],\n 'label' => $pipeline['label'],\n ];\n }\n\n return $pipelines;\n }\n\n /**\n * @return array<CrmFieldOption>\n */\n public function fetchMeetingOutcomeFieldOptions(Field $field): array\n {\n return $field->getCrmProviderId() === 'meetingOutcome'\n ? $this->fetchMeetingOutcomeTypes()\n : $this->fetchCallActivityTypes();\n }\n\n public function fetchMeetingOutcomeTypes(): array\n {\n return $this->extractMeetingTypeOptions(\n 'https://api.hubapi.com/crm/v3/properties/meeting/hs_meeting_outcome'\n );\n }\n\n public function fetchCallActivityTypes(): array\n {\n return $this->extractMeetingTypeOptions(\n 'https://api.hubapi.com/crm/v3/properties/call/hs_activity_type'\n );\n }\n\n private function extractMeetingTypeOptions(string $endpoint): array\n {\n /** @var Response $response */\n $response = $this->getInstance()\n ->getClient()\n ->request('GET', $endpoint);\n\n /**\n * @var array<array{\n * value: string,\n * label: string,\n * displayOrder: int\n * }> $optionData\n */\n $optionData = $response->toArray()['options'] ?? [];\n\n $options = [];\n foreach ($optionData as $item) {\n $options[] = [\n 'id' => $item['value'],\n 'value' => $item['value'],\n 'label' => $item['label'],\n 'display_order' => $item['displayOrder'],\n ];\n }\n\n return $options;\n }\n\n /**\n * @return array<CrmFieldOption>\n */\n public function fetchDispositionFieldOptions(): array\n {\n $options = [];\n\n $dispositions = $this->fetchCallDispositions();\n\n foreach ($dispositions as $disposition) {\n if ($disposition['deleted'] !== false) {\n continue;\n }\n\n $option['value'] = $disposition['id'];\n $option['id'] = $disposition['id'];\n $option['label'] = $disposition['label'];\n\n $options[] = $option;\n }\n\n return $options;\n }\n\n /**\n * @return array<CrmFieldOption>\n */\n public function fetchOpportunityFieldOptions(Field $field): array\n {\n if ($field->isStageField()) {\n return $this->fetchOpportunityPipelineStages();\n }\n\n if ($field->isPipelineField()) {\n return $this->fetchOpportunityPipelines();\n }\n\n return $this->fetchPropertyOptions('deals', $field->getCrmProviderId());\n }\n\n /**\n * @throws BadRequest\n * @throws HubspotException\n */\n public function makeRequest(string $endpoint, $method = 'GET', $payload = [], ?string $queryString = null)\n {\n $endpoint = self::BASE_URL . $endpoint;\n\n if ($method === 'GET') {\n $response = $this->getInstance()->getClient()?->request(\n method: $method,\n endpoint: $endpoint,\n query_string: $queryString\n );\n } else {\n $response = $this->getInstance()->getClient()->request($method, $endpoint, [\n 'json' => ($payload),\n ]);\n }\n//\n// $max = $response->getHeaderLine('X-HubSpot-RateLimit-Max'); // \"110\"\n// $remaining = $response->getHeaderLine('X-HubSpot-RateLimit-Remaining'); // \"109\"\n// $interval = $response->getHeaderLine('X-HubSpot-RateLimit-Interval-Milliseconds'); // \"10000\"\n// $body = json_decode((string) $response->getBody(), true);\n//\n// \\Illuminate\\Support\\Facades\\Log::channel('custom_channel')->info('$max ' . PHP_EOL . print_r($max, true));\n// \\Illuminate\\Support\\Facades\\Log::channel('custom_channel')->info('$remaining ' . PHP_EOL . print_r($remaining, true));\n// \\Illuminate\\Support\\Facades\\Log::channel('custom_channel')->info('$interval ' . PHP_EOL . print_r($interval, true));\n// \\Illuminate\\Support\\Facades\\Log::channel('custom_channel')->info('$body ' . PHP_EOL . print_r($body, true));\n\n return $response;\n }\n\n /**\n * @throws BadRequest\n * @throws HubspotException\n */\n public function createMeeting(array $payload): Response\n {\n $endpoint = '/crm/v3/objects/meetings';\n\n return $this->makeRequest($endpoint, 'POST', $payload);\n }\n\n /**\n * @throws BadRequest\n * @throws HubspotException\n */\n public function updateMeeting(string $meetingId, array $payload): Response\n {\n $endpoint = '/crm/v3/objects/meetings/' . $meetingId;\n\n return $this->makeRequest($endpoint, 'PATCH', $payload);\n }\n\n /**\n * @throws \\Exception\n */\n public function createNote(\n string $body,\n string $ownerId,\n int $timestamp,\n string $objectId,\n NoteObject $noteObject\n ): ?string {\n try {\n $noteInput = new SimplePublicObjectInput([\n 'properties' => [\n 'hs_note_body' => $body,\n 'hubspot_owner_id' => $ownerId,\n 'hs_timestamp' => $timestamp,\n ],\n ]);\n\n // Create note\n $note = $this->getNewInstance()->crm()->objects()->basicApi()->create('note', $noteInput);\n\n $this->getNewInstance()->crm()->objects()->associationsApi()->create(\n 'note',\n $note->getId(),\n $this->getNoteObject($noteObject),\n $objectId,\n $this->getNoteAssociationType($noteObject),\n );\n\n return $note->getId();\n } catch (\\Exception $e) {\n $this->log->error('[Hubspot] Failed to create note', [\n 'objectId' => $objectId,\n 'noteObject' => $noteObject->getObjectType(),\n 'reason' => $e->getMessage(),\n ]);\n\n \\Sentry::captureException($e);\n }\n\n return null;\n }\n\n public function updateEngagement(string $objectId, array $engagement, array $metadata): void\n {\n $this->getInstance()->engagements()->update($objectId, $engagement, $metadata);\n }\n\n public function getEngagementData(string $engagementId): array\n {\n $engagement = $this->getInstance()->engagements()->get($engagementId);\n\n return $engagement->toArray();\n }\n\n public function createEngagement(array $engagement, array $associations, array $metadata): Response\n {\n return $this->getInstance()\n ->engagements()\n ->create($engagement, $associations, $metadata);\n }\n\n public function isUnauthorizedException(\\Exception $e): bool\n {\n if ($e instanceof BadRequest\n || $e instanceof DealApiException\n || $e instanceof ContactApiException\n || $e instanceof CompanyApiException\n ) {\n return (int) $e->getCode() === 401;\n }\n\n if ($e instanceof \\GuzzleHttp\\Exception\\RequestException && $e->hasResponse()) {\n return $e->getResponse()?->getStatusCode() === 401;\n }\n\n $message = strtolower($e->getMessage());\n\n return str_contains($message, '401 unauthorized') ||\n str_contains($message, 'http 401') ||\n str_contains($message, 'status code 401') ||\n (preg_match('/\\b401\\b/', $message) === 1 && str_contains($message, 'unauthorized'));\n }\n\n /**\n * Validates and refreshes the access token if needed before API requests.\n * This ensures long-running processes don't fail due to token expiration.\n *\n * @throws SocialAccountTokenInvalidException\n */\n public function ensureValidToken(): void\n {\n if ($this->oauthAccount === null) {\n return;\n }\n\n $newToken = $this->tokenManager->ensureValidToken($this->oauthAccount);\n if ($newToken !== null) {\n $this->accessToken = $newToken;\n }\n }\n\n public function getConfig()\n {\n return $this->config;\n }\n\n // returns only active (archived=false)\n public function getOwners(): array\n {\n return $this->getNewInstance()->crm()->owners()->getAll();\n }\n\n /**\n * @param bool $archived\n *\n * @return array<Owner>|[]\n */\n public function getOwnersArchived(bool $archived = true): array\n {\n $endpoint = '/crm/v3/owners';\n $queryParams = [\n 'archived' => $archived ? 'true' : 'false',\n ];\n $queryString = http_build_query($queryParams);\n\n $owners = [];\n\n try {\n $response = $this->makeRequest(endpoint: $endpoint, queryString: $queryString);\n $responseData = $response?->toArray();\n\n foreach ($responseData['results'] as $result) {\n try {\n $owners[] = Owner::create($result);\n } catch (Throwable $e) {\n $this->log->error('[HubSpot] Failed to process owner data', [\n 'result' => $result,\n 'error' => $e->getMessage(),\n ]);\n\n continue;\n }\n }\n } catch (Throwable $e) {\n $this->log->error('HubSpot] Failed to fetch owners', [\n 'archived' => $archived,\n 'error' => $e->getMessage(),\n ]);\n\n return [];\n }\n\n return $owners;\n }\n\n public function getMeeting(string $engagementId): ObjectWithAssociations\n {\n return $this->getNewInstance()->crm()->objects()->basicApi()\n ->getById('meeting', $engagementId, null, 'contact,company,deal');\n }\n\n public function deleteEngagement(string $engagementId): void\n {\n $this->getInstance()->engagements()->delete((int) $engagementId);\n }\n\n public function getAssociationsData(array $ids, string $fromObject, string $toObject): array\n {\n $associationData = [];\n $idChunks = array_chunk($ids, self::ASSOCIATIONS_BATCH_SIZE_LIMIT);\n\n foreach ($idChunks as $idChunk) {\n try {\n $batchInput = new \\HubSpot\\Client\\Crm\\Associations\\Model\\BatchInputPublicObjectId();\n $batchInput->setInputs(array_map(function ($id) {\n $publicObjectId = new \\HubSpot\\Client\\Crm\\Associations\\Model\\PublicObjectId();\n $publicObjectId->setId($id);\n\n return $publicObjectId;\n }, $idChunk));\n\n $associatedObjectsData = $this\n ->getNewInstance()\n ->crm()\n ->associations()\n ->batchApi()\n ->read($fromObject, $toObject, $batchInput);\n\n if ($associatedObjectsData instanceof \\HubSpot\\Client\\Crm\\Associations\\Model\\BatchResponsePublicAssociationMulti) {\n foreach ($associatedObjectsData->getResults() as $association) {\n $from = $association->getFrom()->getId();\n $toAssociations = $association->getTo();\n\n if (! empty($toAssociations)) {\n $associationData[$from] = array_map(function ($item) {\n return $item->getId();\n }, $toAssociations);\n }\n }\n }\n } catch (RateLimitException $e) {\n throw $e;\n } catch (\\Exception $e) {\n $this->log->error('[Hubspot] Failed to fetch associations', [\n 'from_object' => $fromObject,\n 'to_object' => $toObject,\n 'reason' => $e->getMessage(),\n ]);\n }\n }\n\n return $associationData;\n }\n\n /**\n * @throws \\Exception\n */\n private function getNoteAssociationType(NoteObject $noteObject): string\n {\n return match($noteObject) {\n NoteObject::Opportunity => 'note_to_deal',\n NoteObject::Lead, NoteObject::Contact => 'note_to_contact', // or 'note_to_lead' if your portal supports it\n NoteObject::Account => 'note_to_company',\n NoteObject::Call, NoteObject::Event => throw new \\Exception('Not supported'),\n };\n }\n\n /**\n * @throws \\Exception\n */\n private function getNoteObject(NoteObject $noteObject): string\n {\n return match($noteObject) {\n NoteObject::Opportunity => 'deal',\n NoteObject::Lead, NoteObject::Contact => 'contact',\n NoteObject::Account => 'company',\n NoteObject::Call, NoteObject::Event => throw new \\Exception('Not supported'),\n };\n }\n\n public function addAssociations(string $objectType, string $associationType, array $payload): Response\n {\n $endpoint = \"/crm/v4/associations/$objectType/$associationType/batch/create\";\n\n return $this->makeRequest($endpoint, 'POST', $payload);\n }\n\n public function removeAssociations(string $objectType, string $associationType, array $payload): Response\n {\n $endpoint = \"/crm/v4/associations/$objectType/$associationType/batch/archive\";\n\n return $this->makeRequest($endpoint, 'POST', $payload);\n }\n}","depth":4,"on_screen":true,"value":"<?php\n\ndeclare(strict_types=1);\n\nnamespace Jiminny\\Services\\Crm\\Hubspot;\n\nuse HubSpot\\Client\\Crm\\Deals\\ApiException as DealApiException;\nuse HubSpot\\Client\\Crm\\Contacts\\ApiException as ContactApiException;\nuse HubSpot\\Client\\Crm\\Companies\\ApiException as CompanyApiException;\nuse HubSpot\\Client\\Crm\\Contacts\\Model\\SimplePublicObjectWithAssociations as ContactsWithAssociations;\nuse HubSpot\\Client\\Crm\\Companies\\Model\\SimplePublicObjectWithAssociations as CompaniesWithAssociations;\nuse HubSpot\\Client\\Crm\\Deals\\Model\\SimplePublicObjectWithAssociations as DealWithAssociations;\nuse HubSpot\\Client\\Crm\\Objects\\Model\\SimplePublicObjectInput;\nuse HubSpot\\Client\\Crm\\Objects\\Model\\SimplePublicObjectWithAssociations as ObjectWithAssociations;\nuse HubSpot\\Client\\Crm\\Pipelines\\Model\\Error;\nuse HubSpot\\Client\\Crm\\Pipelines\\Model\\PipelineStage;\nuse HubSpot\\Client\\Crm\\Properties\\Model\\Property;\nuse HubSpot\\Discovery\\Discovery;\nuse Jiminny\\Exceptions\\CrmException;\nuse Jiminny\\Exceptions\\RateLimitException;\nuse Jiminny\\Exceptions\\SocialAccountTokenInvalidException;\nuse Jiminny\\Jobs\\Crm\\NoteObject;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Services\\Crm\\BaseClient;\nuse Jiminny\\Services\\Crm\\Hubspot\\DTO\\Response\\Owner;\nuse Jiminny\\Services\\SocialAccountService;\nuse SevenShores\\Hubspot\\Exceptions\\BadRequest;\nuse SevenShores\\Hubspot\\Exceptions\\HubspotException;\nuse SevenShores\\Hubspot\\Factory;\nuse SevenShores\\Hubspot\\Http\\Response;\nuse Jiminny\\Services\\Crm\\Hubspot\\Pagination\\HubspotPaginationService;\nuse Throwable;\n\n/**\n * @phpstan-type CrmFieldOption array{id:string, label:string, value?:string}\n */\nclass Client extends BaseClient implements HubspotClientInterface\n{\n public const string MIN_API_VERSION = '2';\n\n public const string BASE_URL = 'https://api.hubapi.com';\n\n public const int ASSOCIATIONS_BATCH_SIZE_LIMIT = 1000;\n\n private HubspotPaginationService $paginationService;\n private HubspotTokenManager $tokenManager;\n\n public function __construct(\n SocialAccountService $socialAccountService,\n HubspotPaginationService $paginationService,\n HubspotTokenManager $tokenManager,\n ) {\n parent::__construct($socialAccountService);\n $this->paginationService = $paginationService;\n $this->tokenManager = $tokenManager;\n\n $this->setBaseUrl(self::BASE_URL);\n $this->setVersion(self::MIN_API_VERSION);\n }\n\n /**\n * Reacts to a rate limits (429) from HubSpot by translating it\n * into a RateLimitException carrying retry_after.\n *\n * Wrap any outbound HubSpot call (SDK or raw HTTP) like:\n *\n * $this->executeRequest(fn () => $this->getNewInstance()->crm()->...);\n *\n * @template T\n * @param callable(): T $apiCall\n * @return T\n *\n * @throws RateLimitException\n */\n private function executeRequest(callable $apiCall)\n {\n try {\n return $apiCall();\n } catch (Throwable $e) {\n if ($this->isHubspotRateLimit($e)) {\n $retryAfter = $this->parseRetryAfter($e);\n\n $this->log->warning('[Hubspot] Received 429 from API', [\n 'team_id' => $this->config->team_id,\n 'config_id' => $this->config->getId(),\n 'retry_after' => $retryAfter,\n 'reason' => $e->getMessage(),\n ]);\n\n throw new RateLimitException('Hubspot returned 429', $retryAfter, $e);\n }\n\n throw $e;\n }\n }\n\n public function isHubspotRateLimit(Throwable $e): bool\n {\n if ($e instanceof BadRequest\n || $e instanceof DealApiException\n || $e instanceof ContactApiException\n || $e instanceof CompanyApiException\n || $e instanceof \\GuzzleHttp\\Exception\\RequestException\n ) {\n return (int) $e->getCode() === 429;\n }\n\n return false;\n }\n\n public function parseRetryAfter(Throwable $e): int\n {\n // First try to get Retry-After from response headers\n if (method_exists($e, 'getResponseHeaders')) {\n $headers = $e->getResponseHeaders() ?: [];\n $value = $headers['Retry-After'] ?? $headers['retry-after'] ?? null;\n if (is_array($value)) {\n $value = $value[0] ?? null;\n }\n if (is_numeric($value)) {\n return (int) $value;\n }\n }\n\n if (method_exists($e, 'getResponseBody')) {\n $body = $e->getResponseBody();\n if (is_string($body)) {\n $body = json_decode($body, true) ?? [];\n }\n\n $policyName = $body['policyName'] ?? $body['policy'] ?? $body['context']['policyName'] ?? null;\n\n if ($policyName === 'TEN_SECONDLY_ROLLING' || $policyName === 'ten_secondly_rolling') {\n return 10;\n }\n if ($policyName === 'SECONDLY' || $policyName === 'secondly') {\n return 1;\n }\n }\n\n $this->log->warning('[Hubspot] No retry-after header or policy name found, using default', [\n 'exception_class' => get_class($e),\n ]);\n\n return 10;\n }\n\n public function getMinimumApiVersion(): string\n {\n return self::MIN_API_VERSION;\n }\n\n public function getInstance(): Factory\n {\n return new Factory([\n 'key' => $this->accessToken,\n 'oauth2' => true,\n 'base_url' => $this->baseUrl,\n ]);\n }\n\n public function getNewInstance(): Discovery\n {\n return \\HubSpot\\Factory::createWithAccessToken($this->accessToken);\n }\n\n /**\n * Secondly and daily limits for Hubspot API\n *\n * Product Tier: Free & Starter | Professional & Enterprise | API add-on (any tier)\n * Burst: 100/10 seconds | 150/10 seconds | 200/10 seconds\n * Daily: 250,000 | 500,000 | 1,000,000\n *\n * Official documentation states: The search endpoints are rate limited to five requests per second.\n * Since with 5 RPS were still hitting secondly rate limits we lowered it to 4\n */\n public function getPaginatedData(array $payload, string $type, int $offset = 0): array\n {\n $total = 0;\n $lastId = null;\n $rows = [];\n foreach ($this->getPaginatedDataGenerator($payload, $type, $offset, $total, $lastId) as $row) {\n $rows[] = $row;\n }\n\n return ['results' => $rows, 'total' => $total, 'last_record' => $lastId];\n }\n\n /**\n * @throws HubspotException\n * @throws SocialAccountTokenInvalidException\n * @throws BadRequest\n */\n public function getPaginatedDataGenerator(\n array $payload,\n string $type,\n int $offset = 0,\n int &$total = 0,\n ?string &$lastRecordId = null\n ): \\Generator {\n return $this->paginationService->getPaginatedDataGenerator(\n $this,\n $payload,\n $type,\n $offset,\n $total,\n $lastRecordId\n );\n }\n\n /**\n * Execute a search request against HubSpot CRM objects with rate limiting.\n *\n * @param string $objectType The object type ('deals', 'companies', 'contacts', 'calls')\n * @param array<string, mixed> $payload The search payload with filters, sorts, properties, etc.\n * @return array The search response with 'results', 'total', 'paging' keys\n * @throws RateLimitException When rate limit is hit\n * @throws HubspotException On API errors\n */\n public function search(string $objectType, array $payload): array\n {\n $endpoint = self::BASE_URL . \"/crm/v3/objects/{$objectType}/search\";\n\n return $this->executeRequest(function () use ($endpoint, $payload) {\n $response = $this->getInstance()->getClient()->request('POST', $endpoint, ['json' => $payload]);\n\n return $response->toArray();\n });\n }\n\n /**\n * @throws DealApiException\n * @throws CrmException\n */\n public function getOpportunityById(string $crmId, array $fields): array\n {\n try {\n// $deal = $this->executeRequest(fn () => $this->getNewInstance()->crm()->deals()->basicApi()->getById(\n $deal = $this->getNewInstance()->crm()->deals()->basicApi()->getById(\n $crmId,\n implode(',', $fields),\n 'companies,contacts'\n );\n } catch (DealApiException $e) {\n $this->log->info('[Hubspot] Failed to fetch opportunity', [\n 'crm_id' => $crmId,\n 'reason' => $e->getMessage(),\n ]);\n\n throw $e;\n }\n\n if (! $deal instanceof DealWithAssociations) {\n throw new CrmException('Deal not found');\n }\n\n return [\n 'id' => $deal->getId(),\n 'properties' => $deal->getProperties(),\n 'associations' => $deal->getAssociations(),\n ];\n }\n\n /**\n * Generic batch read method for HubSpot objects\n *\n * @param string $objectType The object type ('deals', 'companies', 'contacts')\n * @param array<string> $crmIds Array of HubSpot object IDs (max 100)\n * @param array<string> $fields Array of property names to fetch\n *\n * @return array<string, array> Array keyed by CRM ID with object data\n */\n private function batchReadObjects(string $objectType, array $crmIds, array $fields): array\n {\n if (empty($crmIds)) {\n return [];\n }\n\n $this->validateBatchSize($objectType, $crmIds);\n $this->ensureValidToken();\n\n try {\n $batchConfig = $this->createBatchConfiguration($objectType);\n $batchReadRequest = $this->prepareBatchRequest($batchConfig, $crmIds, $fields);\n $response = $batchConfig['api']->read($batchReadRequest);\n\n $this->validateApiResponse($response, $objectType);\n\n $results = $this->processApiResults($response);\n $this->logBatchResults($objectType, $crmIds, $results);\n\n return $results;\n } catch (\\Throwable $e) {\n $this->handleBatchError($e, $objectType, $crmIds);\n }\n }\n\n private function validateBatchSize(string $objectType, array $crmIds): void\n {\n if (count($crmIds) > 100) {\n throw new \\InvalidArgumentException(\"Batch size cannot exceed 100 {$objectType}\");\n }\n }\n\n private function createBatchConfiguration(string $objectType): array\n {\n $configurations = [\n 'deals' => [\n 'batchReadRequest' => new \\HubSpot\\Client\\Crm\\Deals\\Model\\BatchReadInputSimplePublicObjectId(),\n 'inputClass' => \\HubSpot\\Client\\Crm\\Deals\\Model\\SimplePublicObjectId::class,\n 'api' => $this->getNewInstance()->crm()->deals()->batchApi(),\n ],\n 'companies' => [\n 'batchReadRequest' => new \\HubSpot\\Client\\Crm\\Companies\\Model\\BatchReadInputSimplePublicObjectId(),\n 'inputClass' => \\HubSpot\\Client\\Crm\\Companies\\Model\\SimplePublicObjectId::class,\n 'api' => $this->getNewInstance()->crm()->companies()->batchApi(),\n ],\n 'contacts' => [\n 'batchReadRequest' => new \\HubSpot\\Client\\Crm\\Contacts\\Model\\BatchReadInputSimplePublicObjectId(),\n 'inputClass' => \\HubSpot\\Client\\Crm\\Contacts\\Model\\SimplePublicObjectId::class,\n 'api' => $this->getNewInstance()->crm()->contacts()->batchApi(),\n ],\n ];\n\n if (! isset($configurations[$objectType])) {\n throw new \\InvalidArgumentException(\"Unsupported object type: {$objectType}\");\n }\n\n return $configurations[$objectType];\n }\n\n private function prepareBatchRequest(array $batchConfig, array $crmIds, array $fields): object\n {\n $batchReadRequest = $batchConfig['batchReadRequest'];\n $inputClass = $batchConfig['inputClass'];\n\n $inputs = array_map(function ($crmId) use ($inputClass) {\n $input = new $inputClass();\n $input->setId($crmId);\n\n return $input;\n }, $crmIds);\n\n $batchReadRequest->setInputs($inputs);\n $batchReadRequest->setProperties($fields);\n\n return $batchReadRequest;\n }\n\n private function validateApiResponse($response, string $objectType): void\n {\n if (! $response) {\n throw new CrmException(\"HubSpot API returned null response for {$objectType} batch read\");\n }\n }\n\n private function processApiResults($response): array\n {\n $results = [];\n $responseResults = $response->getResults();\n\n if ($responseResults) {\n foreach ($responseResults as $object) {\n if ($object && $object->getId()) {\n $results[$object->getId()] = [\n 'id' => $object->getId(),\n 'properties' => $object->getProperties() ?: [],\n ];\n }\n }\n }\n\n return $results;\n }\n\n private function logBatchResults(string $objectType, array $crmIds, array $results): void\n {\n $this->log->info(\"[HubSpot] Batch fetched {$objectType}\", [\n 'requested_count' => count($crmIds),\n 'returned_count' => count($results),\n 'crm_ids' => $crmIds,\n ]);\n }\n\n private function handleBatchError(\\Throwable $e, string $objectType, array $crmIds): void\n {\n $errorMessage = $e->getMessage() ?: 'Unknown error';\n $errorTrace = $e->getTraceAsString() ?: 'No trace available';\n\n $this->log->error(\"[HubSpot] Failed to batch fetch {$objectType}\", [\n 'crm_ids' => $crmIds,\n 'error' => $errorMessage,\n 'trace' => $errorTrace,\n ]);\n\n throw new CrmException(\"Failed to batch fetch {$objectType}: \" . $errorMessage);\n }\n\n /**\n * Batch read multiple opportunities by their CRM IDs\n *\n * @param array<string> $crmIds Array of HubSpot deal IDs (max 100)\n * @param array<string> $fields Array of property names to fetch\n *\n * @return array<string, array> Array keyed by CRM ID with opportunity data\n */\n public function getOpportunitiesByIds(array $crmIds, array $fields): array\n {\n return $this->batchReadObjects('deals', $crmIds, $fields);\n }\n\n /**\n * Batch read multiple companies by their CRM IDs\n *\n * @param array<string> $crmIds Array of HubSpot company IDs (max 100)\n * @param array<string> $fields Array of property names to fetch\n *\n * @return array<string, array> Array keyed by CRM ID with company data\n */\n public function getCompaniesByIds(array $crmIds, array $fields): array\n {\n return $this->batchReadObjects('companies', $crmIds, $fields);\n }\n\n /**\n * Batch read multiple contacts by their CRM IDs\n *\n * @param array<string> $crmIds Array of HubSpot contact IDs (max 100)\n * @param array<string> $fields Array of property names to fetch\n *\n * @return array<string, array> Array keyed by CRM ID with contact data\n */\n public function getContactsByIds(array $crmIds, array $fields): array\n {\n return $this->batchReadObjects('contacts', $crmIds, $fields);\n }\n\n /**\n * @throws CompanyApiException\n * @throws CrmException\n */\n public function getAccountById(string $crmId, array $fields): array\n {\n try {\n $company = $this->getNewInstance()->crm()->companies()->basicApi()->getById(\n $crmId,\n implode(',', $fields),\n );\n } catch (CompanyApiException $e) {\n $this->log->info('[Hubspot] Failed to fetch account', [\n 'crm_id' => $crmId,\n 'reason' => $e->getMessage(),\n ]);\n\n throw $e;\n }\n\n if (! $company instanceof CompaniesWithAssociations) {\n throw new CrmException('Account not found');\n }\n\n return [\n 'id' => $company->getId(),\n 'properties' => $company->getProperties(),\n ];\n }\n\n /**\n * @throws ContactApiException\n * @throws CrmException\n */\n public function getContactById(string $crmId, array $fields): array\n {\n try {\n $contact = $this->getNewInstance()->crm()->contacts()->basicApi()->getById(\n $crmId,\n implode(',', $fields)\n );\n } catch (ContactApiException $e) {\n $this->log->info('[Hubspot] Failed to fetch contact', [\n 'crm_id' => $crmId,\n 'reason' => $e->getMessage(),\n ]);\n\n throw $e;\n }\n\n if (! $contact instanceof ContactsWithAssociations) {\n throw new CrmException('Contact not found');\n }\n\n return [\n 'id' => $contact->getId(),\n 'properties' => $contact->getProperties(),\n ];\n }\n\n /**\n * This is email search request that Hubspot offers as GET (more generous quota)\n */\n public function getContactByEmail(string $email, array $fields = []): array\n {\n try {\n $contact = $this->getNewInstance()->crm()->contacts()->basicApi()->getById(\n $email,\n implode(',', $fields),\n null,\n false,\n 'email'\n );\n\n return [\n 'id' => $contact->getId(),\n 'properties' => $contact->getProperties(),\n ];\n } catch (ContactApiException $e) {\n $this->log->info('[Hubspot] Failed to fetch contact', [\n 'email' => $email,\n 'reason' => $e->getMessage(),\n ]);\n\n return [];\n }\n }\n\n /**\n * @throws CrmException\n */\n public function fetchProperty(string $objectType, string $propertyId): Property\n {\n $result = $this->getNewInstance()->crm()->properties()->coreApi()->getByName($objectType, $propertyId);\n\n if (! $result instanceof Property) {\n $this->log->error('[Hubspot] Failed to fetch property', [\n 'object_type' => $objectType,\n 'property_id' => $propertyId,\n 'reason' => $result->getMessage(),\n ]);\n\n throw new CrmException('Failed to fetch property');\n }\n\n return $result;\n }\n\n /**\n * @return array<CrmFieldOption>\n */\n public function fetchPropertyOptions(string $objectType, string $propertyId): array\n {\n /** @var array<CrmFieldOption> */\n return $this->fetchProperty($objectType, $propertyId)->getOptions();\n }\n\n /**\n * @return array<array{id:string, label:string, deleted:bool}>\n */\n public function fetchCallDispositions(): array\n {\n /** @var Response $response */\n $response = $this->getInstance()->engagements()->getCallDispositions();\n\n /**\n * @var array<array{\n * id:string,\n * label:string,\n * deleted: bool\n * }>\n */\n return $response->toArray();\n }\n\n /**\n * @return array<CrmFieldOption>\n */\n public function fetchOpportunityPipelineStages(): array\n {\n $stages = [];\n $apiResponse = $this->getNewInstance()->crm()->pipelines()->pipelinesApi()->getAll('deals');\n\n if ($apiResponse instanceof Error) {\n $this->log->error('[Hubspot] Failed to fetch opportunity pipelines', [\n 'reason' => $apiResponse->getMessage(),\n ]);\n\n return [];\n }\n\n foreach ($apiResponse->getResults() as $pipeline) {\n $pipelineStages = array_map(\n static function (PipelineStage $stage) {\n return [\n 'id' => $stage->getId(),\n 'label' => $stage->getLabel(),\n ];\n },\n $pipeline->getStages()\n );\n\n $stages = array_merge($stages, $pipelineStages);\n }\n\n return $stages;\n }\n\n public function fetchOpportunityPipelines(): array\n {\n $pipelines = [];\n\n try {\n $apiResponse = $this->makeRequest('/crm/v3/pipelines/deals');\n } catch (\\Exception $e) {\n $this->log->info('[Hubspot] Failed to fetch opportunity pipelines', [\n 'reason' => $e->getMessage(),\n ]);\n\n return [];\n }\n\n $response = $apiResponse->toArray();\n\n foreach ($response['results'] as $pipeline) {\n $pipelines[] = [\n 'id' => $pipeline['id'],\n 'label' => $pipeline['label'],\n ];\n }\n\n return $pipelines;\n }\n\n /**\n * @return array<CrmFieldOption>\n */\n public function fetchMeetingOutcomeFieldOptions(Field $field): array\n {\n return $field->getCrmProviderId() === 'meetingOutcome'\n ? $this->fetchMeetingOutcomeTypes()\n : $this->fetchCallActivityTypes();\n }\n\n public function fetchMeetingOutcomeTypes(): array\n {\n return $this->extractMeetingTypeOptions(\n 'https://api.hubapi.com/crm/v3/properties/meeting/hs_meeting_outcome'\n );\n }\n\n public function fetchCallActivityTypes(): array\n {\n return $this->extractMeetingTypeOptions(\n 'https://api.hubapi.com/crm/v3/properties/call/hs_activity_type'\n );\n }\n\n private function extractMeetingTypeOptions(string $endpoint): array\n {\n /** @var Response $response */\n $response = $this->getInstance()\n ->getClient()\n ->request('GET', $endpoint);\n\n /**\n * @var array<array{\n * value: string,\n * label: string,\n * displayOrder: int\n * }> $optionData\n */\n $optionData = $response->toArray()['options'] ?? [];\n\n $options = [];\n foreach ($optionData as $item) {\n $options[] = [\n 'id' => $item['value'],\n 'value' => $item['value'],\n 'label' => $item['label'],\n 'display_order' => $item['displayOrder'],\n ];\n }\n\n return $options;\n }\n\n /**\n * @return array<CrmFieldOption>\n */\n public function fetchDispositionFieldOptions(): array\n {\n $options = [];\n\n $dispositions = $this->fetchCallDispositions();\n\n foreach ($dispositions as $disposition) {\n if ($disposition['deleted'] !== false) {\n continue;\n }\n\n $option['value'] = $disposition['id'];\n $option['id'] = $disposition['id'];\n $option['label'] = $disposition['label'];\n\n $options[] = $option;\n }\n\n return $options;\n }\n\n /**\n * @return array<CrmFieldOption>\n */\n public function fetchOpportunityFieldOptions(Field $field): array\n {\n if ($field->isStageField()) {\n return $this->fetchOpportunityPipelineStages();\n }\n\n if ($field->isPipelineField()) {\n return $this->fetchOpportunityPipelines();\n }\n\n return $this->fetchPropertyOptions('deals', $field->getCrmProviderId());\n }\n\n /**\n * @throws BadRequest\n * @throws HubspotException\n */\n public function makeRequest(string $endpoint, $method = 'GET', $payload = [], ?string $queryString = null)\n {\n $endpoint = self::BASE_URL . $endpoint;\n\n if ($method === 'GET') {\n $response = $this->getInstance()->getClient()?->request(\n method: $method,\n endpoint: $endpoint,\n query_string: $queryString\n );\n } else {\n $response = $this->getInstance()->getClient()->request($method, $endpoint, [\n 'json' => ($payload),\n ]);\n }\n//\n// $max = $response->getHeaderLine('X-HubSpot-RateLimit-Max'); // \"110\"\n// $remaining = $response->getHeaderLine('X-HubSpot-RateLimit-Remaining'); // \"109\"\n// $interval = $response->getHeaderLine('X-HubSpot-RateLimit-Interval-Milliseconds'); // \"10000\"\n// $body = json_decode((string) $response->getBody(), true);\n//\n// \\Illuminate\\Support\\Facades\\Log::channel('custom_channel')->info('$max ' . PHP_EOL . print_r($max, true));\n// \\Illuminate\\Support\\Facades\\Log::channel('custom_channel')->info('$remaining ' . PHP_EOL . print_r($remaining, true));\n// \\Illuminate\\Support\\Facades\\Log::channel('custom_channel')->info('$interval ' . PHP_EOL . print_r($interval, true));\n// \\Illuminate\\Support\\Facades\\Log::channel('custom_channel')->info('$body ' . PHP_EOL . print_r($body, true));\n\n return $response;\n }\n\n /**\n * @throws BadRequest\n * @throws HubspotException\n */\n public function createMeeting(array $payload): Response\n {\n $endpoint = '/crm/v3/objects/meetings';\n\n return $this->makeRequest($endpoint, 'POST', $payload);\n }\n\n /**\n * @throws BadRequest\n * @throws HubspotException\n */\n public function updateMeeting(string $meetingId, array $payload): Response\n {\n $endpoint = '/crm/v3/objects/meetings/' . $meetingId;\n\n return $this->makeRequest($endpoint, 'PATCH', $payload);\n }\n\n /**\n * @throws \\Exception\n */\n public function createNote(\n string $body,\n string $ownerId,\n int $timestamp,\n string $objectId,\n NoteObject $noteObject\n ): ?string {\n try {\n $noteInput = new SimplePublicObjectInput([\n 'properties' => [\n 'hs_note_body' => $body,\n 'hubspot_owner_id' => $ownerId,\n 'hs_timestamp' => $timestamp,\n ],\n ]);\n\n // Create note\n $note = $this->getNewInstance()->crm()->objects()->basicApi()->create('note', $noteInput);\n\n $this->getNewInstance()->crm()->objects()->associationsApi()->create(\n 'note',\n $note->getId(),\n $this->getNoteObject($noteObject),\n $objectId,\n $this->getNoteAssociationType($noteObject),\n );\n\n return $note->getId();\n } catch (\\Exception $e) {\n $this->log->error('[Hubspot] Failed to create note', [\n 'objectId' => $objectId,\n 'noteObject' => $noteObject->getObjectType(),\n 'reason' => $e->getMessage(),\n ]);\n\n \\Sentry::captureException($e);\n }\n\n return null;\n }\n\n public function updateEngagement(string $objectId, array $engagement, array $metadata): void\n {\n $this->getInstance()->engagements()->update($objectId, $engagement, $metadata);\n }\n\n public function getEngagementData(string $engagementId): array\n {\n $engagement = $this->getInstance()->engagements()->get($engagementId);\n\n return $engagement->toArray();\n }\n\n public function createEngagement(array $engagement, array $associations, array $metadata): Response\n {\n return $this->getInstance()\n ->engagements()\n ->create($engagement, $associations, $metadata);\n }\n\n public function isUnauthorizedException(\\Exception $e): bool\n {\n if ($e instanceof BadRequest\n || $e instanceof DealApiException\n || $e instanceof ContactApiException\n || $e instanceof CompanyApiException\n ) {\n return (int) $e->getCode() === 401;\n }\n\n if ($e instanceof \\GuzzleHttp\\Exception\\RequestException && $e->hasResponse()) {\n return $e->getResponse()?->getStatusCode() === 401;\n }\n\n $message = strtolower($e->getMessage());\n\n return str_contains($message, '401 unauthorized') ||\n str_contains($message, 'http 401') ||\n str_contains($message, 'status code 401') ||\n (preg_match('/\\b401\\b/', $message) === 1 && str_contains($message, 'unauthorized'));\n }\n\n /**\n * Validates and refreshes the access token if needed before API requests.\n * This ensures long-running processes don't fail due to token expiration.\n *\n * @throws SocialAccountTokenInvalidException\n */\n public function ensureValidToken(): void\n {\n if ($this->oauthAccount === null) {\n return;\n }\n\n $newToken = $this->tokenManager->ensureValidToken($this->oauthAccount);\n if ($newToken !== null) {\n $this->accessToken = $newToken;\n }\n }\n\n public function getConfig()\n {\n return $this->config;\n }\n\n // returns only active (archived=false)\n public function getOwners(): array\n {\n return $this->getNewInstance()->crm()->owners()->getAll();\n }\n\n /**\n * @param bool $archived\n *\n * @return array<Owner>|[]\n */\n public function getOwnersArchived(bool $archived = true): array\n {\n $endpoint = '/crm/v3/owners';\n $queryParams = [\n 'archived' => $archived ? 'true' : 'false',\n ];\n $queryString = http_build_query($queryParams);\n\n $owners = [];\n\n try {\n $response = $this->makeRequest(endpoint: $endpoint, queryString: $queryString);\n $responseData = $response?->toArray();\n\n foreach ($responseData['results'] as $result) {\n try {\n $owners[] = Owner::create($result);\n } catch (Throwable $e) {\n $this->log->error('[HubSpot] Failed to process owner data', [\n 'result' => $result,\n 'error' => $e->getMessage(),\n ]);\n\n continue;\n }\n }\n } catch (Throwable $e) {\n $this->log->error('HubSpot] Failed to fetch owners', [\n 'archived' => $archived,\n 'error' => $e->getMessage(),\n ]);\n\n return [];\n }\n\n return $owners;\n }\n\n public function getMeeting(string $engagementId): ObjectWithAssociations\n {\n return $this->getNewInstance()->crm()->objects()->basicApi()\n ->getById('meeting', $engagementId, null, 'contact,company,deal');\n }\n\n public function deleteEngagement(string $engagementId): void\n {\n $this->getInstance()->engagements()->delete((int) $engagementId);\n }\n\n public function getAssociationsData(array $ids, string $fromObject, string $toObject): array\n {\n $associationData = [];\n $idChunks = array_chunk($ids, self::ASSOCIATIONS_BATCH_SIZE_LIMIT);\n\n foreach ($idChunks as $idChunk) {\n try {\n $batchInput = new \\HubSpot\\Client\\Crm\\Associations\\Model\\BatchInputPublicObjectId();\n $batchInput->setInputs(array_map(function ($id) {\n $publicObjectId = new \\HubSpot\\Client\\Crm\\Associations\\Model\\PublicObjectId();\n $publicObjectId->setId($id);\n\n return $publicObjectId;\n }, $idChunk));\n\n $associatedObjectsData = $this\n ->getNewInstance()\n ->crm()\n ->associations()\n ->batchApi()\n ->read($fromObject, $toObject, $batchInput);\n\n if ($associatedObjectsData instanceof \\HubSpot\\Client\\Crm\\Associations\\Model\\BatchResponsePublicAssociationMulti) {\n foreach ($associatedObjectsData->getResults() as $association) {\n $from = $association->getFrom()->getId();\n $toAssociations = $association->getTo();\n\n if (! empty($toAssociations)) {\n $associationData[$from] = array_map(function ($item) {\n return $item->getId();\n }, $toAssociations);\n }\n }\n }\n } catch (RateLimitException $e) {\n throw $e;\n } catch (\\Exception $e) {\n $this->log->error('[Hubspot] Failed to fetch associations', [\n 'from_object' => $fromObject,\n 'to_object' => $toObject,\n 'reason' => $e->getMessage(),\n ]);\n }\n }\n\n return $associationData;\n }\n\n /**\n * @throws \\Exception\n */\n private function getNoteAssociationType(NoteObject $noteObject): string\n {\n return match($noteObject) {\n NoteObject::Opportunity => 'note_to_deal',\n NoteObject::Lead, NoteObject::Contact => 'note_to_contact', // or 'note_to_lead' if your portal supports it\n NoteObject::Account => 'note_to_company',\n NoteObject::Call, NoteObject::Event => throw new \\Exception('Not supported'),\n };\n }\n\n /**\n * @throws \\Exception\n */\n private function getNoteObject(NoteObject $noteObject): string\n {\n return match($noteObject) {\n NoteObject::Opportunity => 'deal',\n NoteObject::Lead, NoteObject::Contact => 'contact',\n NoteObject::Account => 'company',\n NoteObject::Call, NoteObject::Event => throw new \\Exception('Not supported'),\n };\n }\n\n public function addAssociations(string $objectType, string $associationType, array $payload): Response\n {\n $endpoint = \"/crm/v4/associations/$objectType/$associationType/batch/create\";\n\n return $this->makeRequest($endpoint, 'POST', $payload);\n }\n\n public function removeAssociations(string $objectType, string $associationType, array $payload): Response\n {\n $endpoint = \"/crm/v4/associations/$objectType/$associationType/batch/archive\";\n\n return $this->makeRequest($endpoint, 'POST', $payload);\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":true,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Project","depth":3,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Project","depth":3,"bounds":{"left":0.011968086,"top":0.047885075,"width":0.024268618,"height":0.024740623},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"New File or Directory…","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Expand Selected","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Collapse All","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Options","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
7579307838644216966
|
-2772555093692774298
|
visual_change
|
accessibility
|
NULL
|
Project: faVsco.js, menu
JY-20725-handle-HS-search Project: faVsco.js, menu
JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
19
Previous Highlighted Error
Next Highlighted Error
[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {
"headers":{
"Date":["Thu,07 May 2026 14:21:15 GMT"],
"Content-Type":["application/json;charset=utf-8"],
"Transfer-Encoding":["chunked"],
"Connection":["keep-alive"],
"CF-Ray":["9f80deb8db60dc3a-SOF"],
"CF-Cache-Status":["DYNAMIC"],
"Strict-Transport-Security":["max-age=31536000; includeSubDomains; preload"],
"Vary":["origin,
accept-encoding"],
"access-control-allow-credentials":["false"],
"server-timing":["hcid;desc=\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\",
cfr;desc=\"9f80deb8e7c6dc3a-IAD\""],
"x-content-type-options":["nosniff"],
"x-hubspot-correlation-id":["019e02d0-6fd8-7812-bdba-885b7ccb3ee3"],
"Set-Cookie":["__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-[IP_ADDRESS]-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,
07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None"],
"Report-To":["{
\"endpoints\":[{
\"url\":\"https:\\/\\/a.nel.cloudflare.com\\/report\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\"}],
\"group\":\"cf-nel\",
\"max_age\":604800}"],
"NEL":["{
\"success_fraction\":0.01,
\"report_to\":\"cf-nel\",
\"max_age\":604800}"],
"Server":["cloudflare"]}} {
"correlation_id":"95236535-ec98-4541-b92a-adfa73b69eab",
"trace_id":"c7ab8365-903f-46d4-9403-0e5b551e3545"}
Show Replace Field
Search History
rateLimiter
New Line
Match Case
Words
Regex
Replace History
Replace
New Line
Preserve case
0 results
Previous Occurrence
Next Occurrence
Filter Search Results
Open in Window, Multiple Cursors
Click to highlight
Close
Sync Changes
Hide This Notification
Code changed:
Hide
2
64
1
1
Previous Highlighted Error
Next Highlighted Error
<?php
declare(strict_types=1);
namespace Jiminny\Services\Crm\Hubspot;
use HubSpot\Client\Crm\Deals\ApiException as DealApiException;
use HubSpot\Client\Crm\Contacts\ApiException as ContactApiException;
use HubSpot\Client\Crm\Companies\ApiException as CompanyApiException;
use HubSpot\Client\Crm\Contacts\Model\SimplePublicObjectWithAssociations as ContactsWithAssociations;
use HubSpot\Client\Crm\Companies\Model\SimplePublicObjectWithAssociations as CompaniesWithAssociations;
use HubSpot\Client\Crm\Deals\Model\SimplePublicObjectWithAssociations as DealWithAssociations;
use HubSpot\Client\Crm\Objects\Model\SimplePublicObjectInput;
use HubSpot\Client\Crm\Objects\Model\SimplePublicObjectWithAssociations as ObjectWithAssociations;
use HubSpot\Client\Crm\Pipelines\Model\Error;
use HubSpot\Client\Crm\Pipelines\Model\PipelineStage;
use HubSpot\Client\Crm\Properties\Model\Property;
use HubSpot\Discovery\Discovery;
use Jiminny\Exceptions\CrmException;
use Jiminny\Exceptions\RateLimitException;
use Jiminny\Exceptions\SocialAccountTokenInvalidException;
use Jiminny\Jobs\Crm\NoteObject;
use Jiminny\Models\Crm\Field;
use Jiminny\Services\Crm\BaseClient;
use Jiminny\Services\Crm\Hubspot\DTO\Response\Owner;
use Jiminny\Services\SocialAccountService;
use SevenShores\Hubspot\Exceptions\BadRequest;
use SevenShores\Hubspot\Exceptions\HubspotException;
use SevenShores\Hubspot\Factory;
use SevenShores\Hubspot\Http\Response;
use Jiminny\Services\Crm\Hubspot\Pagination\HubspotPaginationService;
use Throwable;
/**
* @phpstan-type CrmFieldOption array{id:string, label:string, value?:string}
*/
class Client extends BaseClient implements HubspotClientInterface
{
public const string MIN_API_VERSION = '2';
public const string BASE_URL = '[URL_WITH_CREDENTIALS] T
* @param callable(): T $apiCall
* @return T
*
* @throws RateLimitException
*/
private function executeRequest(callable $apiCall)
{
try {
return $apiCall();
} catch (Throwable $e) {
if ($this->isHubspotRateLimit($e)) {
$retryAfter = $this->parseRetryAfter($e);
$this->log->warning('[Hubspot] Received 429 from API', [
'team_id' => $this->config->team_id,
'config_id' => $this->config->getId(),
'retry_after' => $retryAfter,
'reason' => $e->getMessage(),
]);
throw new RateLimitException('Hubspot returned 429', $retryAfter, $e);
}
throw $e;
}
}
public function isHubspotRateLimit(Throwable $e): bool
{
if ($e instanceof BadRequest
|| $e instanceof DealApiException
|| $e instanceof ContactApiException
|| $e instanceof CompanyApiException
|| $e instanceof \GuzzleHttp\Exception\RequestException
) {
return (int) $e->getCode() === 429;
}
return false;
}
public function parseRetryAfter(Throwable $e): int
{
// First try to get Retry-After from response headers
if (method_exists($e, 'getResponseHeaders')) {
$headers = $e->getResponseHeaders() ?: [];
$value = $headers['Retry-After'] ?? $headers['retry-after'] ?? null;
if (is_array($value)) {
$value = $value[0] ?? null;
}
if (is_numeric($value)) {
return (int) $value;
}
}
if (method_exists($e, 'getResponseBody')) {
$body = $e->getResponseBody();
if (is_string($body)) {
$body = json_decode($body, true) ?? [];
}
$policyName = $body['policyName'] ?? $body['policy'] ?? $body['context']['policyName'] ?? null;
if ($policyName === 'TEN_SECONDLY_ROLLING' || $policyName === 'ten_secondly_rolling') {
return 10;
}
if ($policyName === 'SECONDLY' || $policyName === 'secondly') {
return 1;
}
}
$this->log->warning('[Hubspot] No retry-after header or policy name found, using default', [
'exception_class' => get_class($e),
]);
return 10;
}
public function getMinimumApiVersion(): string
{
return self::MIN_API_VERSION;
}
public function getInstance(): Factory
{
return new Factory([
'key' => $this->accessToken,
'oauth2' => true,
'base_url' => $this->baseUrl,
]);
}
public function getNewInstance(): Discovery
{
return \HubSpot\Factory::createWithAccessToken($this->accessToken);
}
/**
* Secondly and daily limits for Hubspot API
*
* Product Tier: Free & Starter | Professional & Enterprise | API add-on (any tier)
* Burst: 100/10 seconds | 150/10 seconds | 200/10 seconds
* Daily: 250,000 | 500,000 | 1,000,000
*
* Official documentation states: The search endpoints are rate limited to five requests per second.
* Since with 5 RPS were still hitting secondly rate limits we lowered it to 4
*/
public function getPaginatedData(array $payload, string $type, int $offset = 0): array
{
$total = 0;
$lastId = null;
$rows = [];
foreach ($this->getPaginatedDataGenerator($payload, $type, $offset, $total, $lastId) as $row) {
$rows[] = $row;
}
return ['results' => $rows, 'total' => $total, 'last_record' => $lastId];
}
/**
* @throws HubspotException
* @throws SocialAccountTokenInvalidException
* @throws BadRequest
*/
public function getPaginatedDataGenerator(
array $payload,
string $type,
int $offset = 0,
int &$total = 0,
?string &$lastRecordId = null
): \Generator {
return $this->paginationService->getPaginatedDataGenerator(
$this,
$payload,
$type,
$offset,
$total,
$lastRecordId
);
}
/**
* Execute a search request against HubSpot CRM objects with rate limiting.
*
* @param string $objectType The object type ('deals', 'companies', 'contacts', 'calls')
* @param array<string, mixed> $payload The search payload with filters, sorts, properties, etc.
* @return array The search response with 'results', 'total', 'paging' keys
* @throws RateLimitException When rate limit is hit
* @throws HubspotException On API errors
*/
public function search(string $objectType, array $payload): array
{
$endpoint = self::BASE_URL . "/crm/v3/objects/{$objectType}/search";
return $this->executeRequest(function () use ($endpoint, $payload) {
$response = $this->getInstance()->getClient()->request('POST', $endpoint, ['json' => $payload]);
return $response->toArray();
});
}
/**
* @throws DealApiException
* @throws CrmException
*/
public function getOpportunityById(string $crmId, array $fields): array
{
try {
// $deal = $this->executeRequest(fn () => $this->getNewInstance()->crm()->deals()->basicApi()->getById(
$deal = $this->getNewInstance()->crm()->deals()->basicApi()->getById(
$crmId,
implode(',', $fields),
'companies,contacts'
);
} catch (DealApiException $e) {
$this->log->info('[Hubspot] Failed to fetch opportunity', [
'crm_id' => $crmId,
'reason' => $e->getMessage(),
]);
throw $e;
}
if (! $deal instanceof DealWithAssociations) {
throw new CrmException('Deal not found');
}
return [
'id' => $deal->getId(),
'properties' => $deal->getProperties(),
'associations' => $deal->getAssociations(),
];
}
/**
* Generic batch read method for HubSpot objects
*
* @param string $objectType The object type ('deals', 'companies', 'contacts')
* @param array<string> $crmIds Array of HubSpot object IDs (max 100)
* @param array<string> $fields Array of property names to fetch
*
* @return array<string, array> Array keyed by CRM ID with object data
*/
private function batchReadObjects(string $objectType, array $crmIds, array $fields): array
{
if (empty($crmIds)) {
return [];
}
$this->validateBatchSize($objectType, $crmIds);
$this->ensureValidToken();
try {
$batchConfig = $this->createBatchConfiguration($objectType);
$batchReadRequest = $this->prepareBatchRequest($batchConfig, $crmIds, $fields);
$response = $batchConfig['api']->read($batchReadRequest);
$this->validateApiResponse($response, $objectType);
$results = $this->processApiResults($response);
$this->logBatchResults($objectType, $crmIds, $results);
return $results;
} catch (\Throwable $e) {
$this->handleBatchError($e, $objectType, $crmIds);
}
}
private function validateBatchSize(string $objectType, array $crmIds): void
{
if (count($crmIds) > 100) {
throw new \InvalidArgumentException("Batch size cannot exceed 100 {$objectType}");
}
}
private function createBatchConfiguration(string $objectType): array
{
$configurations = [
'deals' => [
'batchReadRequest' => new \HubSpot\Client\Crm\Deals\Model\BatchReadInputSimplePublicObjectId(),
'inputClass' => \HubSpot\Client\Crm\Deals\Model\SimplePublicObjectId::class,
'api' => $this->getNewInstance()->crm()->deals()->batchApi(),
],
'companies' => [
'batchReadRequest' => new \HubSpot\Client\Crm\Companies\Model\BatchReadInputSimplePublicObjectId(),
'inputClass' => \HubSpot\Client\Crm\Companies\Model\SimplePublicObjectId::class,
'api' => $this->getNewInstance()->crm()->companies()->batchApi(),
],
'contacts' => [
'batchReadRequest' => new \HubSpot\Client\Crm\Contacts\Model\BatchReadInputSimplePublicObjectId(),
'inputClass' => \HubSpot\Client\Crm\Contacts\Model\SimplePublicObjectId::class,
'api' => $this->getNewInstance()->crm()->contacts()->batchApi(),
],
];
if (! isset($configurations[$objectType])) {
throw new \InvalidArgumentException("Unsupported object type: {$objectType}");
}
return $configurations[$objectType];
}
private function prepareBatchRequest(array $batchConfig, array $crmIds, array $fields): object
{
$batchReadRequest = $batchConfig['batchReadRequest'];
$inputClass = $batchConfig['inputClass'];
$inputs = array_map(function ($crmId) use ($inputClass) {
$input = new $inputClass();
$input->setId($crmId);
return $input;
}, $crmIds);
$batchReadRequest->setInputs($inputs);
$batchReadRequest->setProperties($fields);
return $batchReadRequest;
}
private function validateApiResponse($response, string $objectType): void
{
if (! $response) {
throw new CrmException("HubSpot API returned null response for {$objectType} batch read");
}
}
private function processApiResults($response): array
{
$results = [];
$responseResults = $response->getResults();
if ($responseResults) {
foreach ($responseResults as $object) {
if ($object && $object->getId()) {
$results[$object->getId()] = [
'id' => $object->getId(),
'properties' => $object->getProperties() ?: [],
];
}
}
}
return $results;
}
private function logBatchResults(string $objectType, array $crmIds, array $results): void
{
$this->log->info("[HubSpot] Batch fetched {$objectType}", [
'requested_count' => count($crmIds),
'returned_count' => count($results),
'crm_ids' => $crmIds,
]);
}
private function handleBatchError(\Throwable $e, string $objectType, array $crmIds): void
{
$errorMessage = $e->getMessage() ?: 'Unknown error';
$errorTrace = $e->getTraceAsString() ?: 'No trace available';
$this->log->error("[HubSpot] Failed to batch fetch {$objectType}", [
'crm_ids' => $crmIds,
'error' => $errorMessage,
'trace' => $errorTrace,
]);
throw new CrmException("Failed to batch fetch {$objectType}: " . $errorMessage);
}
/**
* Batch read multiple opportunities by their CRM IDs
*
* @param array<string> $crmIds Array of HubSpot deal IDs (max 100)
* @param array<string> $fields Array of property names to fetch
*
* @return array<string, array> Array keyed by CRM ID with opportunity data
*/
public function getOpportunitiesByIds(array $crmIds, array $fields): array
{
return $this->batchReadObjects('deals', $crmIds, $fields);
}
/**
* Batch read multiple companies by their CRM IDs
*
* @param array<string> $crmIds Array of HubSpot company IDs (max 100)
* @param array<string> $fields Array of property names to fetch
*
* @return array<string, array> Array keyed by CRM ID with company data
*/
public function getCompaniesByIds(array $crmIds, array $fields): array
{
return $this->batchReadObjects('companies', $crmIds, $fields);
}
/**
* Batch read multiple contacts by their CRM IDs
*
* @param array<string> $crmIds Array of HubSpot contact IDs (max 100)
* @param array<string> $fields Array of property names to fetch
*
* @return array<string, array> Array keyed by CRM ID with contact data
*/
public function getContactsByIds(array $crmIds, array $fields): array
{
return $this->batchReadObjects('contacts', $crmIds, $fields);
}
/**
* @throws CompanyApiException
* @throws CrmException
*/
public function getAccountById(string $crmId, array $fields): array
{
try {
$company = $this->getNewInstance()->crm()->companies()->basicApi()->getById(
$crmId,
implode(',', $fields),
);
} catch (CompanyApiException $e) {
$this->log->info('[Hubspot] Failed to fetch account', [
'crm_id' => $crmId,
'reason' => $e->getMessage(),
]);
throw $e;
}
if (! $company instanceof CompaniesWithAssociations) {
throw new CrmException('Account not found');
}
return [
'id' => $company->getId(),
'properties' => $company->getProperties(),
];
}
/**
* @throws ContactApiException
* @throws CrmException
*/
public function getContactById(string $crmId, array $fields): array
{
try {
$contact = $this->getNewInstance()->crm()->contacts()->basicApi()->getById(
$crmId,
implode(',', $fields)
);
} catch (ContactApiException $e) {
$this->log->info('[Hubspot] Failed to fetch contact', [
'crm_id' => $crmId,
'reason' => $e->getMessage(),
]);
throw $e;
}
if (! $contact instanceof ContactsWithAssociations) {
throw new CrmException('Contact not found');
}
return [
'id' => $contact->getId(),
'properties' => $contact->getProperties(),
];
}
/**
* This is email search request that Hubspot offers as GET (more generous quota)
*/
public function getContactByEmail(string $email, array $fields = []): array
{
try {
$contact = $this->getNewInstance()->crm()->contacts()->basicApi()->getById(
$email,
implode(',', $fields),
null,
false,
'email'
);
return [
'id' => $contact->getId(),
'properties' => $contact->getProperties(),
];
} catch (ContactApiException $e) {
$this->log->info('[Hubspot] Failed to fetch contact', [
'email' => $email,
'reason' => $e->getMessage(),
]);
return [];
}
}
/**
* @throws CrmException
*/
public function fetchProperty(string $objectType, string $propertyId): Property
{
$result = $this->getNewInstance()->crm()->properties()->coreApi()->getByName($objectType, $propertyId);
if (! $result instanceof Property) {
$this->log->error('[Hubspot] Failed to fetch property', [
'object_type' => $objectType,
'property_id' => $propertyId,
'reason' => $result->getMessage(),
]);
throw new CrmException('Failed to fetch property');
}
return $result;
}
/**
* @return array<CrmFieldOption>
*/
public function fetchPropertyOptions(string $objectType, string $propertyId): array
{
/** @var array<CrmFieldOption> */
return $this->fetchProperty($objectType, $propertyId)->getOptions();
}
/**
* @return array<array{id:string, label:string, deleted:bool}>
*/
public function fetchCallDispositions(): array
{
/** @var Response $response */
$response = $this->getInstance()->engagements()->getCallDispositions();
/**
* @var array<array{
* id:string,
* label:string,
* deleted: bool
* }>
*/
return $response->toArray();
}
/**
* @return array<CrmFieldOption>
*/
public function fetchOpportunityPipelineStages(): array
{
$stages = [];
$apiResponse = $this->getNewInstance()->crm()->pipelines()->pipelinesApi()->getAll('deals');
if ($apiResponse instanceof Error) {
$this->log->error('[Hubspot] Failed to fetch opportunity pipelines', [
'reason' => $apiResponse->getMessage(),
]);
return [];
}
foreach ($apiResponse->getResults() as $pipeline) {
$pipelineStages = array_map(
static function (PipelineStage $stage) {
return [
'id' => $stage->getId(),
'label' => $stage->getLabel(),
];
},
$pipeline->getStages()
);
$stages = array_merge($stages, $pipelineStages);
}
return $stages;
}
public function fetchOpportunityPipelines(): array
{
$pipelines = [];
try {
$apiResponse = $this->makeRequest('/crm/v3/pipelines/deals');
} catch (\Exception $e) {
$this->log->info('[Hubspot] Failed to fetch opportunity pipelines', [
'reason' => $e->getMessage(),
]);
return [];
}
$response = $apiResponse->toArray();
foreach ($response['results'] as $pipeline) {
$pipelines[] = [
'id' => $pipeline['id'],
'label' => $pipeline['label'],
];
}
return $pipelines;
}
/**
* @return array<CrmFieldOption>
*/
public function fetchMeetingOutcomeFieldOptions(Field $field): array
{
return $field->getCrmProviderId() === 'meetingOutcome'
? $this->fetchMeetingOutcomeTypes()
: $this->fetchCallActivityTypes();
}
public function fetchMeetingOutcomeTypes(): array
{
return $this->extractMeetingTypeOptions(
'[URL_WITH_CREDENTIALS] Response $response */
$response = $this->getInstance()
->getClient()
->request('GET', $endpoint);
/**
* @var array<array{
* value: string,
* label: string,
* displayOrder: int
* }> $optionData
*/
$optionData = $response->toArray()['options'] ?? [];
$options = [];
foreach ($optionData as $item) {
$options[] = [
'id' => $item['value'],
'value' => $item['value'],
'label' => $item['label'],
'display_order' => $item['displayOrder'],
];
}
return $options;
}
/**
* @return array<CrmFieldOption>
*/
public function fetchDispositionFieldOptions(): array
{
$options = [];
$dispositions = $this->fetchCallDispositions();
foreach ($dispositions as $disposition) {
if ($disposition['deleted'] !== false) {
continue;
}
$option['value'] = $disposition['id'];
$option['id'] = $disposition['id'];
$option['label'] = $disposition['label'];
$options[] = $option;
}
return $options;
}
/**
* @return array<CrmFieldOption>
*/
public function fetchOpportunityFieldOptions(Field $field): array
{
if ($field->isStageField()) {
return $this->fetchOpportunityPipelineStages();
}
if ($field->isPipelineField()) {
return $this->fetchOpportunityPipelines();
}
return $this->fetchPropertyOptions('deals', $field->getCrmProviderId());
}
/**
* @throws BadRequest
* @throws HubspotException
*/
public function makeRequest(string $endpoint, $method = 'GET', $payload = [], ?string $queryString = null)
{
$endpoint = self::BASE_URL . $endpoint;
if ($method === 'GET') {
$response = $this->getInstance()->getClient()?->request(
method: $method,
endpoint: $endpoint,
query_string: $queryString
);
} else {
$response = $this->getInstance()->getClient()->request($method, $endpoint, [
'json' => ($payload),
]);
}
//
// $max = $response->getHeaderLine('X-HubSpot-RateLimit-Max'); // "110"
// $remaining = $response->getHeaderLine('X-HubSpot-RateLimit-Remaining'); // "109"
// $interval = $response->getHeaderLine('X-HubSpot-RateLimit-Interval-Milliseconds'); // "10000"
// $body = json_decode((string) $response->getBody(), true);
//
// \Illuminate\Support\Facades\Log::channel('custom_channel')->info('$max ' . PHP_EOL . print_r($max, true));
// \Illuminate\Support\Facades\Log::channel('custom_channel')->info('$remaining ' . PHP_EOL . print_r($remaining, true));
// \Illuminate\Support\Facades\Log::channel('custom_channel')->info('$interval ' . PHP_EOL . print_r($interval, true));
// \Illuminate\Support\Facades\Log::channel('custom_channel')->info('$body ' . PHP_EOL . print_r($body, true));
return $response;
}
/**
* @throws BadRequest
* @throws HubspotException
*/
public function createMeeting(array $payload): Response
{
$endpoint = '/crm/v3/objects/meetings';
return $this->makeRequest($endpoint, 'POST', $payload);
}
/**
* @throws BadRequest
* @throws HubspotException
*/
public function updateMeeting(string $meetingId, array $payload): Response
{
$endpoint = '/crm/v3/objects/meetings/' . $meetingId;
return $this->makeRequest($endpoint, 'PATCH', $payload);
}
/**
* @throws \Exception
*/
public function createNote(
string $body,
string $ownerId,
int $timestamp,
string $objectId,
NoteObject $noteObject
): ?string {
try {
$noteInput = new SimplePublicObjectInput([
'properties' => [
'hs_note_body' => $body,
'hubspot_owner_id' => $ownerId,
'hs_timestamp' => $timestamp,
],
]);
// Create note
$note = $this->getNewInstance()->crm()->objects()->basicApi()->create('note', $noteInput);
$this->getNewInstance()->crm()->objects()->associationsApi()->create(
'note',
$note->getId(),
$this->getNoteObject($noteObject),
$objectId,
$this->getNoteAssociationType($noteObject),
);
return $note->getId();
} catch (\Exception $e) {
$this->log->error('[Hubspot] Failed to create note', [
'objectId' => $objectId,
'noteObject' => $noteObject->getObjectType(),
'reason' => $e->getMessage(),
]);
\Sentry::captureException($e);
}
return null;
}
public function updateEngagement(string $objectId, array $engagement, array $metadata): void
{
$this->getInstance()->engagements()->update($objectId, $engagement, $metadata);
}
public function getEngagementData(string $engagementId): array
{
$engagement = $this->getInstance()->engagements()->get($engagementId);
return $engagement->toArray();
}
public function createEngagement(array $engagement, array $associations, array $metadata): Response
{
return $this->getInstance()
->engagements()
->create($engagement, $associations, $metadata);
}
public function isUnauthorizedException(\Exception $e): bool
{
if ($e instanceof BadRequest
|| $e instanceof DealApiException
|| $e instanceof ContactApiException
|| $e instanceof CompanyApiException
) {
return (int) $e->getCode() === 401;
}
if ($e instanceof \GuzzleHttp\Exception\RequestException && $e->hasResponse()) {
return $e->getResponse()?->getStatusCode() === 401;
}
$message = strtolower($e->getMessage());
return str_contains($message, '401 unauthorized') ||
str_contains($message, 'http 401') ||
str_contains($message, 'status code 401') ||
(preg_match('/\b401\b/', $message) === 1 && str_contains($message, 'unauthorized'));
}
/**
* Validates and refreshes the access token if needed before API requests.
* This ensures long-running processes don't fail due to token expiration.
*
* @throws SocialAccountTokenInvalidException
*/
public function ensureValidToken(): void
{
if ($this->oauthAccount === null) {
return;
}
$newToken = $this->tokenManager->ensureValidToken($this->oauthAccount);
if ($newToken !== null) {
$this->accessToken = $newToken;
}
}
public function getConfig()
{
return $this->config;
}
// returns only active (archived=false)
public function getOwners(): array
{
return $this->getNewInstance()->crm()->owners()->getAll();
}
/**
* @param bool $archived
*
* @return array<Owner>|[]
*/
public function getOwnersArchived(bool $archived = true): array
{
$endpoint = '/crm/v3/owners';
$queryParams = [
'archived' => $archived ? 'true' : 'false',
];
$queryString = http_build_query($queryParams);
$owners = [];
try {
$response = $this->makeRequest(endpoint: $endpoint, queryString: $queryString);
$responseData = $response?->toArray();
foreach ($responseData['results'] as $result) {
try {
$owners[] = Owner::create($result);
} catch (Throwable $e) {
$this->log->error('[HubSpot] Failed to process owner data', [
'result' => $result,
'error' => $e->getMessage(),
]);
continue;
}
}
} catch (Throwable $e) {
$this->log->error('HubSpot] Failed to fetch owners', [
'archived' => $archived,
'error' => $e->getMessage(),
]);
return [];
}
return $owners;
}
public function getMeeting(string $engagementId): ObjectWithAssociations
{
return $this->getNewInstance()->crm()->objects()->basicApi()
->getById('meeting', $engagementId, null, 'contact,company,deal');
}
public function deleteEngagement(string $engagementId): void
{
$this->getInstance()->engagements()->delete((int) $engagementId);
}
public function getAssociationsData(array $ids, string $fromObject, string $toObject): array
{
$associationData = [];
$idChunks = array_chunk($ids, self::ASSOCIATIONS_BATCH_SIZE_LIMIT);
foreach ($idChunks as $idChunk) {
try {
$batchInput = new \HubSpot\Client\Crm\Associations\Model\BatchInputPublicObjectId();
$batchInput->setInputs(array_map(function ($id) {
$publicObjectId = new \HubSpot\Client\Crm\Associations\Model\PublicObjectId();
$publicObjectId->setId($id);
return $publicObjectId;
}, $idChunk));
$associatedObjectsData = $this
->getNewInstance()
->crm()
->associations()
->batchApi()
->read($fromObject, $toObject, $batchInput);
if ($associatedObjectsData instanceof \HubSpot\Client\Crm\Associations\Model\BatchResponsePublicAssociationMulti) {
foreach ($associatedObjectsData->getResults() as $association) {
$from = $association->getFrom()->getId();
$toAssociations = $association->getTo();
if (! empty($toAssociations)) {
$associationData[$from] = array_map(function ($item) {
return $item->getId();
}, $toAssociations);
}
}
}
} catch (RateLimitException $e) {
throw $e;
} catch (\Exception $e) {
$this->log->error('[Hubspot] Failed to fetch associations', [
'from_object' => $fromObject,
'to_object' => $toObject,
'reason' => $e->getMessage(),
]);
}
}
return $associationData;
}
/**
* @throws \Exception
*/
private function getNoteAssociationType(NoteObject $noteObject): string
{
return match($noteObject) {
NoteObject::Opportunity => 'note_to_deal',
NoteObject::Lead, NoteObject::Contact => 'note_to_contact', // or 'note_to_lead' if your portal supports it
NoteObject::Account => 'note_to_company',
NoteObject::Call, NoteObject::Event => throw new \Exception('Not supported'),
};
}
/**
* @throws \Exception
*/
private function getNoteObject(NoteObject $noteObject): string
{
return match($noteObject) {
NoteObject::Opportunity => 'deal',
NoteObject::Lead, NoteObject::Contact => 'contact',
NoteObject::Account => 'company',
NoteObject::Call, NoteObject::Event => throw new \Exception('Not supported'),
};
}
public function addAssociations(string $objectType, string $associationType, array $payload): Response
{
$endpoint = "/crm/v4/associations/$objectType/$associationType/batch/create";
return $this->makeRequest($endpoint, 'POST', $payload);
}
public function removeAssociations(string $objectType, string $associationType, array $payload): Response
{
$endpoint = "/crm/v4/associations/$objectType/$associationType/batch/archive";
return $this->makeRequest($endpoint, 'POST', $payload);
}
}
Project
Project
New File or Directory…
Expand Selected
Collapse All
Options
Hide...
|
9840
|
NULL
|
NULL
|
NULL
|
|
9840
|
444
|
26
|
2026-05-08T13:37:32.599168+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-08/1778 /Users/lukas/.screenpipe/data/data/2026-05-08/1778247452599_m2.jpg...
|
PhpStorm
|
faVsco.js – Client.php
|
1
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
JY-20725-handle-HS-search Project: faVsco.js, menu
JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
19
Previous Highlighted Error
Next Highlighted Error
[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {
"headers":{
"Date":["Thu,07 May 2026 14:21:15 GMT"],
"Content-Type":["application/json;charset=utf-8"],
"Transfer-Encoding":["chunked"],
"Connection":["keep-alive"],
"CF-Ray":["9f80deb8db60dc3a-SOF"],
"CF-Cache-Status":["DYNAMIC"],
"Strict-Transport-Security":["max-age=31536000; includeSubDomains; preload"],
"Vary":["origin,
accept-encoding"],
"access-control-allow-credentials":["false"],
"server-timing":["hcid;desc=\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\",
cfr;desc=\"9f80deb8e7c6dc3a-IAD\""],
"x-content-type-options":["nosniff"],
"x-hubspot-correlation-id":["019e02d0-6fd8-7812-bdba-885b7ccb3ee3"],
"Set-Cookie":["__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-[IP_ADDRESS]-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,
07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None"],
"Report-To":["{
\"endpoints\":[{
\"url\":\"https:\\/\\/a.nel.cloudflare.com\\/report\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\"}],
\"group\":\"cf-nel\",
\"max_age\":604800}"],
"NEL":["{
\"success_fraction\":0.01,
\"report_to\":\"cf-nel\",
\"max_age\":604800}"],
"Server":["cloudflare"]}} {
"correlation_id":"95236535-ec98-4541-b92a-adfa73b69eab",
"trace_id":"c7ab8365-903f-46d4-9403-0e5b551e3545"}
Show Replace Field
Search History
rateLimiter
New Line
Match Case
Words
Regex...
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"bounds":{"left":0.025930852,"top":0.019952115,"width":0.03856383,"height":0.025538707},"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JY-20725-handle-HS-search-rate-limit, menu","depth":5,"bounds":{"left":0.064494684,"top":0.019952115,"width":0.09541223,"height":0.025538707},"on_screen":true,"help_text":"Git Branch: JY-20725-handle-HS-search-rate-limit","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"bounds":{"left":0.8081782,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"AskJiminnyReportActivityServiceTest","depth":6,"bounds":{"left":0.8234708,"top":0.019952115,"width":0.09208777,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'AskJiminnyReportActivityServiceTest'","depth":6,"bounds":{"left":0.9155585,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'AskJiminnyReportActivityServiceTest'","depth":6,"bounds":{"left":0.9268617,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"bounds":{"left":0.9381649,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"bounds":{"left":0.96609044,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"bounds":{"left":0.9773936,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"IDE and Project Settings","depth":5,"bounds":{"left":0.9886968,"top":0.019952115,"width":0.011303186,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.042220745,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"19","depth":4,"bounds":{"left":0.6615692,"top":0.10055866,"width":0.009640957,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"bounds":{"left":0.67287236,"top":0.09896249,"width":0.00731383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"bounds":{"left":0.68018615,"top":0.09896249,"width":0.006981383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {\n\"headers\":{\n\"Date\":[\"Thu,07 May 2026 14:21:15 GMT\"],\n \"Content-Type\":[\"application/json;charset=utf-8\"],\n \"Transfer-Encoding\":[\"chunked\"],\n \"Connection\":[\"keep-alive\"],\n \"CF-Ray\":[\"9f80deb8db60dc3a-SOF\"],\n \"CF-Cache-Status\":[\"DYNAMIC\"],\n \"Strict-Transport-Security\":[\"max-age=31536000; includeSubDomains; preload\"],\n \"Vary\":[\"origin,\n accept-encoding\"],\n \"access-control-allow-credentials\":[\"false\"],\n \"server-timing\":[\"hcid;desc=\\\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\\\",\n cfr;desc=\\\"9f80deb8e7c6dc3a-IAD\\\"\"],\n \"x-content-type-options\":[\"nosniff\"],\n \"x-hubspot-correlation-id\":[\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\"],\n \"Set-Cookie\":[\"__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-1.0.1.1-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,\n 07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None\"],\n \"Report-To\":[\"{\n\\\"endpoints\\\":[{\n\\\"url\\\":\\\"https:\\\\/\\\\/a.nel.cloudflare.com\\\\/report\\\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\\\"}],\n\\\"group\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"NEL\":[\"{\n\\\"success_fraction\\\":0.01,\n\\\"report_to\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"Server\":[\"cloudflare\"]}} {\n\"correlation_id\":\"95236535-ec98-4541-b92a-adfa73b69eab\",\n\"trace_id\":\"c7ab8365-903f-46d4-9403-0e5b551e3545\"}","depth":4,"bounds":{"left":0.37632978,"top":0.09736632,"width":0.5728058,"height":0.8818835},"on_screen":true,"lines":[{"char_start":207,"char_count":30,"bounds":{"left":0.37632978,"top":0.0,"width":0.07513298,"height":0.014365523}},{"char_start":237,"char_count":36,"bounds":{"left":0.37632978,"top":0.0,"width":0.09075798,"height":0.014365523}},{"char_start":273,"char_count":32,"bounds":{"left":0.37632978,"top":0.0,"width":0.080119684,"height":0.014365523}},{"char_start":305,"char_count":79,"bounds":{"left":0.37632978,"top":0.0,"width":0.20212767,"height":0.014365523}},{"char_start":384,"char_count":18,"bounds":{"left":0.37632978,"top":0.0,"width":0.043882977,"height":0.014365523}},{"char_start":402,"char_count":21,"bounds":{"left":0.37632978,"top":0.0,"width":0.051861703,"height":0.014365523}},{"char_start":423,"char_count":48,"bounds":{"left":0.37632978,"top":0.008778931,"width":0.12167553,"height":0.014365523}},{"char_start":471,"char_count":72,"bounds":{"left":0.37632978,"top":0.026336791,"width":0.18384309,"height":0.014365523}},{"char_start":543,"char_count":40,"bounds":{"left":0.37632978,"top":0.043894652,"width":0.10106383,"height":0.014365523}},{"char_start":583,"char_count":41,"bounds":{"left":0.37632978,"top":0.061452515,"width":0.10372341,"height":0.014365523}},{"char_start":624,"char_count":72,"bounds":{"left":0.37632978,"top":0.079010375,"width":0.18384309,"height":0.014365523}},{"char_start":696,"char_count":219,"bounds":{"left":0.37632978,"top":0.096568234,"width":0.56515956,"height":0.014365523}},{"char_start":915,"char_count":83,"bounds":{"left":0.37632978,"top":0.11412609,"width":0.21243352,"height":0.014365523}},{"char_start":998,"char_count":20,"bounds":{"left":0.37632978,"top":0.13168396,"width":0.04920213,"height":0.014365523}},{"char_start":1018,"char_count":17,"bounds":{"left":0.37632978,"top":0.14924182,"width":0.041223403,"height":0.014365523}},{"char_start":1035,"char_count":203,"bounds":{"left":0.37632978,"top":0.16679968,"width":0.52360374,"height":0.014365523}},{"char_start":1238,"char_count":22,"bounds":{"left":0.37632978,"top":0.18435754,"width":0.05418883,"height":0.014365523}},{"char_start":1260,"char_count":23,"bounds":{"left":0.37632978,"top":0.2019154,"width":0.056848403,"height":0.014365523}},{"char_start":1283,"char_count":10,"bounds":{"left":0.37632978,"top":0.21947326,"width":0.023271276,"height":0.014365523}},{"char_start":1293,"char_count":27,"bounds":{"left":0.37632978,"top":0.23703113,"width":0.06715426,"height":0.014365523}},{"char_start":1320,"char_count":26,"bounds":{"left":0.37632978,"top":0.254589,"width":0.06482713,"height":0.014365523}},{"char_start":1346,"char_count":23,"bounds":{"left":0.37632978,"top":0.27214685,"width":0.056848403,"height":0.014365523}},{"char_start":1369,"char_count":28,"bounds":{"left":0.37632978,"top":0.2897047,"width":0.06981383,"height":0.014365523}},{"char_start":1397,"char_count":57,"bounds":{"left":0.37632978,"top":0.30726257,"width":0.14494681,"height":0.014365523}}],"value":"[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {\n\"headers\":{\n\"Date\":[\"Thu,07 May 2026 14:21:15 GMT\"],\n \"Content-Type\":[\"application/json;charset=utf-8\"],\n \"Transfer-Encoding\":[\"chunked\"],\n \"Connection\":[\"keep-alive\"],\n \"CF-Ray\":[\"9f80deb8db60dc3a-SOF\"],\n \"CF-Cache-Status\":[\"DYNAMIC\"],\n \"Strict-Transport-Security\":[\"max-age=31536000; includeSubDomains; preload\"],\n \"Vary\":[\"origin,\n accept-encoding\"],\n \"access-control-allow-credentials\":[\"false\"],\n \"server-timing\":[\"hcid;desc=\\\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\\\",\n cfr;desc=\\\"9f80deb8e7c6dc3a-IAD\\\"\"],\n \"x-content-type-options\":[\"nosniff\"],\n \"x-hubspot-correlation-id\":[\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\"],\n \"Set-Cookie\":[\"__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-1.0.1.1-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,\n 07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None\"],\n \"Report-To\":[\"{\n\\\"endpoints\\\":[{\n\\\"url\\\":\\\"https:\\\\/\\\\/a.nel.cloudflare.com\\\\/report\\\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\\\"}],\n\\\"group\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"NEL\":[\"{\n\\\"success_fraction\\\":0.01,\n\\\"report_to\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"Server\":[\"cloudflare\"]}} {\n\"correlation_id\":\"95236535-ec98-4541-b92a-adfa73b69eab\",\n\"trace_id\":\"c7ab8365-903f-46d4-9403-0e5b551e3545\"}","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Show Replace Field","depth":4,"bounds":{"left":0.10472074,"top":0.20430966,"width":0.008643617,"height":0.01915403},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Search History","depth":3,"bounds":{"left":0.11735372,"top":0.20351157,"width":0.00731383,"height":0.017557861},"on_screen":true,"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"rateLimiter","depth":4,"bounds":{"left":0.12832446,"top":0.20351157,"width":0.043882977,"height":0.015961692},"on_screen":true,"value":"rateLimiter","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"New Line","depth":3,"bounds":{"left":0.18118352,"top":0.20351157,"width":0.00731383,"height":0.017557861},"on_screen":true,"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Match Case","depth":3,"bounds":{"left":0.19115691,"top":0.20351157,"width":0.00731383,"height":0.017557861},"on_screen":true,"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Words","depth":3,"bounds":{"left":0.19980054,"top":0.20351157,"width":0.00731383,"height":0.017557861},"on_screen":true,"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Regex","depth":3,"bounds":{"left":0.20844415,"top":0.20351157,"width":0.00731383,"height":0.017557861},"on_screen":true,"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
2224177541613944473
|
-3047778893203797558
|
click
|
accessibility
|
NULL
|
Project: faVsco.js, menu
JY-20725-handle-HS-search Project: faVsco.js, menu
JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
19
Previous Highlighted Error
Next Highlighted Error
[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {
"headers":{
"Date":["Thu,07 May 2026 14:21:15 GMT"],
"Content-Type":["application/json;charset=utf-8"],
"Transfer-Encoding":["chunked"],
"Connection":["keep-alive"],
"CF-Ray":["9f80deb8db60dc3a-SOF"],
"CF-Cache-Status":["DYNAMIC"],
"Strict-Transport-Security":["max-age=31536000; includeSubDomains; preload"],
"Vary":["origin,
accept-encoding"],
"access-control-allow-credentials":["false"],
"server-timing":["hcid;desc=\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\",
cfr;desc=\"9f80deb8e7c6dc3a-IAD\""],
"x-content-type-options":["nosniff"],
"x-hubspot-correlation-id":["019e02d0-6fd8-7812-bdba-885b7ccb3ee3"],
"Set-Cookie":["__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-[IP_ADDRESS]-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,
07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None"],
"Report-To":["{
\"endpoints\":[{
\"url\":\"https:\\/\\/a.nel.cloudflare.com\\/report\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\"}],
\"group\":\"cf-nel\",
\"max_age\":604800}"],
"NEL":["{
\"success_fraction\":0.01,
\"report_to\":\"cf-nel\",
\"max_age\":604800}"],
"Server":["cloudflare"]}} {
"correlation_id":"95236535-ec98-4541-b92a-adfa73b69eab",
"trace_id":"c7ab8365-903f-46d4-9403-0e5b551e3545"}
Show Replace Field
Search History
rateLimiter
New Line
Match Case
Words
Regex...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
9841
|
443
|
21
|
2026-05-08T13:37:32.599152+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-08/1778 /Users/lukas/.screenpipe/data/data/2026-05-08/1778247452599_m1.jpg...
|
PhpStorm
|
faVsco.js – Client.php
|
1
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
JY-20725-handle-HS-search Project: faVsco.js, menu
JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
19
Previous Highlighted Error
Next Highlighted Error
[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {
"headers":{
"Date":["Thu,07 May 2026 14:21:15 GMT"],
"Content-Type":["application/json;charset=utf-8"],
"Transfer-Encoding":["chunked"],
"Connection":["keep-alive"],
"CF-Ray":["9f80deb8db60dc3a-SOF"],
"CF-Cache-Status":["DYNAMIC"],
"Strict-Transport-Security":["max-age=31536000; includeSubDomains; preload"],
"Vary":["origin,
accept-encoding"],
"access-control-allow-credentials":["false"],
"server-timing":["hcid;desc=\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\",
cfr;desc=\"9f80deb8e7c6dc3a-IAD\""],
"x-content-type-options":["nosniff"],
"x-hubspot-correlation-id":["019e02d0-6fd8-7812-bdba-885b7ccb3ee3"],
"Set-Cookie":["__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-[IP_ADDRESS]-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,
07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None"],
"Report-To":["{
\"endpoints\":[{
\"url\":\"https:\\/\\/a.nel.cloudflare.com\\/report\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\"}],
\"group\":\"cf-nel\",
\"max_age\":604800}"],
"NEL":["{
\"success_fraction\":0.01,
\"report_to\":\"cf-nel\",
\"max_age\":604800}"],
"Server":["cloudflare"]}} {
"correlation_id":"95236535-ec98-4541-b92a-adfa73b69eab",
"trace_id":"c7ab8365-903f-46d4-9403-0e5b551e3545"}
Show Replace Field
Search History
rateLimiter
New Line
Match Case
Words
Regex
Replace History
Replace
New Line
Preserve case
0 results
Previous Occurrence
Next Occurrence
Filter Search Results
Open in Window, Multiple Cursors
Click to highlight
Close
Sync Changes
Hide This Notification
Code changed:
Hide
2
64
1
1
Previous Highlighted Error
Next Highlighted Error
<?php
declare(strict_types=1);
namespace Jiminny\Services\Crm\Hubspot;
use HubSpot\Client\Crm\Deals\ApiException as DealApiException;
use HubSpot\Client\Crm\Contacts\ApiException as ContactApiException;
use HubSpot\Client\Crm\Companies\ApiException as CompanyApiException;
use HubSpot\Client\Crm\Contacts\Model\SimplePublicObjectWithAssociations as ContactsWithAssociations;
use HubSpot\Client\Crm\Companies\Model\SimplePublicObjectWithAssociations as CompaniesWithAssociations;
use HubSpot\Client\Crm\Deals\Model\SimplePublicObjectWithAssociations as DealWithAssociations;
use HubSpot\Client\Crm\Objects\Model\SimplePublicObjectInput;
use HubSpot\Client\Crm\Objects\Model\SimplePublicObjectWithAssociations as ObjectWithAssociations;
use HubSpot\Client\Crm\Pipelines\Model\Error;
use HubSpot\Client\Crm\Pipelines\Model\PipelineStage;
use HubSpot\Client\Crm\Properties\Model\Property;
use HubSpot\Discovery\Discovery;
use Jiminny\Exceptions\CrmException;
use Jiminny\Exceptions\RateLimitException;
use Jiminny\Exceptions\SocialAccountTokenInvalidException;
use Jiminny\Jobs\Crm\NoteObject;
use Jiminny\Models\Crm\Field;
use Jiminny\Services\Crm\BaseClient;
use Jiminny\Services\Crm\Hubspot\DTO\Response\Owner;
use Jiminny\Services\SocialAccountService;
use SevenShores\Hubspot\Exceptions\BadRequest;
use SevenShores\Hubspot\Exceptions\HubspotException;
use SevenShores\Hubspot\Factory;
use SevenShores\Hubspot\Http\Response;
use Jiminny\Services\Crm\Hubspot\Pagination\HubspotPaginationService;
use Throwable;
/**
* @phpstan-type CrmFieldOption array{id:string, label:string, value?:string}
*/
class Client extends BaseClient implements HubspotClientInterface
{
public const string MIN_API_VERSION = '2';
public const string BASE_URL = '[URL_WITH_CREDENTIALS] T
* @param callable(): T $apiCall
* @return T
*
* @throws RateLimitException
*/
private function executeRequest(callable $apiCall)
{
try {
return $apiCall();
} catch (Throwable $e) {
if ($this->isHubspotRateLimit($e)) {
$retryAfter = $this->parseRetryAfter($e);
$this->log->warning('[Hubspot] Received 429 from API', [
'team_id' => $this->config->team_id,
'config_id' => $this->config->getId(),
'retry_after' => $retryAfter,
'reason' => $e->getMessage(),
]);
throw new RateLimitException('Hubspot returned 429', $retryAfter, $e);
}
throw $e;
}
}
public function isHubspotRateLimit(Throwable $e): bool
{
if ($e instanceof BadRequest
|| $e instanceof DealApiException
|| $e instanceof ContactApiException
|| $e instanceof CompanyApiException
|| $e instanceof \GuzzleHttp\Exception\RequestException
) {
return (int) $e->getCode() === 429;
}
return false;
}
public function parseRetryAfter(Throwable $e): int
{
// First try to get Retry-After from response headers
if (method_exists($e, 'getResponseHeaders')) {
$headers = $e->getResponseHeaders() ?: [];
$value = $headers['Retry-After'] ?? $headers['retry-after'] ?? null;
if (is_array($value)) {
$value = $value[0] ?? null;
}
if (is_numeric($value)) {
return (int) $value;
}
}
if (method_exists($e, 'getResponseBody')) {
$body = $e->getResponseBody();
if (is_string($body)) {
$body = json_decode($body, true) ?? [];
}
$policyName = $body['policyName'] ?? $body['policy'] ?? $body['context']['policyName'] ?? null;
if ($policyName === 'TEN_SECONDLY_ROLLING' || $policyName === 'ten_secondly_rolling') {
return 10;
}
if ($policyName === 'SECONDLY' || $policyName === 'secondly') {
return 1;
}
}
$this->log->warning('[Hubspot] No retry-after header or policy name found, using default', [
'exception_class' => get_class($e),
]);
return 10;
}
public function getMinimumApiVersion(): string
{
return self::MIN_API_VERSION;
}
public function getInstance(): Factory
{
return new Factory([
'key' => $this->accessToken,
'oauth2' => true,
'base_url' => $this->baseUrl,
]);
}
public function getNewInstance(): Discovery
{
return \HubSpot\Factory::createWithAccessToken($this->accessToken);
}
/**
* Secondly and daily limits for Hubspot API
*
* Product Tier: Free & Starter | Professional & Enterprise | API add-on (any tier)
* Burst: 100/10 seconds | 150/10 seconds | 200/10 seconds
* Daily: 250,000 | 500,000 | 1,000,000
*
* Official documentation states: The search endpoints are rate limited to five requests per second.
* Since with 5 RPS were still hitting secondly rate limits we lowered it to 4
*/
public function getPaginatedData(array $payload, string $type, int $offset = 0): array
{
$total = 0;
$lastId = null;
$rows = [];
foreach ($this->getPaginatedDataGenerator($payload, $type, $offset, $total, $lastId) as $row) {
$rows[] = $row;
}
return ['results' => $rows, 'total' => $total, 'last_record' => $lastId];
}
/**
* @throws HubspotException
* @throws SocialAccountTokenInvalidException
* @throws BadRequest
*/
public function getPaginatedDataGenerator(
array $payload,
string $type,
int $offset = 0,
int &$total = 0,
?string &$lastRecordId = null
): \Generator {
return $this->paginationService->getPaginatedDataGenerator(
$this,
$payload,
$type,
$offset,
$total,
$lastRecordId
);
}
/**
* Execute a search request against HubSpot CRM objects with rate limiting.
*
* @param string $objectType The object type ('deals', 'companies', 'contacts', 'calls')
* @param array<string, mixed> $payload The search payload with filters, sorts, properties, etc.
* @return array The search response with 'results', 'total', 'paging' keys
* @throws RateLimitException When rate limit is hit
* @throws HubspotException On API errors
*/
public function search(string $objectType, array $payload): array
{
$endpoint = self::BASE_URL . "/crm/v3/objects/{$objectType}/search";
return $this->executeRequest(function () use ($endpoint, $payload) {
$response = $this->getInstance()->getClient()->request('POST', $endpoint, ['json' => $payload]);
return $response->toArray();
});
}
/**
* @throws DealApiException
* @throws CrmException
*/
public function getOpportunityById(string $crmId, array $fields): array
{
try {
// $deal = $this->executeRequest(fn () => $this->getNewInstance()->crm()->deals()->basicApi()->getById(
$deal = $this->getNewInstance()->crm()->deals()->basicApi()->getById(
$crmId,
implode(',', $fields),
'companies,contacts'
);
} catch (DealApiException $e) {
$this->log->info('[Hubspot] Failed to fetch opportunity', [
'crm_id' => $crmId,
'reason' => $e->getMessage(),
]);
throw $e;
}
if (! $deal instanceof DealWithAssociations) {
throw new CrmException('Deal not found');
}
return [
'id' => $deal->getId(),
'properties' => $deal->getProperties(),
'associations' => $deal->getAssociations(),
];
}
/**
* Generic batch read method for HubSpot objects
*
* @param string $objectType The object type ('deals', 'companies', 'contacts')
* @param array<string> $crmIds Array of HubSpot object IDs (max 100)
* @param array<string> $fields Array of property names to fetch
*
* @return array<string, array> Array keyed by CRM ID with object data
*/
private function batchReadObjects(string $objectType, array $crmIds, array $fields): array
{
if (empty($crmIds)) {
return [];
}
$this->validateBatchSize($objectType, $crmIds);
$this->ensureValidToken();
try {
$batchConfig = $this->createBatchConfiguration($objectType);
$batchReadRequest = $this->prepareBatchRequest($batchConfig, $crmIds, $fields);
$response = $batchConfig['api']->read($batchReadRequest);
$this->validateApiResponse($response, $objectType);
$results = $this->processApiResults($response);
$this->logBatchResults($objectType, $crmIds, $results);
return $results;
} catch (\Throwable $e) {
$this->handleBatchError($e, $objectType, $crmIds);
}
}
private function validateBatchSize(string $objectType, array $crmIds): void
{
if (count($crmIds) > 100) {
throw new \InvalidArgumentException("Batch size cannot exceed 100 {$objectType}");
}
}
private function createBatchConfiguration(string $objectType): array
{
$configurations = [
'deals' => [
'batchReadRequest' => new \HubSpot\Client\Crm\Deals\Model\BatchReadInputSimplePublicObjectId(),
'inputClass' => \HubSpot\Client\Crm\Deals\Model\SimplePublicObjectId::class,
'api' => $this->getNewInstance()->crm()->deals()->batchApi(),
],
'companies' => [
'batchReadRequest' => new \HubSpot\Client\Crm\Companies\Model\BatchReadInputSimplePublicObjectId(),
'inputClass' => \HubSpot\Client\Crm\Companies\Model\SimplePublicObjectId::class,
'api' => $this->getNewInstance()->crm()->companies()->batchApi(),
],
'contacts' => [
'batchReadRequest' => new \HubSpot\Client\Crm\Contacts\Model\BatchReadInputSimplePublicObjectId(),
'inputClass' => \HubSpot\Client\Crm\Contacts\Model\SimplePublicObjectId::class,
'api' => $this->getNewInstance()->crm()->contacts()->batchApi(),
],
];
if (! isset($configurations[$objectType])) {
throw new \InvalidArgumentException("Unsupported object type: {$objectType}");
}
return $configurations[$objectType];
}
private function prepareBatchRequest(array $batchConfig, array $crmIds, array $fields): object
{
$batchReadRequest = $batchConfig['batchReadRequest'];
$inputClass = $batchConfig['inputClass'];
$inputs = array_map(function ($crmId) use ($inputClass) {
$input = new $inputClass();
$input->setId($crmId);
return $input;
}, $crmIds);
$batchReadRequest->setInputs($inputs);
$batchReadRequest->setProperties($fields);
return $batchReadRequest;
}
private function validateApiResponse($response, string $objectType): void
{
if (! $response) {
throw new CrmException("HubSpot API returned null response for {$objectType} batch read");
}
}
private function processApiResults($response): array
{
$results = [];
$responseResults = $response->getResults();
if ($responseResults) {
foreach ($responseResults as $object) {
if ($object && $object->getId()) {
$results[$object->getId()] = [
'id' => $object->getId(),
'properties' => $object->getProperties() ?: [],
];
}
}
}
return $results;
}
private function logBatchResults(string $objectType, array $crmIds, array $results): void
{
$this->log->info("[HubSpot] Batch fetched {$objectType}", [
'requested_count' => count($crmIds),
'returned_count' => count($results),
'crm_ids' => $crmIds,
]);
}
private function handleBatchError(\Throwable $e, string $objectType, array $crmIds): void
{
$errorMessage = $e->getMessage() ?: 'Unknown error';
$errorTrace = $e->getTraceAsString() ?: 'No trace available';
$this->log->error("[HubSpot] Failed to batch fetch {$objectType}", [
'crm_ids' => $crmIds,
'error' => $errorMessage,
'trace' => $errorTrace,
]);
throw new CrmException("Failed to batch fetch {$objectType}: " . $errorMessage);
}
/**
* Batch read multiple opportunities by their CRM IDs
*
* @param array<string> $crmIds Array of HubSpot deal IDs (max 100)
* @param array<string> $fields Array of property names to fetch
*
* @return array<string, array> Array keyed by CRM ID with opportunity data
*/
public function getOpportunitiesByIds(array $crmIds, array $fields): array
{
return $this->batchReadObjects('deals', $crmIds, $fields);
}
/**
* Batch read multiple companies by their CRM IDs
*
* @param array<string> $crmIds Array of HubSpot company IDs (max 100)
* @param array<string> $fields Array of property names to fetch
*
* @return array<string, array> Array keyed by CRM ID with company data
*/
public function getCompaniesByIds(array $crmIds, array $fields): array
{
return $this->batchReadObjects('companies', $crmIds, $fields);
}
/**
* Batch read multiple contacts by their CRM IDs
*
* @param array<string> $crmIds Array of HubSpot contact IDs (max 100)
* @param array<string> $fields Array of property names to fetch
*
* @return array<string, array> Array keyed by CRM ID with contact data
*/
public function getContactsByIds(array $crmIds, array $fields): array
{
return $this->batchReadObjects('contacts', $crmIds, $fields);
}
/**
* @throws CompanyApiException
* @throws CrmException
*/
public function getAccountById(string $crmId, array $fields): array
{
try {
$company = $this->getNewInstance()->crm()->companies()->basicApi()->getById(
$crmId,
implode(',', $fields),
);
} catch (CompanyApiException $e) {
$this->log->info('[Hubspot] Failed to fetch account', [
'crm_id' => $crmId,
'reason' => $e->getMessage(),
]);
throw $e;
}
if (! $company instanceof CompaniesWithAssociations) {
throw new CrmException('Account not found');
}
return [
'id' => $company->getId(),
'properties' => $company->getProperties(),
];
}
/**
* @throws ContactApiException
* @throws CrmException
*/
public function getContactById(string $crmId, array $fields): array
{
try {
$contact = $this->getNewInstance()->crm()->contacts()->basicApi()->getById(
$crmId,
implode(',', $fields)
);
} catch (ContactApiException $e) {
$this->log->info('[Hubspot] Failed to fetch contact', [
'crm_id' => $crmId,
'reason' => $e->getMessage(),
]);
throw $e;
}
if (! $contact instanceof ContactsWithAssociations) {
throw new CrmException('Contact not found');
}
return [
'id' => $contact->getId(),
'properties' => $contact->getProperties(),
];
}
/**
* This is email search request that Hubspot offers as GET (more generous quota)
*/
public function getContactByEmail(string $email, array $fields = []): array
{
try {
$contact = $this->getNewInstance()->crm()->contacts()->basicApi()->getById(
$email,
implode(',', $fields),
null,
false,
'email'
);
return [
'id' => $contact->getId(),
'properties' => $contact->getProperties(),
];
} catch (ContactApiException $e) {
$this->log->info('[Hubspot] Failed to fetch contact', [
'email' => $email,
'reason' => $e->getMessage(),
]);
return [];
}
}
/**
* @throws CrmException
*/
public function fetchProperty(string $objectType, string $propertyId): Property
{
$result = $this->getNewInstance()->crm()->properties()->coreApi()->getByName($objectType, $propertyId);
if (! $result instanceof Property) {
$this->log->error('[Hubspot] Failed to fetch property', [
'object_type' => $objectType,
'property_id' => $propertyId,
'reason' => $result->getMessage(),
]);
throw new CrmException('Failed to fetch property');
}
return $result;
}
/**
* @return array<CrmFieldOption>
*/
public function fetchPropertyOptions(string $objectType, string $propertyId): array
{
/** @var array<CrmFieldOption> */
return $this->fetchProperty($objectType, $propertyId)->getOptions();
}
/**
* @return array<array{id:string, label:string, deleted:bool}>
*/
public function fetchCallDispositions(): array
{
/** @var Response $response */
$response = $this->getInstance()->engagements()->getCallDispositions();
/**
* @var array<array{
* id:string,
* label:string,
* deleted: bool
* }>
*/
return $response->toArray();
}
/**
* @return array<CrmFieldOption>
*/
public function fetchOpportunityPipelineStages(): array
{
$stages = [];
$apiResponse = $this->getNewInstance()->crm()->pipelines()->pipelinesApi()->getAll('deals');
if ($apiResponse instanceof Error) {
$this->log->error('[Hubspot] Failed to fetch opportunity pipelines', [
'reason' => $apiResponse->getMessage(),
]);
return [];
}
foreach ($apiResponse->getResults() as $pipeline) {
$pipelineStages = array_map(
static function (PipelineStage $stage) {
return [
'id' => $stage->getId(),
'label' => $stage->getLabel(),
];
},
$pipeline->getStages()
);
$stages = array_merge($stages, $pipelineStages);
}
return $stages;
}
public function fetchOpportunityPipelines(): array
{
$pipelines = [];
try {
$apiResponse = $this->makeRequest('/crm/v3/pipelines/deals');
} catch (\Exception $e) {
$this->log->info('[Hubspot] Failed to fetch opportunity pipelines', [
'reason' => $e->getMessage(),
]);
return [];
}
$response = $apiResponse->toArray();
foreach ($response['results'] as $pipeline) {
$pipelines[] = [
'id' => $pipeline['id'],
'label' => $pipeline['label'],
];
}
return $pipelines;
}
/**
* @return array<CrmFieldOption>
*/
public function fetchMeetingOutcomeFieldOptions(Field $field): array
{
return $field->getCrmProviderId() === 'meetingOutcome'
? $this->fetchMeetingOutcomeTypes()
: $this->fetchCallActivityTypes();
}
public function fetchMeetingOutcomeTypes(): array
{
return $this->extractMeetingTypeOptions(
'[URL_WITH_CREDENTIALS] Response $response */
$response = $this->getInstance()
->getClient()
->request('GET', $endpoint);
/**
* @var array<array{
* value: string,
* label: string,
* displayOrder: int
* }> $optionData
*/
$optionData = $response->toArray()['options'] ?? [];
$options = [];
foreach ($optionData as $item) {
$options[] = [
'id' => $item['value'],
'value' => $item['value'],
'label' => $item['label'],
'display_order' => $item['displayOrder'],
];
}
return $options;
}
/**
* @return array<CrmFieldOption>
*/
public function fetchDispositionFieldOptions(): array
{
$options = [];
$dispositions = $this->fetchCallDispositions();
foreach ($dispositions as $disposition) {
if ($disposition['deleted'] !== false) {
continue;
}
$option['value'] = $disposition['id'];
$option['id'] = $disposition['id'];
$option['label'] = $disposition['label'];
$options[] = $option;
}
return $options;
}
/**
* @return array<CrmFieldOption>
*/
public function fetchOpportunityFieldOptions(Field $field): array
{
if ($field->isStageField()) {
return $this->fetchOpportunityPipelineStages();
}
if ($field->isPipelineField()) {
return $this->fetchOpportunityPipelines();
}
return $this->fetchPropertyOptions('deals', $field->getCrmProviderId());
}
/**
* @throws BadRequest
* @throws HubspotException
*/
public function makeRequest(string $endpoint, $method = 'GET', $payload = [], ?string $queryString = null)
{
$endpoint = self::BASE_URL . $endpoint;
if ($method === 'GET') {
$response = $this->getInstance()->getClient()?->request(
method: $method,
endpoint: $endpoint,
query_string: $queryString
);
} else {
$response = $this->getInstance()->getClient()->request($method, $endpoint, [
'json' => ($payload),
]);
}
//
// $max = $response->getHeaderLine('X-HubSpot-RateLimit-Max'); // "110"
// $remaining = $response->getHeaderLine('X-HubSpot-RateLimit-Remaining'); // "109"
// $interval = $response->getHeaderLine('X-HubSpot-RateLimit-Interval-Milliseconds'); // "10000"
// $body = json_decode((string) $response->getBody(), true);
//
// \Illuminate\Support\Facades\Log::channel('custom_channel')->info('$max ' . PHP_EOL . print_r($max, true));
// \Illuminate\Support\Facades\Log::channel('custom_channel')->info('$remaining ' . PHP_EOL . print_r($remaining, true));
// \Illuminate\Support\Facades\Log::channel('custom_channel')->info('$interval ' . PHP_EOL . print_r($interval, true));
// \Illuminate\Support\Facades\Log::channel('custom_channel')->info('$body ' . PHP_EOL . print_r($body, true));
return $response;
}
/**
* @throws BadRequest
* @throws HubspotException
*/
public function createMeeting(array $payload): Response
{
$endpoint = '/crm/v3/objects/meetings';
return $this->makeRequest($endpoint, 'POST', $payload);
}
/**
* @throws BadRequest
* @throws HubspotException
*/
public function updateMeeting(string $meetingId, array $payload): Response
{
$endpoint = '/crm/v3/objects/meetings/' . $meetingId;
return $this->makeRequest($endpoint, 'PATCH', $payload);
}
/**
* @throws \Exception
*/
public function createNote(
string $body,
string $ownerId,
int $timestamp,
string $objectId,
NoteObject $noteObject
): ?string {
try {
$noteInput = new SimplePublicObjectInput([
'properties' => [
'hs_note_body' => $body,
'hubspot_owner_id' => $ownerId,
'hs_timestamp' => $timestamp,
],
]);
// Create note
$note = $this->getNewInstance()->crm()->objects()->basicApi()->create('note', $noteInput);
$this->getNewInstance()->crm()->objects()->associationsApi()->create(
'note',
$note->getId(),
$this->getNoteObject($noteObject),
$objectId,
$this->getNoteAssociationType($noteObject),
);
return $note->getId();
} catch (\Exception $e) {
$this->log->error('[Hubspot] Failed to create note', [
'objectId' => $objectId,
'noteObject' => $noteObject->getObjectType(),
'reason' => $e->getMessage(),
]);
\Sentry::captureException($e);
}
return null;
}
public function updateEngagement(string $objectId, array $engagement, array $metadata): void
{
$this->getInstance()->engagements()->update($objectId, $engagement, $metadata);
}
public function getEngagementData(string $engagementId): array
{
$engagement = $this->getInstance()->engagements()->get($engagementId);
return $engagement->toArray();
}
public function createEngagement(array $engagement, array $associations, array $metadata): Response
{
return $this->getInstance()
->engagements()
->create($engagement, $associations, $metadata);
}
public function isUnauthorizedException(\Exception $e): bool
{
if ($e instanceof BadRequest
|| $e instanceof DealApiException
|| $e instanceof ContactApiException
|| $e instanceof CompanyApiException
) {
return (int) $e->getCode() === 401;
}
if ($e instanceof \GuzzleHttp\Exception\RequestException && $e->hasResponse()) {
return $e->getResponse()?->getStatusCode() === 401;
}
$message = strtolower($e->getMessage());
return str_contains($message, '401 unauthorized') ||
str_contains($message, 'http 401') ||
str_contains($message, 'status code 401') ||
(preg_match('/\b401\b/', $message) === 1 && str_contains($message, 'unauthorized'));
}
/**
* Validates and refreshes the access token if needed before API requests.
* This ensures long-running processes don't fail due to token expiration.
*
* @throws SocialAccountTokenInvalidException
*/
public function ensureValidToken(): void
{
if ($this->oauthAccount === null) {
return;
}
$newToken = $this->tokenManager->ensureValidToken($this->oauthAccount);
if ($newToken !== null) {
$this->accessToken = $newToken;
}
}
public function getConfig()
{
return $this->config;
}
// returns only active (archived=false)
public function getOwners(): array
{
return $this->getNewInstance()->crm()->owners()->getAll();
}
/**
* @param bool $archived
*
* @return array<Owner>|[]
*/
public function getOwnersArchived(bool $archived = true): array
{
$endpoint = '/crm/v3/owners';
$queryParams = [
'archived' => $archived ? 'true' : 'false',
];
$queryString = http_build_query($queryParams);
$owners = [];
try {
$response = $this->makeRequest(endpoint: $endpoint, queryString: $queryString);
$responseData = $response?->toArray();
foreach ($responseData['results'] as $result) {
try {
$owners[] = Owner::create($result);
} catch (Throwable $e) {
$this->log->error('[HubSpot] Failed to process owner data', [
'result' => $result,
'error' => $e->getMessage(),
]);
continue;
}
}
} catch (Throwable $e) {
$this->log->error('HubSpot] Failed to fetch owners', [
'archived' => $archived,
'error' => $e->getMessage(),
]);
return [];
}
return $owners;
}
public function getMeeting(string $engagementId): ObjectWithAssociations
{
return $this->getNewInstance()->crm()->objects()->basicApi()
->getById('meeting', $engagementId, null, 'contact,company,deal');
}
public function deleteEngagement(string $engagementId): void
{
$this->getInstance()->engagements()->delete((int) $engagementId);
}
public function getAssociationsData(array $ids, string $fromObject, string $toObject): array
{
$associationData = [];
$idChunks = array_chunk($ids, self::ASSOCIATIONS_BATCH_SIZE_LIMIT);
foreach ($idChunks as $idChunk) {
try {
$batchInput = new \HubSpot\Client\Crm\Associations\Model\BatchInputPublicObjectId();
$batchInput->setInputs(array_map(function ($id) {
$publicObjectId = new \HubSpot\Client\Crm\Associations\Model\PublicObjectId();
$publicObjectId->setId($id);
return $publicObjectId;
}, $idChunk));
$associatedObjectsData = $this
->getNewInstance()
->crm()
->associations()
->batchApi()
->read($fromObject, $toObject, $batchInput);
if ($associatedObjectsData instanceof \HubSpot\Client\Crm\Associations\Model\BatchResponsePublicAssociationMulti) {
foreach ($associatedObjectsData->getResults() as $association) {
$from = $association->getFrom()->getId();
$toAssociations = $association->getTo();
if (! empty($toAssociations)) {
$associationData[$from] = array_map(function ($item) {
return $item->getId();
}, $toAssociations);
}
}
}
} catch (RateLimitException $e) {
throw $e;
} catch (\Exception $e) {
$this->log->error('[Hubspot] Failed to fetch associations', [
'from_object' => $fromObject,
'to_object' => $toObject,
'reason' => $e->getMessage(),
]);
}
}
return $associationData;
}
/**
* @throws \Exception
*/
private function getNoteAssociationType(NoteObject $noteObject): string
{
return match($noteObject) {
NoteObject::Opportunity => 'note_to_deal',
NoteObject::Lead, NoteObject::Contact => 'note_to_contact', // or 'note_to_lead' if your portal supports it
NoteObject::Account => 'note_to_company',
NoteObject::Call, NoteObject::Event => throw new \Exception('Not supported'),
};
}
/**
* @throws \Exception
*/
private function getNoteObject(NoteObject $noteObject): string
{
return match($noteObject) {
NoteObject::Opportunity => 'deal',
NoteObject::Lead, NoteObject::Contact => 'contact',
NoteObject::Account => 'company',
NoteObject::Call, NoteObject::Event => throw new \Exception('Not supported'),
};
}
public function addAssociations(string $objectType, string $associationType, array $payload): Response
{
$endpoint = "/crm/v4/associations/$objectType/$associationType/batch/create";
return $this->makeRequest($endpoint, 'POST', $payload);
}
public function removeAssociations(string $objectType, string $associationType, array $payload): Response
{
$endpoint = "/crm/v4/associations/$objectType/$associationType/batch/archive";
return $this->makeRequest($endpoint, 'POST', $payload);
}
}...
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JY-20725-handle-HS-search-rate-limit, menu","depth":5,"on_screen":true,"help_text":"Git Branch: JY-20725-handle-HS-search-rate-limit","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"AskJiminnyReportActivityServiceTest","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'AskJiminnyReportActivityServiceTest'","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'AskJiminnyReportActivityServiceTest'","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"IDE and Project Settings","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.088194445,"height":0.027777778},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"19","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {\n\"headers\":{\n\"Date\":[\"Thu,07 May 2026 14:21:15 GMT\"],\n \"Content-Type\":[\"application/json;charset=utf-8\"],\n \"Transfer-Encoding\":[\"chunked\"],\n \"Connection\":[\"keep-alive\"],\n \"CF-Ray\":[\"9f80deb8db60dc3a-SOF\"],\n \"CF-Cache-Status\":[\"DYNAMIC\"],\n \"Strict-Transport-Security\":[\"max-age=31536000; includeSubDomains; preload\"],\n \"Vary\":[\"origin,\n accept-encoding\"],\n \"access-control-allow-credentials\":[\"false\"],\n \"server-timing\":[\"hcid;desc=\\\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\\\",\n cfr;desc=\\\"9f80deb8e7c6dc3a-IAD\\\"\"],\n \"x-content-type-options\":[\"nosniff\"],\n \"x-hubspot-correlation-id\":[\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\"],\n \"Set-Cookie\":[\"__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-1.0.1.1-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,\n 07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None\"],\n \"Report-To\":[\"{\n\\\"endpoints\\\":[{\n\\\"url\\\":\\\"https:\\\\/\\\\/a.nel.cloudflare.com\\\\/report\\\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\\\"}],\n\\\"group\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"NEL\":[\"{\n\\\"success_fraction\\\":0.01,\n\\\"report_to\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"Server\":[\"cloudflare\"]}} {\n\"correlation_id\":\"95236535-ec98-4541-b92a-adfa73b69eab\",\n\"trace_id\":\"c7ab8365-903f-46d4-9403-0e5b551e3545\"}","depth":4,"on_screen":true,"value":"[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {\n\"headers\":{\n\"Date\":[\"Thu,07 May 2026 14:21:15 GMT\"],\n \"Content-Type\":[\"application/json;charset=utf-8\"],\n \"Transfer-Encoding\":[\"chunked\"],\n \"Connection\":[\"keep-alive\"],\n \"CF-Ray\":[\"9f80deb8db60dc3a-SOF\"],\n \"CF-Cache-Status\":[\"DYNAMIC\"],\n \"Strict-Transport-Security\":[\"max-age=31536000; includeSubDomains; preload\"],\n \"Vary\":[\"origin,\n accept-encoding\"],\n \"access-control-allow-credentials\":[\"false\"],\n \"server-timing\":[\"hcid;desc=\\\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\\\",\n cfr;desc=\\\"9f80deb8e7c6dc3a-IAD\\\"\"],\n \"x-content-type-options\":[\"nosniff\"],\n \"x-hubspot-correlation-id\":[\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\"],\n \"Set-Cookie\":[\"__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-1.0.1.1-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,\n 07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None\"],\n \"Report-To\":[\"{\n\\\"endpoints\\\":[{\n\\\"url\\\":\\\"https:\\\\/\\\\/a.nel.cloudflare.com\\\\/report\\\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\\\"}],\n\\\"group\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"NEL\":[\"{\n\\\"success_fraction\\\":0.01,\n\\\"report_to\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"Server\":[\"cloudflare\"]}} {\n\"correlation_id\":\"95236535-ec98-4541-b92a-adfa73b69eab\",\n\"trace_id\":\"c7ab8365-903f-46d4-9403-0e5b551e3545\"}","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Show Replace Field","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Search History","depth":3,"on_screen":true,"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"rateLimiter","depth":4,"on_screen":true,"value":"rateLimiter","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"New Line","depth":3,"on_screen":true,"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Match Case","depth":3,"on_screen":true,"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Words","depth":3,"on_screen":true,"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Regex","depth":3,"on_screen":true,"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Replace History","depth":3,"bounds":{"left":0.0,"top":0.0,"width":0.015277778,"height":0.024444444},"on_screen":false,"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextField","text":"Replace","depth":4,"on_screen":false,"role_description":"text field","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"New Line","depth":3,"bounds":{"left":0.0,"top":0.0,"width":0.015277778,"height":0.024444444},"on_screen":false,"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Preserve case","depth":3,"bounds":{"left":0.0,"top":0.0,"width":0.015277778,"height":0.024444444},"on_screen":false,"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"0 results","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Occurrence","depth":4,"on_screen":true,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Occurrence","depth":4,"on_screen":true,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Filter Search Results","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Open in Window, Multiple Cursors","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXLink","text":"Click to highlight","depth":4,"on_screen":false,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Close","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.088194445,"height":0.027777778},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"2","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"64","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"1","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"1","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\ndeclare(strict_types=1);\n\nnamespace Jiminny\\Services\\Crm\\Hubspot;\n\nuse HubSpot\\Client\\Crm\\Deals\\ApiException as DealApiException;\nuse HubSpot\\Client\\Crm\\Contacts\\ApiException as ContactApiException;\nuse HubSpot\\Client\\Crm\\Companies\\ApiException as CompanyApiException;\nuse HubSpot\\Client\\Crm\\Contacts\\Model\\SimplePublicObjectWithAssociations as ContactsWithAssociations;\nuse HubSpot\\Client\\Crm\\Companies\\Model\\SimplePublicObjectWithAssociations as CompaniesWithAssociations;\nuse HubSpot\\Client\\Crm\\Deals\\Model\\SimplePublicObjectWithAssociations as DealWithAssociations;\nuse HubSpot\\Client\\Crm\\Objects\\Model\\SimplePublicObjectInput;\nuse HubSpot\\Client\\Crm\\Objects\\Model\\SimplePublicObjectWithAssociations as ObjectWithAssociations;\nuse HubSpot\\Client\\Crm\\Pipelines\\Model\\Error;\nuse HubSpot\\Client\\Crm\\Pipelines\\Model\\PipelineStage;\nuse HubSpot\\Client\\Crm\\Properties\\Model\\Property;\nuse HubSpot\\Discovery\\Discovery;\nuse Jiminny\\Exceptions\\CrmException;\nuse Jiminny\\Exceptions\\RateLimitException;\nuse Jiminny\\Exceptions\\SocialAccountTokenInvalidException;\nuse Jiminny\\Jobs\\Crm\\NoteObject;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Services\\Crm\\BaseClient;\nuse Jiminny\\Services\\Crm\\Hubspot\\DTO\\Response\\Owner;\nuse Jiminny\\Services\\SocialAccountService;\nuse SevenShores\\Hubspot\\Exceptions\\BadRequest;\nuse SevenShores\\Hubspot\\Exceptions\\HubspotException;\nuse SevenShores\\Hubspot\\Factory;\nuse SevenShores\\Hubspot\\Http\\Response;\nuse Jiminny\\Services\\Crm\\Hubspot\\Pagination\\HubspotPaginationService;\nuse Throwable;\n\n/**\n * @phpstan-type CrmFieldOption array{id:string, label:string, value?:string}\n */\nclass Client extends BaseClient implements HubspotClientInterface\n{\n public const string MIN_API_VERSION = '2';\n\n public const string BASE_URL = 'https://api.hubapi.com';\n\n public const int ASSOCIATIONS_BATCH_SIZE_LIMIT = 1000;\n\n private HubspotPaginationService $paginationService;\n private HubspotTokenManager $tokenManager;\n\n public function __construct(\n SocialAccountService $socialAccountService,\n HubspotPaginationService $paginationService,\n HubspotTokenManager $tokenManager,\n ) {\n parent::__construct($socialAccountService);\n $this->paginationService = $paginationService;\n $this->tokenManager = $tokenManager;\n\n $this->setBaseUrl(self::BASE_URL);\n $this->setVersion(self::MIN_API_VERSION);\n }\n\n /**\n * Reacts to a rate limits (429) from HubSpot by translating it\n * into a RateLimitException carrying retry_after.\n *\n * Wrap any outbound HubSpot call (SDK or raw HTTP) like:\n *\n * $this->executeRequest(fn () => $this->getNewInstance()->crm()->...);\n *\n * @template T\n * @param callable(): T $apiCall\n * @return T\n *\n * @throws RateLimitException\n */\n private function executeRequest(callable $apiCall)\n {\n try {\n return $apiCall();\n } catch (Throwable $e) {\n if ($this->isHubspotRateLimit($e)) {\n $retryAfter = $this->parseRetryAfter($e);\n\n $this->log->warning('[Hubspot] Received 429 from API', [\n 'team_id' => $this->config->team_id,\n 'config_id' => $this->config->getId(),\n 'retry_after' => $retryAfter,\n 'reason' => $e->getMessage(),\n ]);\n\n throw new RateLimitException('Hubspot returned 429', $retryAfter, $e);\n }\n\n throw $e;\n }\n }\n\n public function isHubspotRateLimit(Throwable $e): bool\n {\n if ($e instanceof BadRequest\n || $e instanceof DealApiException\n || $e instanceof ContactApiException\n || $e instanceof CompanyApiException\n || $e instanceof \\GuzzleHttp\\Exception\\RequestException\n ) {\n return (int) $e->getCode() === 429;\n }\n\n return false;\n }\n\n public function parseRetryAfter(Throwable $e): int\n {\n // First try to get Retry-After from response headers\n if (method_exists($e, 'getResponseHeaders')) {\n $headers = $e->getResponseHeaders() ?: [];\n $value = $headers['Retry-After'] ?? $headers['retry-after'] ?? null;\n if (is_array($value)) {\n $value = $value[0] ?? null;\n }\n if (is_numeric($value)) {\n return (int) $value;\n }\n }\n\n if (method_exists($e, 'getResponseBody')) {\n $body = $e->getResponseBody();\n if (is_string($body)) {\n $body = json_decode($body, true) ?? [];\n }\n\n $policyName = $body['policyName'] ?? $body['policy'] ?? $body['context']['policyName'] ?? null;\n\n if ($policyName === 'TEN_SECONDLY_ROLLING' || $policyName === 'ten_secondly_rolling') {\n return 10;\n }\n if ($policyName === 'SECONDLY' || $policyName === 'secondly') {\n return 1;\n }\n }\n\n $this->log->warning('[Hubspot] No retry-after header or policy name found, using default', [\n 'exception_class' => get_class($e),\n ]);\n\n return 10;\n }\n\n public function getMinimumApiVersion(): string\n {\n return self::MIN_API_VERSION;\n }\n\n public function getInstance(): Factory\n {\n return new Factory([\n 'key' => $this->accessToken,\n 'oauth2' => true,\n 'base_url' => $this->baseUrl,\n ]);\n }\n\n public function getNewInstance(): Discovery\n {\n return \\HubSpot\\Factory::createWithAccessToken($this->accessToken);\n }\n\n /**\n * Secondly and daily limits for Hubspot API\n *\n * Product Tier: Free & Starter | Professional & Enterprise | API add-on (any tier)\n * Burst: 100/10 seconds | 150/10 seconds | 200/10 seconds\n * Daily: 250,000 | 500,000 | 1,000,000\n *\n * Official documentation states: The search endpoints are rate limited to five requests per second.\n * Since with 5 RPS were still hitting secondly rate limits we lowered it to 4\n */\n public function getPaginatedData(array $payload, string $type, int $offset = 0): array\n {\n $total = 0;\n $lastId = null;\n $rows = [];\n foreach ($this->getPaginatedDataGenerator($payload, $type, $offset, $total, $lastId) as $row) {\n $rows[] = $row;\n }\n\n return ['results' => $rows, 'total' => $total, 'last_record' => $lastId];\n }\n\n /**\n * @throws HubspotException\n * @throws SocialAccountTokenInvalidException\n * @throws BadRequest\n */\n public function getPaginatedDataGenerator(\n array $payload,\n string $type,\n int $offset = 0,\n int &$total = 0,\n ?string &$lastRecordId = null\n ): \\Generator {\n return $this->paginationService->getPaginatedDataGenerator(\n $this,\n $payload,\n $type,\n $offset,\n $total,\n $lastRecordId\n );\n }\n\n /**\n * Execute a search request against HubSpot CRM objects with rate limiting.\n *\n * @param string $objectType The object type ('deals', 'companies', 'contacts', 'calls')\n * @param array<string, mixed> $payload The search payload with filters, sorts, properties, etc.\n * @return array The search response with 'results', 'total', 'paging' keys\n * @throws RateLimitException When rate limit is hit\n * @throws HubspotException On API errors\n */\n public function search(string $objectType, array $payload): array\n {\n $endpoint = self::BASE_URL . \"/crm/v3/objects/{$objectType}/search\";\n\n return $this->executeRequest(function () use ($endpoint, $payload) {\n $response = $this->getInstance()->getClient()->request('POST', $endpoint, ['json' => $payload]);\n\n return $response->toArray();\n });\n }\n\n /**\n * @throws DealApiException\n * @throws CrmException\n */\n public function getOpportunityById(string $crmId, array $fields): array\n {\n try {\n// $deal = $this->executeRequest(fn () => $this->getNewInstance()->crm()->deals()->basicApi()->getById(\n $deal = $this->getNewInstance()->crm()->deals()->basicApi()->getById(\n $crmId,\n implode(',', $fields),\n 'companies,contacts'\n );\n } catch (DealApiException $e) {\n $this->log->info('[Hubspot] Failed to fetch opportunity', [\n 'crm_id' => $crmId,\n 'reason' => $e->getMessage(),\n ]);\n\n throw $e;\n }\n\n if (! $deal instanceof DealWithAssociations) {\n throw new CrmException('Deal not found');\n }\n\n return [\n 'id' => $deal->getId(),\n 'properties' => $deal->getProperties(),\n 'associations' => $deal->getAssociations(),\n ];\n }\n\n /**\n * Generic batch read method for HubSpot objects\n *\n * @param string $objectType The object type ('deals', 'companies', 'contacts')\n * @param array<string> $crmIds Array of HubSpot object IDs (max 100)\n * @param array<string> $fields Array of property names to fetch\n *\n * @return array<string, array> Array keyed by CRM ID with object data\n */\n private function batchReadObjects(string $objectType, array $crmIds, array $fields): array\n {\n if (empty($crmIds)) {\n return [];\n }\n\n $this->validateBatchSize($objectType, $crmIds);\n $this->ensureValidToken();\n\n try {\n $batchConfig = $this->createBatchConfiguration($objectType);\n $batchReadRequest = $this->prepareBatchRequest($batchConfig, $crmIds, $fields);\n $response = $batchConfig['api']->read($batchReadRequest);\n\n $this->validateApiResponse($response, $objectType);\n\n $results = $this->processApiResults($response);\n $this->logBatchResults($objectType, $crmIds, $results);\n\n return $results;\n } catch (\\Throwable $e) {\n $this->handleBatchError($e, $objectType, $crmIds);\n }\n }\n\n private function validateBatchSize(string $objectType, array $crmIds): void\n {\n if (count($crmIds) > 100) {\n throw new \\InvalidArgumentException(\"Batch size cannot exceed 100 {$objectType}\");\n }\n }\n\n private function createBatchConfiguration(string $objectType): array\n {\n $configurations = [\n 'deals' => [\n 'batchReadRequest' => new \\HubSpot\\Client\\Crm\\Deals\\Model\\BatchReadInputSimplePublicObjectId(),\n 'inputClass' => \\HubSpot\\Client\\Crm\\Deals\\Model\\SimplePublicObjectId::class,\n 'api' => $this->getNewInstance()->crm()->deals()->batchApi(),\n ],\n 'companies' => [\n 'batchReadRequest' => new \\HubSpot\\Client\\Crm\\Companies\\Model\\BatchReadInputSimplePublicObjectId(),\n 'inputClass' => \\HubSpot\\Client\\Crm\\Companies\\Model\\SimplePublicObjectId::class,\n 'api' => $this->getNewInstance()->crm()->companies()->batchApi(),\n ],\n 'contacts' => [\n 'batchReadRequest' => new \\HubSpot\\Client\\Crm\\Contacts\\Model\\BatchReadInputSimplePublicObjectId(),\n 'inputClass' => \\HubSpot\\Client\\Crm\\Contacts\\Model\\SimplePublicObjectId::class,\n 'api' => $this->getNewInstance()->crm()->contacts()->batchApi(),\n ],\n ];\n\n if (! isset($configurations[$objectType])) {\n throw new \\InvalidArgumentException(\"Unsupported object type: {$objectType}\");\n }\n\n return $configurations[$objectType];\n }\n\n private function prepareBatchRequest(array $batchConfig, array $crmIds, array $fields): object\n {\n $batchReadRequest = $batchConfig['batchReadRequest'];\n $inputClass = $batchConfig['inputClass'];\n\n $inputs = array_map(function ($crmId) use ($inputClass) {\n $input = new $inputClass();\n $input->setId($crmId);\n\n return $input;\n }, $crmIds);\n\n $batchReadRequest->setInputs($inputs);\n $batchReadRequest->setProperties($fields);\n\n return $batchReadRequest;\n }\n\n private function validateApiResponse($response, string $objectType): void\n {\n if (! $response) {\n throw new CrmException(\"HubSpot API returned null response for {$objectType} batch read\");\n }\n }\n\n private function processApiResults($response): array\n {\n $results = [];\n $responseResults = $response->getResults();\n\n if ($responseResults) {\n foreach ($responseResults as $object) {\n if ($object && $object->getId()) {\n $results[$object->getId()] = [\n 'id' => $object->getId(),\n 'properties' => $object->getProperties() ?: [],\n ];\n }\n }\n }\n\n return $results;\n }\n\n private function logBatchResults(string $objectType, array $crmIds, array $results): void\n {\n $this->log->info(\"[HubSpot] Batch fetched {$objectType}\", [\n 'requested_count' => count($crmIds),\n 'returned_count' => count($results),\n 'crm_ids' => $crmIds,\n ]);\n }\n\n private function handleBatchError(\\Throwable $e, string $objectType, array $crmIds): void\n {\n $errorMessage = $e->getMessage() ?: 'Unknown error';\n $errorTrace = $e->getTraceAsString() ?: 'No trace available';\n\n $this->log->error(\"[HubSpot] Failed to batch fetch {$objectType}\", [\n 'crm_ids' => $crmIds,\n 'error' => $errorMessage,\n 'trace' => $errorTrace,\n ]);\n\n throw new CrmException(\"Failed to batch fetch {$objectType}: \" . $errorMessage);\n }\n\n /**\n * Batch read multiple opportunities by their CRM IDs\n *\n * @param array<string> $crmIds Array of HubSpot deal IDs (max 100)\n * @param array<string> $fields Array of property names to fetch\n *\n * @return array<string, array> Array keyed by CRM ID with opportunity data\n */\n public function getOpportunitiesByIds(array $crmIds, array $fields): array\n {\n return $this->batchReadObjects('deals', $crmIds, $fields);\n }\n\n /**\n * Batch read multiple companies by their CRM IDs\n *\n * @param array<string> $crmIds Array of HubSpot company IDs (max 100)\n * @param array<string> $fields Array of property names to fetch\n *\n * @return array<string, array> Array keyed by CRM ID with company data\n */\n public function getCompaniesByIds(array $crmIds, array $fields): array\n {\n return $this->batchReadObjects('companies', $crmIds, $fields);\n }\n\n /**\n * Batch read multiple contacts by their CRM IDs\n *\n * @param array<string> $crmIds Array of HubSpot contact IDs (max 100)\n * @param array<string> $fields Array of property names to fetch\n *\n * @return array<string, array> Array keyed by CRM ID with contact data\n */\n public function getContactsByIds(array $crmIds, array $fields): array\n {\n return $this->batchReadObjects('contacts', $crmIds, $fields);\n }\n\n /**\n * @throws CompanyApiException\n * @throws CrmException\n */\n public function getAccountById(string $crmId, array $fields): array\n {\n try {\n $company = $this->getNewInstance()->crm()->companies()->basicApi()->getById(\n $crmId,\n implode(',', $fields),\n );\n } catch (CompanyApiException $e) {\n $this->log->info('[Hubspot] Failed to fetch account', [\n 'crm_id' => $crmId,\n 'reason' => $e->getMessage(),\n ]);\n\n throw $e;\n }\n\n if (! $company instanceof CompaniesWithAssociations) {\n throw new CrmException('Account not found');\n }\n\n return [\n 'id' => $company->getId(),\n 'properties' => $company->getProperties(),\n ];\n }\n\n /**\n * @throws ContactApiException\n * @throws CrmException\n */\n public function getContactById(string $crmId, array $fields): array\n {\n try {\n $contact = $this->getNewInstance()->crm()->contacts()->basicApi()->getById(\n $crmId,\n implode(',', $fields)\n );\n } catch (ContactApiException $e) {\n $this->log->info('[Hubspot] Failed to fetch contact', [\n 'crm_id' => $crmId,\n 'reason' => $e->getMessage(),\n ]);\n\n throw $e;\n }\n\n if (! $contact instanceof ContactsWithAssociations) {\n throw new CrmException('Contact not found');\n }\n\n return [\n 'id' => $contact->getId(),\n 'properties' => $contact->getProperties(),\n ];\n }\n\n /**\n * This is email search request that Hubspot offers as GET (more generous quota)\n */\n public function getContactByEmail(string $email, array $fields = []): array\n {\n try {\n $contact = $this->getNewInstance()->crm()->contacts()->basicApi()->getById(\n $email,\n implode(',', $fields),\n null,\n false,\n 'email'\n );\n\n return [\n 'id' => $contact->getId(),\n 'properties' => $contact->getProperties(),\n ];\n } catch (ContactApiException $e) {\n $this->log->info('[Hubspot] Failed to fetch contact', [\n 'email' => $email,\n 'reason' => $e->getMessage(),\n ]);\n\n return [];\n }\n }\n\n /**\n * @throws CrmException\n */\n public function fetchProperty(string $objectType, string $propertyId): Property\n {\n $result = $this->getNewInstance()->crm()->properties()->coreApi()->getByName($objectType, $propertyId);\n\n if (! $result instanceof Property) {\n $this->log->error('[Hubspot] Failed to fetch property', [\n 'object_type' => $objectType,\n 'property_id' => $propertyId,\n 'reason' => $result->getMessage(),\n ]);\n\n throw new CrmException('Failed to fetch property');\n }\n\n return $result;\n }\n\n /**\n * @return array<CrmFieldOption>\n */\n public function fetchPropertyOptions(string $objectType, string $propertyId): array\n {\n /** @var array<CrmFieldOption> */\n return $this->fetchProperty($objectType, $propertyId)->getOptions();\n }\n\n /**\n * @return array<array{id:string, label:string, deleted:bool}>\n */\n public function fetchCallDispositions(): array\n {\n /** @var Response $response */\n $response = $this->getInstance()->engagements()->getCallDispositions();\n\n /**\n * @var array<array{\n * id:string,\n * label:string,\n * deleted: bool\n * }>\n */\n return $response->toArray();\n }\n\n /**\n * @return array<CrmFieldOption>\n */\n public function fetchOpportunityPipelineStages(): array\n {\n $stages = [];\n $apiResponse = $this->getNewInstance()->crm()->pipelines()->pipelinesApi()->getAll('deals');\n\n if ($apiResponse instanceof Error) {\n $this->log->error('[Hubspot] Failed to fetch opportunity pipelines', [\n 'reason' => $apiResponse->getMessage(),\n ]);\n\n return [];\n }\n\n foreach ($apiResponse->getResults() as $pipeline) {\n $pipelineStages = array_map(\n static function (PipelineStage $stage) {\n return [\n 'id' => $stage->getId(),\n 'label' => $stage->getLabel(),\n ];\n },\n $pipeline->getStages()\n );\n\n $stages = array_merge($stages, $pipelineStages);\n }\n\n return $stages;\n }\n\n public function fetchOpportunityPipelines(): array\n {\n $pipelines = [];\n\n try {\n $apiResponse = $this->makeRequest('/crm/v3/pipelines/deals');\n } catch (\\Exception $e) {\n $this->log->info('[Hubspot] Failed to fetch opportunity pipelines', [\n 'reason' => $e->getMessage(),\n ]);\n\n return [];\n }\n\n $response = $apiResponse->toArray();\n\n foreach ($response['results'] as $pipeline) {\n $pipelines[] = [\n 'id' => $pipeline['id'],\n 'label' => $pipeline['label'],\n ];\n }\n\n return $pipelines;\n }\n\n /**\n * @return array<CrmFieldOption>\n */\n public function fetchMeetingOutcomeFieldOptions(Field $field): array\n {\n return $field->getCrmProviderId() === 'meetingOutcome'\n ? $this->fetchMeetingOutcomeTypes()\n : $this->fetchCallActivityTypes();\n }\n\n public function fetchMeetingOutcomeTypes(): array\n {\n return $this->extractMeetingTypeOptions(\n 'https://api.hubapi.com/crm/v3/properties/meeting/hs_meeting_outcome'\n );\n }\n\n public function fetchCallActivityTypes(): array\n {\n return $this->extractMeetingTypeOptions(\n 'https://api.hubapi.com/crm/v3/properties/call/hs_activity_type'\n );\n }\n\n private function extractMeetingTypeOptions(string $endpoint): array\n {\n /** @var Response $response */\n $response = $this->getInstance()\n ->getClient()\n ->request('GET', $endpoint);\n\n /**\n * @var array<array{\n * value: string,\n * label: string,\n * displayOrder: int\n * }> $optionData\n */\n $optionData = $response->toArray()['options'] ?? [];\n\n $options = [];\n foreach ($optionData as $item) {\n $options[] = [\n 'id' => $item['value'],\n 'value' => $item['value'],\n 'label' => $item['label'],\n 'display_order' => $item['displayOrder'],\n ];\n }\n\n return $options;\n }\n\n /**\n * @return array<CrmFieldOption>\n */\n public function fetchDispositionFieldOptions(): array\n {\n $options = [];\n\n $dispositions = $this->fetchCallDispositions();\n\n foreach ($dispositions as $disposition) {\n if ($disposition['deleted'] !== false) {\n continue;\n }\n\n $option['value'] = $disposition['id'];\n $option['id'] = $disposition['id'];\n $option['label'] = $disposition['label'];\n\n $options[] = $option;\n }\n\n return $options;\n }\n\n /**\n * @return array<CrmFieldOption>\n */\n public function fetchOpportunityFieldOptions(Field $field): array\n {\n if ($field->isStageField()) {\n return $this->fetchOpportunityPipelineStages();\n }\n\n if ($field->isPipelineField()) {\n return $this->fetchOpportunityPipelines();\n }\n\n return $this->fetchPropertyOptions('deals', $field->getCrmProviderId());\n }\n\n /**\n * @throws BadRequest\n * @throws HubspotException\n */\n public function makeRequest(string $endpoint, $method = 'GET', $payload = [], ?string $queryString = null)\n {\n $endpoint = self::BASE_URL . $endpoint;\n\n if ($method === 'GET') {\n $response = $this->getInstance()->getClient()?->request(\n method: $method,\n endpoint: $endpoint,\n query_string: $queryString\n );\n } else {\n $response = $this->getInstance()->getClient()->request($method, $endpoint, [\n 'json' => ($payload),\n ]);\n }\n//\n// $max = $response->getHeaderLine('X-HubSpot-RateLimit-Max'); // \"110\"\n// $remaining = $response->getHeaderLine('X-HubSpot-RateLimit-Remaining'); // \"109\"\n// $interval = $response->getHeaderLine('X-HubSpot-RateLimit-Interval-Milliseconds'); // \"10000\"\n// $body = json_decode((string) $response->getBody(), true);\n//\n// \\Illuminate\\Support\\Facades\\Log::channel('custom_channel')->info('$max ' . PHP_EOL . print_r($max, true));\n// \\Illuminate\\Support\\Facades\\Log::channel('custom_channel')->info('$remaining ' . PHP_EOL . print_r($remaining, true));\n// \\Illuminate\\Support\\Facades\\Log::channel('custom_channel')->info('$interval ' . PHP_EOL . print_r($interval, true));\n// \\Illuminate\\Support\\Facades\\Log::channel('custom_channel')->info('$body ' . PHP_EOL . print_r($body, true));\n\n return $response;\n }\n\n /**\n * @throws BadRequest\n * @throws HubspotException\n */\n public function createMeeting(array $payload): Response\n {\n $endpoint = '/crm/v3/objects/meetings';\n\n return $this->makeRequest($endpoint, 'POST', $payload);\n }\n\n /**\n * @throws BadRequest\n * @throws HubspotException\n */\n public function updateMeeting(string $meetingId, array $payload): Response\n {\n $endpoint = '/crm/v3/objects/meetings/' . $meetingId;\n\n return $this->makeRequest($endpoint, 'PATCH', $payload);\n }\n\n /**\n * @throws \\Exception\n */\n public function createNote(\n string $body,\n string $ownerId,\n int $timestamp,\n string $objectId,\n NoteObject $noteObject\n ): ?string {\n try {\n $noteInput = new SimplePublicObjectInput([\n 'properties' => [\n 'hs_note_body' => $body,\n 'hubspot_owner_id' => $ownerId,\n 'hs_timestamp' => $timestamp,\n ],\n ]);\n\n // Create note\n $note = $this->getNewInstance()->crm()->objects()->basicApi()->create('note', $noteInput);\n\n $this->getNewInstance()->crm()->objects()->associationsApi()->create(\n 'note',\n $note->getId(),\n $this->getNoteObject($noteObject),\n $objectId,\n $this->getNoteAssociationType($noteObject),\n );\n\n return $note->getId();\n } catch (\\Exception $e) {\n $this->log->error('[Hubspot] Failed to create note', [\n 'objectId' => $objectId,\n 'noteObject' => $noteObject->getObjectType(),\n 'reason' => $e->getMessage(),\n ]);\n\n \\Sentry::captureException($e);\n }\n\n return null;\n }\n\n public function updateEngagement(string $objectId, array $engagement, array $metadata): void\n {\n $this->getInstance()->engagements()->update($objectId, $engagement, $metadata);\n }\n\n public function getEngagementData(string $engagementId): array\n {\n $engagement = $this->getInstance()->engagements()->get($engagementId);\n\n return $engagement->toArray();\n }\n\n public function createEngagement(array $engagement, array $associations, array $metadata): Response\n {\n return $this->getInstance()\n ->engagements()\n ->create($engagement, $associations, $metadata);\n }\n\n public function isUnauthorizedException(\\Exception $e): bool\n {\n if ($e instanceof BadRequest\n || $e instanceof DealApiException\n || $e instanceof ContactApiException\n || $e instanceof CompanyApiException\n ) {\n return (int) $e->getCode() === 401;\n }\n\n if ($e instanceof \\GuzzleHttp\\Exception\\RequestException && $e->hasResponse()) {\n return $e->getResponse()?->getStatusCode() === 401;\n }\n\n $message = strtolower($e->getMessage());\n\n return str_contains($message, '401 unauthorized') ||\n str_contains($message, 'http 401') ||\n str_contains($message, 'status code 401') ||\n (preg_match('/\\b401\\b/', $message) === 1 && str_contains($message, 'unauthorized'));\n }\n\n /**\n * Validates and refreshes the access token if needed before API requests.\n * This ensures long-running processes don't fail due to token expiration.\n *\n * @throws SocialAccountTokenInvalidException\n */\n public function ensureValidToken(): void\n {\n if ($this->oauthAccount === null) {\n return;\n }\n\n $newToken = $this->tokenManager->ensureValidToken($this->oauthAccount);\n if ($newToken !== null) {\n $this->accessToken = $newToken;\n }\n }\n\n public function getConfig()\n {\n return $this->config;\n }\n\n // returns only active (archived=false)\n public function getOwners(): array\n {\n return $this->getNewInstance()->crm()->owners()->getAll();\n }\n\n /**\n * @param bool $archived\n *\n * @return array<Owner>|[]\n */\n public function getOwnersArchived(bool $archived = true): array\n {\n $endpoint = '/crm/v3/owners';\n $queryParams = [\n 'archived' => $archived ? 'true' : 'false',\n ];\n $queryString = http_build_query($queryParams);\n\n $owners = [];\n\n try {\n $response = $this->makeRequest(endpoint: $endpoint, queryString: $queryString);\n $responseData = $response?->toArray();\n\n foreach ($responseData['results'] as $result) {\n try {\n $owners[] = Owner::create($result);\n } catch (Throwable $e) {\n $this->log->error('[HubSpot] Failed to process owner data', [\n 'result' => $result,\n 'error' => $e->getMessage(),\n ]);\n\n continue;\n }\n }\n } catch (Throwable $e) {\n $this->log->error('HubSpot] Failed to fetch owners', [\n 'archived' => $archived,\n 'error' => $e->getMessage(),\n ]);\n\n return [];\n }\n\n return $owners;\n }\n\n public function getMeeting(string $engagementId): ObjectWithAssociations\n {\n return $this->getNewInstance()->crm()->objects()->basicApi()\n ->getById('meeting', $engagementId, null, 'contact,company,deal');\n }\n\n public function deleteEngagement(string $engagementId): void\n {\n $this->getInstance()->engagements()->delete((int) $engagementId);\n }\n\n public function getAssociationsData(array $ids, string $fromObject, string $toObject): array\n {\n $associationData = [];\n $idChunks = array_chunk($ids, self::ASSOCIATIONS_BATCH_SIZE_LIMIT);\n\n foreach ($idChunks as $idChunk) {\n try {\n $batchInput = new \\HubSpot\\Client\\Crm\\Associations\\Model\\BatchInputPublicObjectId();\n $batchInput->setInputs(array_map(function ($id) {\n $publicObjectId = new \\HubSpot\\Client\\Crm\\Associations\\Model\\PublicObjectId();\n $publicObjectId->setId($id);\n\n return $publicObjectId;\n }, $idChunk));\n\n $associatedObjectsData = $this\n ->getNewInstance()\n ->crm()\n ->associations()\n ->batchApi()\n ->read($fromObject, $toObject, $batchInput);\n\n if ($associatedObjectsData instanceof \\HubSpot\\Client\\Crm\\Associations\\Model\\BatchResponsePublicAssociationMulti) {\n foreach ($associatedObjectsData->getResults() as $association) {\n $from = $association->getFrom()->getId();\n $toAssociations = $association->getTo();\n\n if (! empty($toAssociations)) {\n $associationData[$from] = array_map(function ($item) {\n return $item->getId();\n }, $toAssociations);\n }\n }\n }\n } catch (RateLimitException $e) {\n throw $e;\n } catch (\\Exception $e) {\n $this->log->error('[Hubspot] Failed to fetch associations', [\n 'from_object' => $fromObject,\n 'to_object' => $toObject,\n 'reason' => $e->getMessage(),\n ]);\n }\n }\n\n return $associationData;\n }\n\n /**\n * @throws \\Exception\n */\n private function getNoteAssociationType(NoteObject $noteObject): string\n {\n return match($noteObject) {\n NoteObject::Opportunity => 'note_to_deal',\n NoteObject::Lead, NoteObject::Contact => 'note_to_contact', // or 'note_to_lead' if your portal supports it\n NoteObject::Account => 'note_to_company',\n NoteObject::Call, NoteObject::Event => throw new \\Exception('Not supported'),\n };\n }\n\n /**\n * @throws \\Exception\n */\n private function getNoteObject(NoteObject $noteObject): string\n {\n return match($noteObject) {\n NoteObject::Opportunity => 'deal',\n NoteObject::Lead, NoteObject::Contact => 'contact',\n NoteObject::Account => 'company',\n NoteObject::Call, NoteObject::Event => throw new \\Exception('Not supported'),\n };\n }\n\n public function addAssociations(string $objectType, string $associationType, array $payload): Response\n {\n $endpoint = \"/crm/v4/associations/$objectType/$associationType/batch/create\";\n\n return $this->makeRequest($endpoint, 'POST', $payload);\n }\n\n public function removeAssociations(string $objectType, string $associationType, array $payload): Response\n {\n $endpoint = \"/crm/v4/associations/$objectType/$associationType/batch/archive\";\n\n return $this->makeRequest($endpoint, 'POST', $payload);\n }\n}","depth":4,"on_screen":true,"value":"<?php\n\ndeclare(strict_types=1);\n\nnamespace Jiminny\\Services\\Crm\\Hubspot;\n\nuse HubSpot\\Client\\Crm\\Deals\\ApiException as DealApiException;\nuse HubSpot\\Client\\Crm\\Contacts\\ApiException as ContactApiException;\nuse HubSpot\\Client\\Crm\\Companies\\ApiException as CompanyApiException;\nuse HubSpot\\Client\\Crm\\Contacts\\Model\\SimplePublicObjectWithAssociations as ContactsWithAssociations;\nuse HubSpot\\Client\\Crm\\Companies\\Model\\SimplePublicObjectWithAssociations as CompaniesWithAssociations;\nuse HubSpot\\Client\\Crm\\Deals\\Model\\SimplePublicObjectWithAssociations as DealWithAssociations;\nuse HubSpot\\Client\\Crm\\Objects\\Model\\SimplePublicObjectInput;\nuse HubSpot\\Client\\Crm\\Objects\\Model\\SimplePublicObjectWithAssociations as ObjectWithAssociations;\nuse HubSpot\\Client\\Crm\\Pipelines\\Model\\Error;\nuse HubSpot\\Client\\Crm\\Pipelines\\Model\\PipelineStage;\nuse HubSpot\\Client\\Crm\\Properties\\Model\\Property;\nuse HubSpot\\Discovery\\Discovery;\nuse Jiminny\\Exceptions\\CrmException;\nuse Jiminny\\Exceptions\\RateLimitException;\nuse Jiminny\\Exceptions\\SocialAccountTokenInvalidException;\nuse Jiminny\\Jobs\\Crm\\NoteObject;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Services\\Crm\\BaseClient;\nuse Jiminny\\Services\\Crm\\Hubspot\\DTO\\Response\\Owner;\nuse Jiminny\\Services\\SocialAccountService;\nuse SevenShores\\Hubspot\\Exceptions\\BadRequest;\nuse SevenShores\\Hubspot\\Exceptions\\HubspotException;\nuse SevenShores\\Hubspot\\Factory;\nuse SevenShores\\Hubspot\\Http\\Response;\nuse Jiminny\\Services\\Crm\\Hubspot\\Pagination\\HubspotPaginationService;\nuse Throwable;\n\n/**\n * @phpstan-type CrmFieldOption array{id:string, label:string, value?:string}\n */\nclass Client extends BaseClient implements HubspotClientInterface\n{\n public const string MIN_API_VERSION = '2';\n\n public const string BASE_URL = 'https://api.hubapi.com';\n\n public const int ASSOCIATIONS_BATCH_SIZE_LIMIT = 1000;\n\n private HubspotPaginationService $paginationService;\n private HubspotTokenManager $tokenManager;\n\n public function __construct(\n SocialAccountService $socialAccountService,\n HubspotPaginationService $paginationService,\n HubspotTokenManager $tokenManager,\n ) {\n parent::__construct($socialAccountService);\n $this->paginationService = $paginationService;\n $this->tokenManager = $tokenManager;\n\n $this->setBaseUrl(self::BASE_URL);\n $this->setVersion(self::MIN_API_VERSION);\n }\n\n /**\n * Reacts to a rate limits (429) from HubSpot by translating it\n * into a RateLimitException carrying retry_after.\n *\n * Wrap any outbound HubSpot call (SDK or raw HTTP) like:\n *\n * $this->executeRequest(fn () => $this->getNewInstance()->crm()->...);\n *\n * @template T\n * @param callable(): T $apiCall\n * @return T\n *\n * @throws RateLimitException\n */\n private function executeRequest(callable $apiCall)\n {\n try {\n return $apiCall();\n } catch (Throwable $e) {\n if ($this->isHubspotRateLimit($e)) {\n $retryAfter = $this->parseRetryAfter($e);\n\n $this->log->warning('[Hubspot] Received 429 from API', [\n 'team_id' => $this->config->team_id,\n 'config_id' => $this->config->getId(),\n 'retry_after' => $retryAfter,\n 'reason' => $e->getMessage(),\n ]);\n\n throw new RateLimitException('Hubspot returned 429', $retryAfter, $e);\n }\n\n throw $e;\n }\n }\n\n public function isHubspotRateLimit(Throwable $e): bool\n {\n if ($e instanceof BadRequest\n || $e instanceof DealApiException\n || $e instanceof ContactApiException\n || $e instanceof CompanyApiException\n || $e instanceof \\GuzzleHttp\\Exception\\RequestException\n ) {\n return (int) $e->getCode() === 429;\n }\n\n return false;\n }\n\n public function parseRetryAfter(Throwable $e): int\n {\n // First try to get Retry-After from response headers\n if (method_exists($e, 'getResponseHeaders')) {\n $headers = $e->getResponseHeaders() ?: [];\n $value = $headers['Retry-After'] ?? $headers['retry-after'] ?? null;\n if (is_array($value)) {\n $value = $value[0] ?? null;\n }\n if (is_numeric($value)) {\n return (int) $value;\n }\n }\n\n if (method_exists($e, 'getResponseBody')) {\n $body = $e->getResponseBody();\n if (is_string($body)) {\n $body = json_decode($body, true) ?? [];\n }\n\n $policyName = $body['policyName'] ?? $body['policy'] ?? $body['context']['policyName'] ?? null;\n\n if ($policyName === 'TEN_SECONDLY_ROLLING' || $policyName === 'ten_secondly_rolling') {\n return 10;\n }\n if ($policyName === 'SECONDLY' || $policyName === 'secondly') {\n return 1;\n }\n }\n\n $this->log->warning('[Hubspot] No retry-after header or policy name found, using default', [\n 'exception_class' => get_class($e),\n ]);\n\n return 10;\n }\n\n public function getMinimumApiVersion(): string\n {\n return self::MIN_API_VERSION;\n }\n\n public function getInstance(): Factory\n {\n return new Factory([\n 'key' => $this->accessToken,\n 'oauth2' => true,\n 'base_url' => $this->baseUrl,\n ]);\n }\n\n public function getNewInstance(): Discovery\n {\n return \\HubSpot\\Factory::createWithAccessToken($this->accessToken);\n }\n\n /**\n * Secondly and daily limits for Hubspot API\n *\n * Product Tier: Free & Starter | Professional & Enterprise | API add-on (any tier)\n * Burst: 100/10 seconds | 150/10 seconds | 200/10 seconds\n * Daily: 250,000 | 500,000 | 1,000,000\n *\n * Official documentation states: The search endpoints are rate limited to five requests per second.\n * Since with 5 RPS were still hitting secondly rate limits we lowered it to 4\n */\n public function getPaginatedData(array $payload, string $type, int $offset = 0): array\n {\n $total = 0;\n $lastId = null;\n $rows = [];\n foreach ($this->getPaginatedDataGenerator($payload, $type, $offset, $total, $lastId) as $row) {\n $rows[] = $row;\n }\n\n return ['results' => $rows, 'total' => $total, 'last_record' => $lastId];\n }\n\n /**\n * @throws HubspotException\n * @throws SocialAccountTokenInvalidException\n * @throws BadRequest\n */\n public function getPaginatedDataGenerator(\n array $payload,\n string $type,\n int $offset = 0,\n int &$total = 0,\n ?string &$lastRecordId = null\n ): \\Generator {\n return $this->paginationService->getPaginatedDataGenerator(\n $this,\n $payload,\n $type,\n $offset,\n $total,\n $lastRecordId\n );\n }\n\n /**\n * Execute a search request against HubSpot CRM objects with rate limiting.\n *\n * @param string $objectType The object type ('deals', 'companies', 'contacts', 'calls')\n * @param array<string, mixed> $payload The search payload with filters, sorts, properties, etc.\n * @return array The search response with 'results', 'total', 'paging' keys\n * @throws RateLimitException When rate limit is hit\n * @throws HubspotException On API errors\n */\n public function search(string $objectType, array $payload): array\n {\n $endpoint = self::BASE_URL . \"/crm/v3/objects/{$objectType}/search\";\n\n return $this->executeRequest(function () use ($endpoint, $payload) {\n $response = $this->getInstance()->getClient()->request('POST', $endpoint, ['json' => $payload]);\n\n return $response->toArray();\n });\n }\n\n /**\n * @throws DealApiException\n * @throws CrmException\n */\n public function getOpportunityById(string $crmId, array $fields): array\n {\n try {\n// $deal = $this->executeRequest(fn () => $this->getNewInstance()->crm()->deals()->basicApi()->getById(\n $deal = $this->getNewInstance()->crm()->deals()->basicApi()->getById(\n $crmId,\n implode(',', $fields),\n 'companies,contacts'\n );\n } catch (DealApiException $e) {\n $this->log->info('[Hubspot] Failed to fetch opportunity', [\n 'crm_id' => $crmId,\n 'reason' => $e->getMessage(),\n ]);\n\n throw $e;\n }\n\n if (! $deal instanceof DealWithAssociations) {\n throw new CrmException('Deal not found');\n }\n\n return [\n 'id' => $deal->getId(),\n 'properties' => $deal->getProperties(),\n 'associations' => $deal->getAssociations(),\n ];\n }\n\n /**\n * Generic batch read method for HubSpot objects\n *\n * @param string $objectType The object type ('deals', 'companies', 'contacts')\n * @param array<string> $crmIds Array of HubSpot object IDs (max 100)\n * @param array<string> $fields Array of property names to fetch\n *\n * @return array<string, array> Array keyed by CRM ID with object data\n */\n private function batchReadObjects(string $objectType, array $crmIds, array $fields): array\n {\n if (empty($crmIds)) {\n return [];\n }\n\n $this->validateBatchSize($objectType, $crmIds);\n $this->ensureValidToken();\n\n try {\n $batchConfig = $this->createBatchConfiguration($objectType);\n $batchReadRequest = $this->prepareBatchRequest($batchConfig, $crmIds, $fields);\n $response = $batchConfig['api']->read($batchReadRequest);\n\n $this->validateApiResponse($response, $objectType);\n\n $results = $this->processApiResults($response);\n $this->logBatchResults($objectType, $crmIds, $results);\n\n return $results;\n } catch (\\Throwable $e) {\n $this->handleBatchError($e, $objectType, $crmIds);\n }\n }\n\n private function validateBatchSize(string $objectType, array $crmIds): void\n {\n if (count($crmIds) > 100) {\n throw new \\InvalidArgumentException(\"Batch size cannot exceed 100 {$objectType}\");\n }\n }\n\n private function createBatchConfiguration(string $objectType): array\n {\n $configurations = [\n 'deals' => [\n 'batchReadRequest' => new \\HubSpot\\Client\\Crm\\Deals\\Model\\BatchReadInputSimplePublicObjectId(),\n 'inputClass' => \\HubSpot\\Client\\Crm\\Deals\\Model\\SimplePublicObjectId::class,\n 'api' => $this->getNewInstance()->crm()->deals()->batchApi(),\n ],\n 'companies' => [\n 'batchReadRequest' => new \\HubSpot\\Client\\Crm\\Companies\\Model\\BatchReadInputSimplePublicObjectId(),\n 'inputClass' => \\HubSpot\\Client\\Crm\\Companies\\Model\\SimplePublicObjectId::class,\n 'api' => $this->getNewInstance()->crm()->companies()->batchApi(),\n ],\n 'contacts' => [\n 'batchReadRequest' => new \\HubSpot\\Client\\Crm\\Contacts\\Model\\BatchReadInputSimplePublicObjectId(),\n 'inputClass' => \\HubSpot\\Client\\Crm\\Contacts\\Model\\SimplePublicObjectId::class,\n 'api' => $this->getNewInstance()->crm()->contacts()->batchApi(),\n ],\n ];\n\n if (! isset($configurations[$objectType])) {\n throw new \\InvalidArgumentException(\"Unsupported object type: {$objectType}\");\n }\n\n return $configurations[$objectType];\n }\n\n private function prepareBatchRequest(array $batchConfig, array $crmIds, array $fields): object\n {\n $batchReadRequest = $batchConfig['batchReadRequest'];\n $inputClass = $batchConfig['inputClass'];\n\n $inputs = array_map(function ($crmId) use ($inputClass) {\n $input = new $inputClass();\n $input->setId($crmId);\n\n return $input;\n }, $crmIds);\n\n $batchReadRequest->setInputs($inputs);\n $batchReadRequest->setProperties($fields);\n\n return $batchReadRequest;\n }\n\n private function validateApiResponse($response, string $objectType): void\n {\n if (! $response) {\n throw new CrmException(\"HubSpot API returned null response for {$objectType} batch read\");\n }\n }\n\n private function processApiResults($response): array\n {\n $results = [];\n $responseResults = $response->getResults();\n\n if ($responseResults) {\n foreach ($responseResults as $object) {\n if ($object && $object->getId()) {\n $results[$object->getId()] = [\n 'id' => $object->getId(),\n 'properties' => $object->getProperties() ?: [],\n ];\n }\n }\n }\n\n return $results;\n }\n\n private function logBatchResults(string $objectType, array $crmIds, array $results): void\n {\n $this->log->info(\"[HubSpot] Batch fetched {$objectType}\", [\n 'requested_count' => count($crmIds),\n 'returned_count' => count($results),\n 'crm_ids' => $crmIds,\n ]);\n }\n\n private function handleBatchError(\\Throwable $e, string $objectType, array $crmIds): void\n {\n $errorMessage = $e->getMessage() ?: 'Unknown error';\n $errorTrace = $e->getTraceAsString() ?: 'No trace available';\n\n $this->log->error(\"[HubSpot] Failed to batch fetch {$objectType}\", [\n 'crm_ids' => $crmIds,\n 'error' => $errorMessage,\n 'trace' => $errorTrace,\n ]);\n\n throw new CrmException(\"Failed to batch fetch {$objectType}: \" . $errorMessage);\n }\n\n /**\n * Batch read multiple opportunities by their CRM IDs\n *\n * @param array<string> $crmIds Array of HubSpot deal IDs (max 100)\n * @param array<string> $fields Array of property names to fetch\n *\n * @return array<string, array> Array keyed by CRM ID with opportunity data\n */\n public function getOpportunitiesByIds(array $crmIds, array $fields): array\n {\n return $this->batchReadObjects('deals', $crmIds, $fields);\n }\n\n /**\n * Batch read multiple companies by their CRM IDs\n *\n * @param array<string> $crmIds Array of HubSpot company IDs (max 100)\n * @param array<string> $fields Array of property names to fetch\n *\n * @return array<string, array> Array keyed by CRM ID with company data\n */\n public function getCompaniesByIds(array $crmIds, array $fields): array\n {\n return $this->batchReadObjects('companies', $crmIds, $fields);\n }\n\n /**\n * Batch read multiple contacts by their CRM IDs\n *\n * @param array<string> $crmIds Array of HubSpot contact IDs (max 100)\n * @param array<string> $fields Array of property names to fetch\n *\n * @return array<string, array> Array keyed by CRM ID with contact data\n */\n public function getContactsByIds(array $crmIds, array $fields): array\n {\n return $this->batchReadObjects('contacts', $crmIds, $fields);\n }\n\n /**\n * @throws CompanyApiException\n * @throws CrmException\n */\n public function getAccountById(string $crmId, array $fields): array\n {\n try {\n $company = $this->getNewInstance()->crm()->companies()->basicApi()->getById(\n $crmId,\n implode(',', $fields),\n );\n } catch (CompanyApiException $e) {\n $this->log->info('[Hubspot] Failed to fetch account', [\n 'crm_id' => $crmId,\n 'reason' => $e->getMessage(),\n ]);\n\n throw $e;\n }\n\n if (! $company instanceof CompaniesWithAssociations) {\n throw new CrmException('Account not found');\n }\n\n return [\n 'id' => $company->getId(),\n 'properties' => $company->getProperties(),\n ];\n }\n\n /**\n * @throws ContactApiException\n * @throws CrmException\n */\n public function getContactById(string $crmId, array $fields): array\n {\n try {\n $contact = $this->getNewInstance()->crm()->contacts()->basicApi()->getById(\n $crmId,\n implode(',', $fields)\n );\n } catch (ContactApiException $e) {\n $this->log->info('[Hubspot] Failed to fetch contact', [\n 'crm_id' => $crmId,\n 'reason' => $e->getMessage(),\n ]);\n\n throw $e;\n }\n\n if (! $contact instanceof ContactsWithAssociations) {\n throw new CrmException('Contact not found');\n }\n\n return [\n 'id' => $contact->getId(),\n 'properties' => $contact->getProperties(),\n ];\n }\n\n /**\n * This is email search request that Hubspot offers as GET (more generous quota)\n */\n public function getContactByEmail(string $email, array $fields = []): array\n {\n try {\n $contact = $this->getNewInstance()->crm()->contacts()->basicApi()->getById(\n $email,\n implode(',', $fields),\n null,\n false,\n 'email'\n );\n\n return [\n 'id' => $contact->getId(),\n 'properties' => $contact->getProperties(),\n ];\n } catch (ContactApiException $e) {\n $this->log->info('[Hubspot] Failed to fetch contact', [\n 'email' => $email,\n 'reason' => $e->getMessage(),\n ]);\n\n return [];\n }\n }\n\n /**\n * @throws CrmException\n */\n public function fetchProperty(string $objectType, string $propertyId): Property\n {\n $result = $this->getNewInstance()->crm()->properties()->coreApi()->getByName($objectType, $propertyId);\n\n if (! $result instanceof Property) {\n $this->log->error('[Hubspot] Failed to fetch property', [\n 'object_type' => $objectType,\n 'property_id' => $propertyId,\n 'reason' => $result->getMessage(),\n ]);\n\n throw new CrmException('Failed to fetch property');\n }\n\n return $result;\n }\n\n /**\n * @return array<CrmFieldOption>\n */\n public function fetchPropertyOptions(string $objectType, string $propertyId): array\n {\n /** @var array<CrmFieldOption> */\n return $this->fetchProperty($objectType, $propertyId)->getOptions();\n }\n\n /**\n * @return array<array{id:string, label:string, deleted:bool}>\n */\n public function fetchCallDispositions(): array\n {\n /** @var Response $response */\n $response = $this->getInstance()->engagements()->getCallDispositions();\n\n /**\n * @var array<array{\n * id:string,\n * label:string,\n * deleted: bool\n * }>\n */\n return $response->toArray();\n }\n\n /**\n * @return array<CrmFieldOption>\n */\n public function fetchOpportunityPipelineStages(): array\n {\n $stages = [];\n $apiResponse = $this->getNewInstance()->crm()->pipelines()->pipelinesApi()->getAll('deals');\n\n if ($apiResponse instanceof Error) {\n $this->log->error('[Hubspot] Failed to fetch opportunity pipelines', [\n 'reason' => $apiResponse->getMessage(),\n ]);\n\n return [];\n }\n\n foreach ($apiResponse->getResults() as $pipeline) {\n $pipelineStages = array_map(\n static function (PipelineStage $stage) {\n return [\n 'id' => $stage->getId(),\n 'label' => $stage->getLabel(),\n ];\n },\n $pipeline->getStages()\n );\n\n $stages = array_merge($stages, $pipelineStages);\n }\n\n return $stages;\n }\n\n public function fetchOpportunityPipelines(): array\n {\n $pipelines = [];\n\n try {\n $apiResponse = $this->makeRequest('/crm/v3/pipelines/deals');\n } catch (\\Exception $e) {\n $this->log->info('[Hubspot] Failed to fetch opportunity pipelines', [\n 'reason' => $e->getMessage(),\n ]);\n\n return [];\n }\n\n $response = $apiResponse->toArray();\n\n foreach ($response['results'] as $pipeline) {\n $pipelines[] = [\n 'id' => $pipeline['id'],\n 'label' => $pipeline['label'],\n ];\n }\n\n return $pipelines;\n }\n\n /**\n * @return array<CrmFieldOption>\n */\n public function fetchMeetingOutcomeFieldOptions(Field $field): array\n {\n return $field->getCrmProviderId() === 'meetingOutcome'\n ? $this->fetchMeetingOutcomeTypes()\n : $this->fetchCallActivityTypes();\n }\n\n public function fetchMeetingOutcomeTypes(): array\n {\n return $this->extractMeetingTypeOptions(\n 'https://api.hubapi.com/crm/v3/properties/meeting/hs_meeting_outcome'\n );\n }\n\n public function fetchCallActivityTypes(): array\n {\n return $this->extractMeetingTypeOptions(\n 'https://api.hubapi.com/crm/v3/properties/call/hs_activity_type'\n );\n }\n\n private function extractMeetingTypeOptions(string $endpoint): array\n {\n /** @var Response $response */\n $response = $this->getInstance()\n ->getClient()\n ->request('GET', $endpoint);\n\n /**\n * @var array<array{\n * value: string,\n * label: string,\n * displayOrder: int\n * }> $optionData\n */\n $optionData = $response->toArray()['options'] ?? [];\n\n $options = [];\n foreach ($optionData as $item) {\n $options[] = [\n 'id' => $item['value'],\n 'value' => $item['value'],\n 'label' => $item['label'],\n 'display_order' => $item['displayOrder'],\n ];\n }\n\n return $options;\n }\n\n /**\n * @return array<CrmFieldOption>\n */\n public function fetchDispositionFieldOptions(): array\n {\n $options = [];\n\n $dispositions = $this->fetchCallDispositions();\n\n foreach ($dispositions as $disposition) {\n if ($disposition['deleted'] !== false) {\n continue;\n }\n\n $option['value'] = $disposition['id'];\n $option['id'] = $disposition['id'];\n $option['label'] = $disposition['label'];\n\n $options[] = $option;\n }\n\n return $options;\n }\n\n /**\n * @return array<CrmFieldOption>\n */\n public function fetchOpportunityFieldOptions(Field $field): array\n {\n if ($field->isStageField()) {\n return $this->fetchOpportunityPipelineStages();\n }\n\n if ($field->isPipelineField()) {\n return $this->fetchOpportunityPipelines();\n }\n\n return $this->fetchPropertyOptions('deals', $field->getCrmProviderId());\n }\n\n /**\n * @throws BadRequest\n * @throws HubspotException\n */\n public function makeRequest(string $endpoint, $method = 'GET', $payload = [], ?string $queryString = null)\n {\n $endpoint = self::BASE_URL . $endpoint;\n\n if ($method === 'GET') {\n $response = $this->getInstance()->getClient()?->request(\n method: $method,\n endpoint: $endpoint,\n query_string: $queryString\n );\n } else {\n $response = $this->getInstance()->getClient()->request($method, $endpoint, [\n 'json' => ($payload),\n ]);\n }\n//\n// $max = $response->getHeaderLine('X-HubSpot-RateLimit-Max'); // \"110\"\n// $remaining = $response->getHeaderLine('X-HubSpot-RateLimit-Remaining'); // \"109\"\n// $interval = $response->getHeaderLine('X-HubSpot-RateLimit-Interval-Milliseconds'); // \"10000\"\n// $body = json_decode((string) $response->getBody(), true);\n//\n// \\Illuminate\\Support\\Facades\\Log::channel('custom_channel')->info('$max ' . PHP_EOL . print_r($max, true));\n// \\Illuminate\\Support\\Facades\\Log::channel('custom_channel')->info('$remaining ' . PHP_EOL . print_r($remaining, true));\n// \\Illuminate\\Support\\Facades\\Log::channel('custom_channel')->info('$interval ' . PHP_EOL . print_r($interval, true));\n// \\Illuminate\\Support\\Facades\\Log::channel('custom_channel')->info('$body ' . PHP_EOL . print_r($body, true));\n\n return $response;\n }\n\n /**\n * @throws BadRequest\n * @throws HubspotException\n */\n public function createMeeting(array $payload): Response\n {\n $endpoint = '/crm/v3/objects/meetings';\n\n return $this->makeRequest($endpoint, 'POST', $payload);\n }\n\n /**\n * @throws BadRequest\n * @throws HubspotException\n */\n public function updateMeeting(string $meetingId, array $payload): Response\n {\n $endpoint = '/crm/v3/objects/meetings/' . $meetingId;\n\n return $this->makeRequest($endpoint, 'PATCH', $payload);\n }\n\n /**\n * @throws \\Exception\n */\n public function createNote(\n string $body,\n string $ownerId,\n int $timestamp,\n string $objectId,\n NoteObject $noteObject\n ): ?string {\n try {\n $noteInput = new SimplePublicObjectInput([\n 'properties' => [\n 'hs_note_body' => $body,\n 'hubspot_owner_id' => $ownerId,\n 'hs_timestamp' => $timestamp,\n ],\n ]);\n\n // Create note\n $note = $this->getNewInstance()->crm()->objects()->basicApi()->create('note', $noteInput);\n\n $this->getNewInstance()->crm()->objects()->associationsApi()->create(\n 'note',\n $note->getId(),\n $this->getNoteObject($noteObject),\n $objectId,\n $this->getNoteAssociationType($noteObject),\n );\n\n return $note->getId();\n } catch (\\Exception $e) {\n $this->log->error('[Hubspot] Failed to create note', [\n 'objectId' => $objectId,\n 'noteObject' => $noteObject->getObjectType(),\n 'reason' => $e->getMessage(),\n ]);\n\n \\Sentry::captureException($e);\n }\n\n return null;\n }\n\n public function updateEngagement(string $objectId, array $engagement, array $metadata): void\n {\n $this->getInstance()->engagements()->update($objectId, $engagement, $metadata);\n }\n\n public function getEngagementData(string $engagementId): array\n {\n $engagement = $this->getInstance()->engagements()->get($engagementId);\n\n return $engagement->toArray();\n }\n\n public function createEngagement(array $engagement, array $associations, array $metadata): Response\n {\n return $this->getInstance()\n ->engagements()\n ->create($engagement, $associations, $metadata);\n }\n\n public function isUnauthorizedException(\\Exception $e): bool\n {\n if ($e instanceof BadRequest\n || $e instanceof DealApiException\n || $e instanceof ContactApiException\n || $e instanceof CompanyApiException\n ) {\n return (int) $e->getCode() === 401;\n }\n\n if ($e instanceof \\GuzzleHttp\\Exception\\RequestException && $e->hasResponse()) {\n return $e->getResponse()?->getStatusCode() === 401;\n }\n\n $message = strtolower($e->getMessage());\n\n return str_contains($message, '401 unauthorized') ||\n str_contains($message, 'http 401') ||\n str_contains($message, 'status code 401') ||\n (preg_match('/\\b401\\b/', $message) === 1 && str_contains($message, 'unauthorized'));\n }\n\n /**\n * Validates and refreshes the access token if needed before API requests.\n * This ensures long-running processes don't fail due to token expiration.\n *\n * @throws SocialAccountTokenInvalidException\n */\n public function ensureValidToken(): void\n {\n if ($this->oauthAccount === null) {\n return;\n }\n\n $newToken = $this->tokenManager->ensureValidToken($this->oauthAccount);\n if ($newToken !== null) {\n $this->accessToken = $newToken;\n }\n }\n\n public function getConfig()\n {\n return $this->config;\n }\n\n // returns only active (archived=false)\n public function getOwners(): array\n {\n return $this->getNewInstance()->crm()->owners()->getAll();\n }\n\n /**\n * @param bool $archived\n *\n * @return array<Owner>|[]\n */\n public function getOwnersArchived(bool $archived = true): array\n {\n $endpoint = '/crm/v3/owners';\n $queryParams = [\n 'archived' => $archived ? 'true' : 'false',\n ];\n $queryString = http_build_query($queryParams);\n\n $owners = [];\n\n try {\n $response = $this->makeRequest(endpoint: $endpoint, queryString: $queryString);\n $responseData = $response?->toArray();\n\n foreach ($responseData['results'] as $result) {\n try {\n $owners[] = Owner::create($result);\n } catch (Throwable $e) {\n $this->log->error('[HubSpot] Failed to process owner data', [\n 'result' => $result,\n 'error' => $e->getMessage(),\n ]);\n\n continue;\n }\n }\n } catch (Throwable $e) {\n $this->log->error('HubSpot] Failed to fetch owners', [\n 'archived' => $archived,\n 'error' => $e->getMessage(),\n ]);\n\n return [];\n }\n\n return $owners;\n }\n\n public function getMeeting(string $engagementId): ObjectWithAssociations\n {\n return $this->getNewInstance()->crm()->objects()->basicApi()\n ->getById('meeting', $engagementId, null, 'contact,company,deal');\n }\n\n public function deleteEngagement(string $engagementId): void\n {\n $this->getInstance()->engagements()->delete((int) $engagementId);\n }\n\n public function getAssociationsData(array $ids, string $fromObject, string $toObject): array\n {\n $associationData = [];\n $idChunks = array_chunk($ids, self::ASSOCIATIONS_BATCH_SIZE_LIMIT);\n\n foreach ($idChunks as $idChunk) {\n try {\n $batchInput = new \\HubSpot\\Client\\Crm\\Associations\\Model\\BatchInputPublicObjectId();\n $batchInput->setInputs(array_map(function ($id) {\n $publicObjectId = new \\HubSpot\\Client\\Crm\\Associations\\Model\\PublicObjectId();\n $publicObjectId->setId($id);\n\n return $publicObjectId;\n }, $idChunk));\n\n $associatedObjectsData = $this\n ->getNewInstance()\n ->crm()\n ->associations()\n ->batchApi()\n ->read($fromObject, $toObject, $batchInput);\n\n if ($associatedObjectsData instanceof \\HubSpot\\Client\\Crm\\Associations\\Model\\BatchResponsePublicAssociationMulti) {\n foreach ($associatedObjectsData->getResults() as $association) {\n $from = $association->getFrom()->getId();\n $toAssociations = $association->getTo();\n\n if (! empty($toAssociations)) {\n $associationData[$from] = array_map(function ($item) {\n return $item->getId();\n }, $toAssociations);\n }\n }\n }\n } catch (RateLimitException $e) {\n throw $e;\n } catch (\\Exception $e) {\n $this->log->error('[Hubspot] Failed to fetch associations', [\n 'from_object' => $fromObject,\n 'to_object' => $toObject,\n 'reason' => $e->getMessage(),\n ]);\n }\n }\n\n return $associationData;\n }\n\n /**\n * @throws \\Exception\n */\n private function getNoteAssociationType(NoteObject $noteObject): string\n {\n return match($noteObject) {\n NoteObject::Opportunity => 'note_to_deal',\n NoteObject::Lead, NoteObject::Contact => 'note_to_contact', // or 'note_to_lead' if your portal supports it\n NoteObject::Account => 'note_to_company',\n NoteObject::Call, NoteObject::Event => throw new \\Exception('Not supported'),\n };\n }\n\n /**\n * @throws \\Exception\n */\n private function getNoteObject(NoteObject $noteObject): string\n {\n return match($noteObject) {\n NoteObject::Opportunity => 'deal',\n NoteObject::Lead, NoteObject::Contact => 'contact',\n NoteObject::Account => 'company',\n NoteObject::Call, NoteObject::Event => throw new \\Exception('Not supported'),\n };\n }\n\n public function addAssociations(string $objectType, string $associationType, array $payload): Response\n {\n $endpoint = \"/crm/v4/associations/$objectType/$associationType/batch/create\";\n\n return $this->makeRequest($endpoint, 'POST', $payload);\n }\n\n public function removeAssociations(string $objectType, string $associationType, array $payload): Response\n {\n $endpoint = \"/crm/v4/associations/$objectType/$associationType/batch/archive\";\n\n return $this->makeRequest($endpoint, 'POST', $payload);\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":true,"is_selected":false,"is_expanded":false}]...
|
5322166595402727169
|
-2772555093617276826
|
click
|
accessibility
|
NULL
|
Project: faVsco.js, menu
JY-20725-handle-HS-search Project: faVsco.js, menu
JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
19
Previous Highlighted Error
Next Highlighted Error
[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {
"headers":{
"Date":["Thu,07 May 2026 14:21:15 GMT"],
"Content-Type":["application/json;charset=utf-8"],
"Transfer-Encoding":["chunked"],
"Connection":["keep-alive"],
"CF-Ray":["9f80deb8db60dc3a-SOF"],
"CF-Cache-Status":["DYNAMIC"],
"Strict-Transport-Security":["max-age=31536000; includeSubDomains; preload"],
"Vary":["origin,
accept-encoding"],
"access-control-allow-credentials":["false"],
"server-timing":["hcid;desc=\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\",
cfr;desc=\"9f80deb8e7c6dc3a-IAD\""],
"x-content-type-options":["nosniff"],
"x-hubspot-correlation-id":["019e02d0-6fd8-7812-bdba-885b7ccb3ee3"],
"Set-Cookie":["__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-[IP_ADDRESS]-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,
07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None"],
"Report-To":["{
\"endpoints\":[{
\"url\":\"https:\\/\\/a.nel.cloudflare.com\\/report\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\"}],
\"group\":\"cf-nel\",
\"max_age\":604800}"],
"NEL":["{
\"success_fraction\":0.01,
\"report_to\":\"cf-nel\",
\"max_age\":604800}"],
"Server":["cloudflare"]}} {
"correlation_id":"95236535-ec98-4541-b92a-adfa73b69eab",
"trace_id":"c7ab8365-903f-46d4-9403-0e5b551e3545"}
Show Replace Field
Search History
rateLimiter
New Line
Match Case
Words
Regex
Replace History
Replace
New Line
Preserve case
0 results
Previous Occurrence
Next Occurrence
Filter Search Results
Open in Window, Multiple Cursors
Click to highlight
Close
Sync Changes
Hide This Notification
Code changed:
Hide
2
64
1
1
Previous Highlighted Error
Next Highlighted Error
<?php
declare(strict_types=1);
namespace Jiminny\Services\Crm\Hubspot;
use HubSpot\Client\Crm\Deals\ApiException as DealApiException;
use HubSpot\Client\Crm\Contacts\ApiException as ContactApiException;
use HubSpot\Client\Crm\Companies\ApiException as CompanyApiException;
use HubSpot\Client\Crm\Contacts\Model\SimplePublicObjectWithAssociations as ContactsWithAssociations;
use HubSpot\Client\Crm\Companies\Model\SimplePublicObjectWithAssociations as CompaniesWithAssociations;
use HubSpot\Client\Crm\Deals\Model\SimplePublicObjectWithAssociations as DealWithAssociations;
use HubSpot\Client\Crm\Objects\Model\SimplePublicObjectInput;
use HubSpot\Client\Crm\Objects\Model\SimplePublicObjectWithAssociations as ObjectWithAssociations;
use HubSpot\Client\Crm\Pipelines\Model\Error;
use HubSpot\Client\Crm\Pipelines\Model\PipelineStage;
use HubSpot\Client\Crm\Properties\Model\Property;
use HubSpot\Discovery\Discovery;
use Jiminny\Exceptions\CrmException;
use Jiminny\Exceptions\RateLimitException;
use Jiminny\Exceptions\SocialAccountTokenInvalidException;
use Jiminny\Jobs\Crm\NoteObject;
use Jiminny\Models\Crm\Field;
use Jiminny\Services\Crm\BaseClient;
use Jiminny\Services\Crm\Hubspot\DTO\Response\Owner;
use Jiminny\Services\SocialAccountService;
use SevenShores\Hubspot\Exceptions\BadRequest;
use SevenShores\Hubspot\Exceptions\HubspotException;
use SevenShores\Hubspot\Factory;
use SevenShores\Hubspot\Http\Response;
use Jiminny\Services\Crm\Hubspot\Pagination\HubspotPaginationService;
use Throwable;
/**
* @phpstan-type CrmFieldOption array{id:string, label:string, value?:string}
*/
class Client extends BaseClient implements HubspotClientInterface
{
public const string MIN_API_VERSION = '2';
public const string BASE_URL = '[URL_WITH_CREDENTIALS] T
* @param callable(): T $apiCall
* @return T
*
* @throws RateLimitException
*/
private function executeRequest(callable $apiCall)
{
try {
return $apiCall();
} catch (Throwable $e) {
if ($this->isHubspotRateLimit($e)) {
$retryAfter = $this->parseRetryAfter($e);
$this->log->warning('[Hubspot] Received 429 from API', [
'team_id' => $this->config->team_id,
'config_id' => $this->config->getId(),
'retry_after' => $retryAfter,
'reason' => $e->getMessage(),
]);
throw new RateLimitException('Hubspot returned 429', $retryAfter, $e);
}
throw $e;
}
}
public function isHubspotRateLimit(Throwable $e): bool
{
if ($e instanceof BadRequest
|| $e instanceof DealApiException
|| $e instanceof ContactApiException
|| $e instanceof CompanyApiException
|| $e instanceof \GuzzleHttp\Exception\RequestException
) {
return (int) $e->getCode() === 429;
}
return false;
}
public function parseRetryAfter(Throwable $e): int
{
// First try to get Retry-After from response headers
if (method_exists($e, 'getResponseHeaders')) {
$headers = $e->getResponseHeaders() ?: [];
$value = $headers['Retry-After'] ?? $headers['retry-after'] ?? null;
if (is_array($value)) {
$value = $value[0] ?? null;
}
if (is_numeric($value)) {
return (int) $value;
}
}
if (method_exists($e, 'getResponseBody')) {
$body = $e->getResponseBody();
if (is_string($body)) {
$body = json_decode($body, true) ?? [];
}
$policyName = $body['policyName'] ?? $body['policy'] ?? $body['context']['policyName'] ?? null;
if ($policyName === 'TEN_SECONDLY_ROLLING' || $policyName === 'ten_secondly_rolling') {
return 10;
}
if ($policyName === 'SECONDLY' || $policyName === 'secondly') {
return 1;
}
}
$this->log->warning('[Hubspot] No retry-after header or policy name found, using default', [
'exception_class' => get_class($e),
]);
return 10;
}
public function getMinimumApiVersion(): string
{
return self::MIN_API_VERSION;
}
public function getInstance(): Factory
{
return new Factory([
'key' => $this->accessToken,
'oauth2' => true,
'base_url' => $this->baseUrl,
]);
}
public function getNewInstance(): Discovery
{
return \HubSpot\Factory::createWithAccessToken($this->accessToken);
}
/**
* Secondly and daily limits for Hubspot API
*
* Product Tier: Free & Starter | Professional & Enterprise | API add-on (any tier)
* Burst: 100/10 seconds | 150/10 seconds | 200/10 seconds
* Daily: 250,000 | 500,000 | 1,000,000
*
* Official documentation states: The search endpoints are rate limited to five requests per second.
* Since with 5 RPS were still hitting secondly rate limits we lowered it to 4
*/
public function getPaginatedData(array $payload, string $type, int $offset = 0): array
{
$total = 0;
$lastId = null;
$rows = [];
foreach ($this->getPaginatedDataGenerator($payload, $type, $offset, $total, $lastId) as $row) {
$rows[] = $row;
}
return ['results' => $rows, 'total' => $total, 'last_record' => $lastId];
}
/**
* @throws HubspotException
* @throws SocialAccountTokenInvalidException
* @throws BadRequest
*/
public function getPaginatedDataGenerator(
array $payload,
string $type,
int $offset = 0,
int &$total = 0,
?string &$lastRecordId = null
): \Generator {
return $this->paginationService->getPaginatedDataGenerator(
$this,
$payload,
$type,
$offset,
$total,
$lastRecordId
);
}
/**
* Execute a search request against HubSpot CRM objects with rate limiting.
*
* @param string $objectType The object type ('deals', 'companies', 'contacts', 'calls')
* @param array<string, mixed> $payload The search payload with filters, sorts, properties, etc.
* @return array The search response with 'results', 'total', 'paging' keys
* @throws RateLimitException When rate limit is hit
* @throws HubspotException On API errors
*/
public function search(string $objectType, array $payload): array
{
$endpoint = self::BASE_URL . "/crm/v3/objects/{$objectType}/search";
return $this->executeRequest(function () use ($endpoint, $payload) {
$response = $this->getInstance()->getClient()->request('POST', $endpoint, ['json' => $payload]);
return $response->toArray();
});
}
/**
* @throws DealApiException
* @throws CrmException
*/
public function getOpportunityById(string $crmId, array $fields): array
{
try {
// $deal = $this->executeRequest(fn () => $this->getNewInstance()->crm()->deals()->basicApi()->getById(
$deal = $this->getNewInstance()->crm()->deals()->basicApi()->getById(
$crmId,
implode(',', $fields),
'companies,contacts'
);
} catch (DealApiException $e) {
$this->log->info('[Hubspot] Failed to fetch opportunity', [
'crm_id' => $crmId,
'reason' => $e->getMessage(),
]);
throw $e;
}
if (! $deal instanceof DealWithAssociations) {
throw new CrmException('Deal not found');
}
return [
'id' => $deal->getId(),
'properties' => $deal->getProperties(),
'associations' => $deal->getAssociations(),
];
}
/**
* Generic batch read method for HubSpot objects
*
* @param string $objectType The object type ('deals', 'companies', 'contacts')
* @param array<string> $crmIds Array of HubSpot object IDs (max 100)
* @param array<string> $fields Array of property names to fetch
*
* @return array<string, array> Array keyed by CRM ID with object data
*/
private function batchReadObjects(string $objectType, array $crmIds, array $fields): array
{
if (empty($crmIds)) {
return [];
}
$this->validateBatchSize($objectType, $crmIds);
$this->ensureValidToken();
try {
$batchConfig = $this->createBatchConfiguration($objectType);
$batchReadRequest = $this->prepareBatchRequest($batchConfig, $crmIds, $fields);
$response = $batchConfig['api']->read($batchReadRequest);
$this->validateApiResponse($response, $objectType);
$results = $this->processApiResults($response);
$this->logBatchResults($objectType, $crmIds, $results);
return $results;
} catch (\Throwable $e) {
$this->handleBatchError($e, $objectType, $crmIds);
}
}
private function validateBatchSize(string $objectType, array $crmIds): void
{
if (count($crmIds) > 100) {
throw new \InvalidArgumentException("Batch size cannot exceed 100 {$objectType}");
}
}
private function createBatchConfiguration(string $objectType): array
{
$configurations = [
'deals' => [
'batchReadRequest' => new \HubSpot\Client\Crm\Deals\Model\BatchReadInputSimplePublicObjectId(),
'inputClass' => \HubSpot\Client\Crm\Deals\Model\SimplePublicObjectId::class,
'api' => $this->getNewInstance()->crm()->deals()->batchApi(),
],
'companies' => [
'batchReadRequest' => new \HubSpot\Client\Crm\Companies\Model\BatchReadInputSimplePublicObjectId(),
'inputClass' => \HubSpot\Client\Crm\Companies\Model\SimplePublicObjectId::class,
'api' => $this->getNewInstance()->crm()->companies()->batchApi(),
],
'contacts' => [
'batchReadRequest' => new \HubSpot\Client\Crm\Contacts\Model\BatchReadInputSimplePublicObjectId(),
'inputClass' => \HubSpot\Client\Crm\Contacts\Model\SimplePublicObjectId::class,
'api' => $this->getNewInstance()->crm()->contacts()->batchApi(),
],
];
if (! isset($configurations[$objectType])) {
throw new \InvalidArgumentException("Unsupported object type: {$objectType}");
}
return $configurations[$objectType];
}
private function prepareBatchRequest(array $batchConfig, array $crmIds, array $fields): object
{
$batchReadRequest = $batchConfig['batchReadRequest'];
$inputClass = $batchConfig['inputClass'];
$inputs = array_map(function ($crmId) use ($inputClass) {
$input = new $inputClass();
$input->setId($crmId);
return $input;
}, $crmIds);
$batchReadRequest->setInputs($inputs);
$batchReadRequest->setProperties($fields);
return $batchReadRequest;
}
private function validateApiResponse($response, string $objectType): void
{
if (! $response) {
throw new CrmException("HubSpot API returned null response for {$objectType} batch read");
}
}
private function processApiResults($response): array
{
$results = [];
$responseResults = $response->getResults();
if ($responseResults) {
foreach ($responseResults as $object) {
if ($object && $object->getId()) {
$results[$object->getId()] = [
'id' => $object->getId(),
'properties' => $object->getProperties() ?: [],
];
}
}
}
return $results;
}
private function logBatchResults(string $objectType, array $crmIds, array $results): void
{
$this->log->info("[HubSpot] Batch fetched {$objectType}", [
'requested_count' => count($crmIds),
'returned_count' => count($results),
'crm_ids' => $crmIds,
]);
}
private function handleBatchError(\Throwable $e, string $objectType, array $crmIds): void
{
$errorMessage = $e->getMessage() ?: 'Unknown error';
$errorTrace = $e->getTraceAsString() ?: 'No trace available';
$this->log->error("[HubSpot] Failed to batch fetch {$objectType}", [
'crm_ids' => $crmIds,
'error' => $errorMessage,
'trace' => $errorTrace,
]);
throw new CrmException("Failed to batch fetch {$objectType}: " . $errorMessage);
}
/**
* Batch read multiple opportunities by their CRM IDs
*
* @param array<string> $crmIds Array of HubSpot deal IDs (max 100)
* @param array<string> $fields Array of property names to fetch
*
* @return array<string, array> Array keyed by CRM ID with opportunity data
*/
public function getOpportunitiesByIds(array $crmIds, array $fields): array
{
return $this->batchReadObjects('deals', $crmIds, $fields);
}
/**
* Batch read multiple companies by their CRM IDs
*
* @param array<string> $crmIds Array of HubSpot company IDs (max 100)
* @param array<string> $fields Array of property names to fetch
*
* @return array<string, array> Array keyed by CRM ID with company data
*/
public function getCompaniesByIds(array $crmIds, array $fields): array
{
return $this->batchReadObjects('companies', $crmIds, $fields);
}
/**
* Batch read multiple contacts by their CRM IDs
*
* @param array<string> $crmIds Array of HubSpot contact IDs (max 100)
* @param array<string> $fields Array of property names to fetch
*
* @return array<string, array> Array keyed by CRM ID with contact data
*/
public function getContactsByIds(array $crmIds, array $fields): array
{
return $this->batchReadObjects('contacts', $crmIds, $fields);
}
/**
* @throws CompanyApiException
* @throws CrmException
*/
public function getAccountById(string $crmId, array $fields): array
{
try {
$company = $this->getNewInstance()->crm()->companies()->basicApi()->getById(
$crmId,
implode(',', $fields),
);
} catch (CompanyApiException $e) {
$this->log->info('[Hubspot] Failed to fetch account', [
'crm_id' => $crmId,
'reason' => $e->getMessage(),
]);
throw $e;
}
if (! $company instanceof CompaniesWithAssociations) {
throw new CrmException('Account not found');
}
return [
'id' => $company->getId(),
'properties' => $company->getProperties(),
];
}
/**
* @throws ContactApiException
* @throws CrmException
*/
public function getContactById(string $crmId, array $fields): array
{
try {
$contact = $this->getNewInstance()->crm()->contacts()->basicApi()->getById(
$crmId,
implode(',', $fields)
);
} catch (ContactApiException $e) {
$this->log->info('[Hubspot] Failed to fetch contact', [
'crm_id' => $crmId,
'reason' => $e->getMessage(),
]);
throw $e;
}
if (! $contact instanceof ContactsWithAssociations) {
throw new CrmException('Contact not found');
}
return [
'id' => $contact->getId(),
'properties' => $contact->getProperties(),
];
}
/**
* This is email search request that Hubspot offers as GET (more generous quota)
*/
public function getContactByEmail(string $email, array $fields = []): array
{
try {
$contact = $this->getNewInstance()->crm()->contacts()->basicApi()->getById(
$email,
implode(',', $fields),
null,
false,
'email'
);
return [
'id' => $contact->getId(),
'properties' => $contact->getProperties(),
];
} catch (ContactApiException $e) {
$this->log->info('[Hubspot] Failed to fetch contact', [
'email' => $email,
'reason' => $e->getMessage(),
]);
return [];
}
}
/**
* @throws CrmException
*/
public function fetchProperty(string $objectType, string $propertyId): Property
{
$result = $this->getNewInstance()->crm()->properties()->coreApi()->getByName($objectType, $propertyId);
if (! $result instanceof Property) {
$this->log->error('[Hubspot] Failed to fetch property', [
'object_type' => $objectType,
'property_id' => $propertyId,
'reason' => $result->getMessage(),
]);
throw new CrmException('Failed to fetch property');
}
return $result;
}
/**
* @return array<CrmFieldOption>
*/
public function fetchPropertyOptions(string $objectType, string $propertyId): array
{
/** @var array<CrmFieldOption> */
return $this->fetchProperty($objectType, $propertyId)->getOptions();
}
/**
* @return array<array{id:string, label:string, deleted:bool}>
*/
public function fetchCallDispositions(): array
{
/** @var Response $response */
$response = $this->getInstance()->engagements()->getCallDispositions();
/**
* @var array<array{
* id:string,
* label:string,
* deleted: bool
* }>
*/
return $response->toArray();
}
/**
* @return array<CrmFieldOption>
*/
public function fetchOpportunityPipelineStages(): array
{
$stages = [];
$apiResponse = $this->getNewInstance()->crm()->pipelines()->pipelinesApi()->getAll('deals');
if ($apiResponse instanceof Error) {
$this->log->error('[Hubspot] Failed to fetch opportunity pipelines', [
'reason' => $apiResponse->getMessage(),
]);
return [];
}
foreach ($apiResponse->getResults() as $pipeline) {
$pipelineStages = array_map(
static function (PipelineStage $stage) {
return [
'id' => $stage->getId(),
'label' => $stage->getLabel(),
];
},
$pipeline->getStages()
);
$stages = array_merge($stages, $pipelineStages);
}
return $stages;
}
public function fetchOpportunityPipelines(): array
{
$pipelines = [];
try {
$apiResponse = $this->makeRequest('/crm/v3/pipelines/deals');
} catch (\Exception $e) {
$this->log->info('[Hubspot] Failed to fetch opportunity pipelines', [
'reason' => $e->getMessage(),
]);
return [];
}
$response = $apiResponse->toArray();
foreach ($response['results'] as $pipeline) {
$pipelines[] = [
'id' => $pipeline['id'],
'label' => $pipeline['label'],
];
}
return $pipelines;
}
/**
* @return array<CrmFieldOption>
*/
public function fetchMeetingOutcomeFieldOptions(Field $field): array
{
return $field->getCrmProviderId() === 'meetingOutcome'
? $this->fetchMeetingOutcomeTypes()
: $this->fetchCallActivityTypes();
}
public function fetchMeetingOutcomeTypes(): array
{
return $this->extractMeetingTypeOptions(
'[URL_WITH_CREDENTIALS] Response $response */
$response = $this->getInstance()
->getClient()
->request('GET', $endpoint);
/**
* @var array<array{
* value: string,
* label: string,
* displayOrder: int
* }> $optionData
*/
$optionData = $response->toArray()['options'] ?? [];
$options = [];
foreach ($optionData as $item) {
$options[] = [
'id' => $item['value'],
'value' => $item['value'],
'label' => $item['label'],
'display_order' => $item['displayOrder'],
];
}
return $options;
}
/**
* @return array<CrmFieldOption>
*/
public function fetchDispositionFieldOptions(): array
{
$options = [];
$dispositions = $this->fetchCallDispositions();
foreach ($dispositions as $disposition) {
if ($disposition['deleted'] !== false) {
continue;
}
$option['value'] = $disposition['id'];
$option['id'] = $disposition['id'];
$option['label'] = $disposition['label'];
$options[] = $option;
}
return $options;
}
/**
* @return array<CrmFieldOption>
*/
public function fetchOpportunityFieldOptions(Field $field): array
{
if ($field->isStageField()) {
return $this->fetchOpportunityPipelineStages();
}
if ($field->isPipelineField()) {
return $this->fetchOpportunityPipelines();
}
return $this->fetchPropertyOptions('deals', $field->getCrmProviderId());
}
/**
* @throws BadRequest
* @throws HubspotException
*/
public function makeRequest(string $endpoint, $method = 'GET', $payload = [], ?string $queryString = null)
{
$endpoint = self::BASE_URL . $endpoint;
if ($method === 'GET') {
$response = $this->getInstance()->getClient()?->request(
method: $method,
endpoint: $endpoint,
query_string: $queryString
);
} else {
$response = $this->getInstance()->getClient()->request($method, $endpoint, [
'json' => ($payload),
]);
}
//
// $max = $response->getHeaderLine('X-HubSpot-RateLimit-Max'); // "110"
// $remaining = $response->getHeaderLine('X-HubSpot-RateLimit-Remaining'); // "109"
// $interval = $response->getHeaderLine('X-HubSpot-RateLimit-Interval-Milliseconds'); // "10000"
// $body = json_decode((string) $response->getBody(), true);
//
// \Illuminate\Support\Facades\Log::channel('custom_channel')->info('$max ' . PHP_EOL . print_r($max, true));
// \Illuminate\Support\Facades\Log::channel('custom_channel')->info('$remaining ' . PHP_EOL . print_r($remaining, true));
// \Illuminate\Support\Facades\Log::channel('custom_channel')->info('$interval ' . PHP_EOL . print_r($interval, true));
// \Illuminate\Support\Facades\Log::channel('custom_channel')->info('$body ' . PHP_EOL . print_r($body, true));
return $response;
}
/**
* @throws BadRequest
* @throws HubspotException
*/
public function createMeeting(array $payload): Response
{
$endpoint = '/crm/v3/objects/meetings';
return $this->makeRequest($endpoint, 'POST', $payload);
}
/**
* @throws BadRequest
* @throws HubspotException
*/
public function updateMeeting(string $meetingId, array $payload): Response
{
$endpoint = '/crm/v3/objects/meetings/' . $meetingId;
return $this->makeRequest($endpoint, 'PATCH', $payload);
}
/**
* @throws \Exception
*/
public function createNote(
string $body,
string $ownerId,
int $timestamp,
string $objectId,
NoteObject $noteObject
): ?string {
try {
$noteInput = new SimplePublicObjectInput([
'properties' => [
'hs_note_body' => $body,
'hubspot_owner_id' => $ownerId,
'hs_timestamp' => $timestamp,
],
]);
// Create note
$note = $this->getNewInstance()->crm()->objects()->basicApi()->create('note', $noteInput);
$this->getNewInstance()->crm()->objects()->associationsApi()->create(
'note',
$note->getId(),
$this->getNoteObject($noteObject),
$objectId,
$this->getNoteAssociationType($noteObject),
);
return $note->getId();
} catch (\Exception $e) {
$this->log->error('[Hubspot] Failed to create note', [
'objectId' => $objectId,
'noteObject' => $noteObject->getObjectType(),
'reason' => $e->getMessage(),
]);
\Sentry::captureException($e);
}
return null;
}
public function updateEngagement(string $objectId, array $engagement, array $metadata): void
{
$this->getInstance()->engagements()->update($objectId, $engagement, $metadata);
}
public function getEngagementData(string $engagementId): array
{
$engagement = $this->getInstance()->engagements()->get($engagementId);
return $engagement->toArray();
}
public function createEngagement(array $engagement, array $associations, array $metadata): Response
{
return $this->getInstance()
->engagements()
->create($engagement, $associations, $metadata);
}
public function isUnauthorizedException(\Exception $e): bool
{
if ($e instanceof BadRequest
|| $e instanceof DealApiException
|| $e instanceof ContactApiException
|| $e instanceof CompanyApiException
) {
return (int) $e->getCode() === 401;
}
if ($e instanceof \GuzzleHttp\Exception\RequestException && $e->hasResponse()) {
return $e->getResponse()?->getStatusCode() === 401;
}
$message = strtolower($e->getMessage());
return str_contains($message, '401 unauthorized') ||
str_contains($message, 'http 401') ||
str_contains($message, 'status code 401') ||
(preg_match('/\b401\b/', $message) === 1 && str_contains($message, 'unauthorized'));
}
/**
* Validates and refreshes the access token if needed before API requests.
* This ensures long-running processes don't fail due to token expiration.
*
* @throws SocialAccountTokenInvalidException
*/
public function ensureValidToken(): void
{
if ($this->oauthAccount === null) {
return;
}
$newToken = $this->tokenManager->ensureValidToken($this->oauthAccount);
if ($newToken !== null) {
$this->accessToken = $newToken;
}
}
public function getConfig()
{
return $this->config;
}
// returns only active (archived=false)
public function getOwners(): array
{
return $this->getNewInstance()->crm()->owners()->getAll();
}
/**
* @param bool $archived
*
* @return array<Owner>|[]
*/
public function getOwnersArchived(bool $archived = true): array
{
$endpoint = '/crm/v3/owners';
$queryParams = [
'archived' => $archived ? 'true' : 'false',
];
$queryString = http_build_query($queryParams);
$owners = [];
try {
$response = $this->makeRequest(endpoint: $endpoint, queryString: $queryString);
$responseData = $response?->toArray();
foreach ($responseData['results'] as $result) {
try {
$owners[] = Owner::create($result);
} catch (Throwable $e) {
$this->log->error('[HubSpot] Failed to process owner data', [
'result' => $result,
'error' => $e->getMessage(),
]);
continue;
}
}
} catch (Throwable $e) {
$this->log->error('HubSpot] Failed to fetch owners', [
'archived' => $archived,
'error' => $e->getMessage(),
]);
return [];
}
return $owners;
}
public function getMeeting(string $engagementId): ObjectWithAssociations
{
return $this->getNewInstance()->crm()->objects()->basicApi()
->getById('meeting', $engagementId, null, 'contact,company,deal');
}
public function deleteEngagement(string $engagementId): void
{
$this->getInstance()->engagements()->delete((int) $engagementId);
}
public function getAssociationsData(array $ids, string $fromObject, string $toObject): array
{
$associationData = [];
$idChunks = array_chunk($ids, self::ASSOCIATIONS_BATCH_SIZE_LIMIT);
foreach ($idChunks as $idChunk) {
try {
$batchInput = new \HubSpot\Client\Crm\Associations\Model\BatchInputPublicObjectId();
$batchInput->setInputs(array_map(function ($id) {
$publicObjectId = new \HubSpot\Client\Crm\Associations\Model\PublicObjectId();
$publicObjectId->setId($id);
return $publicObjectId;
}, $idChunk));
$associatedObjectsData = $this
->getNewInstance()
->crm()
->associations()
->batchApi()
->read($fromObject, $toObject, $batchInput);
if ($associatedObjectsData instanceof \HubSpot\Client\Crm\Associations\Model\BatchResponsePublicAssociationMulti) {
foreach ($associatedObjectsData->getResults() as $association) {
$from = $association->getFrom()->getId();
$toAssociations = $association->getTo();
if (! empty($toAssociations)) {
$associationData[$from] = array_map(function ($item) {
return $item->getId();
}, $toAssociations);
}
}
}
} catch (RateLimitException $e) {
throw $e;
} catch (\Exception $e) {
$this->log->error('[Hubspot] Failed to fetch associations', [
'from_object' => $fromObject,
'to_object' => $toObject,
'reason' => $e->getMessage(),
]);
}
}
return $associationData;
}
/**
* @throws \Exception
*/
private function getNoteAssociationType(NoteObject $noteObject): string
{
return match($noteObject) {
NoteObject::Opportunity => 'note_to_deal',
NoteObject::Lead, NoteObject::Contact => 'note_to_contact', // or 'note_to_lead' if your portal supports it
NoteObject::Account => 'note_to_company',
NoteObject::Call, NoteObject::Event => throw new \Exception('Not supported'),
};
}
/**
* @throws \Exception
*/
private function getNoteObject(NoteObject $noteObject): string
{
return match($noteObject) {
NoteObject::Opportunity => 'deal',
NoteObject::Lead, NoteObject::Contact => 'contact',
NoteObject::Account => 'company',
NoteObject::Call, NoteObject::Event => throw new \Exception('Not supported'),
};
}
public function addAssociations(string $objectType, string $associationType, array $payload): Response
{
$endpoint = "/crm/v4/associations/$objectType/$associationType/batch/create";
return $this->makeRequest($endpoint, 'POST', $payload);
}
public function removeAssociations(string $objectType, string $associationType, array $payload): Response
{
$endpoint = "/crm/v4/associations/$objectType/$associationType/batch/archive";
return $this->makeRequest($endpoint, 'POST', $payload);
}
}...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
9838
|
443
|
20
|
2026-05-08T13:37:21.524448+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-08/1778 /Users/lukas/.screenpipe/data/data/2026-05-08/1778247441524_m1.jpg...
|
PhpStorm
|
faVsco.js – Client.php
|
1
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
JY-20725-handle-HS-search Project: faVsco.js, menu
JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
19
Previous Highlighted Error
Next Highlighted Error
[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {
"headers":{
"Date":["Thu,07 May 2026 14:21:15 GMT"],
"Content-Type":["application/json;charset=utf-8"],
"Transfer-Encoding":["chunked"],
"Connection":["keep-alive"],
"CF-Ray":["9f80deb8db60dc3a-SOF"],
"CF-Cache-Status":["DYNAMIC"],
"Strict-Transport-Security":["max-age=31536000; includeSubDomains; preload"],
"Vary":["origin,
accept-encoding"],
"access-control-allow-credentials":["false"],
"server-timing":["hcid;desc=\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\",
cfr;desc=\"9f80deb8e7c6dc3a-IAD\""],
"x-content-type-options":["nosniff"],
"x-hubspot-correlation-id":["019e02d0-6fd8-7812-bdba-885b7ccb3ee3"],
"Set-Cookie":["__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-[IP_ADDRESS]-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,
07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None"],
"Report-To":["{
\"endpoints\":[{
\"url\":\"https:\\/\\/a.nel.cloudflare.com\\/report\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\"}],
\"group\":\"cf-nel\",
\"max_age\":604800}"],
"NEL":["{
\"success_fraction\":0.01,
\"report_to\":\"cf-nel\",
\"max_age\":604800}"],
"Server":["cloudflare"]}} {
"correlation_id":"95236535-ec98-4541-b92a-adfa73b69eab",
"trace_id":"c7ab8365-903f-46d4-9403-0e5b551e3545"}
Show Replace Field
Search History
rateLimiter
New Line
Match Case
Words
Regex
Replace History
Replace...
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JY-20725-handle-HS-search-rate-limit, menu","depth":5,"on_screen":true,"help_text":"Git Branch: JY-20725-handle-HS-search-rate-limit","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"AskJiminnyReportActivityServiceTest","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'AskJiminnyReportActivityServiceTest'","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'AskJiminnyReportActivityServiceTest'","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"IDE and Project Settings","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.088194445,"height":0.027777778},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"19","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {\n\"headers\":{\n\"Date\":[\"Thu,07 May 2026 14:21:15 GMT\"],\n \"Content-Type\":[\"application/json;charset=utf-8\"],\n \"Transfer-Encoding\":[\"chunked\"],\n \"Connection\":[\"keep-alive\"],\n \"CF-Ray\":[\"9f80deb8db60dc3a-SOF\"],\n \"CF-Cache-Status\":[\"DYNAMIC\"],\n \"Strict-Transport-Security\":[\"max-age=31536000; includeSubDomains; preload\"],\n \"Vary\":[\"origin,\n accept-encoding\"],\n \"access-control-allow-credentials\":[\"false\"],\n \"server-timing\":[\"hcid;desc=\\\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\\\",\n cfr;desc=\\\"9f80deb8e7c6dc3a-IAD\\\"\"],\n \"x-content-type-options\":[\"nosniff\"],\n \"x-hubspot-correlation-id\":[\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\"],\n \"Set-Cookie\":[\"__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-1.0.1.1-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,\n 07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None\"],\n \"Report-To\":[\"{\n\\\"endpoints\\\":[{\n\\\"url\\\":\\\"https:\\\\/\\\\/a.nel.cloudflare.com\\\\/report\\\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\\\"}],\n\\\"group\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"NEL\":[\"{\n\\\"success_fraction\\\":0.01,\n\\\"report_to\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"Server\":[\"cloudflare\"]}} {\n\"correlation_id\":\"95236535-ec98-4541-b92a-adfa73b69eab\",\n\"trace_id\":\"c7ab8365-903f-46d4-9403-0e5b551e3545\"}","depth":4,"on_screen":true,"value":"[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {\n\"headers\":{\n\"Date\":[\"Thu,07 May 2026 14:21:15 GMT\"],\n \"Content-Type\":[\"application/json;charset=utf-8\"],\n \"Transfer-Encoding\":[\"chunked\"],\n \"Connection\":[\"keep-alive\"],\n \"CF-Ray\":[\"9f80deb8db60dc3a-SOF\"],\n \"CF-Cache-Status\":[\"DYNAMIC\"],\n \"Strict-Transport-Security\":[\"max-age=31536000; includeSubDomains; preload\"],\n \"Vary\":[\"origin,\n accept-encoding\"],\n \"access-control-allow-credentials\":[\"false\"],\n \"server-timing\":[\"hcid;desc=\\\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\\\",\n cfr;desc=\\\"9f80deb8e7c6dc3a-IAD\\\"\"],\n \"x-content-type-options\":[\"nosniff\"],\n \"x-hubspot-correlation-id\":[\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\"],\n \"Set-Cookie\":[\"__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-1.0.1.1-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,\n 07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None\"],\n \"Report-To\":[\"{\n\\\"endpoints\\\":[{\n\\\"url\\\":\\\"https:\\\\/\\\\/a.nel.cloudflare.com\\\\/report\\\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\\\"}],\n\\\"group\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"NEL\":[\"{\n\\\"success_fraction\\\":0.01,\n\\\"report_to\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"Server\":[\"cloudflare\"]}} {\n\"correlation_id\":\"95236535-ec98-4541-b92a-adfa73b69eab\",\n\"trace_id\":\"c7ab8365-903f-46d4-9403-0e5b551e3545\"}","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Show Replace Field","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Search History","depth":3,"on_screen":true,"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"rateLimiter","depth":4,"on_screen":true,"value":"rateLimiter","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"New Line","depth":3,"on_screen":true,"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Match Case","depth":3,"on_screen":true,"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Words","depth":3,"on_screen":true,"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Regex","depth":3,"on_screen":true,"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Replace History","depth":3,"bounds":{"left":0.0,"top":0.0,"width":0.015277778,"height":0.024444444},"on_screen":false,"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextField","text":"Replace","depth":4,"on_screen":false,"role_description":"text field","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
605454064966368576
|
-3048923519438845494
|
click
|
accessibility
|
NULL
|
Project: faVsco.js, menu
JY-20725-handle-HS-search Project: faVsco.js, menu
JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
19
Previous Highlighted Error
Next Highlighted Error
[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {
"headers":{
"Date":["Thu,07 May 2026 14:21:15 GMT"],
"Content-Type":["application/json;charset=utf-8"],
"Transfer-Encoding":["chunked"],
"Connection":["keep-alive"],
"CF-Ray":["9f80deb8db60dc3a-SOF"],
"CF-Cache-Status":["DYNAMIC"],
"Strict-Transport-Security":["max-age=31536000; includeSubDomains; preload"],
"Vary":["origin,
accept-encoding"],
"access-control-allow-credentials":["false"],
"server-timing":["hcid;desc=\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\",
cfr;desc=\"9f80deb8e7c6dc3a-IAD\""],
"x-content-type-options":["nosniff"],
"x-hubspot-correlation-id":["019e02d0-6fd8-7812-bdba-885b7ccb3ee3"],
"Set-Cookie":["__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-[IP_ADDRESS]-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,
07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None"],
"Report-To":["{
\"endpoints\":[{
\"url\":\"https:\\/\\/a.nel.cloudflare.com\\/report\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\"}],
\"group\":\"cf-nel\",
\"max_age\":604800}"],
"NEL":["{
\"success_fraction\":0.01,
\"report_to\":\"cf-nel\",
\"max_age\":604800}"],
"Server":["cloudflare"]}} {
"correlation_id":"95236535-ec98-4541-b92a-adfa73b69eab",
"trace_id":"c7ab8365-903f-46d4-9403-0e5b551e3545"}
Show Replace Field
Search History
rateLimiter
New Line
Match Case
Words
Regex
Replace History
Replace...
|
9836
|
NULL
|
NULL
|
NULL
|
|
9839
|
444
|
25
|
2026-05-08T13:37:20.720394+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-08/1778 /Users/lukas/.screenpipe/data/data/2026-05-08/1778247440720_m2.jpg...
|
PhpStorm
|
faVsco.js – Client.php
|
1
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
JY-20725-handle-HS-search Project: faVsco.js, menu
JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections...
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"bounds":{"left":0.025930852,"top":0.019952115,"width":0.03856383,"height":0.025538707},"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JY-20725-handle-HS-search-rate-limit, menu","depth":5,"bounds":{"left":0.064494684,"top":0.019952115,"width":0.09541223,"height":0.025538707},"on_screen":true,"help_text":"Git Branch: JY-20725-handle-HS-search-rate-limit","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"bounds":{"left":0.8081782,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
-817699417600123759
|
-7159315621853853246
|
click
|
hybrid
|
NULL
|
Project: faVsco.js, menu
JY-20725-handle-HS-search Project: faVsco.js, menu
JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
PhostormVIewINavigareCodeFV faVsco.js°9 JY-20725-handle-HS-search-rate-IiyProiect© BatchSyncCollector.phpCachedcmmservicebecorator.ongJiminnyDeougcommand.png© balchsynckealsserviceCheckAndRetryRemoteMatch.php© ClosedDealStagesServic © MatchActivityCrmData.phpDealrielasservice.onp© PaginationState.phpc) DecorateAcuiviiv.ono© FieldDefinitions.php) PaqinationContia.phgC) FieldTvoeconverter.phgO• rateLimiterCc WU HubspotClientintertace(c) HubspotTokenManagerC) PavloadBuilder.ohpC) RemotecrmobiectManir@ ResponseNormalize.ohr(c) Service.ono© SyncFieldAction.php(C) SvncRelatedActivitvMan(C) WebhookSvncBatchProv MintearationAoo.M AccoccorsApiD ConfigIMnTOD FiltersD JobsD ProspectSearchStrateg)Tm CorvicoTraite(c) DataClient.phpc DecorateAcuivly.onecLocalsearch.ono• LocalSearchInterface.phc) Remotesearch.pho(c) Service.phpv _ Listenersc) ConvertLeadActivities.pc) PurceLookuocache.onoM Metadate2 MiarationM PipedriveSalesforceFields• M OnnortunitvMatchenOpportunitySyncStrateg>@ ProspectSearchStrateg)TM ServiceTraits(C) DecorateActivitv nhnn. [EMAIL](c CioldNofinitione nho© PayloadBuilder.php© Profile.php() AuorvRuildor nhnPatch succoccfullv annlied (2 minutes aaol948 C956057|961O resultsclass Cllent excenas baseullent implements nuospocullencincertar w12 A64 V1 V1 Apubuic tunccion getuwnersarchivea dool sarchived = true: arrayble Se) 1>errorHubSoot Failed to fetch ownens'.ed' => Sarchived.=> Se->aetMessageOlMeeting(string $engagementId): ObjectWithAssociationsetNewInstance->crmO->obiects(->basicApi0'meeting", sengagementiddes: null.associations:"contact.company.deal'):eteEngagement(string SengagementId): voidnceo->engagementso->delete((int) Senaagementd:AssociationsDatalarrav Sids. strina SfromObiect, strina Sto0biect): arravav chunk Sids.lenath: self.•ASSOCTATTONS RATCH STZE LTMTT)•nks as SidChunk) ‹nnut - now HubSnot Clion+ Com.Jccociatione.ModolRatchTnnu+Public0hoc+tdonnut-scottnnutclannav manfuncetion Sidhblicbdocttd - now |HubSno+\01jon+|Cnm|Accociatione|Modol|Publ4c0hzocttd().blichhion+td-scottd(cidh.urn Spublic0biectId:hunk)):=2528100% 12AskJiminnyReportActivityServiceTest v= custom.log x4 HS_local (jiminny@localhost)console [PROD]A console leu)Cascade# console [SlAvINGHubspot Rate LimitingHubSpot Rate Limit ReInvestigating Rate LinNew CascadeHubspot erM call Re[2026-05-07 14:21:15] local.INF0: [Hubspot] DEBUG Getting headers {M X19 A V"deals, 'contactsyoportunztlesbatch() luine ~280"Vace".L"Inu,or May 2020 14.21.15 bMl"Jclient->getCompaniesByIds() or getContactsByIds()syncCrmObjects() (line ~520)"Loncent-lvpe". "applicacionson,charser=utt-o'"Transter-Encodinq":"Chunked".iServiceTraits/SyncFieldsTrait-phpMethod context"CF-Ray":["9f80deb8db60dc3a-SOF"].client->getInstance()->companyProperties().all()getObjectFields() (line ~79)"Strict-Transport-Secur1ty":"max-aqe=31536000* 1ncLudeSubDomains: preload")client->getInstance()->contactProperties().all()getObjectFields() (line ~80)client->getInstance()->dealProperties().all()getObjectFields() (line ~81)client->getInstance()->objectProperties('call').all()getObjectFields() (line ~83)client->getInstance()->objectProperties('meeting').all()getObjectFields() (line ~84)"server-timing": ["hcid;desc=|"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\",client->getInstance()->objectProperties('task').all()cfr;desc=|"9f80deb8e7c6dc3a-1AD\""],aetObiectFields( (line ~85)client->getInstance()->objectProperties($type)•get($id)imoortObiectFieldo Xline ~232)'x-hubsoot-correlation-id":"019e02d0-6fd8-7812-bdba-885b7cch3ee3"7'Set-Cookie":["__cf_bm=S1UrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-[IP_ADDRESS]-rI.ZggtDKxTge5zr8_2gbBfW100.ufZ07-Mav-26 14•51•15 GMT• domain=hubani.com• H+tnûnlv: Secuno• SameSite=Nono".)OpportunitySvncStrateay/HubsootSvncStrateavBase.oho"Renont-To"•r"s"endnoints ".fclient->getPaginatedDataGenerator($payload, 'deals')fetchûpportunities() (line ~55"urz\":\"https:|\/\V/a.nel.cloudflare.com\/report\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKnOpoortunitvSvncStrateov/HubsootSinoleSvncStrateav.ono\"group\":\"cf-nel\".\"max_age\":604800}"],CalliMethod contexiINCII.TIS\"success_fraction\":0.01,client->getOpportunityById(reporcto. "cr-nel,SyncRelatedActivitvmanager.ond"max age":604800,","Server": ["cloudflare"]}} {CalliMethod context"correlation_ 1d":"95256555-ec98-4541-b92a-adta/sb69eab",Client→>aetPaqinatedData(Spayload, 'meetina')fetchRelatedMeetinaso line ~142"trace_1d":"C/Ab8565-905f-4604-9405-0e5b551e5545"countMatchingProspects (line ~290)Remotecrmob1ectman1oulator.ohoMethod contextclient->updateEngagementupdateCrmActivityFromLocal line ~73)rields/Spec1alrielddecorator.ohgMothod contextclient->fetchDispositionField0ptionsgetEngagementOptions line ~39)client->fetchMeetingOutcometypesgetEngagementOptions line ~42)client->fetchCallActivityTypes()getEngagementOptions() (line ~45)client->tetchopportun1tyP1pel1neStagesgetopportunityoptions (line ~54client->fetchOpportunityPipelines()getOpportunityOptions() (line ~57)Ask anvthing (8+D)+ «> CodeC° AdantiveFri 8 May 16:37:22BUKSIBURSTRate LimitBURSTBURSTBURSTBURSTBURSTBURSTRate LimitSEARCHRate LimitSEARCHRate LimitBURSTRate LimitBURSTBURSTBURSTBURSTpulDeTWN Windsurf Teams020-28 LTC.8io 4 spaces...
|
9837
|
NULL
|
NULL
|
NULL
|
|
9837
|
444
|
24
|
2026-05-08T13:37:17.829619+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-08/1778 /Users/lukas/.screenpipe/data/data/2026-05-08/1778247437829_m2.jpg...
|
PhpStorm
|
faVsco.js – SyncRelatedActivityManager.php
|
1
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
JY-20725-handle-HS-search Project: faVsco.js, menu
JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
19
Previous Highlighted Error
Next Highlighted Error
[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {
"headers":{
"Date":["Thu,07 May 2026 14:21:15 GMT"],
"Content-Type":["application/json;charset=utf-8"],
"Transfer-Encoding":["chunked"],
"Connection":["keep-alive"],
"CF-Ray":["9f80deb8db60dc3a-SOF"],
"CF-Cache-Status":["DYNAMIC"],
"Strict-Transport-Security":["max-age=31536000; includeSubDomains; preload"],
"Vary":["origin,
accept-encoding"],
"access-control-allow-credentials":["false"],
"server-timing":["hcid;desc=\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\",
cfr;desc=\"9f80deb8e7c6dc3a-IAD\""],
"x-content-type-options":["nosniff"],
"x-hubspot-correlation-id":["019e02d0-6fd8-7812-bdba-885b7ccb3ee3"],
"Set-Cookie":["__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-[IP_ADDRESS]-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,
07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None"],
"Report-To":["{
\"endpoints\":[{
\"url\":\"https:\\/\\/a.nel.cloudflare.com\\/report\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\"}],
\"group\":\"cf-nel\",
\"max_age\":604800}"],
"NEL":["{
\"success_fraction\":0.01,
\"report_to\":\"cf-nel\",
\"max_age\":604800}"],
"Server":["cloudflare"]}} {
"correlation_id":"95236535-ec98-4541-b92a-adfa73b69eab",
"trace_id":"c7ab8365-903f-46d4-9403-0e5b551e3545"}
Sync Changes
Hide This Notification
Code changed:
Hide
3
11
Previous Highlighted Error
Next Highlighted Error
<?php
declare(strict_types=1);
namespace Jiminny\Services\Crm\Hubspot;
use Carbon\Carbon;
use Exception;
use HubSpot\Client\Crm\Objects\ApiException;
use Jiminny\Integrations\PlaybookResolver;
use Jiminny\Models\Activity;
use Jiminny\Models\Calendar\CalendarEvent;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\Profile;
use Jiminny\Models\Playbook;
use Jiminny\Models\PlaybookCategory;
use Jiminny\Repositories\ActivityRepository;
use Jiminny\Repositories\PlaybookCategoryRepository;
use Psr\Log\LoggerInterface;
class SyncRelatedActivityManager
{
public const string OBJECT_MEETING = 'meetings';
public const string DEFAULT_ACTIVITY_TYPE = 'hs_activity_type';
private RemoteCrmObjectManipulator $crmManipulator;
public function __construct(
private readonly Client $client,
private readonly PayloadBuilder $payloadBuilder,
private readonly LoggerInterface $logger
) {
$this->crmManipulator = app(RemoteCrmObjectManipulator::class, [
'client' => $this->client,
'logger' => $this->logger,
]);
}
public function fetchAndAssociateRelatedActivity(Activity $activity): ?Activity
{
// Validate if the activity is applicable.
if ($this->isActivityApplicable($activity) === false) {
$this->logger->info('[Hubspot] Fetch related activity - not applicable', [
'activityId' => $activity->getUuid(),
]);
return null;
}
// Find and fetch related activity.
try {
$crmActivity = $this->fetchRelatedActivity($activity);
if (empty($crmActivity)) {
return null;
}
} catch (Exception $exception) {
$this->logger->error('[Hubspot] Fetch related activity - failed', [
'activityId' => $activity->getUuid(),
'reason' => $exception->getMessage(),
]);
throw $exception;
}
// get playbook category id
$playbookCategoryId = $this->getPlaybookCategoryId($activity, $crmActivity);
$data = [
'crm_provider_id' => $crmActivity['id'],
'playbook_category_id' => $playbookCategoryId,
];
if (! $this->updateActivity($activity, $data)) {
return null;
}
$this->crmManipulator->updateRelatedCrmActivity($activity, $crmActivity);
$activity->refresh();
return $activity;
}
private function isActivityApplicable(Activity $activity): bool
{
if (! $activity->isTypeConference()) {
return false;
}
if (! $this->hasMinimumFilterRequirements($activity)) {
$this->logger->info('[Hubspot] Fetch related activity - not enough filter data', [
'activityId' => $activity->getUuid(),
]);
return false;
}
// The related activity is already linked.
if ($activity->hasCrmProviderId()) {
return false;
}
if (! $activity->getUser()->getProfile() instanceof Profile) {
$this->logger->info('[Hubspot] Fetch related activity - activity user has no crm profile', [
'activityId' => $activity->getUuid(),
]);
return false;
}
return true;
}
private function hasMinimumFilterRequirements(Activity $activity): bool
{
// We need to know when it started to determine when to look for related activity.
if (! $activity->hasActualStartTime()) {
return false;
}
if (! $activity->hasActualEndTime()) {
return false;
}
// At least one prospect should be associated to the activity
if ($activity->hasContact() || $activity->hasAccount()) {
return true;
}
return false;
}
public function fetchRelatedActivity(Activity $activity): array
{
$this->logger->info('[Hubspot] Fetch related activity - searching related CRM activity', [
'activityId' => $activity->getUuid(),
]);
$payloadData = $this->getPayloadFilterData($activity);
try {
$payload = $this->payloadBuilder->getFindRelatedActivityPayload($payloadData, self::OBJECT_MEETING);
$response = $this->client->getPaginatedData($payload, self::OBJECT_MEETING);
$crmActivities = $response['results'];
} catch (Exception $e) {
$this->logger->error('[Hubspot] Fetch related activity - failed', [
'activityId' => $activity->getUuid(),
'reason' => $e->getMessage(),
]);
throw $e;
}
if (empty($crmActivities)) {
$this->logger->info('[Hubspot] Fetch related activity - no CRM related activity found', [
'activityId' => $activity->getUuid(),
'payload' => $payload,
]);
return [];
}
$relatedActivity = $this->getBestCrmActivityCandidate($crmActivities, $payloadData);
if (empty($relatedActivity)) {
$this->logger->info('[Hubspot] Fetch related activity - no related activity candidate', [
'activityId' => $activity->getUuid(),
]);
return [];
}
$this->logger->info('[Hubspot] Fetch related activity - found related CRM activity', [
'activityId' => $activity->getUuid(),
'crmActivity' => $relatedActivity,
]);
return $relatedActivity;
}
/**
* @param Activity $activity
*
* @return array
*/
protected function getPayloadFilterData(Activity $activity): array
{
[$from, $to] = $this->getActivityTimeFilterRange($activity);
$data = [
'from' => $from->getPreciseTimestamp(3),
'to' => $to->getPreciseTimestamp(3),
'contact' => $activity->hasContact() ? $activity->getContact()->getCrmProviderId() : null,
'company' => $activity->hasAccount() ? $activity->getAccount()->getCrmProviderId() : null,
'deal' => $activity->hasOpportunity() ? $activity->getOpportunity()->getCrmProviderId() : null,
'owner' => $activity->getUser()->getProfile()->getCrmProviderId(),
'activity' => $activity->getUuid(),
];
return array_filter($data);
}
private function getActivityTimeFilterRange(Activity $activity): array
{
if ($activity->hasScheduledStartTime() && $activity->hasScheduledEndTime()) {
$calendarEvent = $activity->getCalendarEvent();
if ($calendarEvent instanceof CalendarEvent) {
$from = $calendarEvent->getStartTime();
$to = $calendarEvent->getEndTime()->addMinutes(15);
} else {
$from = Carbon::instance($activity->getScheduledStartTime());
$to = Carbon::instance($activity->getScheduledEndTime())->addMinutes(15);
}
return [$from, $to];
}
$from = Carbon::instance($activity->getActualStartTime());
$to = Carbon::instance($activity->getActualEndTime());
return [$from, $to];
}
/**
* Determines the best CRM activity candidate based on the number of matching prospects and the owner.
*
* @param array $crmActivities List of CRM activities to evaluate.
* @param array $data Data containing the prospects and owner information.
*
* @return array The best CRM activity candidate.
*/
private function getBestCrmActivityCandidate(array $crmActivities, array $data): array
{
$prospectCount = count(array_intersect_key($data, array_flip(['contact', 'company', 'deal'])));
$bestCandidate = null;
$maxMatchingProspects = 0;
$validActivitiesCount = count($crmActivities);
if ($validActivitiesCount > 1) {
$this->logger->info('[Hubspot] Fetch related activity - determining the best candidate', [
'activityId' => $data['activity'],
'activitiesCount' => $validActivitiesCount,
'crmActivities' => $crmActivities,
]);
}
foreach ($crmActivities as $crmActivity) {
if ($this->isOwnerMismatch($crmActivity, $data)) {
$validActivitiesCount--;
continue;
}
if ($validActivitiesCount === 1) {
return $crmActivity;
}
if ($prospectCount > 1) {
$matchingProspects = $this->countMatchingProspects($crmActivity, $data);
if ($matchingProspects > $maxMatchingProspects) {
$maxMatchingProspects = $matchingProspects;
$bestCandidate = $crmActivity;
}
} else {
return $crmActivity;
}
}
return $bestCandidate ?? [];
}
private function isOwnerMismatch(array $crmActivity, array $data): bool
{
if ($crmActivity['properties']['hubspot_owner_id'] !== $data['owner']) {
$this->logger->info('[Hubspot] Fetch related activity - owner mismatch', [
'activityId' => $data['activity'],
'crmActivity' => $crmActivity['id'],
'jiminnyOwner' => $data['owner'],
'hubspotOwner' => $crmActivity['properties']['hubspot_owner_id'],
]);
return true;
}
return false;
}
private function countMatchingProspects(array $crmActivity, array $data): int
{
try {
$crmActivityWithAssociations = $this->client->getMeeting($crmActivity['id']);
} catch (ApiException $e) {
$this->logger->error('[Hubspot] Fetch related activity - failed to get meeting associations', [
'activityId' => $data['activity'],
'crmActivity' => $crmActivity['id'],
'reason' => $e->getMessage(),
]);
return 0;
}
$associations = $crmActivityWithAssociations->getAssociations();
$matchingProspects = 0;
$matchingProspects += $this->countMatchingAssociation($data, $associations, 'company', 'companies');
$matchingProspects += $this->countMatchingAssociation($data, $associations, 'contact', 'contacts');
$matchingProspects += $this->countMatchingAssociation($data, $associations, 'deal', 'deals');
return $matchingProspects;
}
private function countMatchingAssociation(array $data, array $associations, string $dataKey, string $associationKey): int
{
if (isset($data[$dataKey]) && isset($associations[$associationKey])) {
$associatedItems = $associations[$associationKey]->getResults();
foreach ($associatedItems as $associatedItem) {
if ($data[$dataKey] == $associatedItem->getId()) {
return 1;
}
}
}
return 0;
}
private function getPlaybookCategoryId(Activity $activity, array $crmActivity): ?int
{
if (! $activity->hasUser()) {
$this->logger->info('[Hubspot] Fetch related activity - activity has no user', [
'activityId' => $activity->getUuid(),
]);
return null;
}
$playbookResolver = app(PlaybookResolver::class);
$playbook = $playbookResolver->resolvePlaybookByUser($activity->getUser());
if (! $playbook instanceof Playbook) {
$this->logger->info('[Hubspot] Fetch related activity - playbook not set for user', [
'activityId' => $activity->getUuid(),
]);
return null;
}
if (! $playbook->getActivityField() instanceof Field) {
$this->logger->info('[Hubspot] Fetch related activity - playbook activity field not set', [
'activityId' => $activity->getUuid(),
]);
return null;
}
$playbookCategory = null;
$activityTypeSource = null;
$playbookActivityFieldName = $playbook->getActivityField()->getCrmProviderId();
if ($playbookActivityFieldName === 'activityType') {
$playbookActivityFieldName = self::DEFAULT_ACTIVITY_TYPE;
}
// First attempt to match playbook category from crm activity
if (! empty($crmActivity['properties'][$playbookActivityFieldName])) {
$playbookCategoryRepository = app(PlaybookCategoryRepository::class);
$playbookCategory = $playbookCategoryRepository->getPlaybookCategoryByName(
$playbook,
strval($crmActivity['properties'][$playbookActivityFieldName])
);
$activityTypeSource = 'crm';
}
// If not matched get playbook category from Jiminny activity
if (! $playbookCategory instanceof PlaybookCategory && $activity->hasActivityType()) {
$playbookCategory = $activity->getActivityType();
$activityTypeSource = 'jiminny';
}
if ($playbookCategory instanceof PlaybookCategory) {
$this->logger->info('[Hubspot] Fetch related activity - Playbook category matched', [
'activityId' => $activity->getUuid(),
'playbookCategoryId' => $playbookCategory->getId(),
'activityFieldName' => $playbookActivityFieldName,
'crmActivity' => $crmActivity['id'],
'source' => $activityTypeSource,
]);
return $playbookCategory->getId();
}
$this->logger->info('[Hubspot] Fetch related activity - Playbook category not found', [
'activityId' => $activity->getUuid(),
'activityFieldName' => $playbookActivityFieldName,
'crmActivity' => $crmActivity['id'],
]);
return null;
}
private function updateActivity(Activity $activity, array $data): bool
{
$activityRepository = app(ActivityRepository::class);
$alreadyLoggedActivity = $activityRepository->findUserActivityByCrmProviderId(
$activity->getUser(),
$data['crm_provider_id']
);
if ($alreadyLoggedActivity instanceof Activity) {
$this->logger->info('[Hubspot] Fetch related activity - activity already linked', [
'currentActivityId' => $activity->getUuid(),
'linkedActivityId' => $alreadyLoggedActivity->getUuid(),
'crmActivityId' => $data['crm_provider_id'],
]);
// Return false to prevent updating the remote CRM engagement. This can happen when two Jiminny
// activities share the same calendar event (e.g., rescheduled meetings). Without this, the first
// activity's summary would be overwritten by the second activity.
return false;
}
try {
$activityRepository->update($activity, $data);
} catch (Exception $e) {
$this->logger->error('[Hubspot] Fetch related activity - activity update failed', [
'activityId' => $activity->getUuid(),
'reason' => $e->getMessage(),
]);
return false;
}
$this->logger->info('[Hubspot] Fetch related activity - activity updated', [
'activityId' => $activity->getUuid(),
'data' => $data,
]);
return true;
}
}
Project
Project
New File or Directory…
Expand Selected
Collapse All
Options
Hide
app ~/jiminny/app, folder
.circleci, folder
.cursor, folder
.github
.sonarlint, folder
.vscode, folder
.windsurf, folder
app, sources root
Actions, folder
Component, folder
Acl, folder
ActionItems, folder
Activity, folder
ActivityAnalytics, folder
ActivitySearch, folder
AiActivityType, folder
AiAutomation, folder
AiCallScoring, folder
AskAnything, folder
Dtos, folder
Events, folder
AskAnythingPromptService.php, class
HistoryService.php, class
AskJiminnyAi, folder
AWS, folder
BillingManagement, folder
Cache, folder
CoachingFeedback, folder
Country, folder
CustomerApi, folder
Database, folder
Datadog, folder
DateTime, folder
DealInsights, folder
DealRisks, folder
ElasticSearch, folder
Eloquent, folder
Encoding, folder
Encryption, folder
ES, folder
Faker, folder
FeatureFlags, folder
FFMpeg, folder
FileSystem, folder
Gecko, folder
Gong, folder
GuzzleHttp, folder
KeyPoints, folder
Kiosk, folder
LanguageDetection, folder
LiveFeed, folder
Locks, folder
Math, folder
MediaPipeline, folder
MeetingBot, folder
MobileSettings, folder
Model, folder
Notification, folder
Nudge, folder
ParagraphBreaker, folder
ParticipantSpeech, folder
PartitionedCookie, folder
PlaybackPage, folder
Playlist, folder
Prophet, folder
ProphetAi, folder
ProsperWorks, folder
Queue, folder
Router, folder
Saml2, folder
SCIM, folder
Seeder, folder
Sentry, folder
Serializer, folder
Settings, folder
Sidekick, folder
Slack, folder
TeamInsights, folder
TimeMemoryMapper, folder
Transcription, folder
TranscriptionSummary, folder
Twilio, folder
Uploader, folder
UrlGenerator, folder
Utility, folder
Exceptions, folder
Service, folder
BaseRateLimiter.php, class
EfficientJsonParser.php, class
ProviderRateLimiter.php, class
RateLimiterInstance.php, class
Uuid
Waveform
Webhooks
Workflow
Configuration
Console
Commands
Activities
Analytics
Calendars
Crm
Hubspot
IntegrationApp
Traits
AddLayoutEntities.php, class
AutologDelayedCommand.php, class
BullhornCommandAbstract.php, abstract class
BullhornPingCommand.php, class
BullhornSearchCommand.php, class
BullhornSessionCommand.php, class
CheckActivityLoggableCommand.php, final class
CleanDuplicateFieldDataCommand.php, class
FullSyncOpportunityCommand.php, class
LogActivitiesCommand.php, final class
ManageSyncStrategyCommand.php, class
MatchCrmObjectsCommand.php, class
MatchOpportunityActivitiesCommand.php, class
MigrateProvider.php, class
ProcessHubspotObjectsSyncBatches.php, class
PurgeDeletedOpportunitiesCommand.php, class
ResetGovernorLimits.php, class
SendNotLogged.php, class
SetupActivityTypeForFollowUp.php, final class
SetupCloseCrm.php, class
SetupCopperCrm.php, class
SetupCrmCommand.php, abstract class
SetupLayouts.php, class
SyncAccount.php, class
SyncContact.php, class
SyncFieldMetadata.php, class
SyncHubspotActiveDeals.php, class
SyncHubspotObjects.php, class
SyncLead.php, class
SyncObjects.php, class
SyncOpportunitiesMissingFieldDataCommand.php, class
SyncOpportunity.php, class
SyncProfileMetadata.php, class
SyncTeamMetadata.php, class
UpdateOpportunitySpecifications.php, class
DealInsights, folder
Dev, folder
Dialers, folder
DTOs, folder
Elasticsearch, folder
EngagementStats, folder
GeckoExport, folder
Livestream, folder
Mailboxes, folder
Migrate, folder
PlaybackThemes, folder
Playbooks, folder
Playlists, folder
Postmark, folder
ProphetAi, folder
Reports, folder
AutomatedReportsCommand.php, class
AutomatedReportsRetentionPolicyCommand.php, class
AutomatedReportsSendCommand.php, class
CreateMockAskJiminnyReportResultCommand.php, class
DeleteReportCommand.php, class
GenerateMarketingReport.php, class
Team.php, class
Usage.php, class
Slack, folder
Teams, folder
Tracks, folder...
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"bounds":{"left":0.025930852,"top":0.019952115,"width":0.03856383,"height":0.025538707},"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JY-20725-handle-HS-search-rate-limit, menu","depth":5,"bounds":{"left":0.064494684,"top":0.019952115,"width":0.09541223,"height":0.025538707},"on_screen":true,"help_text":"Git Branch: JY-20725-handle-HS-search-rate-limit","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"bounds":{"left":0.8081782,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"AskJiminnyReportActivityServiceTest","depth":6,"bounds":{"left":0.8234708,"top":0.019952115,"width":0.09208777,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'AskJiminnyReportActivityServiceTest'","depth":6,"bounds":{"left":0.9155585,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'AskJiminnyReportActivityServiceTest'","depth":6,"bounds":{"left":0.9268617,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"bounds":{"left":0.9381649,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"bounds":{"left":0.96609044,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"bounds":{"left":0.9773936,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"IDE and Project Settings","depth":5,"bounds":{"left":0.9886968,"top":0.019952115,"width":0.011303186,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.042220745,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"19","depth":4,"bounds":{"left":0.6615692,"top":0.10055866,"width":0.009640957,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"bounds":{"left":0.67287236,"top":0.09896249,"width":0.00731383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"bounds":{"left":0.68018615,"top":0.09896249,"width":0.006981383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {\n\"headers\":{\n\"Date\":[\"Thu,07 May 2026 14:21:15 GMT\"],\n \"Content-Type\":[\"application/json;charset=utf-8\"],\n \"Transfer-Encoding\":[\"chunked\"],\n \"Connection\":[\"keep-alive\"],\n \"CF-Ray\":[\"9f80deb8db60dc3a-SOF\"],\n \"CF-Cache-Status\":[\"DYNAMIC\"],\n \"Strict-Transport-Security\":[\"max-age=31536000; includeSubDomains; preload\"],\n \"Vary\":[\"origin,\n accept-encoding\"],\n \"access-control-allow-credentials\":[\"false\"],\n \"server-timing\":[\"hcid;desc=\\\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\\\",\n cfr;desc=\\\"9f80deb8e7c6dc3a-IAD\\\"\"],\n \"x-content-type-options\":[\"nosniff\"],\n \"x-hubspot-correlation-id\":[\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\"],\n \"Set-Cookie\":[\"__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-1.0.1.1-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,\n 07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None\"],\n \"Report-To\":[\"{\n\\\"endpoints\\\":[{\n\\\"url\\\":\\\"https:\\\\/\\\\/a.nel.cloudflare.com\\\\/report\\\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\\\"}],\n\\\"group\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"NEL\":[\"{\n\\\"success_fraction\\\":0.01,\n\\\"report_to\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"Server\":[\"cloudflare\"]}} {\n\"correlation_id\":\"95236535-ec98-4541-b92a-adfa73b69eab\",\n\"trace_id\":\"c7ab8365-903f-46d4-9403-0e5b551e3545\"}","depth":4,"bounds":{"left":0.37632978,"top":0.09736632,"width":0.5728058,"height":0.8818835},"on_screen":true,"lines":[{"char_start":207,"char_count":30,"bounds":{"left":0.37632978,"top":0.0,"width":0.07513298,"height":0.014365523}},{"char_start":237,"char_count":36,"bounds":{"left":0.37632978,"top":0.0,"width":0.09075798,"height":0.014365523}},{"char_start":273,"char_count":32,"bounds":{"left":0.37632978,"top":0.0,"width":0.080119684,"height":0.014365523}},{"char_start":305,"char_count":79,"bounds":{"left":0.37632978,"top":0.0,"width":0.20212767,"height":0.014365523}},{"char_start":384,"char_count":18,"bounds":{"left":0.37632978,"top":0.0,"width":0.043882977,"height":0.014365523}},{"char_start":402,"char_count":21,"bounds":{"left":0.37632978,"top":0.0,"width":0.051861703,"height":0.014365523}},{"char_start":423,"char_count":48,"bounds":{"left":0.37632978,"top":0.008778931,"width":0.12167553,"height":0.014365523}},{"char_start":471,"char_count":72,"bounds":{"left":0.37632978,"top":0.026336791,"width":0.18384309,"height":0.014365523}},{"char_start":543,"char_count":40,"bounds":{"left":0.37632978,"top":0.043894652,"width":0.10106383,"height":0.014365523}},{"char_start":583,"char_count":41,"bounds":{"left":0.37632978,"top":0.061452515,"width":0.10372341,"height":0.014365523}},{"char_start":624,"char_count":72,"bounds":{"left":0.37632978,"top":0.079010375,"width":0.18384309,"height":0.014365523}},{"char_start":696,"char_count":219,"bounds":{"left":0.37632978,"top":0.096568234,"width":0.56515956,"height":0.014365523}},{"char_start":915,"char_count":83,"bounds":{"left":0.37632978,"top":0.11412609,"width":0.21243352,"height":0.014365523}},{"char_start":998,"char_count":20,"bounds":{"left":0.37632978,"top":0.13168396,"width":0.04920213,"height":0.014365523}},{"char_start":1018,"char_count":17,"bounds":{"left":0.37632978,"top":0.14924182,"width":0.041223403,"height":0.014365523}},{"char_start":1035,"char_count":203,"bounds":{"left":0.37632978,"top":0.16679968,"width":0.52360374,"height":0.014365523}},{"char_start":1238,"char_count":22,"bounds":{"left":0.37632978,"top":0.18435754,"width":0.05418883,"height":0.014365523}},{"char_start":1260,"char_count":23,"bounds":{"left":0.37632978,"top":0.2019154,"width":0.056848403,"height":0.014365523}},{"char_start":1283,"char_count":10,"bounds":{"left":0.37632978,"top":0.21947326,"width":0.023271276,"height":0.014365523}},{"char_start":1293,"char_count":27,"bounds":{"left":0.37632978,"top":0.23703113,"width":0.06715426,"height":0.014365523}},{"char_start":1320,"char_count":26,"bounds":{"left":0.37632978,"top":0.254589,"width":0.06482713,"height":0.014365523}},{"char_start":1346,"char_count":23,"bounds":{"left":0.37632978,"top":0.27214685,"width":0.056848403,"height":0.014365523}},{"char_start":1369,"char_count":28,"bounds":{"left":0.37632978,"top":0.2897047,"width":0.06981383,"height":0.014365523}},{"char_start":1397,"char_count":57,"bounds":{"left":0.37632978,"top":0.30726257,"width":0.14494681,"height":0.014365523}}],"value":"[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {\n\"headers\":{\n\"Date\":[\"Thu,07 May 2026 14:21:15 GMT\"],\n \"Content-Type\":[\"application/json;charset=utf-8\"],\n \"Transfer-Encoding\":[\"chunked\"],\n \"Connection\":[\"keep-alive\"],\n \"CF-Ray\":[\"9f80deb8db60dc3a-SOF\"],\n \"CF-Cache-Status\":[\"DYNAMIC\"],\n \"Strict-Transport-Security\":[\"max-age=31536000; includeSubDomains; preload\"],\n \"Vary\":[\"origin,\n accept-encoding\"],\n \"access-control-allow-credentials\":[\"false\"],\n \"server-timing\":[\"hcid;desc=\\\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\\\",\n cfr;desc=\\\"9f80deb8e7c6dc3a-IAD\\\"\"],\n \"x-content-type-options\":[\"nosniff\"],\n \"x-hubspot-correlation-id\":[\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\"],\n \"Set-Cookie\":[\"__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-1.0.1.1-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,\n 07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None\"],\n \"Report-To\":[\"{\n\\\"endpoints\\\":[{\n\\\"url\\\":\\\"https:\\\\/\\\\/a.nel.cloudflare.com\\\\/report\\\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\\\"}],\n\\\"group\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"NEL\":[\"{\n\\\"success_fraction\\\":0.01,\n\\\"report_to\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"Server\":[\"cloudflare\"]}} {\n\"correlation_id\":\"95236535-ec98-4541-b92a-adfa73b69eab\",\n\"trace_id\":\"c7ab8365-903f-46d4-9403-0e5b551e3545\"}","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.042220745,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"3","depth":4,"bounds":{"left":0.32214096,"top":0.19952115,"width":0.007978723,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"11","depth":4,"bounds":{"left":0.33211437,"top":0.19952115,"width":0.008976064,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"bounds":{"left":0.34275267,"top":0.19792499,"width":0.00731383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"bounds":{"left":0.35006648,"top":0.19792499,"width":0.006981383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\ndeclare(strict_types=1);\n\nnamespace Jiminny\\Services\\Crm\\Hubspot;\n\nuse Carbon\\Carbon;\nuse Exception;\nuse HubSpot\\Client\\Crm\\Objects\\ApiException;\nuse Jiminny\\Integrations\\PlaybookResolver;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Calendar\\CalendarEvent;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Models\\Crm\\Profile;\nuse Jiminny\\Models\\Playbook;\nuse Jiminny\\Models\\PlaybookCategory;\nuse Jiminny\\Repositories\\ActivityRepository;\nuse Jiminny\\Repositories\\PlaybookCategoryRepository;\nuse Psr\\Log\\LoggerInterface;\n\nclass SyncRelatedActivityManager\n{\n public const string OBJECT_MEETING = 'meetings';\n public const string DEFAULT_ACTIVITY_TYPE = 'hs_activity_type';\n private RemoteCrmObjectManipulator $crmManipulator;\n\n public function __construct(\n private readonly Client $client,\n private readonly PayloadBuilder $payloadBuilder,\n private readonly LoggerInterface $logger\n ) {\n $this->crmManipulator = app(RemoteCrmObjectManipulator::class, [\n 'client' => $this->client,\n 'logger' => $this->logger,\n ]);\n }\n\n public function fetchAndAssociateRelatedActivity(Activity $activity): ?Activity\n {\n // Validate if the activity is applicable.\n if ($this->isActivityApplicable($activity) === false) {\n $this->logger->info('[Hubspot] Fetch related activity - not applicable', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n return null;\n }\n\n // Find and fetch related activity.\n try {\n $crmActivity = $this->fetchRelatedActivity($activity);\n if (empty($crmActivity)) {\n return null;\n }\n } catch (Exception $exception) {\n $this->logger->error('[Hubspot] Fetch related activity - failed', [\n 'activityId' => $activity->getUuid(),\n 'reason' => $exception->getMessage(),\n ]);\n\n throw $exception;\n }\n\n // get playbook category id\n $playbookCategoryId = $this->getPlaybookCategoryId($activity, $crmActivity);\n\n $data = [\n 'crm_provider_id' => $crmActivity['id'],\n 'playbook_category_id' => $playbookCategoryId,\n ];\n\n if (! $this->updateActivity($activity, $data)) {\n return null;\n }\n\n $this->crmManipulator->updateRelatedCrmActivity($activity, $crmActivity);\n\n $activity->refresh();\n\n return $activity;\n }\n\n private function isActivityApplicable(Activity $activity): bool\n {\n if (! $activity->isTypeConference()) {\n return false;\n }\n\n if (! $this->hasMinimumFilterRequirements($activity)) {\n $this->logger->info('[Hubspot] Fetch related activity - not enough filter data', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n return false;\n }\n\n // The related activity is already linked.\n if ($activity->hasCrmProviderId()) {\n return false;\n }\n\n if (! $activity->getUser()->getProfile() instanceof Profile) {\n $this->logger->info('[Hubspot] Fetch related activity - activity user has no crm profile', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n return false;\n }\n\n return true;\n }\n\n private function hasMinimumFilterRequirements(Activity $activity): bool\n {\n // We need to know when it started to determine when to look for related activity.\n if (! $activity->hasActualStartTime()) {\n return false;\n }\n\n if (! $activity->hasActualEndTime()) {\n return false;\n }\n\n // At least one prospect should be associated to the activity\n if ($activity->hasContact() || $activity->hasAccount()) {\n return true;\n }\n\n return false;\n }\n\n public function fetchRelatedActivity(Activity $activity): array\n {\n $this->logger->info('[Hubspot] Fetch related activity - searching related CRM activity', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n $payloadData = $this->getPayloadFilterData($activity);\n\n try {\n $payload = $this->payloadBuilder->getFindRelatedActivityPayload($payloadData, self::OBJECT_MEETING);\n $response = $this->client->getPaginatedData($payload, self::OBJECT_MEETING);\n $crmActivities = $response['results'];\n } catch (Exception $e) {\n $this->logger->error('[Hubspot] Fetch related activity - failed', [\n 'activityId' => $activity->getUuid(),\n 'reason' => $e->getMessage(),\n ]);\n\n throw $e;\n }\n\n if (empty($crmActivities)) {\n $this->logger->info('[Hubspot] Fetch related activity - no CRM related activity found', [\n 'activityId' => $activity->getUuid(),\n 'payload' => $payload,\n ]);\n\n return [];\n }\n\n $relatedActivity = $this->getBestCrmActivityCandidate($crmActivities, $payloadData);\n if (empty($relatedActivity)) {\n $this->logger->info('[Hubspot] Fetch related activity - no related activity candidate', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n return [];\n }\n\n $this->logger->info('[Hubspot] Fetch related activity - found related CRM activity', [\n 'activityId' => $activity->getUuid(),\n 'crmActivity' => $relatedActivity,\n ]);\n\n return $relatedActivity;\n }\n\n /**\n * @param Activity $activity\n *\n * @return array\n */\n protected function getPayloadFilterData(Activity $activity): array\n {\n [$from, $to] = $this->getActivityTimeFilterRange($activity);\n\n $data = [\n 'from' => $from->getPreciseTimestamp(3),\n 'to' => $to->getPreciseTimestamp(3),\n 'contact' => $activity->hasContact() ? $activity->getContact()->getCrmProviderId() : null,\n 'company' => $activity->hasAccount() ? $activity->getAccount()->getCrmProviderId() : null,\n 'deal' => $activity->hasOpportunity() ? $activity->getOpportunity()->getCrmProviderId() : null,\n 'owner' => $activity->getUser()->getProfile()->getCrmProviderId(),\n 'activity' => $activity->getUuid(),\n ];\n\n return array_filter($data);\n }\n\n private function getActivityTimeFilterRange(Activity $activity): array\n {\n if ($activity->hasScheduledStartTime() && $activity->hasScheduledEndTime()) {\n $calendarEvent = $activity->getCalendarEvent();\n\n if ($calendarEvent instanceof CalendarEvent) {\n $from = $calendarEvent->getStartTime();\n $to = $calendarEvent->getEndTime()->addMinutes(15);\n } else {\n $from = Carbon::instance($activity->getScheduledStartTime());\n $to = Carbon::instance($activity->getScheduledEndTime())->addMinutes(15);\n }\n\n return [$from, $to];\n }\n\n $from = Carbon::instance($activity->getActualStartTime());\n $to = Carbon::instance($activity->getActualEndTime());\n\n return [$from, $to];\n }\n\n /**\n * Determines the best CRM activity candidate based on the number of matching prospects and the owner.\n *\n * @param array $crmActivities List of CRM activities to evaluate.\n * @param array $data Data containing the prospects and owner information.\n *\n * @return array The best CRM activity candidate.\n */\n private function getBestCrmActivityCandidate(array $crmActivities, array $data): array\n {\n $prospectCount = count(array_intersect_key($data, array_flip(['contact', 'company', 'deal'])));\n $bestCandidate = null;\n $maxMatchingProspects = 0;\n $validActivitiesCount = count($crmActivities);\n\n if ($validActivitiesCount > 1) {\n $this->logger->info('[Hubspot] Fetch related activity - determining the best candidate', [\n 'activityId' => $data['activity'],\n 'activitiesCount' => $validActivitiesCount,\n 'crmActivities' => $crmActivities,\n ]);\n }\n\n foreach ($crmActivities as $crmActivity) {\n if ($this->isOwnerMismatch($crmActivity, $data)) {\n $validActivitiesCount--;\n\n continue;\n }\n\n if ($validActivitiesCount === 1) {\n return $crmActivity;\n }\n\n if ($prospectCount > 1) {\n $matchingProspects = $this->countMatchingProspects($crmActivity, $data);\n if ($matchingProspects > $maxMatchingProspects) {\n $maxMatchingProspects = $matchingProspects;\n $bestCandidate = $crmActivity;\n }\n } else {\n return $crmActivity;\n }\n }\n\n return $bestCandidate ?? [];\n }\n\n private function isOwnerMismatch(array $crmActivity, array $data): bool\n {\n if ($crmActivity['properties']['hubspot_owner_id'] !== $data['owner']) {\n $this->logger->info('[Hubspot] Fetch related activity - owner mismatch', [\n 'activityId' => $data['activity'],\n 'crmActivity' => $crmActivity['id'],\n 'jiminnyOwner' => $data['owner'],\n 'hubspotOwner' => $crmActivity['properties']['hubspot_owner_id'],\n ]);\n\n return true;\n }\n\n return false;\n }\n\n private function countMatchingProspects(array $crmActivity, array $data): int\n {\n try {\n $crmActivityWithAssociations = $this->client->getMeeting($crmActivity['id']);\n } catch (ApiException $e) {\n $this->logger->error('[Hubspot] Fetch related activity - failed to get meeting associations', [\n 'activityId' => $data['activity'],\n 'crmActivity' => $crmActivity['id'],\n 'reason' => $e->getMessage(),\n ]);\n\n return 0;\n }\n\n $associations = $crmActivityWithAssociations->getAssociations();\n $matchingProspects = 0;\n\n $matchingProspects += $this->countMatchingAssociation($data, $associations, 'company', 'companies');\n $matchingProspects += $this->countMatchingAssociation($data, $associations, 'contact', 'contacts');\n $matchingProspects += $this->countMatchingAssociation($data, $associations, 'deal', 'deals');\n\n return $matchingProspects;\n }\n\n private function countMatchingAssociation(array $data, array $associations, string $dataKey, string $associationKey): int\n {\n if (isset($data[$dataKey]) && isset($associations[$associationKey])) {\n $associatedItems = $associations[$associationKey]->getResults();\n foreach ($associatedItems as $associatedItem) {\n if ($data[$dataKey] == $associatedItem->getId()) {\n\n return 1;\n }\n }\n }\n\n return 0;\n }\n\n private function getPlaybookCategoryId(Activity $activity, array $crmActivity): ?int\n {\n if (! $activity->hasUser()) {\n $this->logger->info('[Hubspot] Fetch related activity - activity has no user', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n return null;\n }\n\n $playbookResolver = app(PlaybookResolver::class);\n $playbook = $playbookResolver->resolvePlaybookByUser($activity->getUser());\n\n if (! $playbook instanceof Playbook) {\n $this->logger->info('[Hubspot] Fetch related activity - playbook not set for user', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n return null;\n }\n\n if (! $playbook->getActivityField() instanceof Field) {\n $this->logger->info('[Hubspot] Fetch related activity - playbook activity field not set', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n return null;\n }\n\n $playbookCategory = null;\n $activityTypeSource = null;\n\n $playbookActivityFieldName = $playbook->getActivityField()->getCrmProviderId();\n if ($playbookActivityFieldName === 'activityType') {\n $playbookActivityFieldName = self::DEFAULT_ACTIVITY_TYPE;\n }\n\n // First attempt to match playbook category from crm activity\n if (! empty($crmActivity['properties'][$playbookActivityFieldName])) {\n $playbookCategoryRepository = app(PlaybookCategoryRepository::class);\n $playbookCategory = $playbookCategoryRepository->getPlaybookCategoryByName(\n $playbook,\n strval($crmActivity['properties'][$playbookActivityFieldName])\n );\n $activityTypeSource = 'crm';\n }\n\n // If not matched get playbook category from Jiminny activity\n if (! $playbookCategory instanceof PlaybookCategory && $activity->hasActivityType()) {\n $playbookCategory = $activity->getActivityType();\n $activityTypeSource = 'jiminny';\n }\n\n if ($playbookCategory instanceof PlaybookCategory) {\n $this->logger->info('[Hubspot] Fetch related activity - Playbook category matched', [\n 'activityId' => $activity->getUuid(),\n 'playbookCategoryId' => $playbookCategory->getId(),\n 'activityFieldName' => $playbookActivityFieldName,\n 'crmActivity' => $crmActivity['id'],\n 'source' => $activityTypeSource,\n ]);\n\n return $playbookCategory->getId();\n }\n\n $this->logger->info('[Hubspot] Fetch related activity - Playbook category not found', [\n 'activityId' => $activity->getUuid(),\n 'activityFieldName' => $playbookActivityFieldName,\n 'crmActivity' => $crmActivity['id'],\n ]);\n\n return null;\n }\n\n private function updateActivity(Activity $activity, array $data): bool\n {\n $activityRepository = app(ActivityRepository::class);\n\n $alreadyLoggedActivity = $activityRepository->findUserActivityByCrmProviderId(\n $activity->getUser(),\n $data['crm_provider_id']\n );\n\n if ($alreadyLoggedActivity instanceof Activity) {\n $this->logger->info('[Hubspot] Fetch related activity - activity already linked', [\n 'currentActivityId' => $activity->getUuid(),\n 'linkedActivityId' => $alreadyLoggedActivity->getUuid(),\n 'crmActivityId' => $data['crm_provider_id'],\n ]);\n\n // Return false to prevent updating the remote CRM engagement. This can happen when two Jiminny\n // activities share the same calendar event (e.g., rescheduled meetings). Without this, the first\n // activity's summary would be overwritten by the second activity.\n return false;\n }\n\n try {\n $activityRepository->update($activity, $data);\n } catch (Exception $e) {\n $this->logger->error('[Hubspot] Fetch related activity - activity update failed', [\n 'activityId' => $activity->getUuid(),\n 'reason' => $e->getMessage(),\n ]);\n\n return false;\n }\n\n $this->logger->info('[Hubspot] Fetch related activity - activity updated', [\n 'activityId' => $activity->getUuid(),\n 'data' => $data,\n ]);\n\n return true;\n }\n}","depth":4,"on_screen":true,"value":"<?php\n\ndeclare(strict_types=1);\n\nnamespace Jiminny\\Services\\Crm\\Hubspot;\n\nuse Carbon\\Carbon;\nuse Exception;\nuse HubSpot\\Client\\Crm\\Objects\\ApiException;\nuse Jiminny\\Integrations\\PlaybookResolver;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Calendar\\CalendarEvent;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Models\\Crm\\Profile;\nuse Jiminny\\Models\\Playbook;\nuse Jiminny\\Models\\PlaybookCategory;\nuse Jiminny\\Repositories\\ActivityRepository;\nuse Jiminny\\Repositories\\PlaybookCategoryRepository;\nuse Psr\\Log\\LoggerInterface;\n\nclass SyncRelatedActivityManager\n{\n public const string OBJECT_MEETING = 'meetings';\n public const string DEFAULT_ACTIVITY_TYPE = 'hs_activity_type';\n private RemoteCrmObjectManipulator $crmManipulator;\n\n public function __construct(\n private readonly Client $client,\n private readonly PayloadBuilder $payloadBuilder,\n private readonly LoggerInterface $logger\n ) {\n $this->crmManipulator = app(RemoteCrmObjectManipulator::class, [\n 'client' => $this->client,\n 'logger' => $this->logger,\n ]);\n }\n\n public function fetchAndAssociateRelatedActivity(Activity $activity): ?Activity\n {\n // Validate if the activity is applicable.\n if ($this->isActivityApplicable($activity) === false) {\n $this->logger->info('[Hubspot] Fetch related activity - not applicable', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n return null;\n }\n\n // Find and fetch related activity.\n try {\n $crmActivity = $this->fetchRelatedActivity($activity);\n if (empty($crmActivity)) {\n return null;\n }\n } catch (Exception $exception) {\n $this->logger->error('[Hubspot] Fetch related activity - failed', [\n 'activityId' => $activity->getUuid(),\n 'reason' => $exception->getMessage(),\n ]);\n\n throw $exception;\n }\n\n // get playbook category id\n $playbookCategoryId = $this->getPlaybookCategoryId($activity, $crmActivity);\n\n $data = [\n 'crm_provider_id' => $crmActivity['id'],\n 'playbook_category_id' => $playbookCategoryId,\n ];\n\n if (! $this->updateActivity($activity, $data)) {\n return null;\n }\n\n $this->crmManipulator->updateRelatedCrmActivity($activity, $crmActivity);\n\n $activity->refresh();\n\n return $activity;\n }\n\n private function isActivityApplicable(Activity $activity): bool\n {\n if (! $activity->isTypeConference()) {\n return false;\n }\n\n if (! $this->hasMinimumFilterRequirements($activity)) {\n $this->logger->info('[Hubspot] Fetch related activity - not enough filter data', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n return false;\n }\n\n // The related activity is already linked.\n if ($activity->hasCrmProviderId()) {\n return false;\n }\n\n if (! $activity->getUser()->getProfile() instanceof Profile) {\n $this->logger->info('[Hubspot] Fetch related activity - activity user has no crm profile', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n return false;\n }\n\n return true;\n }\n\n private function hasMinimumFilterRequirements(Activity $activity): bool\n {\n // We need to know when it started to determine when to look for related activity.\n if (! $activity->hasActualStartTime()) {\n return false;\n }\n\n if (! $activity->hasActualEndTime()) {\n return false;\n }\n\n // At least one prospect should be associated to the activity\n if ($activity->hasContact() || $activity->hasAccount()) {\n return true;\n }\n\n return false;\n }\n\n public function fetchRelatedActivity(Activity $activity): array\n {\n $this->logger->info('[Hubspot] Fetch related activity - searching related CRM activity', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n $payloadData = $this->getPayloadFilterData($activity);\n\n try {\n $payload = $this->payloadBuilder->getFindRelatedActivityPayload($payloadData, self::OBJECT_MEETING);\n $response = $this->client->getPaginatedData($payload, self::OBJECT_MEETING);\n $crmActivities = $response['results'];\n } catch (Exception $e) {\n $this->logger->error('[Hubspot] Fetch related activity - failed', [\n 'activityId' => $activity->getUuid(),\n 'reason' => $e->getMessage(),\n ]);\n\n throw $e;\n }\n\n if (empty($crmActivities)) {\n $this->logger->info('[Hubspot] Fetch related activity - no CRM related activity found', [\n 'activityId' => $activity->getUuid(),\n 'payload' => $payload,\n ]);\n\n return [];\n }\n\n $relatedActivity = $this->getBestCrmActivityCandidate($crmActivities, $payloadData);\n if (empty($relatedActivity)) {\n $this->logger->info('[Hubspot] Fetch related activity - no related activity candidate', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n return [];\n }\n\n $this->logger->info('[Hubspot] Fetch related activity - found related CRM activity', [\n 'activityId' => $activity->getUuid(),\n 'crmActivity' => $relatedActivity,\n ]);\n\n return $relatedActivity;\n }\n\n /**\n * @param Activity $activity\n *\n * @return array\n */\n protected function getPayloadFilterData(Activity $activity): array\n {\n [$from, $to] = $this->getActivityTimeFilterRange($activity);\n\n $data = [\n 'from' => $from->getPreciseTimestamp(3),\n 'to' => $to->getPreciseTimestamp(3),\n 'contact' => $activity->hasContact() ? $activity->getContact()->getCrmProviderId() : null,\n 'company' => $activity->hasAccount() ? $activity->getAccount()->getCrmProviderId() : null,\n 'deal' => $activity->hasOpportunity() ? $activity->getOpportunity()->getCrmProviderId() : null,\n 'owner' => $activity->getUser()->getProfile()->getCrmProviderId(),\n 'activity' => $activity->getUuid(),\n ];\n\n return array_filter($data);\n }\n\n private function getActivityTimeFilterRange(Activity $activity): array\n {\n if ($activity->hasScheduledStartTime() && $activity->hasScheduledEndTime()) {\n $calendarEvent = $activity->getCalendarEvent();\n\n if ($calendarEvent instanceof CalendarEvent) {\n $from = $calendarEvent->getStartTime();\n $to = $calendarEvent->getEndTime()->addMinutes(15);\n } else {\n $from = Carbon::instance($activity->getScheduledStartTime());\n $to = Carbon::instance($activity->getScheduledEndTime())->addMinutes(15);\n }\n\n return [$from, $to];\n }\n\n $from = Carbon::instance($activity->getActualStartTime());\n $to = Carbon::instance($activity->getActualEndTime());\n\n return [$from, $to];\n }\n\n /**\n * Determines the best CRM activity candidate based on the number of matching prospects and the owner.\n *\n * @param array $crmActivities List of CRM activities to evaluate.\n * @param array $data Data containing the prospects and owner information.\n *\n * @return array The best CRM activity candidate.\n */\n private function getBestCrmActivityCandidate(array $crmActivities, array $data): array\n {\n $prospectCount = count(array_intersect_key($data, array_flip(['contact', 'company', 'deal'])));\n $bestCandidate = null;\n $maxMatchingProspects = 0;\n $validActivitiesCount = count($crmActivities);\n\n if ($validActivitiesCount > 1) {\n $this->logger->info('[Hubspot] Fetch related activity - determining the best candidate', [\n 'activityId' => $data['activity'],\n 'activitiesCount' => $validActivitiesCount,\n 'crmActivities' => $crmActivities,\n ]);\n }\n\n foreach ($crmActivities as $crmActivity) {\n if ($this->isOwnerMismatch($crmActivity, $data)) {\n $validActivitiesCount--;\n\n continue;\n }\n\n if ($validActivitiesCount === 1) {\n return $crmActivity;\n }\n\n if ($prospectCount > 1) {\n $matchingProspects = $this->countMatchingProspects($crmActivity, $data);\n if ($matchingProspects > $maxMatchingProspects) {\n $maxMatchingProspects = $matchingProspects;\n $bestCandidate = $crmActivity;\n }\n } else {\n return $crmActivity;\n }\n }\n\n return $bestCandidate ?? [];\n }\n\n private function isOwnerMismatch(array $crmActivity, array $data): bool\n {\n if ($crmActivity['properties']['hubspot_owner_id'] !== $data['owner']) {\n $this->logger->info('[Hubspot] Fetch related activity - owner mismatch', [\n 'activityId' => $data['activity'],\n 'crmActivity' => $crmActivity['id'],\n 'jiminnyOwner' => $data['owner'],\n 'hubspotOwner' => $crmActivity['properties']['hubspot_owner_id'],\n ]);\n\n return true;\n }\n\n return false;\n }\n\n private function countMatchingProspects(array $crmActivity, array $data): int\n {\n try {\n $crmActivityWithAssociations = $this->client->getMeeting($crmActivity['id']);\n } catch (ApiException $e) {\n $this->logger->error('[Hubspot] Fetch related activity - failed to get meeting associations', [\n 'activityId' => $data['activity'],\n 'crmActivity' => $crmActivity['id'],\n 'reason' => $e->getMessage(),\n ]);\n\n return 0;\n }\n\n $associations = $crmActivityWithAssociations->getAssociations();\n $matchingProspects = 0;\n\n $matchingProspects += $this->countMatchingAssociation($data, $associations, 'company', 'companies');\n $matchingProspects += $this->countMatchingAssociation($data, $associations, 'contact', 'contacts');\n $matchingProspects += $this->countMatchingAssociation($data, $associations, 'deal', 'deals');\n\n return $matchingProspects;\n }\n\n private function countMatchingAssociation(array $data, array $associations, string $dataKey, string $associationKey): int\n {\n if (isset($data[$dataKey]) && isset($associations[$associationKey])) {\n $associatedItems = $associations[$associationKey]->getResults();\n foreach ($associatedItems as $associatedItem) {\n if ($data[$dataKey] == $associatedItem->getId()) {\n\n return 1;\n }\n }\n }\n\n return 0;\n }\n\n private function getPlaybookCategoryId(Activity $activity, array $crmActivity): ?int\n {\n if (! $activity->hasUser()) {\n $this->logger->info('[Hubspot] Fetch related activity - activity has no user', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n return null;\n }\n\n $playbookResolver = app(PlaybookResolver::class);\n $playbook = $playbookResolver->resolvePlaybookByUser($activity->getUser());\n\n if (! $playbook instanceof Playbook) {\n $this->logger->info('[Hubspot] Fetch related activity - playbook not set for user', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n return null;\n }\n\n if (! $playbook->getActivityField() instanceof Field) {\n $this->logger->info('[Hubspot] Fetch related activity - playbook activity field not set', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n return null;\n }\n\n $playbookCategory = null;\n $activityTypeSource = null;\n\n $playbookActivityFieldName = $playbook->getActivityField()->getCrmProviderId();\n if ($playbookActivityFieldName === 'activityType') {\n $playbookActivityFieldName = self::DEFAULT_ACTIVITY_TYPE;\n }\n\n // First attempt to match playbook category from crm activity\n if (! empty($crmActivity['properties'][$playbookActivityFieldName])) {\n $playbookCategoryRepository = app(PlaybookCategoryRepository::class);\n $playbookCategory = $playbookCategoryRepository->getPlaybookCategoryByName(\n $playbook,\n strval($crmActivity['properties'][$playbookActivityFieldName])\n );\n $activityTypeSource = 'crm';\n }\n\n // If not matched get playbook category from Jiminny activity\n if (! $playbookCategory instanceof PlaybookCategory && $activity->hasActivityType()) {\n $playbookCategory = $activity->getActivityType();\n $activityTypeSource = 'jiminny';\n }\n\n if ($playbookCategory instanceof PlaybookCategory) {\n $this->logger->info('[Hubspot] Fetch related activity - Playbook category matched', [\n 'activityId' => $activity->getUuid(),\n 'playbookCategoryId' => $playbookCategory->getId(),\n 'activityFieldName' => $playbookActivityFieldName,\n 'crmActivity' => $crmActivity['id'],\n 'source' => $activityTypeSource,\n ]);\n\n return $playbookCategory->getId();\n }\n\n $this->logger->info('[Hubspot] Fetch related activity - Playbook category not found', [\n 'activityId' => $activity->getUuid(),\n 'activityFieldName' => $playbookActivityFieldName,\n 'crmActivity' => $crmActivity['id'],\n ]);\n\n return null;\n }\n\n private function updateActivity(Activity $activity, array $data): bool\n {\n $activityRepository = app(ActivityRepository::class);\n\n $alreadyLoggedActivity = $activityRepository->findUserActivityByCrmProviderId(\n $activity->getUser(),\n $data['crm_provider_id']\n );\n\n if ($alreadyLoggedActivity instanceof Activity) {\n $this->logger->info('[Hubspot] Fetch related activity - activity already linked', [\n 'currentActivityId' => $activity->getUuid(),\n 'linkedActivityId' => $alreadyLoggedActivity->getUuid(),\n 'crmActivityId' => $data['crm_provider_id'],\n ]);\n\n // Return false to prevent updating the remote CRM engagement. This can happen when two Jiminny\n // activities share the same calendar event (e.g., rescheduled meetings). Without this, the first\n // activity's summary would be overwritten by the second activity.\n return false;\n }\n\n try {\n $activityRepository->update($activity, $data);\n } catch (Exception $e) {\n $this->logger->error('[Hubspot] Fetch related activity - activity update failed', [\n 'activityId' => $activity->getUuid(),\n 'reason' => $e->getMessage(),\n ]);\n\n return false;\n }\n\n $this->logger->info('[Hubspot] Fetch related activity - activity updated', [\n 'activityId' => $activity->getUuid(),\n 'data' => $data,\n ]);\n\n return true;\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":true,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Project","depth":3,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Project","depth":3,"bounds":{"left":0.011968086,"top":0.047885075,"width":0.024268618,"height":0.024740623},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"New File or Directory…","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Expand Selected","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Collapse All","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Options","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"app ~/jiminny/app, folder","depth":6,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":".circleci, folder","depth":7,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":".cursor, folder","depth":7,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":".github","depth":7,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":".sonarlint, folder","depth":7,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":".vscode, folder","depth":7,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":".windsurf, folder","depth":7,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"app, sources root","depth":7,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Actions, folder","depth":8,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Component, folder","depth":8,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Acl, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"ActionItems, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Activity, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"ActivityAnalytics, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"ActivitySearch, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"AiActivityType, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"AiAutomation, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"AiCallScoring, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"AskAnything, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Dtos, folder","depth":10,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Events, folder","depth":10,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"AskAnythingPromptService.php, class","depth":10,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"HistoryService.php, class","depth":10,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"AskJiminnyAi, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"AWS, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"BillingManagement, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Cache, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"CoachingFeedback, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Country, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"CustomerApi, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Database, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Datadog, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"DateTime, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"DealInsights, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"DealRisks, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"ElasticSearch, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Eloquent, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Encoding, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Encryption, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"ES, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Faker, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"FeatureFlags, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"FFMpeg, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"FileSystem, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Gecko, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Gong, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"GuzzleHttp, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"KeyPoints, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Kiosk, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"LanguageDetection, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"LiveFeed, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Locks, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Math, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"MediaPipeline, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"MeetingBot, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"MobileSettings, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Model, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Notification, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Nudge, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"ParagraphBreaker, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"ParticipantSpeech, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"PartitionedCookie, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"PlaybackPage, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Playlist, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Prophet, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"ProphetAi, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"ProsperWorks, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Queue, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Router, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Saml2, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"SCIM, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Seeder, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Sentry, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Serializer, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Settings, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Sidekick, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Slack, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"TeamInsights, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"TimeMemoryMapper, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Transcription, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"TranscriptionSummary, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Twilio, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Uploader, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"UrlGenerator, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Utility, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Exceptions, folder","depth":10,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Service, folder","depth":10,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"BaseRateLimiter.php, class","depth":11,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"EfficientJsonParser.php, class","depth":11,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"ProviderRateLimiter.php, class","depth":11,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"RateLimiterInstance.php, class","depth":11,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Uuid","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Waveform","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Webhooks","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Workflow","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Configuration","depth":8,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Console","depth":8,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Commands","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Activities","depth":10,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Analytics","depth":10,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Calendars","depth":10,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Crm","depth":10,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Hubspot","depth":11,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"IntegrationApp","depth":11,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Traits","depth":11,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"AddLayoutEntities.php, class","depth":11,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"AutologDelayedCommand.php, class","depth":11,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"BullhornCommandAbstract.php, abstract class","depth":11,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"BullhornPingCommand.php, class","depth":11,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"BullhornSearchCommand.php, class","depth":11,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"BullhornSessionCommand.php, class","depth":11,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"CheckActivityLoggableCommand.php, final class","depth":11,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"CleanDuplicateFieldDataCommand.php, class","depth":11,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"FullSyncOpportunityCommand.php, class","depth":11,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"LogActivitiesCommand.php, final class","depth":11,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"ManageSyncStrategyCommand.php, class","depth":11,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"MatchCrmObjectsCommand.php, class","depth":11,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"MatchOpportunityActivitiesCommand.php, class","depth":11,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"MigrateProvider.php, class","depth":11,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"ProcessHubspotObjectsSyncBatches.php, class","depth":11,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"PurgeDeletedOpportunitiesCommand.php, class","depth":11,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"ResetGovernorLimits.php, class","depth":11,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"SendNotLogged.php, class","depth":11,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"SetupActivityTypeForFollowUp.php, final class","depth":11,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"SetupCloseCrm.php, class","depth":11,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"SetupCopperCrm.php, class","depth":11,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"SetupCrmCommand.php, abstract class","depth":11,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"SetupLayouts.php, class","depth":11,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"SyncAccount.php, class","depth":11,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"SyncContact.php, class","depth":11,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"SyncFieldMetadata.php, class","depth":11,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"SyncHubspotActiveDeals.php, class","depth":11,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"SyncHubspotObjects.php, class","depth":11,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"SyncLead.php, class","depth":11,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"SyncObjects.php, class","depth":11,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"SyncOpportunitiesMissingFieldDataCommand.php, class","depth":11,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"SyncOpportunity.php, class","depth":11,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"SyncProfileMetadata.php, class","depth":11,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"SyncTeamMetadata.php, class","depth":11,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"UpdateOpportunitySpecifications.php, class","depth":11,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"DealInsights, folder","depth":10,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Dev, folder","depth":10,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Dialers, folder","depth":10,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"DTOs, folder","depth":10,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Elasticsearch, folder","depth":10,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"EngagementStats, folder","depth":10,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"GeckoExport, folder","depth":10,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Livestream, folder","depth":10,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Mailboxes, folder","depth":10,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Migrate, folder","depth":10,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"PlaybackThemes, folder","depth":10,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Playbooks, folder","depth":10,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Playlists, folder","depth":10,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Postmark, folder","depth":10,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"ProphetAi, folder","depth":10,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Reports, folder","depth":10,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"AutomatedReportsCommand.php, class","depth":11,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"AutomatedReportsRetentionPolicyCommand.php, class","depth":11,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"AutomatedReportsSendCommand.php, class","depth":11,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"CreateMockAskJiminnyReportResultCommand.php, class","depth":11,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"DeleteReportCommand.php, class","depth":11,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"GenerateMarketingReport.php, class","depth":11,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Team.php, class","depth":11,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Usage.php, class","depth":11,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Slack, folder","depth":10,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Teams, folder","depth":10,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Tracks, folder","depth":10,"on_screen":false,"role_description":"text"}]...
|
-9058662698352960174
|
-2224747446103676860
|
click
|
accessibility
|
NULL
|
Project: faVsco.js, menu
JY-20725-handle-HS-search Project: faVsco.js, menu
JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
19
Previous Highlighted Error
Next Highlighted Error
[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {
"headers":{
"Date":["Thu,07 May 2026 14:21:15 GMT"],
"Content-Type":["application/json;charset=utf-8"],
"Transfer-Encoding":["chunked"],
"Connection":["keep-alive"],
"CF-Ray":["9f80deb8db60dc3a-SOF"],
"CF-Cache-Status":["DYNAMIC"],
"Strict-Transport-Security":["max-age=31536000; includeSubDomains; preload"],
"Vary":["origin,
accept-encoding"],
"access-control-allow-credentials":["false"],
"server-timing":["hcid;desc=\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\",
cfr;desc=\"9f80deb8e7c6dc3a-IAD\""],
"x-content-type-options":["nosniff"],
"x-hubspot-correlation-id":["019e02d0-6fd8-7812-bdba-885b7ccb3ee3"],
"Set-Cookie":["__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-[IP_ADDRESS]-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,
07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None"],
"Report-To":["{
\"endpoints\":[{
\"url\":\"https:\\/\\/a.nel.cloudflare.com\\/report\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\"}],
\"group\":\"cf-nel\",
\"max_age\":604800}"],
"NEL":["{
\"success_fraction\":0.01,
\"report_to\":\"cf-nel\",
\"max_age\":604800}"],
"Server":["cloudflare"]}} {
"correlation_id":"95236535-ec98-4541-b92a-adfa73b69eab",
"trace_id":"c7ab8365-903f-46d4-9403-0e5b551e3545"}
Sync Changes
Hide This Notification
Code changed:
Hide
3
11
Previous Highlighted Error
Next Highlighted Error
<?php
declare(strict_types=1);
namespace Jiminny\Services\Crm\Hubspot;
use Carbon\Carbon;
use Exception;
use HubSpot\Client\Crm\Objects\ApiException;
use Jiminny\Integrations\PlaybookResolver;
use Jiminny\Models\Activity;
use Jiminny\Models\Calendar\CalendarEvent;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\Profile;
use Jiminny\Models\Playbook;
use Jiminny\Models\PlaybookCategory;
use Jiminny\Repositories\ActivityRepository;
use Jiminny\Repositories\PlaybookCategoryRepository;
use Psr\Log\LoggerInterface;
class SyncRelatedActivityManager
{
public const string OBJECT_MEETING = 'meetings';
public const string DEFAULT_ACTIVITY_TYPE = 'hs_activity_type';
private RemoteCrmObjectManipulator $crmManipulator;
public function __construct(
private readonly Client $client,
private readonly PayloadBuilder $payloadBuilder,
private readonly LoggerInterface $logger
) {
$this->crmManipulator = app(RemoteCrmObjectManipulator::class, [
'client' => $this->client,
'logger' => $this->logger,
]);
}
public function fetchAndAssociateRelatedActivity(Activity $activity): ?Activity
{
// Validate if the activity is applicable.
if ($this->isActivityApplicable($activity) === false) {
$this->logger->info('[Hubspot] Fetch related activity - not applicable', [
'activityId' => $activity->getUuid(),
]);
return null;
}
// Find and fetch related activity.
try {
$crmActivity = $this->fetchRelatedActivity($activity);
if (empty($crmActivity)) {
return null;
}
} catch (Exception $exception) {
$this->logger->error('[Hubspot] Fetch related activity - failed', [
'activityId' => $activity->getUuid(),
'reason' => $exception->getMessage(),
]);
throw $exception;
}
// get playbook category id
$playbookCategoryId = $this->getPlaybookCategoryId($activity, $crmActivity);
$data = [
'crm_provider_id' => $crmActivity['id'],
'playbook_category_id' => $playbookCategoryId,
];
if (! $this->updateActivity($activity, $data)) {
return null;
}
$this->crmManipulator->updateRelatedCrmActivity($activity, $crmActivity);
$activity->refresh();
return $activity;
}
private function isActivityApplicable(Activity $activity): bool
{
if (! $activity->isTypeConference()) {
return false;
}
if (! $this->hasMinimumFilterRequirements($activity)) {
$this->logger->info('[Hubspot] Fetch related activity - not enough filter data', [
'activityId' => $activity->getUuid(),
]);
return false;
}
// The related activity is already linked.
if ($activity->hasCrmProviderId()) {
return false;
}
if (! $activity->getUser()->getProfile() instanceof Profile) {
$this->logger->info('[Hubspot] Fetch related activity - activity user has no crm profile', [
'activityId' => $activity->getUuid(),
]);
return false;
}
return true;
}
private function hasMinimumFilterRequirements(Activity $activity): bool
{
// We need to know when it started to determine when to look for related activity.
if (! $activity->hasActualStartTime()) {
return false;
}
if (! $activity->hasActualEndTime()) {
return false;
}
// At least one prospect should be associated to the activity
if ($activity->hasContact() || $activity->hasAccount()) {
return true;
}
return false;
}
public function fetchRelatedActivity(Activity $activity): array
{
$this->logger->info('[Hubspot] Fetch related activity - searching related CRM activity', [
'activityId' => $activity->getUuid(),
]);
$payloadData = $this->getPayloadFilterData($activity);
try {
$payload = $this->payloadBuilder->getFindRelatedActivityPayload($payloadData, self::OBJECT_MEETING);
$response = $this->client->getPaginatedData($payload, self::OBJECT_MEETING);
$crmActivities = $response['results'];
} catch (Exception $e) {
$this->logger->error('[Hubspot] Fetch related activity - failed', [
'activityId' => $activity->getUuid(),
'reason' => $e->getMessage(),
]);
throw $e;
}
if (empty($crmActivities)) {
$this->logger->info('[Hubspot] Fetch related activity - no CRM related activity found', [
'activityId' => $activity->getUuid(),
'payload' => $payload,
]);
return [];
}
$relatedActivity = $this->getBestCrmActivityCandidate($crmActivities, $payloadData);
if (empty($relatedActivity)) {
$this->logger->info('[Hubspot] Fetch related activity - no related activity candidate', [
'activityId' => $activity->getUuid(),
]);
return [];
}
$this->logger->info('[Hubspot] Fetch related activity - found related CRM activity', [
'activityId' => $activity->getUuid(),
'crmActivity' => $relatedActivity,
]);
return $relatedActivity;
}
/**
* @param Activity $activity
*
* @return array
*/
protected function getPayloadFilterData(Activity $activity): array
{
[$from, $to] = $this->getActivityTimeFilterRange($activity);
$data = [
'from' => $from->getPreciseTimestamp(3),
'to' => $to->getPreciseTimestamp(3),
'contact' => $activity->hasContact() ? $activity->getContact()->getCrmProviderId() : null,
'company' => $activity->hasAccount() ? $activity->getAccount()->getCrmProviderId() : null,
'deal' => $activity->hasOpportunity() ? $activity->getOpportunity()->getCrmProviderId() : null,
'owner' => $activity->getUser()->getProfile()->getCrmProviderId(),
'activity' => $activity->getUuid(),
];
return array_filter($data);
}
private function getActivityTimeFilterRange(Activity $activity): array
{
if ($activity->hasScheduledStartTime() && $activity->hasScheduledEndTime()) {
$calendarEvent = $activity->getCalendarEvent();
if ($calendarEvent instanceof CalendarEvent) {
$from = $calendarEvent->getStartTime();
$to = $calendarEvent->getEndTime()->addMinutes(15);
} else {
$from = Carbon::instance($activity->getScheduledStartTime());
$to = Carbon::instance($activity->getScheduledEndTime())->addMinutes(15);
}
return [$from, $to];
}
$from = Carbon::instance($activity->getActualStartTime());
$to = Carbon::instance($activity->getActualEndTime());
return [$from, $to];
}
/**
* Determines the best CRM activity candidate based on the number of matching prospects and the owner.
*
* @param array $crmActivities List of CRM activities to evaluate.
* @param array $data Data containing the prospects and owner information.
*
* @return array The best CRM activity candidate.
*/
private function getBestCrmActivityCandidate(array $crmActivities, array $data): array
{
$prospectCount = count(array_intersect_key($data, array_flip(['contact', 'company', 'deal'])));
$bestCandidate = null;
$maxMatchingProspects = 0;
$validActivitiesCount = count($crmActivities);
if ($validActivitiesCount > 1) {
$this->logger->info('[Hubspot] Fetch related activity - determining the best candidate', [
'activityId' => $data['activity'],
'activitiesCount' => $validActivitiesCount,
'crmActivities' => $crmActivities,
]);
}
foreach ($crmActivities as $crmActivity) {
if ($this->isOwnerMismatch($crmActivity, $data)) {
$validActivitiesCount--;
continue;
}
if ($validActivitiesCount === 1) {
return $crmActivity;
}
if ($prospectCount > 1) {
$matchingProspects = $this->countMatchingProspects($crmActivity, $data);
if ($matchingProspects > $maxMatchingProspects) {
$maxMatchingProspects = $matchingProspects;
$bestCandidate = $crmActivity;
}
} else {
return $crmActivity;
}
}
return $bestCandidate ?? [];
}
private function isOwnerMismatch(array $crmActivity, array $data): bool
{
if ($crmActivity['properties']['hubspot_owner_id'] !== $data['owner']) {
$this->logger->info('[Hubspot] Fetch related activity - owner mismatch', [
'activityId' => $data['activity'],
'crmActivity' => $crmActivity['id'],
'jiminnyOwner' => $data['owner'],
'hubspotOwner' => $crmActivity['properties']['hubspot_owner_id'],
]);
return true;
}
return false;
}
private function countMatchingProspects(array $crmActivity, array $data): int
{
try {
$crmActivityWithAssociations = $this->client->getMeeting($crmActivity['id']);
} catch (ApiException $e) {
$this->logger->error('[Hubspot] Fetch related activity - failed to get meeting associations', [
'activityId' => $data['activity'],
'crmActivity' => $crmActivity['id'],
'reason' => $e->getMessage(),
]);
return 0;
}
$associations = $crmActivityWithAssociations->getAssociations();
$matchingProspects = 0;
$matchingProspects += $this->countMatchingAssociation($data, $associations, 'company', 'companies');
$matchingProspects += $this->countMatchingAssociation($data, $associations, 'contact', 'contacts');
$matchingProspects += $this->countMatchingAssociation($data, $associations, 'deal', 'deals');
return $matchingProspects;
}
private function countMatchingAssociation(array $data, array $associations, string $dataKey, string $associationKey): int
{
if (isset($data[$dataKey]) && isset($associations[$associationKey])) {
$associatedItems = $associations[$associationKey]->getResults();
foreach ($associatedItems as $associatedItem) {
if ($data[$dataKey] == $associatedItem->getId()) {
return 1;
}
}
}
return 0;
}
private function getPlaybookCategoryId(Activity $activity, array $crmActivity): ?int
{
if (! $activity->hasUser()) {
$this->logger->info('[Hubspot] Fetch related activity - activity has no user', [
'activityId' => $activity->getUuid(),
]);
return null;
}
$playbookResolver = app(PlaybookResolver::class);
$playbook = $playbookResolver->resolvePlaybookByUser($activity->getUser());
if (! $playbook instanceof Playbook) {
$this->logger->info('[Hubspot] Fetch related activity - playbook not set for user', [
'activityId' => $activity->getUuid(),
]);
return null;
}
if (! $playbook->getActivityField() instanceof Field) {
$this->logger->info('[Hubspot] Fetch related activity - playbook activity field not set', [
'activityId' => $activity->getUuid(),
]);
return null;
}
$playbookCategory = null;
$activityTypeSource = null;
$playbookActivityFieldName = $playbook->getActivityField()->getCrmProviderId();
if ($playbookActivityFieldName === 'activityType') {
$playbookActivityFieldName = self::DEFAULT_ACTIVITY_TYPE;
}
// First attempt to match playbook category from crm activity
if (! empty($crmActivity['properties'][$playbookActivityFieldName])) {
$playbookCategoryRepository = app(PlaybookCategoryRepository::class);
$playbookCategory = $playbookCategoryRepository->getPlaybookCategoryByName(
$playbook,
strval($crmActivity['properties'][$playbookActivityFieldName])
);
$activityTypeSource = 'crm';
}
// If not matched get playbook category from Jiminny activity
if (! $playbookCategory instanceof PlaybookCategory && $activity->hasActivityType()) {
$playbookCategory = $activity->getActivityType();
$activityTypeSource = 'jiminny';
}
if ($playbookCategory instanceof PlaybookCategory) {
$this->logger->info('[Hubspot] Fetch related activity - Playbook category matched', [
'activityId' => $activity->getUuid(),
'playbookCategoryId' => $playbookCategory->getId(),
'activityFieldName' => $playbookActivityFieldName,
'crmActivity' => $crmActivity['id'],
'source' => $activityTypeSource,
]);
return $playbookCategory->getId();
}
$this->logger->info('[Hubspot] Fetch related activity - Playbook category not found', [
'activityId' => $activity->getUuid(),
'activityFieldName' => $playbookActivityFieldName,
'crmActivity' => $crmActivity['id'],
]);
return null;
}
private function updateActivity(Activity $activity, array $data): bool
{
$activityRepository = app(ActivityRepository::class);
$alreadyLoggedActivity = $activityRepository->findUserActivityByCrmProviderId(
$activity->getUser(),
$data['crm_provider_id']
);
if ($alreadyLoggedActivity instanceof Activity) {
$this->logger->info('[Hubspot] Fetch related activity - activity already linked', [
'currentActivityId' => $activity->getUuid(),
'linkedActivityId' => $alreadyLoggedActivity->getUuid(),
'crmActivityId' => $data['crm_provider_id'],
]);
// Return false to prevent updating the remote CRM engagement. This can happen when two Jiminny
// activities share the same calendar event (e.g., rescheduled meetings). Without this, the first
// activity's summary would be overwritten by the second activity.
return false;
}
try {
$activityRepository->update($activity, $data);
} catch (Exception $e) {
$this->logger->error('[Hubspot] Fetch related activity - activity update failed', [
'activityId' => $activity->getUuid(),
'reason' => $e->getMessage(),
]);
return false;
}
$this->logger->info('[Hubspot] Fetch related activity - activity updated', [
'activityId' => $activity->getUuid(),
'data' => $data,
]);
return true;
}
}
Project
Project
New File or Directory…
Expand Selected
Collapse All
Options
Hide
app ~/jiminny/app, folder
.circleci, folder
.cursor, folder
.github
.sonarlint, folder
.vscode, folder
.windsurf, folder
app, sources root
Actions, folder
Component, folder
Acl, folder
ActionItems, folder
Activity, folder
ActivityAnalytics, folder
ActivitySearch, folder
AiActivityType, folder
AiAutomation, folder
AiCallScoring, folder
AskAnything, folder
Dtos, folder
Events, folder
AskAnythingPromptService.php, class
HistoryService.php, class
AskJiminnyAi, folder
AWS, folder
BillingManagement, folder
Cache, folder
CoachingFeedback, folder
Country, folder
CustomerApi, folder
Database, folder
Datadog, folder
DateTime, folder
DealInsights, folder
DealRisks, folder
ElasticSearch, folder
Eloquent, folder
Encoding, folder
Encryption, folder
ES, folder
Faker, folder
FeatureFlags, folder
FFMpeg, folder
FileSystem, folder
Gecko, folder
Gong, folder
GuzzleHttp, folder
KeyPoints, folder
Kiosk, folder
LanguageDetection, folder
LiveFeed, folder
Locks, folder
Math, folder
MediaPipeline, folder
MeetingBot, folder
MobileSettings, folder
Model, folder
Notification, folder
Nudge, folder
ParagraphBreaker, folder
ParticipantSpeech, folder
PartitionedCookie, folder
PlaybackPage, folder
Playlist, folder
Prophet, folder
ProphetAi, folder
ProsperWorks, folder
Queue, folder
Router, folder
Saml2, folder
SCIM, folder
Seeder, folder
Sentry, folder
Serializer, folder
Settings, folder
Sidekick, folder
Slack, folder
TeamInsights, folder
TimeMemoryMapper, folder
Transcription, folder
TranscriptionSummary, folder
Twilio, folder
Uploader, folder
UrlGenerator, folder
Utility, folder
Exceptions, folder
Service, folder
BaseRateLimiter.php, class
EfficientJsonParser.php, class
ProviderRateLimiter.php, class
RateLimiterInstance.php, class
Uuid
Waveform
Webhooks
Workflow
Configuration
Console
Commands
Activities
Analytics
Calendars
Crm
Hubspot
IntegrationApp
Traits
AddLayoutEntities.php, class
AutologDelayedCommand.php, class
BullhornCommandAbstract.php, abstract class
BullhornPingCommand.php, class
BullhornSearchCommand.php, class
BullhornSessionCommand.php, class
CheckActivityLoggableCommand.php, final class
CleanDuplicateFieldDataCommand.php, class
FullSyncOpportunityCommand.php, class
LogActivitiesCommand.php, final class
ManageSyncStrategyCommand.php, class
MatchCrmObjectsCommand.php, class
MatchOpportunityActivitiesCommand.php, class
MigrateProvider.php, class
ProcessHubspotObjectsSyncBatches.php, class
PurgeDeletedOpportunitiesCommand.php, class
ResetGovernorLimits.php, class
SendNotLogged.php, class
SetupActivityTypeForFollowUp.php, final class
SetupCloseCrm.php, class
SetupCopperCrm.php, class
SetupCrmCommand.php, abstract class
SetupLayouts.php, class
SyncAccount.php, class
SyncContact.php, class
SyncFieldMetadata.php, class
SyncHubspotActiveDeals.php, class
SyncHubspotObjects.php, class
SyncLead.php, class
SyncObjects.php, class
SyncOpportunitiesMissingFieldDataCommand.php, class
SyncOpportunity.php, class
SyncProfileMetadata.php, class
SyncTeamMetadata.php, class
UpdateOpportunitySpecifications.php, class
DealInsights, folder
Dev, folder
Dialers, folder
DTOs, folder
Elasticsearch, folder
EngagementStats, folder
GeckoExport, folder
Livestream, folder
Mailboxes, folder
Migrate, folder
PlaybackThemes, folder
Playbooks, folder
Playlists, folder
Postmark, folder
ProphetAi, folder
Reports, folder
AutomatedReportsCommand.php, class
AutomatedReportsRetentionPolicyCommand.php, class
AutomatedReportsSendCommand.php, class
CreateMockAskJiminnyReportResultCommand.php, class
DeleteReportCommand.php, class
GenerateMarketingReport.php, class
Team.php, class
Usage.php, class
Slack, folder
Teams, folder
Tracks, folder...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
9836
|
443
|
19
|
2026-05-08T13:37:17.743881+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-08/1778 /Users/lukas/.screenpipe/data/data/2026-05-08/1778247437743_m1.jpg...
|
PhpStorm
|
faVsco.js – SyncRelatedActivityManager.php
|
1
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
JY-20725-handle-HS-search Project: faVsco.js, menu
JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
19
Previous Highlighted Error
Next Highlighted Error
[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {
"headers":{
"Date":["Thu,07 May 2026 14:21:15 GMT"],
"Content-Type":["application/json;charset=utf-8"],
"Transfer-Encoding":["chunked"],
"Connection":["keep-alive"],
"CF-Ray":["9f80deb8db60dc3a-SOF"],
"CF-Cache-Status":["DYNAMIC"],
"Strict-Transport-Security":["max-age=31536000; includeSubDomains; preload"],
"Vary":["origin,
accept-encoding"],
"access-control-allow-credentials":["false"],
"server-timing":["hcid;desc=\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\",
cfr;desc=\"9f80deb8e7c6dc3a-IAD\""],
"x-content-type-options":["nosniff"],
"x-hubspot-correlation-id":["019e02d0-6fd8-7812-bdba-885b7ccb3ee3"],
"Set-Cookie":["__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-[IP_ADDRESS]-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,
07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None"],
"Report-To":["{
\"endpoints\":[{
\"url\":\"https:\\/\\/a.nel.cloudflare.com\\/report\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\"}],
\"group\":\"cf-nel\",
\"max_age\":604800}"],
"NEL":["{
\"success_fraction\":0.01,
\"report_to\":\"cf-nel\",
\"max_age\":604800}"],
"Server":["cloudflare"]}} {
"correlation_id":"95236535-ec98-4541-b92a-adfa73b69eab",
"trace_id":"c7ab8365-903f-46d4-9403-0e5b551e3545"}
Sync Changes
Hide This Notification
Code changed:
Hide
3
11
Previous Highlighted Error
Next Highlighted Error
<?php
declare(strict_types=1);
namespace Jiminny\Services\Crm\Hubspot;
use Carbon\Carbon;
use Exception;
use HubSpot\Client\Crm\Objects\ApiException;
use Jiminny\Integrations\PlaybookResolver;
use Jiminny\Models\Activity;
use Jiminny\Models\Calendar\CalendarEvent;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\Profile;
use Jiminny\Models\Playbook;
use Jiminny\Models\PlaybookCategory;
use Jiminny\Repositories\ActivityRepository;
use Jiminny\Repositories\PlaybookCategoryRepository;
use Psr\Log\LoggerInterface;
class SyncRelatedActivityManager
{
public const string OBJECT_MEETING = 'meetings';
public const string DEFAULT_ACTIVITY_TYPE = 'hs_activity_type';
private RemoteCrmObjectManipulator $crmManipulator;
public function __construct(
private readonly Client $client,
private readonly PayloadBuilder $payloadBuilder,
private readonly LoggerInterface $logger
) {
$this->crmManipulator = app(RemoteCrmObjectManipulator::class, [
'client' => $this->client,
'logger' => $this->logger,
]);
}
public function fetchAndAssociateRelatedActivity(Activity $activity): ?Activity
{
// Validate if the activity is applicable.
if ($this->isActivityApplicable($activity) === false) {
$this->logger->info('[Hubspot] Fetch related activity - not applicable', [
'activityId' => $activity->getUuid(),
]);
return null;
}
// Find and fetch related activity.
try {
$crmActivity = $this->fetchRelatedActivity($activity);
if (empty($crmActivity)) {
return null;
}
} catch (Exception $exception) {
$this->logger->error('[Hubspot] Fetch related activity - failed', [
'activityId' => $activity->getUuid(),
'reason' => $exception->getMessage(),
]);
throw $exception;
}
// get playbook category id
$playbookCategoryId = $this->getPlaybookCategoryId($activity, $crmActivity);
$data = [
'crm_provider_id' => $crmActivity['id'],
'playbook_category_id' => $playbookCategoryId,
];
if (! $this->updateActivity($activity, $data)) {
return null;
}
$this->crmManipulator->updateRelatedCrmActivity($activity, $crmActivity);
$activity->refresh();
return $activity;
}
private function isActivityApplicable(Activity $activity): bool
{
if (! $activity->isTypeConference()) {
return false;
}
if (! $this->hasMinimumFilterRequirements($activity)) {
$this->logger->info('[Hubspot] Fetch related activity - not enough filter data', [
'activityId' => $activity->getUuid(),
]);
return false;
}
// The related activity is already linked.
if ($activity->hasCrmProviderId()) {
return false;
}
if (! $activity->getUser()->getProfile() instanceof Profile) {
$this->logger->info('[Hubspot] Fetch related activity - activity user has no crm profile', [
'activityId' => $activity->getUuid(),
]);
return false;
}
return true;
}
private function hasMinimumFilterRequirements(Activity $activity): bool
{
// We need to know when it started to determine when to look for related activity.
if (! $activity->hasActualStartTime()) {
return false;
}
if (! $activity->hasActualEndTime()) {
return false;
}
// At least one prospect should be associated to the activity
if ($activity->hasContact() || $activity->hasAccount()) {
return true;
}
return false;
}
public function fetchRelatedActivity(Activity $activity): array
{
$this->logger->info('[Hubspot] Fetch related activity - searching related CRM activity', [
'activityId' => $activity->getUuid(),
]);
$payloadData = $this->getPayloadFilterData($activity);
try {
$payload = $this->payloadBuilder->getFindRelatedActivityPayload($payloadData, self::OBJECT_MEETING);
$response = $this->client->getPaginatedData($payload, self::OBJECT_MEETING);
$crmActivities = $response['results'];
} catch (Exception $e) {
$this->logger->error('[Hubspot] Fetch related activity - failed', [
'activityId' => $activity->getUuid(),
'reason' => $e->getMessage(),
]);
throw $e;
}
if (empty($crmActivities)) {
$this->logger->info('[Hubspot] Fetch related activity - no CRM related activity found', [
'activityId' => $activity->getUuid(),
'payload' => $payload,
]);
return [];
}
$relatedActivity = $this->getBestCrmActivityCandidate($crmActivities, $payloadData);
if (empty($relatedActivity)) {
$this->logger->info('[Hubspot] Fetch related activity - no related activity candidate', [
'activityId' => $activity->getUuid(),
]);
return [];
}
$this->logger->info('[Hubspot] Fetch related activity - found related CRM activity', [
'activityId' => $activity->getUuid(),
'crmActivity' => $relatedActivity,
]);
return $relatedActivity;
}
/**
* @param Activity $activity
*
* @return array
*/
protected function getPayloadFilterData(Activity $activity): array
{
[$from, $to] = $this->getActivityTimeFilterRange($activity);
$data = [
'from' => $from->getPreciseTimestamp(3),
'to' => $to->getPreciseTimestamp(3),
'contact' => $activity->hasContact() ? $activity->getContact()->getCrmProviderId() : null,
'company' => $activity->hasAccount() ? $activity->getAccount()->getCrmProviderId() : null,
'deal' => $activity->hasOpportunity() ? $activity->getOpportunity()->getCrmProviderId() : null,
'owner' => $activity->getUser()->getProfile()->getCrmProviderId(),
'activity' => $activity->getUuid(),
];
return array_filter($data);
}
private function getActivityTimeFilterRange(Activity $activity): array
{
if ($activity->hasScheduledStartTime() && $activity->hasScheduledEndTime()) {
$calendarEvent = $activity->getCalendarEvent();
if ($calendarEvent instanceof CalendarEvent) {
$from = $calendarEvent->getStartTime();
$to = $calendarEvent->getEndTime()->addMinutes(15);
} else {
$from = Carbon::instance($activity->getScheduledStartTime());
$to = Carbon::instance($activity->getScheduledEndTime())->addMinutes(15);
}
return [$from, $to];
}
$from = Carbon::instance($activity->getActualStartTime());
$to = Carbon::instance($activity->getActualEndTime());
return [$from, $to];
}
/**
* Determines the best CRM activity candidate based on the number of matching prospects and the owner.
*
* @param array $crmActivities List of CRM activities to evaluate.
* @param array $data Data containing the prospects and owner information.
*
* @return array The best CRM activity candidate.
*/
private function getBestCrmActivityCandidate(array $crmActivities, array $data): array
{
$prospectCount = count(array_intersect_key($data, array_flip(['contact', 'company', 'deal'])));
$bestCandidate = null;
$maxMatchingProspects = 0;
$validActivitiesCount = count($crmActivities);
if ($validActivitiesCount > 1) {
$this->logger->info('[Hubspot] Fetch related activity - determining the best candidate', [
'activityId' => $data['activity'],
'activitiesCount' => $validActivitiesCount,
'crmActivities' => $crmActivities,
]);
}
foreach ($crmActivities as $crmActivity) {
if ($this->isOwnerMismatch($crmActivity, $data)) {
$validActivitiesCount--;
continue;
}
if ($validActivitiesCount === 1) {
return $crmActivity;
}
if ($prospectCount > 1) {
$matchingProspects = $this->countMatchingProspects($crmActivity, $data);
if ($matchingProspects > $maxMatchingProspects) {
$maxMatchingProspects = $matchingProspects;
$bestCandidate = $crmActivity;
}
} else {
return $crmActivity;
}
}
return $bestCandidate ?? [];
}
private function isOwnerMismatch(array $crmActivity, array $data): bool
{
if ($crmActivity['properties']['hubspot_owner_id'] !== $data['owner']) {
$this->logger->info('[Hubspot] Fetch related activity - owner mismatch', [
'activityId' => $data['activity'],
'crmActivity' => $crmActivity['id'],
'jiminnyOwner' => $data['owner'],
'hubspotOwner' => $crmActivity['properties']['hubspot_owner_id'],
]);
return true;
}
return false;
}
private function countMatchingProspects(array $crmActivity, array $data): int
{
try {
$crmActivityWithAssociations = $this->client->getMeeting($crmActivity['id']);
} catch (ApiException $e) {
$this->logger->error('[Hubspot] Fetch related activity - failed to get meeting associations', [
'activityId' => $data['activity'],
'crmActivity' => $crmActivity['id'],
'reason' => $e->getMessage(),
]);
return 0;
}
$associations = $crmActivityWithAssociations->getAssociations();
$matchingProspects = 0;
$matchingProspects += $this->countMatchingAssociation($data, $associations, 'company', 'companies');
$matchingProspects += $this->countMatchingAssociation($data, $associations, 'contact', 'contacts');
$matchingProspects += $this->countMatchingAssociation($data, $associations, 'deal', 'deals');
return $matchingProspects;
}
private function countMatchingAssociation(array $data, array $associations, string $dataKey, string $associationKey): int
{
if (isset($data[$dataKey]) && isset($associations[$associationKey])) {
$associatedItems = $associations[$associationKey]->getResults();
foreach ($associatedItems as $associatedItem) {
if ($data[$dataKey] == $associatedItem->getId()) {
return 1;
}
}
}
return 0;
}
private function getPlaybookCategoryId(Activity $activity, array $crmActivity): ?int
{
if (! $activity->hasUser()) {
$this->logger->info('[Hubspot] Fetch related activity - activity has no user', [
'activityId' => $activity->getUuid(),
]);
return null;
}
$playbookResolver = app(PlaybookResolver::class);
$playbook = $playbookResolver->resolvePlaybookByUser($activity->getUser());
if (! $playbook instanceof Playbook) {
$this->logger->info('[Hubspot] Fetch related activity - playbook not set for user', [
'activityId' => $activity->getUuid(),
]);
return null;
}
if (! $playbook->getActivityField() instanceof Field) {
$this->logger->info('[Hubspot] Fetch related activity - playbook activity field not set', [
'activityId' => $activity->getUuid(),
]);
return null;
}
$playbookCategory = null;
$activityTypeSource = null;
$playbookActivityFieldName = $playbook->getActivityField()->getCrmProviderId();
if ($playbookActivityFieldName === 'activityType') {
$playbookActivityFieldName = self::DEFAULT_ACTIVITY_TYPE;
}
// First attempt to match playbook category from crm activity
if (! empty($crmActivity['properties'][$playbookActivityFieldName])) {
$playbookCategoryRepository = app(PlaybookCategoryRepository::class);
$playbookCategory = $playbookCategoryRepository->getPlaybookCategoryByName(
$playbook,
strval($crmActivity['properties'][$playbookActivityFieldName])
);
$activityTypeSource = 'crm';
}
// If not matched get playbook category from Jiminny activity
if (! $playbookCategory instanceof PlaybookCategory && $activity->hasActivityType()) {
$playbookCategory = $activity->getActivityType();
$activityTypeSource = 'jiminny';
}
if ($playbookCategory instanceof PlaybookCategory) {
$this->logger->info('[Hubspot] Fetch related activity - Playbook category matched', [
'activityId' => $activity->getUuid(),
'playbookCategoryId' => $playbookCategory->getId(),
'activityFieldName' => $playbookActivityFieldName,
'crmActivity' => $crmActivity['id'],
'source' => $activityTypeSource,
]);
return $playbookCategory->getId();
}
$this->logger->info('[Hubspot] Fetch related activity - Playbook category not found', [
'activityId' => $activity->getUuid(),
'activityFieldName' => $playbookActivityFieldName,
'crmActivity' => $crmActivity['id'],
]);
return null;
}
private function updateActivity(Activity $activity, array $data): bool
{
$activityRepository = app(ActivityRepository::class);
$alreadyLoggedActivity = $activityRepository->findUserActivityByCrmProviderId(
$activity->getUser(),
$data['crm_provider_id']
);
if ($alreadyLoggedActivity instanceof Activity) {
$this->logger->info('[Hubspot] Fetch related activity - activity already linked', [
'currentActivityId' => $activity->getUuid(),
'linkedActivityId' => $alreadyLoggedActivity->getUuid(),
'crmActivityId' => $data['crm_provider_id'],
]);
// Return false to prevent updating the remote CRM engagement. This can happen when two Jiminny
// activities share the same calendar event (e.g., rescheduled meetings). Without this, the first
// activity's summary would be overwritten by the second activity.
return false;
}
try {
$activityRepository->update($activity, $data);
} catch (Exception $e) {
$this->logger->error('[Hubspot] Fetch related activity - activity update failed', [
'activityId' => $activity->getUuid(),
'reason' => $e->getMessage(),
]);
return false;
}
$this->logger->info('[Hubspot] Fetch related activity - activity updated', [
'activityId' => $activity->getUuid(),
'data' => $data,
]);
return true;
}
}
Project
Project
New File or Directory…
Expand Selected
Collapse All
Options
Hide
app ~/jiminny/app, folder
.circleci, folder
.cursor, folder
.github
.sonarlint, folder
.vscode, folder
.windsurf, folder
app, sources root
Actions, folder
Component, folder
Acl, folder
ActionItems, folder
Activity, folder
ActivityAnalytics, folder
ActivitySearch, folder
AiActivityType, folder
AiAutomation, folder
AiCallScoring, folder
AskAnything, folder
Dtos, folder
Events, folder
AskAnythingPromptService.php, class
HistoryService.php, class
AskJiminnyAi, folder
AWS, folder
BillingManagement, folder
Cache, folder
CoachingFeedback, folder
Country, folder
CustomerApi, folder
Database, folder
Datadog, folder
DateTime, folder
DealInsights, folder
DealRisks, folder
ElasticSearch, folder
Eloquent, folder
Encoding, folder
Encryption, folder
ES, folder
Faker, folder
FeatureFlags, folder
FFMpeg, folder
FileSystem, folder
Gecko, folder
Gong, folder
GuzzleHttp, folder
KeyPoints, folder
Kiosk, folder
LanguageDetection, folder
LiveFeed, folder
Locks, folder
Math, folder
MediaPipeline, folder
MeetingBot, folder
MobileSettings, folder
Model, folder
Notification, folder
Nudge, folder
ParagraphBreaker, folder
ParticipantSpeech, folder
PartitionedCookie, folder
PlaybackPage, folder
Playlist, folder
Prophet, folder
ProphetAi, folder
ProsperWorks, folder
Queue, folder
Router, folder
Saml2, folder
SCIM, folder
Seeder, folder
Sentry, folder
Serializer, folder
Settings, folder
Sidekick, folder
Slack, folder
TeamInsights, folder
TimeMemoryMapper, folder
Transcription, folder
TranscriptionSummary, folder
Twilio, folder
Uploader, folder
UrlGenerator, folder
Utility, folder
Exceptions, folder
Service, folder
BaseRateLimiter.php, class
EfficientJsonParser.php, class
ProviderRateLimiter.php, class
RateLimiterInstance.php, class
Uuid
Waveform
Webhooks
Workflow
Configuration...
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JY-20725-handle-HS-search-rate-limit, menu","depth":5,"on_screen":true,"help_text":"Git Branch: JY-20725-handle-HS-search-rate-limit","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"AskJiminnyReportActivityServiceTest","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'AskJiminnyReportActivityServiceTest'","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'AskJiminnyReportActivityServiceTest'","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"IDE and Project Settings","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.088194445,"height":0.027777778},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"19","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {\n\"headers\":{\n\"Date\":[\"Thu,07 May 2026 14:21:15 GMT\"],\n \"Content-Type\":[\"application/json;charset=utf-8\"],\n \"Transfer-Encoding\":[\"chunked\"],\n \"Connection\":[\"keep-alive\"],\n \"CF-Ray\":[\"9f80deb8db60dc3a-SOF\"],\n \"CF-Cache-Status\":[\"DYNAMIC\"],\n \"Strict-Transport-Security\":[\"max-age=31536000; includeSubDomains; preload\"],\n \"Vary\":[\"origin,\n accept-encoding\"],\n \"access-control-allow-credentials\":[\"false\"],\n \"server-timing\":[\"hcid;desc=\\\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\\\",\n cfr;desc=\\\"9f80deb8e7c6dc3a-IAD\\\"\"],\n \"x-content-type-options\":[\"nosniff\"],\n \"x-hubspot-correlation-id\":[\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\"],\n \"Set-Cookie\":[\"__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-1.0.1.1-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,\n 07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None\"],\n \"Report-To\":[\"{\n\\\"endpoints\\\":[{\n\\\"url\\\":\\\"https:\\\\/\\\\/a.nel.cloudflare.com\\\\/report\\\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\\\"}],\n\\\"group\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"NEL\":[\"{\n\\\"success_fraction\\\":0.01,\n\\\"report_to\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"Server\":[\"cloudflare\"]}} {\n\"correlation_id\":\"95236535-ec98-4541-b92a-adfa73b69eab\",\n\"trace_id\":\"c7ab8365-903f-46d4-9403-0e5b551e3545\"}","depth":4,"on_screen":true,"value":"[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {\n\"headers\":{\n\"Date\":[\"Thu,07 May 2026 14:21:15 GMT\"],\n \"Content-Type\":[\"application/json;charset=utf-8\"],\n \"Transfer-Encoding\":[\"chunked\"],\n \"Connection\":[\"keep-alive\"],\n \"CF-Ray\":[\"9f80deb8db60dc3a-SOF\"],\n \"CF-Cache-Status\":[\"DYNAMIC\"],\n \"Strict-Transport-Security\":[\"max-age=31536000; includeSubDomains; preload\"],\n \"Vary\":[\"origin,\n accept-encoding\"],\n \"access-control-allow-credentials\":[\"false\"],\n \"server-timing\":[\"hcid;desc=\\\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\\\",\n cfr;desc=\\\"9f80deb8e7c6dc3a-IAD\\\"\"],\n \"x-content-type-options\":[\"nosniff\"],\n \"x-hubspot-correlation-id\":[\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\"],\n \"Set-Cookie\":[\"__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-1.0.1.1-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,\n 07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None\"],\n \"Report-To\":[\"{\n\\\"endpoints\\\":[{\n\\\"url\\\":\\\"https:\\\\/\\\\/a.nel.cloudflare.com\\\\/report\\\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\\\"}],\n\\\"group\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"NEL\":[\"{\n\\\"success_fraction\\\":0.01,\n\\\"report_to\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"Server\":[\"cloudflare\"]}} {\n\"correlation_id\":\"95236535-ec98-4541-b92a-adfa73b69eab\",\n\"trace_id\":\"c7ab8365-903f-46d4-9403-0e5b551e3545\"}","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.088194445,"height":0.027777778},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"3","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"11","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\ndeclare(strict_types=1);\n\nnamespace Jiminny\\Services\\Crm\\Hubspot;\n\nuse Carbon\\Carbon;\nuse Exception;\nuse HubSpot\\Client\\Crm\\Objects\\ApiException;\nuse Jiminny\\Integrations\\PlaybookResolver;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Calendar\\CalendarEvent;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Models\\Crm\\Profile;\nuse Jiminny\\Models\\Playbook;\nuse Jiminny\\Models\\PlaybookCategory;\nuse Jiminny\\Repositories\\ActivityRepository;\nuse Jiminny\\Repositories\\PlaybookCategoryRepository;\nuse Psr\\Log\\LoggerInterface;\n\nclass SyncRelatedActivityManager\n{\n public const string OBJECT_MEETING = 'meetings';\n public const string DEFAULT_ACTIVITY_TYPE = 'hs_activity_type';\n private RemoteCrmObjectManipulator $crmManipulator;\n\n public function __construct(\n private readonly Client $client,\n private readonly PayloadBuilder $payloadBuilder,\n private readonly LoggerInterface $logger\n ) {\n $this->crmManipulator = app(RemoteCrmObjectManipulator::class, [\n 'client' => $this->client,\n 'logger' => $this->logger,\n ]);\n }\n\n public function fetchAndAssociateRelatedActivity(Activity $activity): ?Activity\n {\n // Validate if the activity is applicable.\n if ($this->isActivityApplicable($activity) === false) {\n $this->logger->info('[Hubspot] Fetch related activity - not applicable', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n return null;\n }\n\n // Find and fetch related activity.\n try {\n $crmActivity = $this->fetchRelatedActivity($activity);\n if (empty($crmActivity)) {\n return null;\n }\n } catch (Exception $exception) {\n $this->logger->error('[Hubspot] Fetch related activity - failed', [\n 'activityId' => $activity->getUuid(),\n 'reason' => $exception->getMessage(),\n ]);\n\n throw $exception;\n }\n\n // get playbook category id\n $playbookCategoryId = $this->getPlaybookCategoryId($activity, $crmActivity);\n\n $data = [\n 'crm_provider_id' => $crmActivity['id'],\n 'playbook_category_id' => $playbookCategoryId,\n ];\n\n if (! $this->updateActivity($activity, $data)) {\n return null;\n }\n\n $this->crmManipulator->updateRelatedCrmActivity($activity, $crmActivity);\n\n $activity->refresh();\n\n return $activity;\n }\n\n private function isActivityApplicable(Activity $activity): bool\n {\n if (! $activity->isTypeConference()) {\n return false;\n }\n\n if (! $this->hasMinimumFilterRequirements($activity)) {\n $this->logger->info('[Hubspot] Fetch related activity - not enough filter data', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n return false;\n }\n\n // The related activity is already linked.\n if ($activity->hasCrmProviderId()) {\n return false;\n }\n\n if (! $activity->getUser()->getProfile() instanceof Profile) {\n $this->logger->info('[Hubspot] Fetch related activity - activity user has no crm profile', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n return false;\n }\n\n return true;\n }\n\n private function hasMinimumFilterRequirements(Activity $activity): bool\n {\n // We need to know when it started to determine when to look for related activity.\n if (! $activity->hasActualStartTime()) {\n return false;\n }\n\n if (! $activity->hasActualEndTime()) {\n return false;\n }\n\n // At least one prospect should be associated to the activity\n if ($activity->hasContact() || $activity->hasAccount()) {\n return true;\n }\n\n return false;\n }\n\n public function fetchRelatedActivity(Activity $activity): array\n {\n $this->logger->info('[Hubspot] Fetch related activity - searching related CRM activity', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n $payloadData = $this->getPayloadFilterData($activity);\n\n try {\n $payload = $this->payloadBuilder->getFindRelatedActivityPayload($payloadData, self::OBJECT_MEETING);\n $response = $this->client->getPaginatedData($payload, self::OBJECT_MEETING);\n $crmActivities = $response['results'];\n } catch (Exception $e) {\n $this->logger->error('[Hubspot] Fetch related activity - failed', [\n 'activityId' => $activity->getUuid(),\n 'reason' => $e->getMessage(),\n ]);\n\n throw $e;\n }\n\n if (empty($crmActivities)) {\n $this->logger->info('[Hubspot] Fetch related activity - no CRM related activity found', [\n 'activityId' => $activity->getUuid(),\n 'payload' => $payload,\n ]);\n\n return [];\n }\n\n $relatedActivity = $this->getBestCrmActivityCandidate($crmActivities, $payloadData);\n if (empty($relatedActivity)) {\n $this->logger->info('[Hubspot] Fetch related activity - no related activity candidate', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n return [];\n }\n\n $this->logger->info('[Hubspot] Fetch related activity - found related CRM activity', [\n 'activityId' => $activity->getUuid(),\n 'crmActivity' => $relatedActivity,\n ]);\n\n return $relatedActivity;\n }\n\n /**\n * @param Activity $activity\n *\n * @return array\n */\n protected function getPayloadFilterData(Activity $activity): array\n {\n [$from, $to] = $this->getActivityTimeFilterRange($activity);\n\n $data = [\n 'from' => $from->getPreciseTimestamp(3),\n 'to' => $to->getPreciseTimestamp(3),\n 'contact' => $activity->hasContact() ? $activity->getContact()->getCrmProviderId() : null,\n 'company' => $activity->hasAccount() ? $activity->getAccount()->getCrmProviderId() : null,\n 'deal' => $activity->hasOpportunity() ? $activity->getOpportunity()->getCrmProviderId() : null,\n 'owner' => $activity->getUser()->getProfile()->getCrmProviderId(),\n 'activity' => $activity->getUuid(),\n ];\n\n return array_filter($data);\n }\n\n private function getActivityTimeFilterRange(Activity $activity): array\n {\n if ($activity->hasScheduledStartTime() && $activity->hasScheduledEndTime()) {\n $calendarEvent = $activity->getCalendarEvent();\n\n if ($calendarEvent instanceof CalendarEvent) {\n $from = $calendarEvent->getStartTime();\n $to = $calendarEvent->getEndTime()->addMinutes(15);\n } else {\n $from = Carbon::instance($activity->getScheduledStartTime());\n $to = Carbon::instance($activity->getScheduledEndTime())->addMinutes(15);\n }\n\n return [$from, $to];\n }\n\n $from = Carbon::instance($activity->getActualStartTime());\n $to = Carbon::instance($activity->getActualEndTime());\n\n return [$from, $to];\n }\n\n /**\n * Determines the best CRM activity candidate based on the number of matching prospects and the owner.\n *\n * @param array $crmActivities List of CRM activities to evaluate.\n * @param array $data Data containing the prospects and owner information.\n *\n * @return array The best CRM activity candidate.\n */\n private function getBestCrmActivityCandidate(array $crmActivities, array $data): array\n {\n $prospectCount = count(array_intersect_key($data, array_flip(['contact', 'company', 'deal'])));\n $bestCandidate = null;\n $maxMatchingProspects = 0;\n $validActivitiesCount = count($crmActivities);\n\n if ($validActivitiesCount > 1) {\n $this->logger->info('[Hubspot] Fetch related activity - determining the best candidate', [\n 'activityId' => $data['activity'],\n 'activitiesCount' => $validActivitiesCount,\n 'crmActivities' => $crmActivities,\n ]);\n }\n\n foreach ($crmActivities as $crmActivity) {\n if ($this->isOwnerMismatch($crmActivity, $data)) {\n $validActivitiesCount--;\n\n continue;\n }\n\n if ($validActivitiesCount === 1) {\n return $crmActivity;\n }\n\n if ($prospectCount > 1) {\n $matchingProspects = $this->countMatchingProspects($crmActivity, $data);\n if ($matchingProspects > $maxMatchingProspects) {\n $maxMatchingProspects = $matchingProspects;\n $bestCandidate = $crmActivity;\n }\n } else {\n return $crmActivity;\n }\n }\n\n return $bestCandidate ?? [];\n }\n\n private function isOwnerMismatch(array $crmActivity, array $data): bool\n {\n if ($crmActivity['properties']['hubspot_owner_id'] !== $data['owner']) {\n $this->logger->info('[Hubspot] Fetch related activity - owner mismatch', [\n 'activityId' => $data['activity'],\n 'crmActivity' => $crmActivity['id'],\n 'jiminnyOwner' => $data['owner'],\n 'hubspotOwner' => $crmActivity['properties']['hubspot_owner_id'],\n ]);\n\n return true;\n }\n\n return false;\n }\n\n private function countMatchingProspects(array $crmActivity, array $data): int\n {\n try {\n $crmActivityWithAssociations = $this->client->getMeeting($crmActivity['id']);\n } catch (ApiException $e) {\n $this->logger->error('[Hubspot] Fetch related activity - failed to get meeting associations', [\n 'activityId' => $data['activity'],\n 'crmActivity' => $crmActivity['id'],\n 'reason' => $e->getMessage(),\n ]);\n\n return 0;\n }\n\n $associations = $crmActivityWithAssociations->getAssociations();\n $matchingProspects = 0;\n\n $matchingProspects += $this->countMatchingAssociation($data, $associations, 'company', 'companies');\n $matchingProspects += $this->countMatchingAssociation($data, $associations, 'contact', 'contacts');\n $matchingProspects += $this->countMatchingAssociation($data, $associations, 'deal', 'deals');\n\n return $matchingProspects;\n }\n\n private function countMatchingAssociation(array $data, array $associations, string $dataKey, string $associationKey): int\n {\n if (isset($data[$dataKey]) && isset($associations[$associationKey])) {\n $associatedItems = $associations[$associationKey]->getResults();\n foreach ($associatedItems as $associatedItem) {\n if ($data[$dataKey] == $associatedItem->getId()) {\n\n return 1;\n }\n }\n }\n\n return 0;\n }\n\n private function getPlaybookCategoryId(Activity $activity, array $crmActivity): ?int\n {\n if (! $activity->hasUser()) {\n $this->logger->info('[Hubspot] Fetch related activity - activity has no user', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n return null;\n }\n\n $playbookResolver = app(PlaybookResolver::class);\n $playbook = $playbookResolver->resolvePlaybookByUser($activity->getUser());\n\n if (! $playbook instanceof Playbook) {\n $this->logger->info('[Hubspot] Fetch related activity - playbook not set for user', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n return null;\n }\n\n if (! $playbook->getActivityField() instanceof Field) {\n $this->logger->info('[Hubspot] Fetch related activity - playbook activity field not set', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n return null;\n }\n\n $playbookCategory = null;\n $activityTypeSource = null;\n\n $playbookActivityFieldName = $playbook->getActivityField()->getCrmProviderId();\n if ($playbookActivityFieldName === 'activityType') {\n $playbookActivityFieldName = self::DEFAULT_ACTIVITY_TYPE;\n }\n\n // First attempt to match playbook category from crm activity\n if (! empty($crmActivity['properties'][$playbookActivityFieldName])) {\n $playbookCategoryRepository = app(PlaybookCategoryRepository::class);\n $playbookCategory = $playbookCategoryRepository->getPlaybookCategoryByName(\n $playbook,\n strval($crmActivity['properties'][$playbookActivityFieldName])\n );\n $activityTypeSource = 'crm';\n }\n\n // If not matched get playbook category from Jiminny activity\n if (! $playbookCategory instanceof PlaybookCategory && $activity->hasActivityType()) {\n $playbookCategory = $activity->getActivityType();\n $activityTypeSource = 'jiminny';\n }\n\n if ($playbookCategory instanceof PlaybookCategory) {\n $this->logger->info('[Hubspot] Fetch related activity - Playbook category matched', [\n 'activityId' => $activity->getUuid(),\n 'playbookCategoryId' => $playbookCategory->getId(),\n 'activityFieldName' => $playbookActivityFieldName,\n 'crmActivity' => $crmActivity['id'],\n 'source' => $activityTypeSource,\n ]);\n\n return $playbookCategory->getId();\n }\n\n $this->logger->info('[Hubspot] Fetch related activity - Playbook category not found', [\n 'activityId' => $activity->getUuid(),\n 'activityFieldName' => $playbookActivityFieldName,\n 'crmActivity' => $crmActivity['id'],\n ]);\n\n return null;\n }\n\n private function updateActivity(Activity $activity, array $data): bool\n {\n $activityRepository = app(ActivityRepository::class);\n\n $alreadyLoggedActivity = $activityRepository->findUserActivityByCrmProviderId(\n $activity->getUser(),\n $data['crm_provider_id']\n );\n\n if ($alreadyLoggedActivity instanceof Activity) {\n $this->logger->info('[Hubspot] Fetch related activity - activity already linked', [\n 'currentActivityId' => $activity->getUuid(),\n 'linkedActivityId' => $alreadyLoggedActivity->getUuid(),\n 'crmActivityId' => $data['crm_provider_id'],\n ]);\n\n // Return false to prevent updating the remote CRM engagement. This can happen when two Jiminny\n // activities share the same calendar event (e.g., rescheduled meetings). Without this, the first\n // activity's summary would be overwritten by the second activity.\n return false;\n }\n\n try {\n $activityRepository->update($activity, $data);\n } catch (Exception $e) {\n $this->logger->error('[Hubspot] Fetch related activity - activity update failed', [\n 'activityId' => $activity->getUuid(),\n 'reason' => $e->getMessage(),\n ]);\n\n return false;\n }\n\n $this->logger->info('[Hubspot] Fetch related activity - activity updated', [\n 'activityId' => $activity->getUuid(),\n 'data' => $data,\n ]);\n\n return true;\n }\n}","depth":4,"on_screen":true,"value":"<?php\n\ndeclare(strict_types=1);\n\nnamespace Jiminny\\Services\\Crm\\Hubspot;\n\nuse Carbon\\Carbon;\nuse Exception;\nuse HubSpot\\Client\\Crm\\Objects\\ApiException;\nuse Jiminny\\Integrations\\PlaybookResolver;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Calendar\\CalendarEvent;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Models\\Crm\\Profile;\nuse Jiminny\\Models\\Playbook;\nuse Jiminny\\Models\\PlaybookCategory;\nuse Jiminny\\Repositories\\ActivityRepository;\nuse Jiminny\\Repositories\\PlaybookCategoryRepository;\nuse Psr\\Log\\LoggerInterface;\n\nclass SyncRelatedActivityManager\n{\n public const string OBJECT_MEETING = 'meetings';\n public const string DEFAULT_ACTIVITY_TYPE = 'hs_activity_type';\n private RemoteCrmObjectManipulator $crmManipulator;\n\n public function __construct(\n private readonly Client $client,\n private readonly PayloadBuilder $payloadBuilder,\n private readonly LoggerInterface $logger\n ) {\n $this->crmManipulator = app(RemoteCrmObjectManipulator::class, [\n 'client' => $this->client,\n 'logger' => $this->logger,\n ]);\n }\n\n public function fetchAndAssociateRelatedActivity(Activity $activity): ?Activity\n {\n // Validate if the activity is applicable.\n if ($this->isActivityApplicable($activity) === false) {\n $this->logger->info('[Hubspot] Fetch related activity - not applicable', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n return null;\n }\n\n // Find and fetch related activity.\n try {\n $crmActivity = $this->fetchRelatedActivity($activity);\n if (empty($crmActivity)) {\n return null;\n }\n } catch (Exception $exception) {\n $this->logger->error('[Hubspot] Fetch related activity - failed', [\n 'activityId' => $activity->getUuid(),\n 'reason' => $exception->getMessage(),\n ]);\n\n throw $exception;\n }\n\n // get playbook category id\n $playbookCategoryId = $this->getPlaybookCategoryId($activity, $crmActivity);\n\n $data = [\n 'crm_provider_id' => $crmActivity['id'],\n 'playbook_category_id' => $playbookCategoryId,\n ];\n\n if (! $this->updateActivity($activity, $data)) {\n return null;\n }\n\n $this->crmManipulator->updateRelatedCrmActivity($activity, $crmActivity);\n\n $activity->refresh();\n\n return $activity;\n }\n\n private function isActivityApplicable(Activity $activity): bool\n {\n if (! $activity->isTypeConference()) {\n return false;\n }\n\n if (! $this->hasMinimumFilterRequirements($activity)) {\n $this->logger->info('[Hubspot] Fetch related activity - not enough filter data', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n return false;\n }\n\n // The related activity is already linked.\n if ($activity->hasCrmProviderId()) {\n return false;\n }\n\n if (! $activity->getUser()->getProfile() instanceof Profile) {\n $this->logger->info('[Hubspot] Fetch related activity - activity user has no crm profile', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n return false;\n }\n\n return true;\n }\n\n private function hasMinimumFilterRequirements(Activity $activity): bool\n {\n // We need to know when it started to determine when to look for related activity.\n if (! $activity->hasActualStartTime()) {\n return false;\n }\n\n if (! $activity->hasActualEndTime()) {\n return false;\n }\n\n // At least one prospect should be associated to the activity\n if ($activity->hasContact() || $activity->hasAccount()) {\n return true;\n }\n\n return false;\n }\n\n public function fetchRelatedActivity(Activity $activity): array\n {\n $this->logger->info('[Hubspot] Fetch related activity - searching related CRM activity', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n $payloadData = $this->getPayloadFilterData($activity);\n\n try {\n $payload = $this->payloadBuilder->getFindRelatedActivityPayload($payloadData, self::OBJECT_MEETING);\n $response = $this->client->getPaginatedData($payload, self::OBJECT_MEETING);\n $crmActivities = $response['results'];\n } catch (Exception $e) {\n $this->logger->error('[Hubspot] Fetch related activity - failed', [\n 'activityId' => $activity->getUuid(),\n 'reason' => $e->getMessage(),\n ]);\n\n throw $e;\n }\n\n if (empty($crmActivities)) {\n $this->logger->info('[Hubspot] Fetch related activity - no CRM related activity found', [\n 'activityId' => $activity->getUuid(),\n 'payload' => $payload,\n ]);\n\n return [];\n }\n\n $relatedActivity = $this->getBestCrmActivityCandidate($crmActivities, $payloadData);\n if (empty($relatedActivity)) {\n $this->logger->info('[Hubspot] Fetch related activity - no related activity candidate', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n return [];\n }\n\n $this->logger->info('[Hubspot] Fetch related activity - found related CRM activity', [\n 'activityId' => $activity->getUuid(),\n 'crmActivity' => $relatedActivity,\n ]);\n\n return $relatedActivity;\n }\n\n /**\n * @param Activity $activity\n *\n * @return array\n */\n protected function getPayloadFilterData(Activity $activity): array\n {\n [$from, $to] = $this->getActivityTimeFilterRange($activity);\n\n $data = [\n 'from' => $from->getPreciseTimestamp(3),\n 'to' => $to->getPreciseTimestamp(3),\n 'contact' => $activity->hasContact() ? $activity->getContact()->getCrmProviderId() : null,\n 'company' => $activity->hasAccount() ? $activity->getAccount()->getCrmProviderId() : null,\n 'deal' => $activity->hasOpportunity() ? $activity->getOpportunity()->getCrmProviderId() : null,\n 'owner' => $activity->getUser()->getProfile()->getCrmProviderId(),\n 'activity' => $activity->getUuid(),\n ];\n\n return array_filter($data);\n }\n\n private function getActivityTimeFilterRange(Activity $activity): array\n {\n if ($activity->hasScheduledStartTime() && $activity->hasScheduledEndTime()) {\n $calendarEvent = $activity->getCalendarEvent();\n\n if ($calendarEvent instanceof CalendarEvent) {\n $from = $calendarEvent->getStartTime();\n $to = $calendarEvent->getEndTime()->addMinutes(15);\n } else {\n $from = Carbon::instance($activity->getScheduledStartTime());\n $to = Carbon::instance($activity->getScheduledEndTime())->addMinutes(15);\n }\n\n return [$from, $to];\n }\n\n $from = Carbon::instance($activity->getActualStartTime());\n $to = Carbon::instance($activity->getActualEndTime());\n\n return [$from, $to];\n }\n\n /**\n * Determines the best CRM activity candidate based on the number of matching prospects and the owner.\n *\n * @param array $crmActivities List of CRM activities to evaluate.\n * @param array $data Data containing the prospects and owner information.\n *\n * @return array The best CRM activity candidate.\n */\n private function getBestCrmActivityCandidate(array $crmActivities, array $data): array\n {\n $prospectCount = count(array_intersect_key($data, array_flip(['contact', 'company', 'deal'])));\n $bestCandidate = null;\n $maxMatchingProspects = 0;\n $validActivitiesCount = count($crmActivities);\n\n if ($validActivitiesCount > 1) {\n $this->logger->info('[Hubspot] Fetch related activity - determining the best candidate', [\n 'activityId' => $data['activity'],\n 'activitiesCount' => $validActivitiesCount,\n 'crmActivities' => $crmActivities,\n ]);\n }\n\n foreach ($crmActivities as $crmActivity) {\n if ($this->isOwnerMismatch($crmActivity, $data)) {\n $validActivitiesCount--;\n\n continue;\n }\n\n if ($validActivitiesCount === 1) {\n return $crmActivity;\n }\n\n if ($prospectCount > 1) {\n $matchingProspects = $this->countMatchingProspects($crmActivity, $data);\n if ($matchingProspects > $maxMatchingProspects) {\n $maxMatchingProspects = $matchingProspects;\n $bestCandidate = $crmActivity;\n }\n } else {\n return $crmActivity;\n }\n }\n\n return $bestCandidate ?? [];\n }\n\n private function isOwnerMismatch(array $crmActivity, array $data): bool\n {\n if ($crmActivity['properties']['hubspot_owner_id'] !== $data['owner']) {\n $this->logger->info('[Hubspot] Fetch related activity - owner mismatch', [\n 'activityId' => $data['activity'],\n 'crmActivity' => $crmActivity['id'],\n 'jiminnyOwner' => $data['owner'],\n 'hubspotOwner' => $crmActivity['properties']['hubspot_owner_id'],\n ]);\n\n return true;\n }\n\n return false;\n }\n\n private function countMatchingProspects(array $crmActivity, array $data): int\n {\n try {\n $crmActivityWithAssociations = $this->client->getMeeting($crmActivity['id']);\n } catch (ApiException $e) {\n $this->logger->error('[Hubspot] Fetch related activity - failed to get meeting associations', [\n 'activityId' => $data['activity'],\n 'crmActivity' => $crmActivity['id'],\n 'reason' => $e->getMessage(),\n ]);\n\n return 0;\n }\n\n $associations = $crmActivityWithAssociations->getAssociations();\n $matchingProspects = 0;\n\n $matchingProspects += $this->countMatchingAssociation($data, $associations, 'company', 'companies');\n $matchingProspects += $this->countMatchingAssociation($data, $associations, 'contact', 'contacts');\n $matchingProspects += $this->countMatchingAssociation($data, $associations, 'deal', 'deals');\n\n return $matchingProspects;\n }\n\n private function countMatchingAssociation(array $data, array $associations, string $dataKey, string $associationKey): int\n {\n if (isset($data[$dataKey]) && isset($associations[$associationKey])) {\n $associatedItems = $associations[$associationKey]->getResults();\n foreach ($associatedItems as $associatedItem) {\n if ($data[$dataKey] == $associatedItem->getId()) {\n\n return 1;\n }\n }\n }\n\n return 0;\n }\n\n private function getPlaybookCategoryId(Activity $activity, array $crmActivity): ?int\n {\n if (! $activity->hasUser()) {\n $this->logger->info('[Hubspot] Fetch related activity - activity has no user', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n return null;\n }\n\n $playbookResolver = app(PlaybookResolver::class);\n $playbook = $playbookResolver->resolvePlaybookByUser($activity->getUser());\n\n if (! $playbook instanceof Playbook) {\n $this->logger->info('[Hubspot] Fetch related activity - playbook not set for user', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n return null;\n }\n\n if (! $playbook->getActivityField() instanceof Field) {\n $this->logger->info('[Hubspot] Fetch related activity - playbook activity field not set', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n return null;\n }\n\n $playbookCategory = null;\n $activityTypeSource = null;\n\n $playbookActivityFieldName = $playbook->getActivityField()->getCrmProviderId();\n if ($playbookActivityFieldName === 'activityType') {\n $playbookActivityFieldName = self::DEFAULT_ACTIVITY_TYPE;\n }\n\n // First attempt to match playbook category from crm activity\n if (! empty($crmActivity['properties'][$playbookActivityFieldName])) {\n $playbookCategoryRepository = app(PlaybookCategoryRepository::class);\n $playbookCategory = $playbookCategoryRepository->getPlaybookCategoryByName(\n $playbook,\n strval($crmActivity['properties'][$playbookActivityFieldName])\n );\n $activityTypeSource = 'crm';\n }\n\n // If not matched get playbook category from Jiminny activity\n if (! $playbookCategory instanceof PlaybookCategory && $activity->hasActivityType()) {\n $playbookCategory = $activity->getActivityType();\n $activityTypeSource = 'jiminny';\n }\n\n if ($playbookCategory instanceof PlaybookCategory) {\n $this->logger->info('[Hubspot] Fetch related activity - Playbook category matched', [\n 'activityId' => $activity->getUuid(),\n 'playbookCategoryId' => $playbookCategory->getId(),\n 'activityFieldName' => $playbookActivityFieldName,\n 'crmActivity' => $crmActivity['id'],\n 'source' => $activityTypeSource,\n ]);\n\n return $playbookCategory->getId();\n }\n\n $this->logger->info('[Hubspot] Fetch related activity - Playbook category not found', [\n 'activityId' => $activity->getUuid(),\n 'activityFieldName' => $playbookActivityFieldName,\n 'crmActivity' => $crmActivity['id'],\n ]);\n\n return null;\n }\n\n private function updateActivity(Activity $activity, array $data): bool\n {\n $activityRepository = app(ActivityRepository::class);\n\n $alreadyLoggedActivity = $activityRepository->findUserActivityByCrmProviderId(\n $activity->getUser(),\n $data['crm_provider_id']\n );\n\n if ($alreadyLoggedActivity instanceof Activity) {\n $this->logger->info('[Hubspot] Fetch related activity - activity already linked', [\n 'currentActivityId' => $activity->getUuid(),\n 'linkedActivityId' => $alreadyLoggedActivity->getUuid(),\n 'crmActivityId' => $data['crm_provider_id'],\n ]);\n\n // Return false to prevent updating the remote CRM engagement. This can happen when two Jiminny\n // activities share the same calendar event (e.g., rescheduled meetings). Without this, the first\n // activity's summary would be overwritten by the second activity.\n return false;\n }\n\n try {\n $activityRepository->update($activity, $data);\n } catch (Exception $e) {\n $this->logger->error('[Hubspot] Fetch related activity - activity update failed', [\n 'activityId' => $activity->getUuid(),\n 'reason' => $e->getMessage(),\n ]);\n\n return false;\n }\n\n $this->logger->info('[Hubspot] Fetch related activity - activity updated', [\n 'activityId' => $activity->getUuid(),\n 'data' => $data,\n ]);\n\n return true;\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":true,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Project","depth":3,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Project","depth":3,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"New File or Directory…","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Expand Selected","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Collapse All","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Options","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"app ~/jiminny/app, folder","depth":6,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":".circleci, folder","depth":7,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":".cursor, folder","depth":7,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":".github","depth":7,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":".sonarlint, folder","depth":7,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":".vscode, folder","depth":7,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":".windsurf, folder","depth":7,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"app, sources root","depth":7,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Actions, folder","depth":8,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Component, folder","depth":8,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Acl, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"ActionItems, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Activity, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"ActivityAnalytics, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"ActivitySearch, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"AiActivityType, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"AiAutomation, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"AiCallScoring, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"AskAnything, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Dtos, folder","depth":10,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Events, folder","depth":10,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"AskAnythingPromptService.php, class","depth":10,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"HistoryService.php, class","depth":10,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"AskJiminnyAi, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"AWS, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"BillingManagement, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Cache, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"CoachingFeedback, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Country, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"CustomerApi, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Database, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Datadog, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"DateTime, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"DealInsights, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"DealRisks, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"ElasticSearch, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Eloquent, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Encoding, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Encryption, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"ES, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Faker, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"FeatureFlags, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"FFMpeg, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"FileSystem, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Gecko, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Gong, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"GuzzleHttp, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"KeyPoints, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Kiosk, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"LanguageDetection, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"LiveFeed, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Locks, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Math, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"MediaPipeline, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"MeetingBot, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"MobileSettings, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Model, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Notification, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Nudge, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"ParagraphBreaker, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"ParticipantSpeech, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"PartitionedCookie, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"PlaybackPage, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Playlist, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Prophet, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"ProphetAi, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"ProsperWorks, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Queue, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Router, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Saml2, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"SCIM, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Seeder, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Sentry, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Serializer, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Settings, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Sidekick, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Slack, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"TeamInsights, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"TimeMemoryMapper, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Transcription, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"TranscriptionSummary, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Twilio, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Uploader, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"UrlGenerator, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Utility, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Exceptions, folder","depth":10,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Service, folder","depth":10,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"BaseRateLimiter.php, class","depth":11,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"EfficientJsonParser.php, class","depth":11,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"ProviderRateLimiter.php, class","depth":11,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"RateLimiterInstance.php, class","depth":11,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Uuid","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Waveform","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Webhooks","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Workflow","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Configuration","depth":8,"on_screen":false,"role_description":"text"}]...
|
-5380269026303630474
|
-4530590524036716476
|
click
|
accessibility
|
NULL
|
Project: faVsco.js, menu
JY-20725-handle-HS-search Project: faVsco.js, menu
JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
19
Previous Highlighted Error
Next Highlighted Error
[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {
"headers":{
"Date":["Thu,07 May 2026 14:21:15 GMT"],
"Content-Type":["application/json;charset=utf-8"],
"Transfer-Encoding":["chunked"],
"Connection":["keep-alive"],
"CF-Ray":["9f80deb8db60dc3a-SOF"],
"CF-Cache-Status":["DYNAMIC"],
"Strict-Transport-Security":["max-age=31536000; includeSubDomains; preload"],
"Vary":["origin,
accept-encoding"],
"access-control-allow-credentials":["false"],
"server-timing":["hcid;desc=\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\",
cfr;desc=\"9f80deb8e7c6dc3a-IAD\""],
"x-content-type-options":["nosniff"],
"x-hubspot-correlation-id":["019e02d0-6fd8-7812-bdba-885b7ccb3ee3"],
"Set-Cookie":["__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-[IP_ADDRESS]-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,
07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None"],
"Report-To":["{
\"endpoints\":[{
\"url\":\"https:\\/\\/a.nel.cloudflare.com\\/report\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\"}],
\"group\":\"cf-nel\",
\"max_age\":604800}"],
"NEL":["{
\"success_fraction\":0.01,
\"report_to\":\"cf-nel\",
\"max_age\":604800}"],
"Server":["cloudflare"]}} {
"correlation_id":"95236535-ec98-4541-b92a-adfa73b69eab",
"trace_id":"c7ab8365-903f-46d4-9403-0e5b551e3545"}
Sync Changes
Hide This Notification
Code changed:
Hide
3
11
Previous Highlighted Error
Next Highlighted Error
<?php
declare(strict_types=1);
namespace Jiminny\Services\Crm\Hubspot;
use Carbon\Carbon;
use Exception;
use HubSpot\Client\Crm\Objects\ApiException;
use Jiminny\Integrations\PlaybookResolver;
use Jiminny\Models\Activity;
use Jiminny\Models\Calendar\CalendarEvent;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\Profile;
use Jiminny\Models\Playbook;
use Jiminny\Models\PlaybookCategory;
use Jiminny\Repositories\ActivityRepository;
use Jiminny\Repositories\PlaybookCategoryRepository;
use Psr\Log\LoggerInterface;
class SyncRelatedActivityManager
{
public const string OBJECT_MEETING = 'meetings';
public const string DEFAULT_ACTIVITY_TYPE = 'hs_activity_type';
private RemoteCrmObjectManipulator $crmManipulator;
public function __construct(
private readonly Client $client,
private readonly PayloadBuilder $payloadBuilder,
private readonly LoggerInterface $logger
) {
$this->crmManipulator = app(RemoteCrmObjectManipulator::class, [
'client' => $this->client,
'logger' => $this->logger,
]);
}
public function fetchAndAssociateRelatedActivity(Activity $activity): ?Activity
{
// Validate if the activity is applicable.
if ($this->isActivityApplicable($activity) === false) {
$this->logger->info('[Hubspot] Fetch related activity - not applicable', [
'activityId' => $activity->getUuid(),
]);
return null;
}
// Find and fetch related activity.
try {
$crmActivity = $this->fetchRelatedActivity($activity);
if (empty($crmActivity)) {
return null;
}
} catch (Exception $exception) {
$this->logger->error('[Hubspot] Fetch related activity - failed', [
'activityId' => $activity->getUuid(),
'reason' => $exception->getMessage(),
]);
throw $exception;
}
// get playbook category id
$playbookCategoryId = $this->getPlaybookCategoryId($activity, $crmActivity);
$data = [
'crm_provider_id' => $crmActivity['id'],
'playbook_category_id' => $playbookCategoryId,
];
if (! $this->updateActivity($activity, $data)) {
return null;
}
$this->crmManipulator->updateRelatedCrmActivity($activity, $crmActivity);
$activity->refresh();
return $activity;
}
private function isActivityApplicable(Activity $activity): bool
{
if (! $activity->isTypeConference()) {
return false;
}
if (! $this->hasMinimumFilterRequirements($activity)) {
$this->logger->info('[Hubspot] Fetch related activity - not enough filter data', [
'activityId' => $activity->getUuid(),
]);
return false;
}
// The related activity is already linked.
if ($activity->hasCrmProviderId()) {
return false;
}
if (! $activity->getUser()->getProfile() instanceof Profile) {
$this->logger->info('[Hubspot] Fetch related activity - activity user has no crm profile', [
'activityId' => $activity->getUuid(),
]);
return false;
}
return true;
}
private function hasMinimumFilterRequirements(Activity $activity): bool
{
// We need to know when it started to determine when to look for related activity.
if (! $activity->hasActualStartTime()) {
return false;
}
if (! $activity->hasActualEndTime()) {
return false;
}
// At least one prospect should be associated to the activity
if ($activity->hasContact() || $activity->hasAccount()) {
return true;
}
return false;
}
public function fetchRelatedActivity(Activity $activity): array
{
$this->logger->info('[Hubspot] Fetch related activity - searching related CRM activity', [
'activityId' => $activity->getUuid(),
]);
$payloadData = $this->getPayloadFilterData($activity);
try {
$payload = $this->payloadBuilder->getFindRelatedActivityPayload($payloadData, self::OBJECT_MEETING);
$response = $this->client->getPaginatedData($payload, self::OBJECT_MEETING);
$crmActivities = $response['results'];
} catch (Exception $e) {
$this->logger->error('[Hubspot] Fetch related activity - failed', [
'activityId' => $activity->getUuid(),
'reason' => $e->getMessage(),
]);
throw $e;
}
if (empty($crmActivities)) {
$this->logger->info('[Hubspot] Fetch related activity - no CRM related activity found', [
'activityId' => $activity->getUuid(),
'payload' => $payload,
]);
return [];
}
$relatedActivity = $this->getBestCrmActivityCandidate($crmActivities, $payloadData);
if (empty($relatedActivity)) {
$this->logger->info('[Hubspot] Fetch related activity - no related activity candidate', [
'activityId' => $activity->getUuid(),
]);
return [];
}
$this->logger->info('[Hubspot] Fetch related activity - found related CRM activity', [
'activityId' => $activity->getUuid(),
'crmActivity' => $relatedActivity,
]);
return $relatedActivity;
}
/**
* @param Activity $activity
*
* @return array
*/
protected function getPayloadFilterData(Activity $activity): array
{
[$from, $to] = $this->getActivityTimeFilterRange($activity);
$data = [
'from' => $from->getPreciseTimestamp(3),
'to' => $to->getPreciseTimestamp(3),
'contact' => $activity->hasContact() ? $activity->getContact()->getCrmProviderId() : null,
'company' => $activity->hasAccount() ? $activity->getAccount()->getCrmProviderId() : null,
'deal' => $activity->hasOpportunity() ? $activity->getOpportunity()->getCrmProviderId() : null,
'owner' => $activity->getUser()->getProfile()->getCrmProviderId(),
'activity' => $activity->getUuid(),
];
return array_filter($data);
}
private function getActivityTimeFilterRange(Activity $activity): array
{
if ($activity->hasScheduledStartTime() && $activity->hasScheduledEndTime()) {
$calendarEvent = $activity->getCalendarEvent();
if ($calendarEvent instanceof CalendarEvent) {
$from = $calendarEvent->getStartTime();
$to = $calendarEvent->getEndTime()->addMinutes(15);
} else {
$from = Carbon::instance($activity->getScheduledStartTime());
$to = Carbon::instance($activity->getScheduledEndTime())->addMinutes(15);
}
return [$from, $to];
}
$from = Carbon::instance($activity->getActualStartTime());
$to = Carbon::instance($activity->getActualEndTime());
return [$from, $to];
}
/**
* Determines the best CRM activity candidate based on the number of matching prospects and the owner.
*
* @param array $crmActivities List of CRM activities to evaluate.
* @param array $data Data containing the prospects and owner information.
*
* @return array The best CRM activity candidate.
*/
private function getBestCrmActivityCandidate(array $crmActivities, array $data): array
{
$prospectCount = count(array_intersect_key($data, array_flip(['contact', 'company', 'deal'])));
$bestCandidate = null;
$maxMatchingProspects = 0;
$validActivitiesCount = count($crmActivities);
if ($validActivitiesCount > 1) {
$this->logger->info('[Hubspot] Fetch related activity - determining the best candidate', [
'activityId' => $data['activity'],
'activitiesCount' => $validActivitiesCount,
'crmActivities' => $crmActivities,
]);
}
foreach ($crmActivities as $crmActivity) {
if ($this->isOwnerMismatch($crmActivity, $data)) {
$validActivitiesCount--;
continue;
}
if ($validActivitiesCount === 1) {
return $crmActivity;
}
if ($prospectCount > 1) {
$matchingProspects = $this->countMatchingProspects($crmActivity, $data);
if ($matchingProspects > $maxMatchingProspects) {
$maxMatchingProspects = $matchingProspects;
$bestCandidate = $crmActivity;
}
} else {
return $crmActivity;
}
}
return $bestCandidate ?? [];
}
private function isOwnerMismatch(array $crmActivity, array $data): bool
{
if ($crmActivity['properties']['hubspot_owner_id'] !== $data['owner']) {
$this->logger->info('[Hubspot] Fetch related activity - owner mismatch', [
'activityId' => $data['activity'],
'crmActivity' => $crmActivity['id'],
'jiminnyOwner' => $data['owner'],
'hubspotOwner' => $crmActivity['properties']['hubspot_owner_id'],
]);
return true;
}
return false;
}
private function countMatchingProspects(array $crmActivity, array $data): int
{
try {
$crmActivityWithAssociations = $this->client->getMeeting($crmActivity['id']);
} catch (ApiException $e) {
$this->logger->error('[Hubspot] Fetch related activity - failed to get meeting associations', [
'activityId' => $data['activity'],
'crmActivity' => $crmActivity['id'],
'reason' => $e->getMessage(),
]);
return 0;
}
$associations = $crmActivityWithAssociations->getAssociations();
$matchingProspects = 0;
$matchingProspects += $this->countMatchingAssociation($data, $associations, 'company', 'companies');
$matchingProspects += $this->countMatchingAssociation($data, $associations, 'contact', 'contacts');
$matchingProspects += $this->countMatchingAssociation($data, $associations, 'deal', 'deals');
return $matchingProspects;
}
private function countMatchingAssociation(array $data, array $associations, string $dataKey, string $associationKey): int
{
if (isset($data[$dataKey]) && isset($associations[$associationKey])) {
$associatedItems = $associations[$associationKey]->getResults();
foreach ($associatedItems as $associatedItem) {
if ($data[$dataKey] == $associatedItem->getId()) {
return 1;
}
}
}
return 0;
}
private function getPlaybookCategoryId(Activity $activity, array $crmActivity): ?int
{
if (! $activity->hasUser()) {
$this->logger->info('[Hubspot] Fetch related activity - activity has no user', [
'activityId' => $activity->getUuid(),
]);
return null;
}
$playbookResolver = app(PlaybookResolver::class);
$playbook = $playbookResolver->resolvePlaybookByUser($activity->getUser());
if (! $playbook instanceof Playbook) {
$this->logger->info('[Hubspot] Fetch related activity - playbook not set for user', [
'activityId' => $activity->getUuid(),
]);
return null;
}
if (! $playbook->getActivityField() instanceof Field) {
$this->logger->info('[Hubspot] Fetch related activity - playbook activity field not set', [
'activityId' => $activity->getUuid(),
]);
return null;
}
$playbookCategory = null;
$activityTypeSource = null;
$playbookActivityFieldName = $playbook->getActivityField()->getCrmProviderId();
if ($playbookActivityFieldName === 'activityType') {
$playbookActivityFieldName = self::DEFAULT_ACTIVITY_TYPE;
}
// First attempt to match playbook category from crm activity
if (! empty($crmActivity['properties'][$playbookActivityFieldName])) {
$playbookCategoryRepository = app(PlaybookCategoryRepository::class);
$playbookCategory = $playbookCategoryRepository->getPlaybookCategoryByName(
$playbook,
strval($crmActivity['properties'][$playbookActivityFieldName])
);
$activityTypeSource = 'crm';
}
// If not matched get playbook category from Jiminny activity
if (! $playbookCategory instanceof PlaybookCategory && $activity->hasActivityType()) {
$playbookCategory = $activity->getActivityType();
$activityTypeSource = 'jiminny';
}
if ($playbookCategory instanceof PlaybookCategory) {
$this->logger->info('[Hubspot] Fetch related activity - Playbook category matched', [
'activityId' => $activity->getUuid(),
'playbookCategoryId' => $playbookCategory->getId(),
'activityFieldName' => $playbookActivityFieldName,
'crmActivity' => $crmActivity['id'],
'source' => $activityTypeSource,
]);
return $playbookCategory->getId();
}
$this->logger->info('[Hubspot] Fetch related activity - Playbook category not found', [
'activityId' => $activity->getUuid(),
'activityFieldName' => $playbookActivityFieldName,
'crmActivity' => $crmActivity['id'],
]);
return null;
}
private function updateActivity(Activity $activity, array $data): bool
{
$activityRepository = app(ActivityRepository::class);
$alreadyLoggedActivity = $activityRepository->findUserActivityByCrmProviderId(
$activity->getUser(),
$data['crm_provider_id']
);
if ($alreadyLoggedActivity instanceof Activity) {
$this->logger->info('[Hubspot] Fetch related activity - activity already linked', [
'currentActivityId' => $activity->getUuid(),
'linkedActivityId' => $alreadyLoggedActivity->getUuid(),
'crmActivityId' => $data['crm_provider_id'],
]);
// Return false to prevent updating the remote CRM engagement. This can happen when two Jiminny
// activities share the same calendar event (e.g., rescheduled meetings). Without this, the first
// activity's summary would be overwritten by the second activity.
return false;
}
try {
$activityRepository->update($activity, $data);
} catch (Exception $e) {
$this->logger->error('[Hubspot] Fetch related activity - activity update failed', [
'activityId' => $activity->getUuid(),
'reason' => $e->getMessage(),
]);
return false;
}
$this->logger->info('[Hubspot] Fetch related activity - activity updated', [
'activityId' => $activity->getUuid(),
'data' => $data,
]);
return true;
}
}
Project
Project
New File or Directory…
Expand Selected
Collapse All
Options
Hide
app ~/jiminny/app, folder
.circleci, folder
.cursor, folder
.github
.sonarlint, folder
.vscode, folder
.windsurf, folder
app, sources root
Actions, folder
Component, folder
Acl, folder
ActionItems, folder
Activity, folder
ActivityAnalytics, folder
ActivitySearch, folder
AiActivityType, folder
AiAutomation, folder
AiCallScoring, folder
AskAnything, folder
Dtos, folder
Events, folder
AskAnythingPromptService.php, class
HistoryService.php, class
AskJiminnyAi, folder
AWS, folder
BillingManagement, folder
Cache, folder
CoachingFeedback, folder
Country, folder
CustomerApi, folder
Database, folder
Datadog, folder
DateTime, folder
DealInsights, folder
DealRisks, folder
ElasticSearch, folder
Eloquent, folder
Encoding, folder
Encryption, folder
ES, folder
Faker, folder
FeatureFlags, folder
FFMpeg, folder
FileSystem, folder
Gecko, folder
Gong, folder
GuzzleHttp, folder
KeyPoints, folder
Kiosk, folder
LanguageDetection, folder
LiveFeed, folder
Locks, folder
Math, folder
MediaPipeline, folder
MeetingBot, folder
MobileSettings, folder
Model, folder
Notification, folder
Nudge, folder
ParagraphBreaker, folder
ParticipantSpeech, folder
PartitionedCookie, folder
PlaybackPage, folder
Playlist, folder
Prophet, folder
ProphetAi, folder
ProsperWorks, folder
Queue, folder
Router, folder
Saml2, folder
SCIM, folder
Seeder, folder
Sentry, folder
Serializer, folder
Settings, folder
Sidekick, folder
Slack, folder
TeamInsights, folder
TimeMemoryMapper, folder
Transcription, folder
TranscriptionSummary, folder
Twilio, folder
Uploader, folder
UrlGenerator, folder
Utility, folder
Exceptions, folder
Service, folder
BaseRateLimiter.php, class
EfficientJsonParser.php, class
ProviderRateLimiter.php, class
RateLimiterInstance.php, class
Uuid
Waveform
Webhooks
Workflow
Configuration...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
9835
|
444
|
23
|
2026-05-08T13:37:14.185534+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-08/1778 /Users/lukas/.screenpipe/data/data/2026-05-08/1778247434185_m2.jpg...
|
PhpStorm
|
faVsco.js – SyncRelatedActivityManager.php
|
1
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
JY-20725-handle-HS-search Project: faVsco.js, menu
JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
19
Previous Highlighted Error
Next Highlighted Error
[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {
"headers":{
"Date":["Thu,07 May 2026 14:21:15 GMT"],
"Content-Type":["application/json;charset=utf-8"],
"Transfer-Encoding":["chunked"],
"Connection":["keep-alive"],
"CF-Ray":["9f80deb8db60dc3a-SOF"],
"CF-Cache-Status":["DYNAMIC"],
"Strict-Transport-Security":["max-age=31536000; includeSubDomains; preload"],
"Vary":["origin,
accept-encoding"],
"access-control-allow-credentials":["false"],
"server-timing":["hcid;desc=\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\",
cfr;desc=\"9f80deb8e7c6dc3a-IAD\""],
"x-content-type-options":["nosniff"],
"x-hubspot-correlation-id":["019e02d0-6fd8-7812-bdba-885b7ccb3ee3"],
"Set-Cookie":["__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-[IP_ADDRESS]-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,
07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None"],
"Report-To":["{
\"endpoints\":[{
\"url\":\"https:\\/\\/a.nel.cloudflare.com\\/report\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\"}],
\"group\":\"cf-nel\",
\"max_age\":604800}"],
"NEL":["{
\"success_fraction\":0.01,
\"report_to\":\"cf-nel\",
\"max_age\":604800}"],
"Server":["cloudflare"]}} {
"correlation_id":"95236535-ec98-4541-b92a-adfa73b69eab",
"trace_id":"c7ab8365-903f-46d4-9403-0e5b551e3545"}
Sync Changes
Hide This Notification
Code changed:
Hide
3
11
Previous Highlighted Error
Next Highlighted Error
<?php
declare(strict_types=1);
namespace Jiminny\Services\Crm\Hubspot;
use Carbon\Carbon;
use Exception;
use HubSpot\Client\Crm\Objects\ApiException;
use Jiminny\Integrations\PlaybookResolver;
use Jiminny\Models\Activity;
use Jiminny\Models\Calendar\CalendarEvent;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\Profile;
use Jiminny\Models\Playbook;
use Jiminny\Models\PlaybookCategory;
use Jiminny\Repositories\ActivityRepository;
use Jiminny\Repositories\PlaybookCategoryRepository;
use Psr\Log\LoggerInterface;
class SyncRelatedActivityManager
{
public const string OBJECT_MEETING = 'meetings';
public const string DEFAULT_ACTIVITY_TYPE = 'hs_activity_type';
private RemoteCrmObjectManipulator $crmManipulator;
public function __construct(
private readonly Client $client,
private readonly PayloadBuilder $payloadBuilder,
private readonly LoggerInterface $logger
) {
$this->crmManipulator = app(RemoteCrmObjectManipulator::class, [
'client' => $this->client,
'logger' => $this->logger,
]);
}
public function fetchAndAssociateRelatedActivity(Activity $activity): ?Activity
{
// Validate if the activity is applicable.
if ($this->isActivityApplicable($activity) === false) {
$this->logger->info('[Hubspot] Fetch related activity - not applicable', [
'activityId' => $activity->getUuid(),
]);
return null;
}
// Find and fetch related activity.
try {
$crmActivity = $this->fetchRelatedActivity($activity);
if (empty($crmActivity)) {
return null;
}
} catch (Exception $exception) {
$this->logger->error('[Hubspot] Fetch related activity - failed', [
'activityId' => $activity->getUuid(),
'reason' => $exception->getMessage(),
]);
throw $exception;
}
// get playbook category id
$playbookCategoryId = $this->getPlaybookCategoryId($activity, $crmActivity);
$data = [
'crm_provider_id' => $crmActivity['id'],
'playbook_category_id' => $playbookCategoryId,
];
if (! $this->updateActivity($activity, $data)) {
return null;
}
$this->crmManipulator->updateRelatedCrmActivity($activity, $crmActivity);
$activity->refresh();
return $activity;
}
private function isActivityApplicable(Activity $activity): bool
{
if (! $activity->isTypeConference()) {
return false;
}
if (! $this->hasMinimumFilterRequirements($activity)) {
$this->logger->info('[Hubspot] Fetch related activity - not enough filter data', [
'activityId' => $activity->getUuid(),
]);
return false;
}
// The related activity is already linked.
if ($activity->hasCrmProviderId()) {
return false;
}
if (! $activity->getUser()->getProfile() instanceof Profile) {
$this->logger->info('[Hubspot] Fetch related activity - activity user has no crm profile', [
'activityId' => $activity->getUuid(),
]);
return false;
}
return true;
}
private function hasMinimumFilterRequirements(Activity $activity): bool
{
// We need to know when it started to determine when to look for related activity.
if (! $activity->hasActualStartTime()) {
return false;
}
if (! $activity->hasActualEndTime()) {
return false;
}
// At least one prospect should be associated to the activity
if ($activity->hasContact() || $activity->hasAccount()) {
return true;
}
return false;
}
public function fetchRelatedActivity(Activity $activity): array
{
$this->logger->info('[Hubspot] Fetch related activity - searching related CRM activity', [
'activityId' => $activity->getUuid(),
]);
$payloadData = $this->getPayloadFilterData($activity);
try {
$payload = $this->payloadBuilder->getFindRelatedActivityPayload($payloadData, self::OBJECT_MEETING);
$response = $this->client->getPaginatedData($payload, self::OBJECT_MEETING);
$crmActivities = $response['results'];
} catch (Exception $e) {
$this->logger->error('[Hubspot] Fetch related activity - failed', [
'activityId' => $activity->getUuid(),
'reason' => $e->getMessage(),
]);
throw $e;
}
if (empty($crmActivities)) {
$this->logger->info('[Hubspot] Fetch related activity - no CRM related activity found', [
'activityId' => $activity->getUuid(),
'payload' => $payload,
]);
return [];
}
$relatedActivity = $this->getBestCrmActivityCandidate($crmActivities, $payloadData);
if (empty($relatedActivity)) {
$this->logger->info('[Hubspot] Fetch related activity - no related activity candidate', [
'activityId' => $activity->getUuid(),
]);
return [];
}
$this->logger->info('[Hubspot] Fetch related activity - found related CRM activity', [
'activityId' => $activity->getUuid(),
'crmActivity' => $relatedActivity,
]);
return $relatedActivity;
}
/**
* @param Activity $activity
*
* @return array
*/
protected function getPayloadFilterData(Activity $activity): array
{
[$from, $to] = $this->getActivityTimeFilterRange($activity);
$data = [
'from' => $from->getPreciseTimestamp(3),
'to' => $to->getPreciseTimestamp(3),
'contact' => $activity->hasContact() ? $activity->getContact()->getCrmProviderId() : null,
'company' => $activity->hasAccount() ? $activity->getAccount()->getCrmProviderId() : null,
'deal' => $activity->hasOpportunity() ? $activity->getOpportunity()->getCrmProviderId() : null,
'owner' => $activity->getUser()->getProfile()->getCrmProviderId(),
'activity' => $activity->getUuid(),
];
return array_filter($data);
}
private function getActivityTimeFilterRange(Activity $activity): array
{
if ($activity->hasScheduledStartTime() && $activity->hasScheduledEndTime()) {
$calendarEvent = $activity->getCalendarEvent();
if ($calendarEvent instanceof CalendarEvent) {
$from = $calendarEvent->getStartTime();
$to = $calendarEvent->getEndTime()->addMinutes(15);
} else {
$from = Carbon::instance($activity->getScheduledStartTime());
$to = Carbon::instance($activity->getScheduledEndTime())->addMinutes(15);
}
return [$from, $to];
}
$from = Carbon::instance($activity->getActualStartTime());
$to = Carbon::instance($activity->getActualEndTime());
return [$from, $to];
}
/**
* Determines the best CRM activity candidate based on the number of matching prospects and the owner.
*
* @param array $crmActivities List of CRM activities to evaluate.
* @param array $data Data containing the prospects and owner information.
*
* @return array The best CRM activity candidate.
*/
private function getBestCrmActivityCandidate(array $crmActivities, array $data): array
{
$prospectCount = count(array_intersect_key($data, array_flip(['contact', 'company', 'deal'])));
$bestCandidate = null;
$maxMatchingProspects = 0;
$validActivitiesCount = count($crmActivities);
if ($validActivitiesCount > 1) {
$this->logger->info('[Hubspot] Fetch related activity - determining the best candidate', [
'activityId' => $data['activity'],
'activitiesCount' => $validActivitiesCount,
'crmActivities' => $crmActivities,
]);
}
foreach ($crmActivities as $crmActivity) {
if ($this->isOwnerMismatch($crmActivity, $data)) {
$validActivitiesCount--;
continue;
}
if ($validActivitiesCount === 1) {
return $crmActivity;
}
if ($prospectCount > 1) {
$matchingProspects = $this->countMatchingProspects($crmActivity, $data);
if ($matchingProspects > $maxMatchingProspects) {
$maxMatchingProspects = $matchingProspects;
$bestCandidate = $crmActivity;
}
} else {
return $crmActivity;
}
}
return $bestCandidate ?? [];
}
private function isOwnerMismatch(array $crmActivity, array $data): bool
{
if ($crmActivity['properties']['hubspot_owner_id'] !== $data['owner']) {
$this->logger->info('[Hubspot] Fetch related activity - owner mismatch', [
'activityId' => $data['activity'],
'crmActivity' => $crmActivity['id'],
'jiminnyOwner' => $data['owner'],
'hubspotOwner' => $crmActivity['properties']['hubspot_owner_id'],
]);
return true;
}
return false;
}
private function countMatchingProspects(array $crmActivity, array $data): int
{
try {
$crmActivityWithAssociations = $this->client->getMeeting($crmActivity['id']);
} catch (ApiException $e) {
$this->logger->error('[Hubspot] Fetch related activity - failed to get meeting associations', [
'activityId' => $data['activity'],
'crmActivity' => $crmActivity['id'],
'reason' => $e->getMessage(),
]);
return 0;
}
$associations = $crmActivityWithAssociations->getAssociations();
$matchingProspects = 0;
$matchingProspects += $this->countMatchingAssociation($data, $associations, 'company', 'companies');
$matchingProspects += $this->countMatchingAssociation($data, $associations, 'contact', 'contacts');
$matchingProspects += $this->countMatchingAssociation($data, $associations, 'deal', 'deals');
return $matchingProspects;
}
private function countMatchingAssociation(array $data, array $associations, string $dataKey, string $associationKey): int
{
if (isset($data[$dataKey]) && isset($associations[$associationKey])) {
$associatedItems = $associations[$associationKey]->getResults();
foreach ($associatedItems as $associatedItem) {
if ($data[$dataKey] == $associatedItem->getId()) {
return 1;
}
}
}
return 0;
}
private function getPlaybookCategoryId(Activity $activity, array $crmActivity): ?int
{
if (! $activity->hasUser()) {
$this->logger->info('[Hubspot] Fetch related activity - activity has no user', [
'activityId' => $activity->getUuid(),
]);
return null;
}
$playbookResolver = app(PlaybookResolver::class);
$playbook = $playbookResolver->resolvePlaybookByUser($activity->getUser());
if (! $playbook instanceof Playbook) {
$this->logger->info('[Hubspot] Fetch related activity - playbook not set for user', [
'activityId' => $activity->getUuid(),
]);
return null;
}
if (! $playbook->getActivityField() instanceof Field) {
$this->logger->info('[Hubspot] Fetch related activity - playbook activity field not set', [
'activityId' => $activity->getUuid(),
]);
return null;
}
$playbookCategory = null;
$activityTypeSource = null;
$playbookActivityFieldName = $playbook->getActivityField()->getCrmProviderId();
if ($playbookActivityFieldName === 'activityType') {
$playbookActivityFieldName = self::DEFAULT_ACTIVITY_TYPE;
}
// First attempt to match playbook category from crm activity
if (! empty($crmActivity['properties'][$playbookActivityFieldName])) {
$playbookCategoryRepository = app(PlaybookCategoryRepository::class);
$playbookCategory = $playbookCategoryRepository->getPlaybookCategoryByName(
$playbook,
strval($crmActivity['properties'][$playbookActivityFieldName])
);
$activityTypeSource = 'crm';
}
// If not matched get playbook category from Jiminny activity
if (! $playbookCategory instanceof PlaybookCategory && $activity->hasActivityType()) {
$playbookCategory = $activity->getActivityType();
$activityTypeSource = 'jiminny';
}
if ($playbookCategory instanceof PlaybookCategory) {
$this->logger->info('[Hubspot] Fetch related activity - Playbook category matched', [
'activityId' => $activity->getUuid(),
'playbookCategoryId' => $playbookCategory->getId(),
'activityFieldName' => $playbookActivityFieldName,
'crmActivity' => $crmActivity['id'],
'source' => $activityTypeSource,
]);
return $playbookCategory->getId();
}
$this->logger->info('[Hubspot] Fetch related activity - Playbook category not found', [
'activityId' => $activity->getUuid(),
'activityFieldName' => $playbookActivityFieldName,
'crmActivity' => $crmActivity['id'],
]);
return null;
}
private function updateActivity(Activity $activity, array $data): bool
{
$activityRepository = app(ActivityRepository::class);
$alreadyLoggedActivity = $activityRepository->findUserActivityByCrmProviderId(
$activity->getUser(),
$data['crm_provider_id']
);
if ($alreadyLoggedActivity instanceof Activity) {
$this->logger->info('[Hubspot] Fetch related activity - activity already linked', [
'currentActivityId' => $activity->getUuid(),
'linkedActivityId' => $alreadyLoggedActivity->getUuid(),
'crmActivityId' => $data['crm_provider_id'],
]);
// Return false to prevent updating the remote CRM engagement. This can happen when two Jiminny
// activities share the same calendar event (e.g., rescheduled meetings). Without this, the first
// activity's summary would be overwritten by the second activity.
return false;
}
try {
$activityRepository->update($activity, $data);
} catch (Exception $e) {
$this->logger->error('[Hubspot] Fetch related activity - activity update failed', [
'activityId' => $activity->getUuid(),
'reason' => $e->getMessage(),
]);
return false;
}
$this->logger->info('[Hubspot] Fetch related activity - activity updated', [
'activityId' => $activity->getUuid(),
'data' => $data,
]);
return true;
}
}
Project
Project
New File or Directory…
Expand Selected
Collapse All
Options
Hide
app ~/jiminny/app, folder
.circleci, folder
.cursor, folder
.github
.sonarlint, folder
.vscode, folder
.windsurf, folder
app, sources root
Actions, folder
Component, folder
Acl, folder
ActionItems, folder
Activity, folder
ActivityAnalytics, folder
ActivitySearch, folder
AiActivityType, folder
AiAutomation, folder
AiCallScoring, folder
AskAnything, folder
Dtos, folder
Events, folder
AskAnythingPromptService.php, class
HistoryService.php, class
AskJiminnyAi, folder
AWS, folder
BillingManagement, folder
Cache, folder
CoachingFeedback, folder
Country, folder
CustomerApi, folder
Database, folder
Datadog, folder
DateTime, folder
DealInsights, folder
DealRisks, folder
ElasticSearch, folder
Eloquent, folder
Encoding, folder
Encryption, folder
ES, folder
Faker, folder
FeatureFlags, folder
FFMpeg, folder
FileSystem, folder
Gecko, folder
Gong, folder
GuzzleHttp, folder
KeyPoints, folder
Kiosk, folder
LanguageDetection, folder
LiveFeed, folder
Locks, folder
Math, folder
MediaPipeline, folder
MeetingBot, folder
MobileSettings, folder
Model, folder
Notification, folder
Nudge, folder
ParagraphBreaker, folder
ParticipantSpeech, folder
PartitionedCookie, folder
PlaybackPage, folder
Playlist, folder
Prophet, folder
ProphetAi, folder
ProsperWorks, folder
Queue, folder
Router, folder
Saml2, folder
SCIM, folder
Seeder, folder
Sentry, folder
Serializer, folder
Settings, folder
Sidekick, folder
Slack, folder
TeamInsights, folder
TimeMemoryMapper, folder
Transcription, folder
TranscriptionSummary, folder
Twilio, folder
Uploader, folder
UrlGenerator, folder
Utility, folder
Exceptions, folder
Service, folder
BaseRateLimiter.php, class
EfficientJsonParser.php, class
ProviderRateLimiter.php, class
RateLimiterInstance.php, class
Uuid
Waveform
Webhooks
Workflow
Configuration
Console
Commands
Activities
Analytics
Calendars
Crm
Hubspot
IntegrationApp
Traits
AddLayoutEntities.php, class
AutologDelayedCommand.php, class
BullhornCommandAbstract.php, abstract class
BullhornPingCommand.php, class...
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"bounds":{"left":0.025930852,"top":0.019952115,"width":0.03856383,"height":0.025538707},"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JY-20725-handle-HS-search-rate-limit, menu","depth":5,"bounds":{"left":0.064494684,"top":0.019952115,"width":0.09541223,"height":0.025538707},"on_screen":true,"help_text":"Git Branch: JY-20725-handle-HS-search-rate-limit","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"bounds":{"left":0.8081782,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"AskJiminnyReportActivityServiceTest","depth":6,"bounds":{"left":0.8234708,"top":0.019952115,"width":0.09208777,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'AskJiminnyReportActivityServiceTest'","depth":6,"bounds":{"left":0.9155585,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'AskJiminnyReportActivityServiceTest'","depth":6,"bounds":{"left":0.9268617,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"bounds":{"left":0.9381649,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"bounds":{"left":0.96609044,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"bounds":{"left":0.9773936,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"IDE and Project Settings","depth":5,"bounds":{"left":0.9886968,"top":0.019952115,"width":0.011303186,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.042220745,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"19","depth":4,"bounds":{"left":0.6615692,"top":0.10055866,"width":0.009640957,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"bounds":{"left":0.67287236,"top":0.09896249,"width":0.00731383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"bounds":{"left":0.68018615,"top":0.09896249,"width":0.006981383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {\n\"headers\":{\n\"Date\":[\"Thu,07 May 2026 14:21:15 GMT\"],\n \"Content-Type\":[\"application/json;charset=utf-8\"],\n \"Transfer-Encoding\":[\"chunked\"],\n \"Connection\":[\"keep-alive\"],\n \"CF-Ray\":[\"9f80deb8db60dc3a-SOF\"],\n \"CF-Cache-Status\":[\"DYNAMIC\"],\n \"Strict-Transport-Security\":[\"max-age=31536000; includeSubDomains; preload\"],\n \"Vary\":[\"origin,\n accept-encoding\"],\n \"access-control-allow-credentials\":[\"false\"],\n \"server-timing\":[\"hcid;desc=\\\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\\\",\n cfr;desc=\\\"9f80deb8e7c6dc3a-IAD\\\"\"],\n \"x-content-type-options\":[\"nosniff\"],\n \"x-hubspot-correlation-id\":[\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\"],\n \"Set-Cookie\":[\"__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-1.0.1.1-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,\n 07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None\"],\n \"Report-To\":[\"{\n\\\"endpoints\\\":[{\n\\\"url\\\":\\\"https:\\\\/\\\\/a.nel.cloudflare.com\\\\/report\\\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\\\"}],\n\\\"group\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"NEL\":[\"{\n\\\"success_fraction\\\":0.01,\n\\\"report_to\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"Server\":[\"cloudflare\"]}} {\n\"correlation_id\":\"95236535-ec98-4541-b92a-adfa73b69eab\",\n\"trace_id\":\"c7ab8365-903f-46d4-9403-0e5b551e3545\"}","depth":4,"bounds":{"left":0.37632978,"top":0.09736632,"width":0.5728058,"height":0.8818835},"on_screen":true,"lines":[{"char_start":207,"char_count":30,"bounds":{"left":0.37632978,"top":0.0,"width":0.07513298,"height":0.014365523}},{"char_start":237,"char_count":36,"bounds":{"left":0.37632978,"top":0.0,"width":0.09075798,"height":0.014365523}},{"char_start":273,"char_count":32,"bounds":{"left":0.37632978,"top":0.0,"width":0.080119684,"height":0.014365523}},{"char_start":305,"char_count":79,"bounds":{"left":0.37632978,"top":0.0,"width":0.20212767,"height":0.014365523}},{"char_start":384,"char_count":18,"bounds":{"left":0.37632978,"top":0.0,"width":0.043882977,"height":0.014365523}},{"char_start":402,"char_count":21,"bounds":{"left":0.37632978,"top":0.0,"width":0.051861703,"height":0.014365523}},{"char_start":423,"char_count":48,"bounds":{"left":0.37632978,"top":0.008778931,"width":0.12167553,"height":0.014365523}},{"char_start":471,"char_count":72,"bounds":{"left":0.37632978,"top":0.026336791,"width":0.18384309,"height":0.014365523}},{"char_start":543,"char_count":40,"bounds":{"left":0.37632978,"top":0.043894652,"width":0.10106383,"height":0.014365523}},{"char_start":583,"char_count":41,"bounds":{"left":0.37632978,"top":0.061452515,"width":0.10372341,"height":0.014365523}},{"char_start":624,"char_count":72,"bounds":{"left":0.37632978,"top":0.079010375,"width":0.18384309,"height":0.014365523}},{"char_start":696,"char_count":219,"bounds":{"left":0.37632978,"top":0.096568234,"width":0.56515956,"height":0.014365523}},{"char_start":915,"char_count":83,"bounds":{"left":0.37632978,"top":0.11412609,"width":0.21243352,"height":0.014365523}},{"char_start":998,"char_count":20,"bounds":{"left":0.37632978,"top":0.13168396,"width":0.04920213,"height":0.014365523}},{"char_start":1018,"char_count":17,"bounds":{"left":0.37632978,"top":0.14924182,"width":0.041223403,"height":0.014365523}},{"char_start":1035,"char_count":203,"bounds":{"left":0.37632978,"top":0.16679968,"width":0.52360374,"height":0.014365523}},{"char_start":1238,"char_count":22,"bounds":{"left":0.37632978,"top":0.18435754,"width":0.05418883,"height":0.014365523}},{"char_start":1260,"char_count":23,"bounds":{"left":0.37632978,"top":0.2019154,"width":0.056848403,"height":0.014365523}},{"char_start":1283,"char_count":10,"bounds":{"left":0.37632978,"top":0.21947326,"width":0.023271276,"height":0.014365523}},{"char_start":1293,"char_count":27,"bounds":{"left":0.37632978,"top":0.23703113,"width":0.06715426,"height":0.014365523}},{"char_start":1320,"char_count":26,"bounds":{"left":0.37632978,"top":0.254589,"width":0.06482713,"height":0.014365523}},{"char_start":1346,"char_count":23,"bounds":{"left":0.37632978,"top":0.27214685,"width":0.056848403,"height":0.014365523}},{"char_start":1369,"char_count":28,"bounds":{"left":0.37632978,"top":0.2897047,"width":0.06981383,"height":0.014365523}},{"char_start":1397,"char_count":57,"bounds":{"left":0.37632978,"top":0.30726257,"width":0.14494681,"height":0.014365523}}],"value":"[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {\n\"headers\":{\n\"Date\":[\"Thu,07 May 2026 14:21:15 GMT\"],\n \"Content-Type\":[\"application/json;charset=utf-8\"],\n \"Transfer-Encoding\":[\"chunked\"],\n \"Connection\":[\"keep-alive\"],\n \"CF-Ray\":[\"9f80deb8db60dc3a-SOF\"],\n \"CF-Cache-Status\":[\"DYNAMIC\"],\n \"Strict-Transport-Security\":[\"max-age=31536000; includeSubDomains; preload\"],\n \"Vary\":[\"origin,\n accept-encoding\"],\n \"access-control-allow-credentials\":[\"false\"],\n \"server-timing\":[\"hcid;desc=\\\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\\\",\n cfr;desc=\\\"9f80deb8e7c6dc3a-IAD\\\"\"],\n \"x-content-type-options\":[\"nosniff\"],\n \"x-hubspot-correlation-id\":[\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\"],\n \"Set-Cookie\":[\"__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-1.0.1.1-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,\n 07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None\"],\n \"Report-To\":[\"{\n\\\"endpoints\\\":[{\n\\\"url\\\":\\\"https:\\\\/\\\\/a.nel.cloudflare.com\\\\/report\\\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\\\"}],\n\\\"group\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"NEL\":[\"{\n\\\"success_fraction\\\":0.01,\n\\\"report_to\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"Server\":[\"cloudflare\"]}} {\n\"correlation_id\":\"95236535-ec98-4541-b92a-adfa73b69eab\",\n\"trace_id\":\"c7ab8365-903f-46d4-9403-0e5b551e3545\"}","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.042220745,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"3","depth":4,"bounds":{"left":0.32214096,"top":0.19952115,"width":0.007978723,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"11","depth":4,"bounds":{"left":0.33211437,"top":0.19952115,"width":0.008976064,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"bounds":{"left":0.34275267,"top":0.19792499,"width":0.00731383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"bounds":{"left":0.35006648,"top":0.19792499,"width":0.006981383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\ndeclare(strict_types=1);\n\nnamespace Jiminny\\Services\\Crm\\Hubspot;\n\nuse Carbon\\Carbon;\nuse Exception;\nuse HubSpot\\Client\\Crm\\Objects\\ApiException;\nuse Jiminny\\Integrations\\PlaybookResolver;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Calendar\\CalendarEvent;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Models\\Crm\\Profile;\nuse Jiminny\\Models\\Playbook;\nuse Jiminny\\Models\\PlaybookCategory;\nuse Jiminny\\Repositories\\ActivityRepository;\nuse Jiminny\\Repositories\\PlaybookCategoryRepository;\nuse Psr\\Log\\LoggerInterface;\n\nclass SyncRelatedActivityManager\n{\n public const string OBJECT_MEETING = 'meetings';\n public const string DEFAULT_ACTIVITY_TYPE = 'hs_activity_type';\n private RemoteCrmObjectManipulator $crmManipulator;\n\n public function __construct(\n private readonly Client $client,\n private readonly PayloadBuilder $payloadBuilder,\n private readonly LoggerInterface $logger\n ) {\n $this->crmManipulator = app(RemoteCrmObjectManipulator::class, [\n 'client' => $this->client,\n 'logger' => $this->logger,\n ]);\n }\n\n public function fetchAndAssociateRelatedActivity(Activity $activity): ?Activity\n {\n // Validate if the activity is applicable.\n if ($this->isActivityApplicable($activity) === false) {\n $this->logger->info('[Hubspot] Fetch related activity - not applicable', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n return null;\n }\n\n // Find and fetch related activity.\n try {\n $crmActivity = $this->fetchRelatedActivity($activity);\n if (empty($crmActivity)) {\n return null;\n }\n } catch (Exception $exception) {\n $this->logger->error('[Hubspot] Fetch related activity - failed', [\n 'activityId' => $activity->getUuid(),\n 'reason' => $exception->getMessage(),\n ]);\n\n throw $exception;\n }\n\n // get playbook category id\n $playbookCategoryId = $this->getPlaybookCategoryId($activity, $crmActivity);\n\n $data = [\n 'crm_provider_id' => $crmActivity['id'],\n 'playbook_category_id' => $playbookCategoryId,\n ];\n\n if (! $this->updateActivity($activity, $data)) {\n return null;\n }\n\n $this->crmManipulator->updateRelatedCrmActivity($activity, $crmActivity);\n\n $activity->refresh();\n\n return $activity;\n }\n\n private function isActivityApplicable(Activity $activity): bool\n {\n if (! $activity->isTypeConference()) {\n return false;\n }\n\n if (! $this->hasMinimumFilterRequirements($activity)) {\n $this->logger->info('[Hubspot] Fetch related activity - not enough filter data', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n return false;\n }\n\n // The related activity is already linked.\n if ($activity->hasCrmProviderId()) {\n return false;\n }\n\n if (! $activity->getUser()->getProfile() instanceof Profile) {\n $this->logger->info('[Hubspot] Fetch related activity - activity user has no crm profile', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n return false;\n }\n\n return true;\n }\n\n private function hasMinimumFilterRequirements(Activity $activity): bool\n {\n // We need to know when it started to determine when to look for related activity.\n if (! $activity->hasActualStartTime()) {\n return false;\n }\n\n if (! $activity->hasActualEndTime()) {\n return false;\n }\n\n // At least one prospect should be associated to the activity\n if ($activity->hasContact() || $activity->hasAccount()) {\n return true;\n }\n\n return false;\n }\n\n public function fetchRelatedActivity(Activity $activity): array\n {\n $this->logger->info('[Hubspot] Fetch related activity - searching related CRM activity', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n $payloadData = $this->getPayloadFilterData($activity);\n\n try {\n $payload = $this->payloadBuilder->getFindRelatedActivityPayload($payloadData, self::OBJECT_MEETING);\n $response = $this->client->getPaginatedData($payload, self::OBJECT_MEETING);\n $crmActivities = $response['results'];\n } catch (Exception $e) {\n $this->logger->error('[Hubspot] Fetch related activity - failed', [\n 'activityId' => $activity->getUuid(),\n 'reason' => $e->getMessage(),\n ]);\n\n throw $e;\n }\n\n if (empty($crmActivities)) {\n $this->logger->info('[Hubspot] Fetch related activity - no CRM related activity found', [\n 'activityId' => $activity->getUuid(),\n 'payload' => $payload,\n ]);\n\n return [];\n }\n\n $relatedActivity = $this->getBestCrmActivityCandidate($crmActivities, $payloadData);\n if (empty($relatedActivity)) {\n $this->logger->info('[Hubspot] Fetch related activity - no related activity candidate', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n return [];\n }\n\n $this->logger->info('[Hubspot] Fetch related activity - found related CRM activity', [\n 'activityId' => $activity->getUuid(),\n 'crmActivity' => $relatedActivity,\n ]);\n\n return $relatedActivity;\n }\n\n /**\n * @param Activity $activity\n *\n * @return array\n */\n protected function getPayloadFilterData(Activity $activity): array\n {\n [$from, $to] = $this->getActivityTimeFilterRange($activity);\n\n $data = [\n 'from' => $from->getPreciseTimestamp(3),\n 'to' => $to->getPreciseTimestamp(3),\n 'contact' => $activity->hasContact() ? $activity->getContact()->getCrmProviderId() : null,\n 'company' => $activity->hasAccount() ? $activity->getAccount()->getCrmProviderId() : null,\n 'deal' => $activity->hasOpportunity() ? $activity->getOpportunity()->getCrmProviderId() : null,\n 'owner' => $activity->getUser()->getProfile()->getCrmProviderId(),\n 'activity' => $activity->getUuid(),\n ];\n\n return array_filter($data);\n }\n\n private function getActivityTimeFilterRange(Activity $activity): array\n {\n if ($activity->hasScheduledStartTime() && $activity->hasScheduledEndTime()) {\n $calendarEvent = $activity->getCalendarEvent();\n\n if ($calendarEvent instanceof CalendarEvent) {\n $from = $calendarEvent->getStartTime();\n $to = $calendarEvent->getEndTime()->addMinutes(15);\n } else {\n $from = Carbon::instance($activity->getScheduledStartTime());\n $to = Carbon::instance($activity->getScheduledEndTime())->addMinutes(15);\n }\n\n return [$from, $to];\n }\n\n $from = Carbon::instance($activity->getActualStartTime());\n $to = Carbon::instance($activity->getActualEndTime());\n\n return [$from, $to];\n }\n\n /**\n * Determines the best CRM activity candidate based on the number of matching prospects and the owner.\n *\n * @param array $crmActivities List of CRM activities to evaluate.\n * @param array $data Data containing the prospects and owner information.\n *\n * @return array The best CRM activity candidate.\n */\n private function getBestCrmActivityCandidate(array $crmActivities, array $data): array\n {\n $prospectCount = count(array_intersect_key($data, array_flip(['contact', 'company', 'deal'])));\n $bestCandidate = null;\n $maxMatchingProspects = 0;\n $validActivitiesCount = count($crmActivities);\n\n if ($validActivitiesCount > 1) {\n $this->logger->info('[Hubspot] Fetch related activity - determining the best candidate', [\n 'activityId' => $data['activity'],\n 'activitiesCount' => $validActivitiesCount,\n 'crmActivities' => $crmActivities,\n ]);\n }\n\n foreach ($crmActivities as $crmActivity) {\n if ($this->isOwnerMismatch($crmActivity, $data)) {\n $validActivitiesCount--;\n\n continue;\n }\n\n if ($validActivitiesCount === 1) {\n return $crmActivity;\n }\n\n if ($prospectCount > 1) {\n $matchingProspects = $this->countMatchingProspects($crmActivity, $data);\n if ($matchingProspects > $maxMatchingProspects) {\n $maxMatchingProspects = $matchingProspects;\n $bestCandidate = $crmActivity;\n }\n } else {\n return $crmActivity;\n }\n }\n\n return $bestCandidate ?? [];\n }\n\n private function isOwnerMismatch(array $crmActivity, array $data): bool\n {\n if ($crmActivity['properties']['hubspot_owner_id'] !== $data['owner']) {\n $this->logger->info('[Hubspot] Fetch related activity - owner mismatch', [\n 'activityId' => $data['activity'],\n 'crmActivity' => $crmActivity['id'],\n 'jiminnyOwner' => $data['owner'],\n 'hubspotOwner' => $crmActivity['properties']['hubspot_owner_id'],\n ]);\n\n return true;\n }\n\n return false;\n }\n\n private function countMatchingProspects(array $crmActivity, array $data): int\n {\n try {\n $crmActivityWithAssociations = $this->client->getMeeting($crmActivity['id']);\n } catch (ApiException $e) {\n $this->logger->error('[Hubspot] Fetch related activity - failed to get meeting associations', [\n 'activityId' => $data['activity'],\n 'crmActivity' => $crmActivity['id'],\n 'reason' => $e->getMessage(),\n ]);\n\n return 0;\n }\n\n $associations = $crmActivityWithAssociations->getAssociations();\n $matchingProspects = 0;\n\n $matchingProspects += $this->countMatchingAssociation($data, $associations, 'company', 'companies');\n $matchingProspects += $this->countMatchingAssociation($data, $associations, 'contact', 'contacts');\n $matchingProspects += $this->countMatchingAssociation($data, $associations, 'deal', 'deals');\n\n return $matchingProspects;\n }\n\n private function countMatchingAssociation(array $data, array $associations, string $dataKey, string $associationKey): int\n {\n if (isset($data[$dataKey]) && isset($associations[$associationKey])) {\n $associatedItems = $associations[$associationKey]->getResults();\n foreach ($associatedItems as $associatedItem) {\n if ($data[$dataKey] == $associatedItem->getId()) {\n\n return 1;\n }\n }\n }\n\n return 0;\n }\n\n private function getPlaybookCategoryId(Activity $activity, array $crmActivity): ?int\n {\n if (! $activity->hasUser()) {\n $this->logger->info('[Hubspot] Fetch related activity - activity has no user', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n return null;\n }\n\n $playbookResolver = app(PlaybookResolver::class);\n $playbook = $playbookResolver->resolvePlaybookByUser($activity->getUser());\n\n if (! $playbook instanceof Playbook) {\n $this->logger->info('[Hubspot] Fetch related activity - playbook not set for user', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n return null;\n }\n\n if (! $playbook->getActivityField() instanceof Field) {\n $this->logger->info('[Hubspot] Fetch related activity - playbook activity field not set', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n return null;\n }\n\n $playbookCategory = null;\n $activityTypeSource = null;\n\n $playbookActivityFieldName = $playbook->getActivityField()->getCrmProviderId();\n if ($playbookActivityFieldName === 'activityType') {\n $playbookActivityFieldName = self::DEFAULT_ACTIVITY_TYPE;\n }\n\n // First attempt to match playbook category from crm activity\n if (! empty($crmActivity['properties'][$playbookActivityFieldName])) {\n $playbookCategoryRepository = app(PlaybookCategoryRepository::class);\n $playbookCategory = $playbookCategoryRepository->getPlaybookCategoryByName(\n $playbook,\n strval($crmActivity['properties'][$playbookActivityFieldName])\n );\n $activityTypeSource = 'crm';\n }\n\n // If not matched get playbook category from Jiminny activity\n if (! $playbookCategory instanceof PlaybookCategory && $activity->hasActivityType()) {\n $playbookCategory = $activity->getActivityType();\n $activityTypeSource = 'jiminny';\n }\n\n if ($playbookCategory instanceof PlaybookCategory) {\n $this->logger->info('[Hubspot] Fetch related activity - Playbook category matched', [\n 'activityId' => $activity->getUuid(),\n 'playbookCategoryId' => $playbookCategory->getId(),\n 'activityFieldName' => $playbookActivityFieldName,\n 'crmActivity' => $crmActivity['id'],\n 'source' => $activityTypeSource,\n ]);\n\n return $playbookCategory->getId();\n }\n\n $this->logger->info('[Hubspot] Fetch related activity - Playbook category not found', [\n 'activityId' => $activity->getUuid(),\n 'activityFieldName' => $playbookActivityFieldName,\n 'crmActivity' => $crmActivity['id'],\n ]);\n\n return null;\n }\n\n private function updateActivity(Activity $activity, array $data): bool\n {\n $activityRepository = app(ActivityRepository::class);\n\n $alreadyLoggedActivity = $activityRepository->findUserActivityByCrmProviderId(\n $activity->getUser(),\n $data['crm_provider_id']\n );\n\n if ($alreadyLoggedActivity instanceof Activity) {\n $this->logger->info('[Hubspot] Fetch related activity - activity already linked', [\n 'currentActivityId' => $activity->getUuid(),\n 'linkedActivityId' => $alreadyLoggedActivity->getUuid(),\n 'crmActivityId' => $data['crm_provider_id'],\n ]);\n\n // Return false to prevent updating the remote CRM engagement. This can happen when two Jiminny\n // activities share the same calendar event (e.g., rescheduled meetings). Without this, the first\n // activity's summary would be overwritten by the second activity.\n return false;\n }\n\n try {\n $activityRepository->update($activity, $data);\n } catch (Exception $e) {\n $this->logger->error('[Hubspot] Fetch related activity - activity update failed', [\n 'activityId' => $activity->getUuid(),\n 'reason' => $e->getMessage(),\n ]);\n\n return false;\n }\n\n $this->logger->info('[Hubspot] Fetch related activity - activity updated', [\n 'activityId' => $activity->getUuid(),\n 'data' => $data,\n ]);\n\n return true;\n }\n}","depth":4,"on_screen":true,"value":"<?php\n\ndeclare(strict_types=1);\n\nnamespace Jiminny\\Services\\Crm\\Hubspot;\n\nuse Carbon\\Carbon;\nuse Exception;\nuse HubSpot\\Client\\Crm\\Objects\\ApiException;\nuse Jiminny\\Integrations\\PlaybookResolver;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Calendar\\CalendarEvent;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Models\\Crm\\Profile;\nuse Jiminny\\Models\\Playbook;\nuse Jiminny\\Models\\PlaybookCategory;\nuse Jiminny\\Repositories\\ActivityRepository;\nuse Jiminny\\Repositories\\PlaybookCategoryRepository;\nuse Psr\\Log\\LoggerInterface;\n\nclass SyncRelatedActivityManager\n{\n public const string OBJECT_MEETING = 'meetings';\n public const string DEFAULT_ACTIVITY_TYPE = 'hs_activity_type';\n private RemoteCrmObjectManipulator $crmManipulator;\n\n public function __construct(\n private readonly Client $client,\n private readonly PayloadBuilder $payloadBuilder,\n private readonly LoggerInterface $logger\n ) {\n $this->crmManipulator = app(RemoteCrmObjectManipulator::class, [\n 'client' => $this->client,\n 'logger' => $this->logger,\n ]);\n }\n\n public function fetchAndAssociateRelatedActivity(Activity $activity): ?Activity\n {\n // Validate if the activity is applicable.\n if ($this->isActivityApplicable($activity) === false) {\n $this->logger->info('[Hubspot] Fetch related activity - not applicable', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n return null;\n }\n\n // Find and fetch related activity.\n try {\n $crmActivity = $this->fetchRelatedActivity($activity);\n if (empty($crmActivity)) {\n return null;\n }\n } catch (Exception $exception) {\n $this->logger->error('[Hubspot] Fetch related activity - failed', [\n 'activityId' => $activity->getUuid(),\n 'reason' => $exception->getMessage(),\n ]);\n\n throw $exception;\n }\n\n // get playbook category id\n $playbookCategoryId = $this->getPlaybookCategoryId($activity, $crmActivity);\n\n $data = [\n 'crm_provider_id' => $crmActivity['id'],\n 'playbook_category_id' => $playbookCategoryId,\n ];\n\n if (! $this->updateActivity($activity, $data)) {\n return null;\n }\n\n $this->crmManipulator->updateRelatedCrmActivity($activity, $crmActivity);\n\n $activity->refresh();\n\n return $activity;\n }\n\n private function isActivityApplicable(Activity $activity): bool\n {\n if (! $activity->isTypeConference()) {\n return false;\n }\n\n if (! $this->hasMinimumFilterRequirements($activity)) {\n $this->logger->info('[Hubspot] Fetch related activity - not enough filter data', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n return false;\n }\n\n // The related activity is already linked.\n if ($activity->hasCrmProviderId()) {\n return false;\n }\n\n if (! $activity->getUser()->getProfile() instanceof Profile) {\n $this->logger->info('[Hubspot] Fetch related activity - activity user has no crm profile', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n return false;\n }\n\n return true;\n }\n\n private function hasMinimumFilterRequirements(Activity $activity): bool\n {\n // We need to know when it started to determine when to look for related activity.\n if (! $activity->hasActualStartTime()) {\n return false;\n }\n\n if (! $activity->hasActualEndTime()) {\n return false;\n }\n\n // At least one prospect should be associated to the activity\n if ($activity->hasContact() || $activity->hasAccount()) {\n return true;\n }\n\n return false;\n }\n\n public function fetchRelatedActivity(Activity $activity): array\n {\n $this->logger->info('[Hubspot] Fetch related activity - searching related CRM activity', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n $payloadData = $this->getPayloadFilterData($activity);\n\n try {\n $payload = $this->payloadBuilder->getFindRelatedActivityPayload($payloadData, self::OBJECT_MEETING);\n $response = $this->client->getPaginatedData($payload, self::OBJECT_MEETING);\n $crmActivities = $response['results'];\n } catch (Exception $e) {\n $this->logger->error('[Hubspot] Fetch related activity - failed', [\n 'activityId' => $activity->getUuid(),\n 'reason' => $e->getMessage(),\n ]);\n\n throw $e;\n }\n\n if (empty($crmActivities)) {\n $this->logger->info('[Hubspot] Fetch related activity - no CRM related activity found', [\n 'activityId' => $activity->getUuid(),\n 'payload' => $payload,\n ]);\n\n return [];\n }\n\n $relatedActivity = $this->getBestCrmActivityCandidate($crmActivities, $payloadData);\n if (empty($relatedActivity)) {\n $this->logger->info('[Hubspot] Fetch related activity - no related activity candidate', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n return [];\n }\n\n $this->logger->info('[Hubspot] Fetch related activity - found related CRM activity', [\n 'activityId' => $activity->getUuid(),\n 'crmActivity' => $relatedActivity,\n ]);\n\n return $relatedActivity;\n }\n\n /**\n * @param Activity $activity\n *\n * @return array\n */\n protected function getPayloadFilterData(Activity $activity): array\n {\n [$from, $to] = $this->getActivityTimeFilterRange($activity);\n\n $data = [\n 'from' => $from->getPreciseTimestamp(3),\n 'to' => $to->getPreciseTimestamp(3),\n 'contact' => $activity->hasContact() ? $activity->getContact()->getCrmProviderId() : null,\n 'company' => $activity->hasAccount() ? $activity->getAccount()->getCrmProviderId() : null,\n 'deal' => $activity->hasOpportunity() ? $activity->getOpportunity()->getCrmProviderId() : null,\n 'owner' => $activity->getUser()->getProfile()->getCrmProviderId(),\n 'activity' => $activity->getUuid(),\n ];\n\n return array_filter($data);\n }\n\n private function getActivityTimeFilterRange(Activity $activity): array\n {\n if ($activity->hasScheduledStartTime() && $activity->hasScheduledEndTime()) {\n $calendarEvent = $activity->getCalendarEvent();\n\n if ($calendarEvent instanceof CalendarEvent) {\n $from = $calendarEvent->getStartTime();\n $to = $calendarEvent->getEndTime()->addMinutes(15);\n } else {\n $from = Carbon::instance($activity->getScheduledStartTime());\n $to = Carbon::instance($activity->getScheduledEndTime())->addMinutes(15);\n }\n\n return [$from, $to];\n }\n\n $from = Carbon::instance($activity->getActualStartTime());\n $to = Carbon::instance($activity->getActualEndTime());\n\n return [$from, $to];\n }\n\n /**\n * Determines the best CRM activity candidate based on the number of matching prospects and the owner.\n *\n * @param array $crmActivities List of CRM activities to evaluate.\n * @param array $data Data containing the prospects and owner information.\n *\n * @return array The best CRM activity candidate.\n */\n private function getBestCrmActivityCandidate(array $crmActivities, array $data): array\n {\n $prospectCount = count(array_intersect_key($data, array_flip(['contact', 'company', 'deal'])));\n $bestCandidate = null;\n $maxMatchingProspects = 0;\n $validActivitiesCount = count($crmActivities);\n\n if ($validActivitiesCount > 1) {\n $this->logger->info('[Hubspot] Fetch related activity - determining the best candidate', [\n 'activityId' => $data['activity'],\n 'activitiesCount' => $validActivitiesCount,\n 'crmActivities' => $crmActivities,\n ]);\n }\n\n foreach ($crmActivities as $crmActivity) {\n if ($this->isOwnerMismatch($crmActivity, $data)) {\n $validActivitiesCount--;\n\n continue;\n }\n\n if ($validActivitiesCount === 1) {\n return $crmActivity;\n }\n\n if ($prospectCount > 1) {\n $matchingProspects = $this->countMatchingProspects($crmActivity, $data);\n if ($matchingProspects > $maxMatchingProspects) {\n $maxMatchingProspects = $matchingProspects;\n $bestCandidate = $crmActivity;\n }\n } else {\n return $crmActivity;\n }\n }\n\n return $bestCandidate ?? [];\n }\n\n private function isOwnerMismatch(array $crmActivity, array $data): bool\n {\n if ($crmActivity['properties']['hubspot_owner_id'] !== $data['owner']) {\n $this->logger->info('[Hubspot] Fetch related activity - owner mismatch', [\n 'activityId' => $data['activity'],\n 'crmActivity' => $crmActivity['id'],\n 'jiminnyOwner' => $data['owner'],\n 'hubspotOwner' => $crmActivity['properties']['hubspot_owner_id'],\n ]);\n\n return true;\n }\n\n return false;\n }\n\n private function countMatchingProspects(array $crmActivity, array $data): int\n {\n try {\n $crmActivityWithAssociations = $this->client->getMeeting($crmActivity['id']);\n } catch (ApiException $e) {\n $this->logger->error('[Hubspot] Fetch related activity - failed to get meeting associations', [\n 'activityId' => $data['activity'],\n 'crmActivity' => $crmActivity['id'],\n 'reason' => $e->getMessage(),\n ]);\n\n return 0;\n }\n\n $associations = $crmActivityWithAssociations->getAssociations();\n $matchingProspects = 0;\n\n $matchingProspects += $this->countMatchingAssociation($data, $associations, 'company', 'companies');\n $matchingProspects += $this->countMatchingAssociation($data, $associations, 'contact', 'contacts');\n $matchingProspects += $this->countMatchingAssociation($data, $associations, 'deal', 'deals');\n\n return $matchingProspects;\n }\n\n private function countMatchingAssociation(array $data, array $associations, string $dataKey, string $associationKey): int\n {\n if (isset($data[$dataKey]) && isset($associations[$associationKey])) {\n $associatedItems = $associations[$associationKey]->getResults();\n foreach ($associatedItems as $associatedItem) {\n if ($data[$dataKey] == $associatedItem->getId()) {\n\n return 1;\n }\n }\n }\n\n return 0;\n }\n\n private function getPlaybookCategoryId(Activity $activity, array $crmActivity): ?int\n {\n if (! $activity->hasUser()) {\n $this->logger->info('[Hubspot] Fetch related activity - activity has no user', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n return null;\n }\n\n $playbookResolver = app(PlaybookResolver::class);\n $playbook = $playbookResolver->resolvePlaybookByUser($activity->getUser());\n\n if (! $playbook instanceof Playbook) {\n $this->logger->info('[Hubspot] Fetch related activity - playbook not set for user', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n return null;\n }\n\n if (! $playbook->getActivityField() instanceof Field) {\n $this->logger->info('[Hubspot] Fetch related activity - playbook activity field not set', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n return null;\n }\n\n $playbookCategory = null;\n $activityTypeSource = null;\n\n $playbookActivityFieldName = $playbook->getActivityField()->getCrmProviderId();\n if ($playbookActivityFieldName === 'activityType') {\n $playbookActivityFieldName = self::DEFAULT_ACTIVITY_TYPE;\n }\n\n // First attempt to match playbook category from crm activity\n if (! empty($crmActivity['properties'][$playbookActivityFieldName])) {\n $playbookCategoryRepository = app(PlaybookCategoryRepository::class);\n $playbookCategory = $playbookCategoryRepository->getPlaybookCategoryByName(\n $playbook,\n strval($crmActivity['properties'][$playbookActivityFieldName])\n );\n $activityTypeSource = 'crm';\n }\n\n // If not matched get playbook category from Jiminny activity\n if (! $playbookCategory instanceof PlaybookCategory && $activity->hasActivityType()) {\n $playbookCategory = $activity->getActivityType();\n $activityTypeSource = 'jiminny';\n }\n\n if ($playbookCategory instanceof PlaybookCategory) {\n $this->logger->info('[Hubspot] Fetch related activity - Playbook category matched', [\n 'activityId' => $activity->getUuid(),\n 'playbookCategoryId' => $playbookCategory->getId(),\n 'activityFieldName' => $playbookActivityFieldName,\n 'crmActivity' => $crmActivity['id'],\n 'source' => $activityTypeSource,\n ]);\n\n return $playbookCategory->getId();\n }\n\n $this->logger->info('[Hubspot] Fetch related activity - Playbook category not found', [\n 'activityId' => $activity->getUuid(),\n 'activityFieldName' => $playbookActivityFieldName,\n 'crmActivity' => $crmActivity['id'],\n ]);\n\n return null;\n }\n\n private function updateActivity(Activity $activity, array $data): bool\n {\n $activityRepository = app(ActivityRepository::class);\n\n $alreadyLoggedActivity = $activityRepository->findUserActivityByCrmProviderId(\n $activity->getUser(),\n $data['crm_provider_id']\n );\n\n if ($alreadyLoggedActivity instanceof Activity) {\n $this->logger->info('[Hubspot] Fetch related activity - activity already linked', [\n 'currentActivityId' => $activity->getUuid(),\n 'linkedActivityId' => $alreadyLoggedActivity->getUuid(),\n 'crmActivityId' => $data['crm_provider_id'],\n ]);\n\n // Return false to prevent updating the remote CRM engagement. This can happen when two Jiminny\n // activities share the same calendar event (e.g., rescheduled meetings). Without this, the first\n // activity's summary would be overwritten by the second activity.\n return false;\n }\n\n try {\n $activityRepository->update($activity, $data);\n } catch (Exception $e) {\n $this->logger->error('[Hubspot] Fetch related activity - activity update failed', [\n 'activityId' => $activity->getUuid(),\n 'reason' => $e->getMessage(),\n ]);\n\n return false;\n }\n\n $this->logger->info('[Hubspot] Fetch related activity - activity updated', [\n 'activityId' => $activity->getUuid(),\n 'data' => $data,\n ]);\n\n return true;\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":true,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Project","depth":3,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Project","depth":3,"bounds":{"left":0.011968086,"top":0.047885075,"width":0.024268618,"height":0.024740623},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"New File or Directory…","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Expand Selected","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Collapse All","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Options","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"app ~/jiminny/app, folder","depth":6,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":".circleci, folder","depth":7,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":".cursor, folder","depth":7,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":".github","depth":7,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":".sonarlint, folder","depth":7,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":".vscode, folder","depth":7,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":".windsurf, folder","depth":7,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"app, sources root","depth":7,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Actions, folder","depth":8,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Component, folder","depth":8,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Acl, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"ActionItems, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Activity, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"ActivityAnalytics, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"ActivitySearch, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"AiActivityType, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"AiAutomation, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"AiCallScoring, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"AskAnything, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Dtos, folder","depth":10,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Events, folder","depth":10,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"AskAnythingPromptService.php, class","depth":10,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"HistoryService.php, class","depth":10,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"AskJiminnyAi, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"AWS, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"BillingManagement, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Cache, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"CoachingFeedback, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Country, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"CustomerApi, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Database, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Datadog, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"DateTime, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"DealInsights, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"DealRisks, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"ElasticSearch, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Eloquent, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Encoding, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Encryption, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"ES, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Faker, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"FeatureFlags, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"FFMpeg, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"FileSystem, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Gecko, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Gong, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"GuzzleHttp, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"KeyPoints, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Kiosk, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"LanguageDetection, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"LiveFeed, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Locks, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Math, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"MediaPipeline, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"MeetingBot, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"MobileSettings, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Model, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Notification, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Nudge, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"ParagraphBreaker, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"ParticipantSpeech, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"PartitionedCookie, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"PlaybackPage, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Playlist, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Prophet, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"ProphetAi, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"ProsperWorks, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Queue, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Router, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Saml2, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"SCIM, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Seeder, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Sentry, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Serializer, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Settings, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Sidekick, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Slack, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"TeamInsights, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"TimeMemoryMapper, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Transcription, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"TranscriptionSummary, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Twilio, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Uploader, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"UrlGenerator, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Utility, folder","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Exceptions, folder","depth":10,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Service, folder","depth":10,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"BaseRateLimiter.php, class","depth":11,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"EfficientJsonParser.php, class","depth":11,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"ProviderRateLimiter.php, class","depth":11,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"RateLimiterInstance.php, class","depth":11,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Uuid","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Waveform","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Webhooks","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Workflow","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Configuration","depth":8,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Console","depth":8,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Commands","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Activities","depth":10,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Analytics","depth":10,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Calendars","depth":10,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Crm","depth":10,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Hubspot","depth":11,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"IntegrationApp","depth":11,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Traits","depth":11,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"AddLayoutEntities.php, class","depth":11,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"AutologDelayedCommand.php, class","depth":11,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"BullhornCommandAbstract.php, abstract class","depth":11,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"BullhornPingCommand.php, class","depth":11,"on_screen":false,"role_description":"text"}]...
|
-1430899612872439489
|
-4530590524036716476
|
visual_change
|
accessibility
|
NULL
|
Project: faVsco.js, menu
JY-20725-handle-HS-search Project: faVsco.js, menu
JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
19
Previous Highlighted Error
Next Highlighted Error
[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {
"headers":{
"Date":["Thu,07 May 2026 14:21:15 GMT"],
"Content-Type":["application/json;charset=utf-8"],
"Transfer-Encoding":["chunked"],
"Connection":["keep-alive"],
"CF-Ray":["9f80deb8db60dc3a-SOF"],
"CF-Cache-Status":["DYNAMIC"],
"Strict-Transport-Security":["max-age=31536000; includeSubDomains; preload"],
"Vary":["origin,
accept-encoding"],
"access-control-allow-credentials":["false"],
"server-timing":["hcid;desc=\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\",
cfr;desc=\"9f80deb8e7c6dc3a-IAD\""],
"x-content-type-options":["nosniff"],
"x-hubspot-correlation-id":["019e02d0-6fd8-7812-bdba-885b7ccb3ee3"],
"Set-Cookie":["__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-[IP_ADDRESS]-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,
07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None"],
"Report-To":["{
\"endpoints\":[{
\"url\":\"https:\\/\\/a.nel.cloudflare.com\\/report\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\"}],
\"group\":\"cf-nel\",
\"max_age\":604800}"],
"NEL":["{
\"success_fraction\":0.01,
\"report_to\":\"cf-nel\",
\"max_age\":604800}"],
"Server":["cloudflare"]}} {
"correlation_id":"95236535-ec98-4541-b92a-adfa73b69eab",
"trace_id":"c7ab8365-903f-46d4-9403-0e5b551e3545"}
Sync Changes
Hide This Notification
Code changed:
Hide
3
11
Previous Highlighted Error
Next Highlighted Error
<?php
declare(strict_types=1);
namespace Jiminny\Services\Crm\Hubspot;
use Carbon\Carbon;
use Exception;
use HubSpot\Client\Crm\Objects\ApiException;
use Jiminny\Integrations\PlaybookResolver;
use Jiminny\Models\Activity;
use Jiminny\Models\Calendar\CalendarEvent;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\Profile;
use Jiminny\Models\Playbook;
use Jiminny\Models\PlaybookCategory;
use Jiminny\Repositories\ActivityRepository;
use Jiminny\Repositories\PlaybookCategoryRepository;
use Psr\Log\LoggerInterface;
class SyncRelatedActivityManager
{
public const string OBJECT_MEETING = 'meetings';
public const string DEFAULT_ACTIVITY_TYPE = 'hs_activity_type';
private RemoteCrmObjectManipulator $crmManipulator;
public function __construct(
private readonly Client $client,
private readonly PayloadBuilder $payloadBuilder,
private readonly LoggerInterface $logger
) {
$this->crmManipulator = app(RemoteCrmObjectManipulator::class, [
'client' => $this->client,
'logger' => $this->logger,
]);
}
public function fetchAndAssociateRelatedActivity(Activity $activity): ?Activity
{
// Validate if the activity is applicable.
if ($this->isActivityApplicable($activity) === false) {
$this->logger->info('[Hubspot] Fetch related activity - not applicable', [
'activityId' => $activity->getUuid(),
]);
return null;
}
// Find and fetch related activity.
try {
$crmActivity = $this->fetchRelatedActivity($activity);
if (empty($crmActivity)) {
return null;
}
} catch (Exception $exception) {
$this->logger->error('[Hubspot] Fetch related activity - failed', [
'activityId' => $activity->getUuid(),
'reason' => $exception->getMessage(),
]);
throw $exception;
}
// get playbook category id
$playbookCategoryId = $this->getPlaybookCategoryId($activity, $crmActivity);
$data = [
'crm_provider_id' => $crmActivity['id'],
'playbook_category_id' => $playbookCategoryId,
];
if (! $this->updateActivity($activity, $data)) {
return null;
}
$this->crmManipulator->updateRelatedCrmActivity($activity, $crmActivity);
$activity->refresh();
return $activity;
}
private function isActivityApplicable(Activity $activity): bool
{
if (! $activity->isTypeConference()) {
return false;
}
if (! $this->hasMinimumFilterRequirements($activity)) {
$this->logger->info('[Hubspot] Fetch related activity - not enough filter data', [
'activityId' => $activity->getUuid(),
]);
return false;
}
// The related activity is already linked.
if ($activity->hasCrmProviderId()) {
return false;
}
if (! $activity->getUser()->getProfile() instanceof Profile) {
$this->logger->info('[Hubspot] Fetch related activity - activity user has no crm profile', [
'activityId' => $activity->getUuid(),
]);
return false;
}
return true;
}
private function hasMinimumFilterRequirements(Activity $activity): bool
{
// We need to know when it started to determine when to look for related activity.
if (! $activity->hasActualStartTime()) {
return false;
}
if (! $activity->hasActualEndTime()) {
return false;
}
// At least one prospect should be associated to the activity
if ($activity->hasContact() || $activity->hasAccount()) {
return true;
}
return false;
}
public function fetchRelatedActivity(Activity $activity): array
{
$this->logger->info('[Hubspot] Fetch related activity - searching related CRM activity', [
'activityId' => $activity->getUuid(),
]);
$payloadData = $this->getPayloadFilterData($activity);
try {
$payload = $this->payloadBuilder->getFindRelatedActivityPayload($payloadData, self::OBJECT_MEETING);
$response = $this->client->getPaginatedData($payload, self::OBJECT_MEETING);
$crmActivities = $response['results'];
} catch (Exception $e) {
$this->logger->error('[Hubspot] Fetch related activity - failed', [
'activityId' => $activity->getUuid(),
'reason' => $e->getMessage(),
]);
throw $e;
}
if (empty($crmActivities)) {
$this->logger->info('[Hubspot] Fetch related activity - no CRM related activity found', [
'activityId' => $activity->getUuid(),
'payload' => $payload,
]);
return [];
}
$relatedActivity = $this->getBestCrmActivityCandidate($crmActivities, $payloadData);
if (empty($relatedActivity)) {
$this->logger->info('[Hubspot] Fetch related activity - no related activity candidate', [
'activityId' => $activity->getUuid(),
]);
return [];
}
$this->logger->info('[Hubspot] Fetch related activity - found related CRM activity', [
'activityId' => $activity->getUuid(),
'crmActivity' => $relatedActivity,
]);
return $relatedActivity;
}
/**
* @param Activity $activity
*
* @return array
*/
protected function getPayloadFilterData(Activity $activity): array
{
[$from, $to] = $this->getActivityTimeFilterRange($activity);
$data = [
'from' => $from->getPreciseTimestamp(3),
'to' => $to->getPreciseTimestamp(3),
'contact' => $activity->hasContact() ? $activity->getContact()->getCrmProviderId() : null,
'company' => $activity->hasAccount() ? $activity->getAccount()->getCrmProviderId() : null,
'deal' => $activity->hasOpportunity() ? $activity->getOpportunity()->getCrmProviderId() : null,
'owner' => $activity->getUser()->getProfile()->getCrmProviderId(),
'activity' => $activity->getUuid(),
];
return array_filter($data);
}
private function getActivityTimeFilterRange(Activity $activity): array
{
if ($activity->hasScheduledStartTime() && $activity->hasScheduledEndTime()) {
$calendarEvent = $activity->getCalendarEvent();
if ($calendarEvent instanceof CalendarEvent) {
$from = $calendarEvent->getStartTime();
$to = $calendarEvent->getEndTime()->addMinutes(15);
} else {
$from = Carbon::instance($activity->getScheduledStartTime());
$to = Carbon::instance($activity->getScheduledEndTime())->addMinutes(15);
}
return [$from, $to];
}
$from = Carbon::instance($activity->getActualStartTime());
$to = Carbon::instance($activity->getActualEndTime());
return [$from, $to];
}
/**
* Determines the best CRM activity candidate based on the number of matching prospects and the owner.
*
* @param array $crmActivities List of CRM activities to evaluate.
* @param array $data Data containing the prospects and owner information.
*
* @return array The best CRM activity candidate.
*/
private function getBestCrmActivityCandidate(array $crmActivities, array $data): array
{
$prospectCount = count(array_intersect_key($data, array_flip(['contact', 'company', 'deal'])));
$bestCandidate = null;
$maxMatchingProspects = 0;
$validActivitiesCount = count($crmActivities);
if ($validActivitiesCount > 1) {
$this->logger->info('[Hubspot] Fetch related activity - determining the best candidate', [
'activityId' => $data['activity'],
'activitiesCount' => $validActivitiesCount,
'crmActivities' => $crmActivities,
]);
}
foreach ($crmActivities as $crmActivity) {
if ($this->isOwnerMismatch($crmActivity, $data)) {
$validActivitiesCount--;
continue;
}
if ($validActivitiesCount === 1) {
return $crmActivity;
}
if ($prospectCount > 1) {
$matchingProspects = $this->countMatchingProspects($crmActivity, $data);
if ($matchingProspects > $maxMatchingProspects) {
$maxMatchingProspects = $matchingProspects;
$bestCandidate = $crmActivity;
}
} else {
return $crmActivity;
}
}
return $bestCandidate ?? [];
}
private function isOwnerMismatch(array $crmActivity, array $data): bool
{
if ($crmActivity['properties']['hubspot_owner_id'] !== $data['owner']) {
$this->logger->info('[Hubspot] Fetch related activity - owner mismatch', [
'activityId' => $data['activity'],
'crmActivity' => $crmActivity['id'],
'jiminnyOwner' => $data['owner'],
'hubspotOwner' => $crmActivity['properties']['hubspot_owner_id'],
]);
return true;
}
return false;
}
private function countMatchingProspects(array $crmActivity, array $data): int
{
try {
$crmActivityWithAssociations = $this->client->getMeeting($crmActivity['id']);
} catch (ApiException $e) {
$this->logger->error('[Hubspot] Fetch related activity - failed to get meeting associations', [
'activityId' => $data['activity'],
'crmActivity' => $crmActivity['id'],
'reason' => $e->getMessage(),
]);
return 0;
}
$associations = $crmActivityWithAssociations->getAssociations();
$matchingProspects = 0;
$matchingProspects += $this->countMatchingAssociation($data, $associations, 'company', 'companies');
$matchingProspects += $this->countMatchingAssociation($data, $associations, 'contact', 'contacts');
$matchingProspects += $this->countMatchingAssociation($data, $associations, 'deal', 'deals');
return $matchingProspects;
}
private function countMatchingAssociation(array $data, array $associations, string $dataKey, string $associationKey): int
{
if (isset($data[$dataKey]) && isset($associations[$associationKey])) {
$associatedItems = $associations[$associationKey]->getResults();
foreach ($associatedItems as $associatedItem) {
if ($data[$dataKey] == $associatedItem->getId()) {
return 1;
}
}
}
return 0;
}
private function getPlaybookCategoryId(Activity $activity, array $crmActivity): ?int
{
if (! $activity->hasUser()) {
$this->logger->info('[Hubspot] Fetch related activity - activity has no user', [
'activityId' => $activity->getUuid(),
]);
return null;
}
$playbookResolver = app(PlaybookResolver::class);
$playbook = $playbookResolver->resolvePlaybookByUser($activity->getUser());
if (! $playbook instanceof Playbook) {
$this->logger->info('[Hubspot] Fetch related activity - playbook not set for user', [
'activityId' => $activity->getUuid(),
]);
return null;
}
if (! $playbook->getActivityField() instanceof Field) {
$this->logger->info('[Hubspot] Fetch related activity - playbook activity field not set', [
'activityId' => $activity->getUuid(),
]);
return null;
}
$playbookCategory = null;
$activityTypeSource = null;
$playbookActivityFieldName = $playbook->getActivityField()->getCrmProviderId();
if ($playbookActivityFieldName === 'activityType') {
$playbookActivityFieldName = self::DEFAULT_ACTIVITY_TYPE;
}
// First attempt to match playbook category from crm activity
if (! empty($crmActivity['properties'][$playbookActivityFieldName])) {
$playbookCategoryRepository = app(PlaybookCategoryRepository::class);
$playbookCategory = $playbookCategoryRepository->getPlaybookCategoryByName(
$playbook,
strval($crmActivity['properties'][$playbookActivityFieldName])
);
$activityTypeSource = 'crm';
}
// If not matched get playbook category from Jiminny activity
if (! $playbookCategory instanceof PlaybookCategory && $activity->hasActivityType()) {
$playbookCategory = $activity->getActivityType();
$activityTypeSource = 'jiminny';
}
if ($playbookCategory instanceof PlaybookCategory) {
$this->logger->info('[Hubspot] Fetch related activity - Playbook category matched', [
'activityId' => $activity->getUuid(),
'playbookCategoryId' => $playbookCategory->getId(),
'activityFieldName' => $playbookActivityFieldName,
'crmActivity' => $crmActivity['id'],
'source' => $activityTypeSource,
]);
return $playbookCategory->getId();
}
$this->logger->info('[Hubspot] Fetch related activity - Playbook category not found', [
'activityId' => $activity->getUuid(),
'activityFieldName' => $playbookActivityFieldName,
'crmActivity' => $crmActivity['id'],
]);
return null;
}
private function updateActivity(Activity $activity, array $data): bool
{
$activityRepository = app(ActivityRepository::class);
$alreadyLoggedActivity = $activityRepository->findUserActivityByCrmProviderId(
$activity->getUser(),
$data['crm_provider_id']
);
if ($alreadyLoggedActivity instanceof Activity) {
$this->logger->info('[Hubspot] Fetch related activity - activity already linked', [
'currentActivityId' => $activity->getUuid(),
'linkedActivityId' => $alreadyLoggedActivity->getUuid(),
'crmActivityId' => $data['crm_provider_id'],
]);
// Return false to prevent updating the remote CRM engagement. This can happen when two Jiminny
// activities share the same calendar event (e.g., rescheduled meetings). Without this, the first
// activity's summary would be overwritten by the second activity.
return false;
}
try {
$activityRepository->update($activity, $data);
} catch (Exception $e) {
$this->logger->error('[Hubspot] Fetch related activity - activity update failed', [
'activityId' => $activity->getUuid(),
'reason' => $e->getMessage(),
]);
return false;
}
$this->logger->info('[Hubspot] Fetch related activity - activity updated', [
'activityId' => $activity->getUuid(),
'data' => $data,
]);
return true;
}
}
Project
Project
New File or Directory…
Expand Selected
Collapse All
Options
Hide
app ~/jiminny/app, folder
.circleci, folder
.cursor, folder
.github
.sonarlint, folder
.vscode, folder
.windsurf, folder
app, sources root
Actions, folder
Component, folder
Acl, folder
ActionItems, folder
Activity, folder
ActivityAnalytics, folder
ActivitySearch, folder
AiActivityType, folder
AiAutomation, folder
AiCallScoring, folder
AskAnything, folder
Dtos, folder
Events, folder
AskAnythingPromptService.php, class
HistoryService.php, class
AskJiminnyAi, folder
AWS, folder
BillingManagement, folder
Cache, folder
CoachingFeedback, folder
Country, folder
CustomerApi, folder
Database, folder
Datadog, folder
DateTime, folder
DealInsights, folder
DealRisks, folder
ElasticSearch, folder
Eloquent, folder
Encoding, folder
Encryption, folder
ES, folder
Faker, folder
FeatureFlags, folder
FFMpeg, folder
FileSystem, folder
Gecko, folder
Gong, folder
GuzzleHttp, folder
KeyPoints, folder
Kiosk, folder
LanguageDetection, folder
LiveFeed, folder
Locks, folder
Math, folder
MediaPipeline, folder
MeetingBot, folder
MobileSettings, folder
Model, folder
Notification, folder
Nudge, folder
ParagraphBreaker, folder
ParticipantSpeech, folder
PartitionedCookie, folder
PlaybackPage, folder
Playlist, folder
Prophet, folder
ProphetAi, folder
ProsperWorks, folder
Queue, folder
Router, folder
Saml2, folder
SCIM, folder
Seeder, folder
Sentry, folder
Serializer, folder
Settings, folder
Sidekick, folder
Slack, folder
TeamInsights, folder
TimeMemoryMapper, folder
Transcription, folder
TranscriptionSummary, folder
Twilio, folder
Uploader, folder
UrlGenerator, folder
Utility, folder
Exceptions, folder
Service, folder
BaseRateLimiter.php, class
EfficientJsonParser.php, class
ProviderRateLimiter.php, class
RateLimiterInstance.php, class
Uuid
Waveform
Webhooks
Workflow
Configuration
Console
Commands
Activities
Analytics
Calendars
Crm
Hubspot
IntegrationApp
Traits
AddLayoutEntities.php, class
AutologDelayedCommand.php, class
BullhornCommandAbstract.php, abstract class
BullhornPingCommand.php, class...
|
9833
|
NULL
|
NULL
|
NULL
|
|
9834
|
443
|
18
|
2026-05-08T13:37:09.792654+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-08/1778 /Users/lukas/.screenpipe/data/data/2026-05-08/1778247429792_m1.jpg...
|
PhpStorm
|
faVsco.js – ~/jiminny/app/vendor/hubspot/api-clien faVsco.js – ~/jiminny/app/vendor/hubspot/api-client/codegen/Crm/Objects/Model/SimplePublicObjectWithAssociations.php...
|
1
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
JY-20725-handle-HS-search Project: faVsco.js, menu
JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
19
Previous Highlighted Error
Next Highlighted Error
[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {
"headers":{
"Date":["Thu,07 May 2026 14:21:15 GMT"],
"Content-Type":["application/json;charset=utf-8"],
"Transfer-Encoding":["chunked"],
"Connection":["keep-alive"],
"CF-Ray":["9f80deb8db60dc3a-SOF"],
"CF-Cache-Status":["DYNAMIC"],
"Strict-Transport-Security":["max-age=31536000; includeSubDomains; preload"],
"Vary":["origin,
accept-encoding"],
"access-control-allow-credentials":["false"],
"server-timing":["hcid;desc=\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\",
cfr;desc=\"9f80deb8e7c6dc3a-IAD\""],
"x-content-type-options":["nosniff"],
"x-hubspot-correlation-id":["019e02d0-6fd8-7812-bdba-885b7ccb3ee3"],
"Set-Cookie":["__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-[IP_ADDRESS]-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,
07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None"],
"Report-To":["{
\"endpoints\":[{
\"url\":\"https:\\/\\/a.nel.cloudflare.com\\/report\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\"}],
\"group\":\"cf-nel\",
\"max_age\":604800}"],
"NEL":["{
\"success_fraction\":0.01,
\"report_to\":\"cf-nel\",
\"max_age\":604800}"],
"Server":["cloudflare"]}} {
"correlation_id":"95236535-ec98-4541-b92a-adfa73b69eab",
"trace_id":"c7ab8365-903f-46d4-9403-0e5b551e3545"}
Sync Changes
Hide This Notification...
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JY-20725-handle-HS-search-rate-limit, menu","depth":5,"on_screen":true,"help_text":"Git Branch: JY-20725-handle-HS-search-rate-limit","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"AskJiminnyReportActivityServiceTest","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'AskJiminnyReportActivityServiceTest'","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'AskJiminnyReportActivityServiceTest'","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"IDE and Project Settings","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.088194445,"height":0.027777778},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"19","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {\n\"headers\":{\n\"Date\":[\"Thu,07 May 2026 14:21:15 GMT\"],\n \"Content-Type\":[\"application/json;charset=utf-8\"],\n \"Transfer-Encoding\":[\"chunked\"],\n \"Connection\":[\"keep-alive\"],\n \"CF-Ray\":[\"9f80deb8db60dc3a-SOF\"],\n \"CF-Cache-Status\":[\"DYNAMIC\"],\n \"Strict-Transport-Security\":[\"max-age=31536000; includeSubDomains; preload\"],\n \"Vary\":[\"origin,\n accept-encoding\"],\n \"access-control-allow-credentials\":[\"false\"],\n \"server-timing\":[\"hcid;desc=\\\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\\\",\n cfr;desc=\\\"9f80deb8e7c6dc3a-IAD\\\"\"],\n \"x-content-type-options\":[\"nosniff\"],\n \"x-hubspot-correlation-id\":[\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\"],\n \"Set-Cookie\":[\"__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-1.0.1.1-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,\n 07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None\"],\n \"Report-To\":[\"{\n\\\"endpoints\\\":[{\n\\\"url\\\":\\\"https:\\\\/\\\\/a.nel.cloudflare.com\\\\/report\\\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\\\"}],\n\\\"group\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"NEL\":[\"{\n\\\"success_fraction\\\":0.01,\n\\\"report_to\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"Server\":[\"cloudflare\"]}} {\n\"correlation_id\":\"95236535-ec98-4541-b92a-adfa73b69eab\",\n\"trace_id\":\"c7ab8365-903f-46d4-9403-0e5b551e3545\"}","depth":4,"on_screen":true,"value":"[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {\n\"headers\":{\n\"Date\":[\"Thu,07 May 2026 14:21:15 GMT\"],\n \"Content-Type\":[\"application/json;charset=utf-8\"],\n \"Transfer-Encoding\":[\"chunked\"],\n \"Connection\":[\"keep-alive\"],\n \"CF-Ray\":[\"9f80deb8db60dc3a-SOF\"],\n \"CF-Cache-Status\":[\"DYNAMIC\"],\n \"Strict-Transport-Security\":[\"max-age=31536000; includeSubDomains; preload\"],\n \"Vary\":[\"origin,\n accept-encoding\"],\n \"access-control-allow-credentials\":[\"false\"],\n \"server-timing\":[\"hcid;desc=\\\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\\\",\n cfr;desc=\\\"9f80deb8e7c6dc3a-IAD\\\"\"],\n \"x-content-type-options\":[\"nosniff\"],\n \"x-hubspot-correlation-id\":[\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\"],\n \"Set-Cookie\":[\"__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-1.0.1.1-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,\n 07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None\"],\n \"Report-To\":[\"{\n\\\"endpoints\\\":[{\n\\\"url\\\":\\\"https:\\\\/\\\\/a.nel.cloudflare.com\\\\/report\\\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\\\"}],\n\\\"group\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"NEL\":[\"{\n\\\"success_fraction\\\":0.01,\n\\\"report_to\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"Server\":[\"cloudflare\"]}} {\n\"correlation_id\":\"95236535-ec98-4541-b92a-adfa73b69eab\",\n\"trace_id\":\"c7ab8365-903f-46d4-9403-0e5b551e3545\"}","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
2309361378997385084
|
-3047780009897375286
|
click
|
accessibility
|
NULL
|
Project: faVsco.js, menu
JY-20725-handle-HS-search Project: faVsco.js, menu
JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
19
Previous Highlighted Error
Next Highlighted Error
[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {
"headers":{
"Date":["Thu,07 May 2026 14:21:15 GMT"],
"Content-Type":["application/json;charset=utf-8"],
"Transfer-Encoding":["chunked"],
"Connection":["keep-alive"],
"CF-Ray":["9f80deb8db60dc3a-SOF"],
"CF-Cache-Status":["DYNAMIC"],
"Strict-Transport-Security":["max-age=31536000; includeSubDomains; preload"],
"Vary":["origin,
accept-encoding"],
"access-control-allow-credentials":["false"],
"server-timing":["hcid;desc=\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\",
cfr;desc=\"9f80deb8e7c6dc3a-IAD\""],
"x-content-type-options":["nosniff"],
"x-hubspot-correlation-id":["019e02d0-6fd8-7812-bdba-885b7ccb3ee3"],
"Set-Cookie":["__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-[IP_ADDRESS]-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,
07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None"],
"Report-To":["{
\"endpoints\":[{
\"url\":\"https:\\/\\/a.nel.cloudflare.com\\/report\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\"}],
\"group\":\"cf-nel\",
\"max_age\":604800}"],
"NEL":["{
\"success_fraction\":0.01,
\"report_to\":\"cf-nel\",
\"max_age\":604800}"],
"Server":["cloudflare"]}} {
"correlation_id":"95236535-ec98-4541-b92a-adfa73b69eab",
"trace_id":"c7ab8365-903f-46d4-9403-0e5b551e3545"}
Sync Changes
Hide This Notification...
|
9831
|
NULL
|
NULL
|
NULL
|
|
9833
|
444
|
22
|
2026-05-08T13:37:09.126959+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-08/1778 /Users/lukas/.screenpipe/data/data/2026-05-08/1778247429126_m2.jpg...
|
PhpStorm
|
faVsco.js – SyncRelatedActivityManager.php
|
1
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
JY-20725-handle-HS-search Project: faVsco.js, menu
JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
19
Previous Highlighted Error
Next Highlighted Error
[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {
"headers":{
"Date":["Thu,07 May 2026 14:21:15 GMT"],
"Content-Type":["application/json;charset=utf-8"],
"Transfer-Encoding":["chunked"],
"Connection":["keep-alive"],
"CF-Ray":["9f80deb8db60dc3a-SOF"],
"CF-Cache-Status":["DYNAMIC"],
"Strict-Transport-Security":["max-age=31536000; includeSubDomains; preload"],
"Vary":["origin,
accept-encoding"],
"access-control-allow-credentials":["false"],
"server-timing":["hcid;desc=\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\",
cfr;desc=\"9f80deb8e7c6dc3a-IAD\""],
"x-content-type-options":["nosniff"],
"x-hubspot-correlation-id":["019e02d0-6fd8-7812-bdba-885b7ccb3ee3"],
"Set-Cookie":["__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-[IP_ADDRESS]-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,
07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None"],
"Report-To":["{
\"endpoints\":[{
\"url\":\"https:\\/\\/a.nel.cloudflare.com\\/report\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\"}],
\"group\":\"cf-nel\",
\"max_age\":604800}"],
"NEL":["{
\"success_fraction\":0.01,
\"report_to\":\"cf-nel\",
\"max_age\":604800}"],
"Server":["cloudflare"]}} {
"correlation_id":"95236535-ec98-4541-b92a-adfa73b69eab",
"trace_id":"c7ab8365-903f-46d4-9403-0e5b551e3545"}
Sync Changes
Hide This Notification...
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"bounds":{"left":0.025930852,"top":0.019952115,"width":0.03856383,"height":0.025538707},"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JY-20725-handle-HS-search-rate-limit, menu","depth":5,"bounds":{"left":0.064494684,"top":0.019952115,"width":0.09541223,"height":0.025538707},"on_screen":true,"help_text":"Git Branch: JY-20725-handle-HS-search-rate-limit","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"bounds":{"left":0.8081782,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"AskJiminnyReportActivityServiceTest","depth":6,"bounds":{"left":0.8234708,"top":0.019952115,"width":0.09208777,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'AskJiminnyReportActivityServiceTest'","depth":6,"bounds":{"left":0.9155585,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'AskJiminnyReportActivityServiceTest'","depth":6,"bounds":{"left":0.9268617,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"bounds":{"left":0.9381649,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"bounds":{"left":0.96609044,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"bounds":{"left":0.9773936,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"IDE and Project Settings","depth":5,"bounds":{"left":0.9886968,"top":0.019952115,"width":0.011303186,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.042220745,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"19","depth":4,"bounds":{"left":0.6615692,"top":0.10055866,"width":0.009640957,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"bounds":{"left":0.67287236,"top":0.09896249,"width":0.00731383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"bounds":{"left":0.68018615,"top":0.09896249,"width":0.006981383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {\n\"headers\":{\n\"Date\":[\"Thu,07 May 2026 14:21:15 GMT\"],\n \"Content-Type\":[\"application/json;charset=utf-8\"],\n \"Transfer-Encoding\":[\"chunked\"],\n \"Connection\":[\"keep-alive\"],\n \"CF-Ray\":[\"9f80deb8db60dc3a-SOF\"],\n \"CF-Cache-Status\":[\"DYNAMIC\"],\n \"Strict-Transport-Security\":[\"max-age=31536000; includeSubDomains; preload\"],\n \"Vary\":[\"origin,\n accept-encoding\"],\n \"access-control-allow-credentials\":[\"false\"],\n \"server-timing\":[\"hcid;desc=\\\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\\\",\n cfr;desc=\\\"9f80deb8e7c6dc3a-IAD\\\"\"],\n \"x-content-type-options\":[\"nosniff\"],\n \"x-hubspot-correlation-id\":[\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\"],\n \"Set-Cookie\":[\"__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-1.0.1.1-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,\n 07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None\"],\n \"Report-To\":[\"{\n\\\"endpoints\\\":[{\n\\\"url\\\":\\\"https:\\\\/\\\\/a.nel.cloudflare.com\\\\/report\\\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\\\"}],\n\\\"group\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"NEL\":[\"{\n\\\"success_fraction\\\":0.01,\n\\\"report_to\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"Server\":[\"cloudflare\"]}} {\n\"correlation_id\":\"95236535-ec98-4541-b92a-adfa73b69eab\",\n\"trace_id\":\"c7ab8365-903f-46d4-9403-0e5b551e3545\"}","depth":4,"bounds":{"left":0.37632978,"top":0.09736632,"width":0.5728058,"height":0.8818835},"on_screen":true,"lines":[{"char_start":207,"char_count":30,"bounds":{"left":0.37632978,"top":0.0,"width":0.07513298,"height":0.014365523}},{"char_start":237,"char_count":36,"bounds":{"left":0.37632978,"top":0.0,"width":0.09075798,"height":0.014365523}},{"char_start":273,"char_count":32,"bounds":{"left":0.37632978,"top":0.0,"width":0.080119684,"height":0.014365523}},{"char_start":305,"char_count":79,"bounds":{"left":0.37632978,"top":0.0,"width":0.20212767,"height":0.014365523}},{"char_start":384,"char_count":18,"bounds":{"left":0.37632978,"top":0.0,"width":0.043882977,"height":0.014365523}},{"char_start":402,"char_count":21,"bounds":{"left":0.37632978,"top":0.0,"width":0.051861703,"height":0.014365523}},{"char_start":423,"char_count":48,"bounds":{"left":0.37632978,"top":0.008778931,"width":0.12167553,"height":0.014365523}},{"char_start":471,"char_count":72,"bounds":{"left":0.37632978,"top":0.026336791,"width":0.18384309,"height":0.014365523}},{"char_start":543,"char_count":40,"bounds":{"left":0.37632978,"top":0.043894652,"width":0.10106383,"height":0.014365523}},{"char_start":583,"char_count":41,"bounds":{"left":0.37632978,"top":0.061452515,"width":0.10372341,"height":0.014365523}},{"char_start":624,"char_count":72,"bounds":{"left":0.37632978,"top":0.079010375,"width":0.18384309,"height":0.014365523}},{"char_start":696,"char_count":219,"bounds":{"left":0.37632978,"top":0.096568234,"width":0.56515956,"height":0.014365523}},{"char_start":915,"char_count":83,"bounds":{"left":0.37632978,"top":0.11412609,"width":0.21243352,"height":0.014365523}},{"char_start":998,"char_count":20,"bounds":{"left":0.37632978,"top":0.13168396,"width":0.04920213,"height":0.014365523}},{"char_start":1018,"char_count":17,"bounds":{"left":0.37632978,"top":0.14924182,"width":0.041223403,"height":0.014365523}},{"char_start":1035,"char_count":203,"bounds":{"left":0.37632978,"top":0.16679968,"width":0.52360374,"height":0.014365523}},{"char_start":1238,"char_count":22,"bounds":{"left":0.37632978,"top":0.18435754,"width":0.05418883,"height":0.014365523}},{"char_start":1260,"char_count":23,"bounds":{"left":0.37632978,"top":0.2019154,"width":0.056848403,"height":0.014365523}},{"char_start":1283,"char_count":10,"bounds":{"left":0.37632978,"top":0.21947326,"width":0.023271276,"height":0.014365523}},{"char_start":1293,"char_count":27,"bounds":{"left":0.37632978,"top":0.23703113,"width":0.06715426,"height":0.014365523}},{"char_start":1320,"char_count":26,"bounds":{"left":0.37632978,"top":0.254589,"width":0.06482713,"height":0.014365523}},{"char_start":1346,"char_count":23,"bounds":{"left":0.37632978,"top":0.27214685,"width":0.056848403,"height":0.014365523}},{"char_start":1369,"char_count":28,"bounds":{"left":0.37632978,"top":0.2897047,"width":0.06981383,"height":0.014365523}},{"char_start":1397,"char_count":57,"bounds":{"left":0.37632978,"top":0.30726257,"width":0.14494681,"height":0.014365523}}],"value":"[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {\n\"headers\":{\n\"Date\":[\"Thu,07 May 2026 14:21:15 GMT\"],\n \"Content-Type\":[\"application/json;charset=utf-8\"],\n \"Transfer-Encoding\":[\"chunked\"],\n \"Connection\":[\"keep-alive\"],\n \"CF-Ray\":[\"9f80deb8db60dc3a-SOF\"],\n \"CF-Cache-Status\":[\"DYNAMIC\"],\n \"Strict-Transport-Security\":[\"max-age=31536000; includeSubDomains; preload\"],\n \"Vary\":[\"origin,\n accept-encoding\"],\n \"access-control-allow-credentials\":[\"false\"],\n \"server-timing\":[\"hcid;desc=\\\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\\\",\n cfr;desc=\\\"9f80deb8e7c6dc3a-IAD\\\"\"],\n \"x-content-type-options\":[\"nosniff\"],\n \"x-hubspot-correlation-id\":[\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\"],\n \"Set-Cookie\":[\"__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-1.0.1.1-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,\n 07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None\"],\n \"Report-To\":[\"{\n\\\"endpoints\\\":[{\n\\\"url\\\":\\\"https:\\\\/\\\\/a.nel.cloudflare.com\\\\/report\\\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\\\"}],\n\\\"group\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"NEL\":[\"{\n\\\"success_fraction\\\":0.01,\n\\\"report_to\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"Server\":[\"cloudflare\"]}} {\n\"correlation_id\":\"95236535-ec98-4541-b92a-adfa73b69eab\",\n\"trace_id\":\"c7ab8365-903f-46d4-9403-0e5b551e3545\"}","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
2309361378997385084
|
-3047780009897375286
|
click
|
accessibility
|
NULL
|
Project: faVsco.js, menu
JY-20725-handle-HS-search Project: faVsco.js, menu
JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
19
Previous Highlighted Error
Next Highlighted Error
[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {
"headers":{
"Date":["Thu,07 May 2026 14:21:15 GMT"],
"Content-Type":["application/json;charset=utf-8"],
"Transfer-Encoding":["chunked"],
"Connection":["keep-alive"],
"CF-Ray":["9f80deb8db60dc3a-SOF"],
"CF-Cache-Status":["DYNAMIC"],
"Strict-Transport-Security":["max-age=31536000; includeSubDomains; preload"],
"Vary":["origin,
accept-encoding"],
"access-control-allow-credentials":["false"],
"server-timing":["hcid;desc=\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\",
cfr;desc=\"9f80deb8e7c6dc3a-IAD\""],
"x-content-type-options":["nosniff"],
"x-hubspot-correlation-id":["019e02d0-6fd8-7812-bdba-885b7ccb3ee3"],
"Set-Cookie":["__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-[IP_ADDRESS]-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,
07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None"],
"Report-To":["{
\"endpoints\":[{
\"url\":\"https:\\/\\/a.nel.cloudflare.com\\/report\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\"}],
\"group\":\"cf-nel\",
\"max_age\":604800}"],
"NEL":["{
\"success_fraction\":0.01,
\"report_to\":\"cf-nel\",
\"max_age\":604800}"],
"Server":["cloudflare"]}} {
"correlation_id":"95236535-ec98-4541-b92a-adfa73b69eab",
"trace_id":"c7ab8365-903f-46d4-9403-0e5b551e3545"}
Sync Changes
Hide This Notification...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
9832
|
444
|
21
|
2026-05-08T13:37:00.488872+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-08/1778 /Users/lukas/.screenpipe/data/data/2026-05-08/1778247420488_m2.jpg...
|
PhpStorm
|
faVsco.js – SyncRelatedActivityManager.php
|
1
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
JY-20725-handle-HS-search Project: faVsco.js, menu
JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
19
Previous Highlighted Error
Next Highlighted Error
[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {
"headers":{
"Date":["Thu,07 May 2026 14:21:15 GMT"],
"Content-Type":["application/json;charset=utf-8"],
"Transfer-Encoding":["chunked"],
"Connection":["keep-alive"],
"CF-Ray":["9f80deb8db60dc3a-SOF"],
"CF-Cache-Status":["DYNAMIC"],
"Strict-Transport-Security":["max-age=31536000; includeSubDomains; preload"],
"Vary":["origin,
accept-encoding"],
"access-control-allow-credentials":["false"],
"server-timing":["hcid;desc=\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\",
cfr;desc=\"9f80deb8e7c6dc3a-IAD\""],
"x-content-type-options":["nosniff"],
"x-hubspot-correlation-id":["019e02d0-6fd8-7812-bdba-885b7ccb3ee3"],
"Set-Cookie":["__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-[IP_ADDRESS]-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,
07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None"],
"Report-To":["{
\"endpoints\":[{
\"url\":\"https:\\/\\/a.nel.cloudflare.com\\/report\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\"}],
\"group\":\"cf-nel\",
\"max_age\":604800}"],
"NEL":["{
\"success_fraction\":0.01,
\"report_to\":\"cf-nel\",
\"max_age\":604800}"],
"Server":["cloudflare"]}} {
"correlation_id":"95236535-ec98-4541-b92a-adfa73b69eab",
"trace_id":"c7ab8365-903f-46d4-9403-0e5b551e3545"}
Sync Changes
Hide This Notification
Code changed:
Hide
3
11
Previous Highlighted Error
Next Highlighted Error
<?php
declare(strict_types=1);
namespace Jiminny\Services\Crm\Hubspot;
use Carbon\Carbon;
use Exception;
use HubSpot\Client\Crm\Objects\ApiException;
use Jiminny\Integrations\PlaybookResolver;
use Jiminny\Models\Activity;
use Jiminny\Models\Calendar\CalendarEvent;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\Profile;
use Jiminny\Models\Playbook;
use Jiminny\Models\PlaybookCategory;
use Jiminny\Repositories\ActivityRepository;
use Jiminny\Repositories\PlaybookCategoryRepository;
use Psr\Log\LoggerInterface;
class SyncRelatedActivityManager
{
public const string OBJECT_MEETING = 'meetings';
public const string DEFAULT_ACTIVITY_TYPE = 'hs_activity_type';
private RemoteCrmObjectManipulator $crmManipulator;
public function __construct(
private readonly Client $client,
private readonly PayloadBuilder $payloadBuilder,
private readonly LoggerInterface $logger
) {
$this->crmManipulator = app(RemoteCrmObjectManipulator::class, [
'client' => $this->client,
'logger' => $this->logger,
]);
}
public function fetchAndAssociateRelatedActivity(Activity $activity): ?Activity
{
// Validate if the activity is applicable.
if ($this->isActivityApplicable($activity) === false) {
$this->logger->info('[Hubspot] Fetch related activity - not applicable', [
'activityId' => $activity->getUuid(),
]);
return null;
}
// Find and fetch related activity.
try {
$crmActivity = $this->fetchRelatedActivity($activity);
if (empty($crmActivity)) {
return null;
}
} catch (Exception $exception) {
$this->logger->error('[Hubspot] Fetch related activity - failed', [
'activityId' => $activity->getUuid(),
'reason' => $exception->getMessage(),
]);
throw $exception;
}
// get playbook category id
$playbookCategoryId = $this->getPlaybookCategoryId($activity, $crmActivity);
$data = [
'crm_provider_id' => $crmActivity['id'],
'playbook_category_id' => $playbookCategoryId,
];
if (! $this->updateActivity($activity, $data)) {
return null;
}
$this->crmManipulator->updateRelatedCrmActivity($activity, $crmActivity);
$activity->refresh();
return $activity;
}
private function isActivityApplicable(Activity $activity): bool
{
if (! $activity->isTypeConference()) {
return false;
}
if (! $this->hasMinimumFilterRequirements($activity)) {
$this->logger->info('[Hubspot] Fetch related activity - not enough filter data', [
'activityId' => $activity->getUuid(),
]);
return false;
}
// The related activity is already linked.
if ($activity->hasCrmProviderId()) {
return false;
}
if (! $activity->getUser()->getProfile() instanceof Profile) {
$this->logger->info('[Hubspot] Fetch related activity - activity user has no crm profile', [
'activityId' => $activity->getUuid(),
]);
return false;
}
return true;
}
private function hasMinimumFilterRequirements(Activity $activity): bool
{
// We need to know when it started to determine when to look for related activity.
if (! $activity->hasActualStartTime()) {
return false;
}
if (! $activity->hasActualEndTime()) {
return false;
}
// At least one prospect should be associated to the activity
if ($activity->hasContact() || $activity->hasAccount()) {
return true;
}
return false;
}
public function fetchRelatedActivity(Activity $activity): array
{
$this->logger->info('[Hubspot] Fetch related activity - searching related CRM activity', [
'activityId' => $activity->getUuid(),
]);
$payloadData = $this->getPayloadFilterData($activity);
try {
$payload = $this->payloadBuilder->getFindRelatedActivityPayload($payloadData, self::OBJECT_MEETING);
$response = $this->client->getPaginatedData($payload, self::OBJECT_MEETING);
$crmActivities = $response['results'];
} catch (Exception $e) {
$this->logger->error('[Hubspot] Fetch related activity - failed', [
'activityId' => $activity->getUuid(),
'reason' => $e->getMessage(),
]);
throw $e;
}
if (empty($crmActivities)) {
$this->logger->info('[Hubspot] Fetch related activity - no CRM related activity found', [
'activityId' => $activity->getUuid(),
'payload' => $payload,
]);
return [];
}
$relatedActivity = $this->getBestCrmActivityCandidate($crmActivities, $payloadData);
if (empty($relatedActivity)) {
$this->logger->info('[Hubspot] Fetch related activity - no related activity candidate', [
'activityId' => $activity->getUuid(),
]);
return [];
}
$this->logger->info('[Hubspot] Fetch related activity - found related CRM activity', [
'activityId' => $activity->getUuid(),
'crmActivity' => $relatedActivity,
]);
return $relatedActivity;
}
/**
* @param Activity $activity
*
* @return array
*/
protected function getPayloadFilterData(Activity $activity): array
{
[$from, $to] = $this->getActivityTimeFilterRange($activity);
$data = [
'from' => $from->getPreciseTimestamp(3),
'to' => $to->getPreciseTimestamp(3),
'contact' => $activity->hasContact() ? $activity->getContact()->getCrmProviderId() : null,
'company' => $activity->hasAccount() ? $activity->getAccount()->getCrmProviderId() : null,
'deal' => $activity->hasOpportunity() ? $activity->getOpportunity()->getCrmProviderId() : null,
'owner' => $activity->getUser()->getProfile()->getCrmProviderId(),
'activity' => $activity->getUuid(),
];
return array_filter($data);
}
private function getActivityTimeFilterRange(Activity $activity): array
{
if ($activity->hasScheduledStartTime() && $activity->hasScheduledEndTime()) {
$calendarEvent = $activity->getCalendarEvent();
if ($calendarEvent instanceof CalendarEvent) {
$from = $calendarEvent->getStartTime();
$to = $calendarEvent->getEndTime()->addMinutes(15);
} else {
$from = Carbon::instance($activity->getScheduledStartTime());
$to = Carbon::instance($activity->getScheduledEndTime())->addMinutes(15);
}
return [$from, $to];
}
$from = Carbon::instance($activity->getActualStartTime());
$to = Carbon::instance($activity->getActualEndTime());
return [$from, $to];
}
/**
* Determines the best CRM activity candidate based on the number of matching prospects and the owner.
*
* @param array $crmActivities List of CRM activities to evaluate.
* @param array $data Data containing the prospects and owner information.
*
* @return array The best CRM activity candidate.
*/
private function getBestCrmActivityCandidate(array $crmActivities, array $data): array
{
$prospectCount = count(array_intersect_key($data, array_flip(['contact', 'company', 'deal'])));
$bestCandidate = null;
$maxMatchingProspects = 0;
$validActivitiesCount = count($crmActivities);
if ($validActivitiesCount > 1) {
$this->logger->info('[Hubspot] Fetch related activity - determining the best candidate', [
'activityId' => $data['activity'],
'activitiesCount' => $validActivitiesCount,
'crmActivities' => $crmActivities,
]);
}
foreach ($crmActivities as $crmActivity) {
if ($this->isOwnerMismatch($crmActivity, $data)) {
$validActivitiesCount--;
continue;
}
if ($validActivitiesCount === 1) {
return $crmActivity;
}
if ($prospectCount > 1) {
$matchingProspects = $this->countMatchingProspects($crmActivity, $data);
if ($matchingProspects > $maxMatchingProspects) {
$maxMatchingProspects = $matchingProspects;
$bestCandidate = $crmActivity;
}
} else {
return $crmActivity;
}
}
return $bestCandidate ?? [];
}
private function isOwnerMismatch(array $crmActivity, array $data): bool
{
if ($crmActivity['properties']['hubspot_owner_id'] !== $data['owner']) {
$this->logger->info('[Hubspot] Fetch related activity - owner mismatch', [
'activityId' => $data['activity'],
'crmActivity' => $crmActivity['id'],
'jiminnyOwner' => $data['owner'],
'hubspotOwner' => $crmActivity['properties']['hubspot_owner_id'],
]);
return true;
}
return false;
}
private function countMatchingProspects(array $crmActivity, array $data): int
{
try {
$crmActivityWithAssociations = $this->client->getMeeting($crmActivity['id']);
} catch (ApiException $e) {
$this->logger->error('[Hubspot] Fetch related activity - failed to get meeting associations', [
'activityId' => $data['activity'],
'crmActivity' => $crmActivity['id'],
'reason' => $e->getMessage(),
]);
return 0;
}
$associations = $crmActivityWithAssociations->getAssociations();
$matchingProspects = 0;
$matchingProspects += $this->countMatchingAssociation($data, $associations, 'company', 'companies');
$matchingProspects += $this->countMatchingAssociation($data, $associations, 'contact', 'contacts');
$matchingProspects += $this->countMatchingAssociation($data, $associations, 'deal', 'deals');
return $matchingProspects;
}
private function countMatchingAssociation(array $data, array $associations, string $dataKey, string $associationKey): int
{
if (isset($data[$dataKey]) && isset($associations[$associationKey])) {
$associatedItems = $associations[$associationKey]->getResults();
foreach ($associatedItems as $associatedItem) {
if ($data[$dataKey] == $associatedItem->getId()) {
return 1;
}
}
}
return 0;
}
private function getPlaybookCategoryId(Activity $activity, array $crmActivity): ?int
{
if (! $activity->hasUser()) {
$this->logger->info('[Hubspot] Fetch related activity - activity has no user', [
'activityId' => $activity->getUuid(),
]);
return null;
}
$playbookResolver = app(PlaybookResolver::class);
$playbook = $playbookResolver->resolvePlaybookByUser($activity->getUser());
if (! $playbook instanceof Playbook) {
$this->logger->info('[Hubspot] Fetch related activity - playbook not set for user', [
'activityId' => $activity->getUuid(),
]);
return null;
}
if (! $playbook->getActivityField() instanceof Field) {
$this->logger->info('[Hubspot] Fetch related activity - playbook activity field not set', [
'activityId' => $activity->getUuid(),
]);
return null;
}
$playbookCategory = null;
$activityTypeSource = null;
$playbookActivityFieldName = $playbook->getActivityField()->getCrmProviderId();
if ($playbookActivityFieldName === 'activityType') {
$playbookActivityFieldName = self::DEFAULT_ACTIVITY_TYPE;
}
// First attempt to match playbook category from crm activity
if (! empty($crmActivity['properties'][$playbookActivityFieldName])) {
$playbookCategoryRepository = app(PlaybookCategoryRepository::class);
$playbookCategory = $playbookCategoryRepository->getPlaybookCategoryByName(
$playbook,
strval($crmActivity['properties'][$playbookActivityFieldName])
);
$activityTypeSource = 'crm';
}
// If not matched get playbook category from Jiminny activity
if (! $playbookCategory instanceof PlaybookCategory && $activity->hasActivityType()) {
$playbookCategory = $activity->getActivityType();
$activityTypeSource = 'jiminny';
}
if ($playbookCategory instanceof PlaybookCategory) {
$this->logger->info('[Hubspot] Fetch related activity - Playbook category matched', [
'activityId' => $activity->getUuid(),
'playbookCategoryId' => $playbookCategory->getId(),
'activityFieldName' => $playbookActivityFieldName,
'crmActivity' => $crmActivity['id'],
'source' => $activityTypeSource,
]);
return $playbookCategory->getId();
}
$this->logger->info('[Hubspot] Fetch related activity - Playbook category not found', [
'activityId' => $activity->getUuid(),
'activityFieldName' => $playbookActivityFieldName,
'crmActivity' => $crmActivity['id'],
]);
return null;
}
private function updateActivity(Activity $activity, array $data): bool
{
$activityRepository = app(ActivityRepository::class);
$alreadyLoggedActivity = $activityRepository->findUserActivityByCrmProviderId(
$activity->getUser(),
$data['crm_provider_id']
);
if ($alreadyLoggedActivity instanceof Activity) {
$this->logger->info('[Hubspot] Fetch related activity - activity already linked', [
'currentActivityId' => $activity->getUuid(),
'linkedActivityId' => $alreadyLoggedActivity->getUuid(),
'crmActivityId' => $data['crm_provider_id'],
]);
// Return false to prevent updating the remote CRM engagement. This can happen when two Jiminny
// activities share the same calendar event (e.g., rescheduled meetings). Without this, the first
// activity's summary would be overwritten by the second activity.
return false;
}
try {
$activityRepository->update($activity, $data);
} catch (Exception $e) {
$this->logger->error('[Hubspot] Fetch related activity - activity update failed', [
'activityId' => $activity->getUuid(),
'reason' => $e->getMessage(),
]);
return false;
}
$this->logger->info('[Hubspot] Fetch related activity - activity updated', [
'activityId' => $activity->getUuid(),
'data' => $data,
]);
return true;
}
}
Project
Project
New File or Directory…
Expand Selected
Collapse All
Options
Hide...
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"bounds":{"left":0.025930852,"top":0.019952115,"width":0.03856383,"height":0.025538707},"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JY-20725-handle-HS-search-rate-limit, menu","depth":5,"bounds":{"left":0.064494684,"top":0.019952115,"width":0.09541223,"height":0.025538707},"on_screen":true,"help_text":"Git Branch: JY-20725-handle-HS-search-rate-limit","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"bounds":{"left":0.8081782,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"AskJiminnyReportActivityServiceTest","depth":6,"bounds":{"left":0.8234708,"top":0.019952115,"width":0.09208777,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'AskJiminnyReportActivityServiceTest'","depth":6,"bounds":{"left":0.9155585,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'AskJiminnyReportActivityServiceTest'","depth":6,"bounds":{"left":0.9268617,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"bounds":{"left":0.9381649,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"bounds":{"left":0.96609044,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"bounds":{"left":0.9773936,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"IDE and Project Settings","depth":5,"bounds":{"left":0.9886968,"top":0.019952115,"width":0.011303186,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.042220745,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"19","depth":4,"bounds":{"left":0.6615692,"top":0.10055866,"width":0.009640957,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"bounds":{"left":0.67287236,"top":0.09896249,"width":0.00731383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"bounds":{"left":0.68018615,"top":0.09896249,"width":0.006981383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {\n\"headers\":{\n\"Date\":[\"Thu,07 May 2026 14:21:15 GMT\"],\n \"Content-Type\":[\"application/json;charset=utf-8\"],\n \"Transfer-Encoding\":[\"chunked\"],\n \"Connection\":[\"keep-alive\"],\n \"CF-Ray\":[\"9f80deb8db60dc3a-SOF\"],\n \"CF-Cache-Status\":[\"DYNAMIC\"],\n \"Strict-Transport-Security\":[\"max-age=31536000; includeSubDomains; preload\"],\n \"Vary\":[\"origin,\n accept-encoding\"],\n \"access-control-allow-credentials\":[\"false\"],\n \"server-timing\":[\"hcid;desc=\\\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\\\",\n cfr;desc=\\\"9f80deb8e7c6dc3a-IAD\\\"\"],\n \"x-content-type-options\":[\"nosniff\"],\n \"x-hubspot-correlation-id\":[\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\"],\n \"Set-Cookie\":[\"__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-1.0.1.1-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,\n 07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None\"],\n \"Report-To\":[\"{\n\\\"endpoints\\\":[{\n\\\"url\\\":\\\"https:\\\\/\\\\/a.nel.cloudflare.com\\\\/report\\\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\\\"}],\n\\\"group\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"NEL\":[\"{\n\\\"success_fraction\\\":0.01,\n\\\"report_to\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"Server\":[\"cloudflare\"]}} {\n\"correlation_id\":\"95236535-ec98-4541-b92a-adfa73b69eab\",\n\"trace_id\":\"c7ab8365-903f-46d4-9403-0e5b551e3545\"}","depth":4,"bounds":{"left":0.37632978,"top":0.09736632,"width":0.5728058,"height":0.8818835},"on_screen":true,"lines":[{"char_start":207,"char_count":30,"bounds":{"left":0.37632978,"top":0.0,"width":0.07513298,"height":0.014365523}},{"char_start":237,"char_count":36,"bounds":{"left":0.37632978,"top":0.0,"width":0.09075798,"height":0.014365523}},{"char_start":273,"char_count":32,"bounds":{"left":0.37632978,"top":0.0,"width":0.080119684,"height":0.014365523}},{"char_start":305,"char_count":79,"bounds":{"left":0.37632978,"top":0.0,"width":0.20212767,"height":0.014365523}},{"char_start":384,"char_count":18,"bounds":{"left":0.37632978,"top":0.0,"width":0.043882977,"height":0.014365523}},{"char_start":402,"char_count":21,"bounds":{"left":0.37632978,"top":0.0,"width":0.051861703,"height":0.014365523}},{"char_start":423,"char_count":48,"bounds":{"left":0.37632978,"top":0.008778931,"width":0.12167553,"height":0.014365523}},{"char_start":471,"char_count":72,"bounds":{"left":0.37632978,"top":0.026336791,"width":0.18384309,"height":0.014365523}},{"char_start":543,"char_count":40,"bounds":{"left":0.37632978,"top":0.043894652,"width":0.10106383,"height":0.014365523}},{"char_start":583,"char_count":41,"bounds":{"left":0.37632978,"top":0.061452515,"width":0.10372341,"height":0.014365523}},{"char_start":624,"char_count":72,"bounds":{"left":0.37632978,"top":0.079010375,"width":0.18384309,"height":0.014365523}},{"char_start":696,"char_count":219,"bounds":{"left":0.37632978,"top":0.096568234,"width":0.56515956,"height":0.014365523}},{"char_start":915,"char_count":83,"bounds":{"left":0.37632978,"top":0.11412609,"width":0.21243352,"height":0.014365523}},{"char_start":998,"char_count":20,"bounds":{"left":0.37632978,"top":0.13168396,"width":0.04920213,"height":0.014365523}},{"char_start":1018,"char_count":17,"bounds":{"left":0.37632978,"top":0.14924182,"width":0.041223403,"height":0.014365523}},{"char_start":1035,"char_count":203,"bounds":{"left":0.37632978,"top":0.16679968,"width":0.52360374,"height":0.014365523}},{"char_start":1238,"char_count":22,"bounds":{"left":0.37632978,"top":0.18435754,"width":0.05418883,"height":0.014365523}},{"char_start":1260,"char_count":23,"bounds":{"left":0.37632978,"top":0.2019154,"width":0.056848403,"height":0.014365523}},{"char_start":1283,"char_count":10,"bounds":{"left":0.37632978,"top":0.21947326,"width":0.023271276,"height":0.014365523}},{"char_start":1293,"char_count":27,"bounds":{"left":0.37632978,"top":0.23703113,"width":0.06715426,"height":0.014365523}},{"char_start":1320,"char_count":26,"bounds":{"left":0.37632978,"top":0.254589,"width":0.06482713,"height":0.014365523}},{"char_start":1346,"char_count":23,"bounds":{"left":0.37632978,"top":0.27214685,"width":0.056848403,"height":0.014365523}},{"char_start":1369,"char_count":28,"bounds":{"left":0.37632978,"top":0.2897047,"width":0.06981383,"height":0.014365523}},{"char_start":1397,"char_count":57,"bounds":{"left":0.37632978,"top":0.30726257,"width":0.14494681,"height":0.014365523}}],"value":"[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {\n\"headers\":{\n\"Date\":[\"Thu,07 May 2026 14:21:15 GMT\"],\n \"Content-Type\":[\"application/json;charset=utf-8\"],\n \"Transfer-Encoding\":[\"chunked\"],\n \"Connection\":[\"keep-alive\"],\n \"CF-Ray\":[\"9f80deb8db60dc3a-SOF\"],\n \"CF-Cache-Status\":[\"DYNAMIC\"],\n \"Strict-Transport-Security\":[\"max-age=31536000; includeSubDomains; preload\"],\n \"Vary\":[\"origin,\n accept-encoding\"],\n \"access-control-allow-credentials\":[\"false\"],\n \"server-timing\":[\"hcid;desc=\\\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\\\",\n cfr;desc=\\\"9f80deb8e7c6dc3a-IAD\\\"\"],\n \"x-content-type-options\":[\"nosniff\"],\n \"x-hubspot-correlation-id\":[\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\"],\n \"Set-Cookie\":[\"__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-1.0.1.1-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,\n 07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None\"],\n \"Report-To\":[\"{\n\\\"endpoints\\\":[{\n\\\"url\\\":\\\"https:\\\\/\\\\/a.nel.cloudflare.com\\\\/report\\\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\\\"}],\n\\\"group\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"NEL\":[\"{\n\\\"success_fraction\\\":0.01,\n\\\"report_to\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"Server\":[\"cloudflare\"]}} {\n\"correlation_id\":\"95236535-ec98-4541-b92a-adfa73b69eab\",\n\"trace_id\":\"c7ab8365-903f-46d4-9403-0e5b551e3545\"}","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.042220745,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"3","depth":4,"bounds":{"left":0.32214096,"top":0.19952115,"width":0.007978723,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"11","depth":4,"bounds":{"left":0.33211437,"top":0.19952115,"width":0.008976064,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"bounds":{"left":0.34275267,"top":0.19792499,"width":0.00731383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"bounds":{"left":0.35006648,"top":0.19792499,"width":0.006981383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\ndeclare(strict_types=1);\n\nnamespace Jiminny\\Services\\Crm\\Hubspot;\n\nuse Carbon\\Carbon;\nuse Exception;\nuse HubSpot\\Client\\Crm\\Objects\\ApiException;\nuse Jiminny\\Integrations\\PlaybookResolver;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Calendar\\CalendarEvent;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Models\\Crm\\Profile;\nuse Jiminny\\Models\\Playbook;\nuse Jiminny\\Models\\PlaybookCategory;\nuse Jiminny\\Repositories\\ActivityRepository;\nuse Jiminny\\Repositories\\PlaybookCategoryRepository;\nuse Psr\\Log\\LoggerInterface;\n\nclass SyncRelatedActivityManager\n{\n public const string OBJECT_MEETING = 'meetings';\n public const string DEFAULT_ACTIVITY_TYPE = 'hs_activity_type';\n private RemoteCrmObjectManipulator $crmManipulator;\n\n public function __construct(\n private readonly Client $client,\n private readonly PayloadBuilder $payloadBuilder,\n private readonly LoggerInterface $logger\n ) {\n $this->crmManipulator = app(RemoteCrmObjectManipulator::class, [\n 'client' => $this->client,\n 'logger' => $this->logger,\n ]);\n }\n\n public function fetchAndAssociateRelatedActivity(Activity $activity): ?Activity\n {\n // Validate if the activity is applicable.\n if ($this->isActivityApplicable($activity) === false) {\n $this->logger->info('[Hubspot] Fetch related activity - not applicable', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n return null;\n }\n\n // Find and fetch related activity.\n try {\n $crmActivity = $this->fetchRelatedActivity($activity);\n if (empty($crmActivity)) {\n return null;\n }\n } catch (Exception $exception) {\n $this->logger->error('[Hubspot] Fetch related activity - failed', [\n 'activityId' => $activity->getUuid(),\n 'reason' => $exception->getMessage(),\n ]);\n\n throw $exception;\n }\n\n // get playbook category id\n $playbookCategoryId = $this->getPlaybookCategoryId($activity, $crmActivity);\n\n $data = [\n 'crm_provider_id' => $crmActivity['id'],\n 'playbook_category_id' => $playbookCategoryId,\n ];\n\n if (! $this->updateActivity($activity, $data)) {\n return null;\n }\n\n $this->crmManipulator->updateRelatedCrmActivity($activity, $crmActivity);\n\n $activity->refresh();\n\n return $activity;\n }\n\n private function isActivityApplicable(Activity $activity): bool\n {\n if (! $activity->isTypeConference()) {\n return false;\n }\n\n if (! $this->hasMinimumFilterRequirements($activity)) {\n $this->logger->info('[Hubspot] Fetch related activity - not enough filter data', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n return false;\n }\n\n // The related activity is already linked.\n if ($activity->hasCrmProviderId()) {\n return false;\n }\n\n if (! $activity->getUser()->getProfile() instanceof Profile) {\n $this->logger->info('[Hubspot] Fetch related activity - activity user has no crm profile', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n return false;\n }\n\n return true;\n }\n\n private function hasMinimumFilterRequirements(Activity $activity): bool\n {\n // We need to know when it started to determine when to look for related activity.\n if (! $activity->hasActualStartTime()) {\n return false;\n }\n\n if (! $activity->hasActualEndTime()) {\n return false;\n }\n\n // At least one prospect should be associated to the activity\n if ($activity->hasContact() || $activity->hasAccount()) {\n return true;\n }\n\n return false;\n }\n\n public function fetchRelatedActivity(Activity $activity): array\n {\n $this->logger->info('[Hubspot] Fetch related activity - searching related CRM activity', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n $payloadData = $this->getPayloadFilterData($activity);\n\n try {\n $payload = $this->payloadBuilder->getFindRelatedActivityPayload($payloadData, self::OBJECT_MEETING);\n $response = $this->client->getPaginatedData($payload, self::OBJECT_MEETING);\n $crmActivities = $response['results'];\n } catch (Exception $e) {\n $this->logger->error('[Hubspot] Fetch related activity - failed', [\n 'activityId' => $activity->getUuid(),\n 'reason' => $e->getMessage(),\n ]);\n\n throw $e;\n }\n\n if (empty($crmActivities)) {\n $this->logger->info('[Hubspot] Fetch related activity - no CRM related activity found', [\n 'activityId' => $activity->getUuid(),\n 'payload' => $payload,\n ]);\n\n return [];\n }\n\n $relatedActivity = $this->getBestCrmActivityCandidate($crmActivities, $payloadData);\n if (empty($relatedActivity)) {\n $this->logger->info('[Hubspot] Fetch related activity - no related activity candidate', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n return [];\n }\n\n $this->logger->info('[Hubspot] Fetch related activity - found related CRM activity', [\n 'activityId' => $activity->getUuid(),\n 'crmActivity' => $relatedActivity,\n ]);\n\n return $relatedActivity;\n }\n\n /**\n * @param Activity $activity\n *\n * @return array\n */\n protected function getPayloadFilterData(Activity $activity): array\n {\n [$from, $to] = $this->getActivityTimeFilterRange($activity);\n\n $data = [\n 'from' => $from->getPreciseTimestamp(3),\n 'to' => $to->getPreciseTimestamp(3),\n 'contact' => $activity->hasContact() ? $activity->getContact()->getCrmProviderId() : null,\n 'company' => $activity->hasAccount() ? $activity->getAccount()->getCrmProviderId() : null,\n 'deal' => $activity->hasOpportunity() ? $activity->getOpportunity()->getCrmProviderId() : null,\n 'owner' => $activity->getUser()->getProfile()->getCrmProviderId(),\n 'activity' => $activity->getUuid(),\n ];\n\n return array_filter($data);\n }\n\n private function getActivityTimeFilterRange(Activity $activity): array\n {\n if ($activity->hasScheduledStartTime() && $activity->hasScheduledEndTime()) {\n $calendarEvent = $activity->getCalendarEvent();\n\n if ($calendarEvent instanceof CalendarEvent) {\n $from = $calendarEvent->getStartTime();\n $to = $calendarEvent->getEndTime()->addMinutes(15);\n } else {\n $from = Carbon::instance($activity->getScheduledStartTime());\n $to = Carbon::instance($activity->getScheduledEndTime())->addMinutes(15);\n }\n\n return [$from, $to];\n }\n\n $from = Carbon::instance($activity->getActualStartTime());\n $to = Carbon::instance($activity->getActualEndTime());\n\n return [$from, $to];\n }\n\n /**\n * Determines the best CRM activity candidate based on the number of matching prospects and the owner.\n *\n * @param array $crmActivities List of CRM activities to evaluate.\n * @param array $data Data containing the prospects and owner information.\n *\n * @return array The best CRM activity candidate.\n */\n private function getBestCrmActivityCandidate(array $crmActivities, array $data): array\n {\n $prospectCount = count(array_intersect_key($data, array_flip(['contact', 'company', 'deal'])));\n $bestCandidate = null;\n $maxMatchingProspects = 0;\n $validActivitiesCount = count($crmActivities);\n\n if ($validActivitiesCount > 1) {\n $this->logger->info('[Hubspot] Fetch related activity - determining the best candidate', [\n 'activityId' => $data['activity'],\n 'activitiesCount' => $validActivitiesCount,\n 'crmActivities' => $crmActivities,\n ]);\n }\n\n foreach ($crmActivities as $crmActivity) {\n if ($this->isOwnerMismatch($crmActivity, $data)) {\n $validActivitiesCount--;\n\n continue;\n }\n\n if ($validActivitiesCount === 1) {\n return $crmActivity;\n }\n\n if ($prospectCount > 1) {\n $matchingProspects = $this->countMatchingProspects($crmActivity, $data);\n if ($matchingProspects > $maxMatchingProspects) {\n $maxMatchingProspects = $matchingProspects;\n $bestCandidate = $crmActivity;\n }\n } else {\n return $crmActivity;\n }\n }\n\n return $bestCandidate ?? [];\n }\n\n private function isOwnerMismatch(array $crmActivity, array $data): bool\n {\n if ($crmActivity['properties']['hubspot_owner_id'] !== $data['owner']) {\n $this->logger->info('[Hubspot] Fetch related activity - owner mismatch', [\n 'activityId' => $data['activity'],\n 'crmActivity' => $crmActivity['id'],\n 'jiminnyOwner' => $data['owner'],\n 'hubspotOwner' => $crmActivity['properties']['hubspot_owner_id'],\n ]);\n\n return true;\n }\n\n return false;\n }\n\n private function countMatchingProspects(array $crmActivity, array $data): int\n {\n try {\n $crmActivityWithAssociations = $this->client->getMeeting($crmActivity['id']);\n } catch (ApiException $e) {\n $this->logger->error('[Hubspot] Fetch related activity - failed to get meeting associations', [\n 'activityId' => $data['activity'],\n 'crmActivity' => $crmActivity['id'],\n 'reason' => $e->getMessage(),\n ]);\n\n return 0;\n }\n\n $associations = $crmActivityWithAssociations->getAssociations();\n $matchingProspects = 0;\n\n $matchingProspects += $this->countMatchingAssociation($data, $associations, 'company', 'companies');\n $matchingProspects += $this->countMatchingAssociation($data, $associations, 'contact', 'contacts');\n $matchingProspects += $this->countMatchingAssociation($data, $associations, 'deal', 'deals');\n\n return $matchingProspects;\n }\n\n private function countMatchingAssociation(array $data, array $associations, string $dataKey, string $associationKey): int\n {\n if (isset($data[$dataKey]) && isset($associations[$associationKey])) {\n $associatedItems = $associations[$associationKey]->getResults();\n foreach ($associatedItems as $associatedItem) {\n if ($data[$dataKey] == $associatedItem->getId()) {\n\n return 1;\n }\n }\n }\n\n return 0;\n }\n\n private function getPlaybookCategoryId(Activity $activity, array $crmActivity): ?int\n {\n if (! $activity->hasUser()) {\n $this->logger->info('[Hubspot] Fetch related activity - activity has no user', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n return null;\n }\n\n $playbookResolver = app(PlaybookResolver::class);\n $playbook = $playbookResolver->resolvePlaybookByUser($activity->getUser());\n\n if (! $playbook instanceof Playbook) {\n $this->logger->info('[Hubspot] Fetch related activity - playbook not set for user', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n return null;\n }\n\n if (! $playbook->getActivityField() instanceof Field) {\n $this->logger->info('[Hubspot] Fetch related activity - playbook activity field not set', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n return null;\n }\n\n $playbookCategory = null;\n $activityTypeSource = null;\n\n $playbookActivityFieldName = $playbook->getActivityField()->getCrmProviderId();\n if ($playbookActivityFieldName === 'activityType') {\n $playbookActivityFieldName = self::DEFAULT_ACTIVITY_TYPE;\n }\n\n // First attempt to match playbook category from crm activity\n if (! empty($crmActivity['properties'][$playbookActivityFieldName])) {\n $playbookCategoryRepository = app(PlaybookCategoryRepository::class);\n $playbookCategory = $playbookCategoryRepository->getPlaybookCategoryByName(\n $playbook,\n strval($crmActivity['properties'][$playbookActivityFieldName])\n );\n $activityTypeSource = 'crm';\n }\n\n // If not matched get playbook category from Jiminny activity\n if (! $playbookCategory instanceof PlaybookCategory && $activity->hasActivityType()) {\n $playbookCategory = $activity->getActivityType();\n $activityTypeSource = 'jiminny';\n }\n\n if ($playbookCategory instanceof PlaybookCategory) {\n $this->logger->info('[Hubspot] Fetch related activity - Playbook category matched', [\n 'activityId' => $activity->getUuid(),\n 'playbookCategoryId' => $playbookCategory->getId(),\n 'activityFieldName' => $playbookActivityFieldName,\n 'crmActivity' => $crmActivity['id'],\n 'source' => $activityTypeSource,\n ]);\n\n return $playbookCategory->getId();\n }\n\n $this->logger->info('[Hubspot] Fetch related activity - Playbook category not found', [\n 'activityId' => $activity->getUuid(),\n 'activityFieldName' => $playbookActivityFieldName,\n 'crmActivity' => $crmActivity['id'],\n ]);\n\n return null;\n }\n\n private function updateActivity(Activity $activity, array $data): bool\n {\n $activityRepository = app(ActivityRepository::class);\n\n $alreadyLoggedActivity = $activityRepository->findUserActivityByCrmProviderId(\n $activity->getUser(),\n $data['crm_provider_id']\n );\n\n if ($alreadyLoggedActivity instanceof Activity) {\n $this->logger->info('[Hubspot] Fetch related activity - activity already linked', [\n 'currentActivityId' => $activity->getUuid(),\n 'linkedActivityId' => $alreadyLoggedActivity->getUuid(),\n 'crmActivityId' => $data['crm_provider_id'],\n ]);\n\n // Return false to prevent updating the remote CRM engagement. This can happen when two Jiminny\n // activities share the same calendar event (e.g., rescheduled meetings). Without this, the first\n // activity's summary would be overwritten by the second activity.\n return false;\n }\n\n try {\n $activityRepository->update($activity, $data);\n } catch (Exception $e) {\n $this->logger->error('[Hubspot] Fetch related activity - activity update failed', [\n 'activityId' => $activity->getUuid(),\n 'reason' => $e->getMessage(),\n ]);\n\n return false;\n }\n\n $this->logger->info('[Hubspot] Fetch related activity - activity updated', [\n 'activityId' => $activity->getUuid(),\n 'data' => $data,\n ]);\n\n return true;\n }\n}","depth":4,"on_screen":true,"value":"<?php\n\ndeclare(strict_types=1);\n\nnamespace Jiminny\\Services\\Crm\\Hubspot;\n\nuse Carbon\\Carbon;\nuse Exception;\nuse HubSpot\\Client\\Crm\\Objects\\ApiException;\nuse Jiminny\\Integrations\\PlaybookResolver;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Calendar\\CalendarEvent;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Models\\Crm\\Profile;\nuse Jiminny\\Models\\Playbook;\nuse Jiminny\\Models\\PlaybookCategory;\nuse Jiminny\\Repositories\\ActivityRepository;\nuse Jiminny\\Repositories\\PlaybookCategoryRepository;\nuse Psr\\Log\\LoggerInterface;\n\nclass SyncRelatedActivityManager\n{\n public const string OBJECT_MEETING = 'meetings';\n public const string DEFAULT_ACTIVITY_TYPE = 'hs_activity_type';\n private RemoteCrmObjectManipulator $crmManipulator;\n\n public function __construct(\n private readonly Client $client,\n private readonly PayloadBuilder $payloadBuilder,\n private readonly LoggerInterface $logger\n ) {\n $this->crmManipulator = app(RemoteCrmObjectManipulator::class, [\n 'client' => $this->client,\n 'logger' => $this->logger,\n ]);\n }\n\n public function fetchAndAssociateRelatedActivity(Activity $activity): ?Activity\n {\n // Validate if the activity is applicable.\n if ($this->isActivityApplicable($activity) === false) {\n $this->logger->info('[Hubspot] Fetch related activity - not applicable', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n return null;\n }\n\n // Find and fetch related activity.\n try {\n $crmActivity = $this->fetchRelatedActivity($activity);\n if (empty($crmActivity)) {\n return null;\n }\n } catch (Exception $exception) {\n $this->logger->error('[Hubspot] Fetch related activity - failed', [\n 'activityId' => $activity->getUuid(),\n 'reason' => $exception->getMessage(),\n ]);\n\n throw $exception;\n }\n\n // get playbook category id\n $playbookCategoryId = $this->getPlaybookCategoryId($activity, $crmActivity);\n\n $data = [\n 'crm_provider_id' => $crmActivity['id'],\n 'playbook_category_id' => $playbookCategoryId,\n ];\n\n if (! $this->updateActivity($activity, $data)) {\n return null;\n }\n\n $this->crmManipulator->updateRelatedCrmActivity($activity, $crmActivity);\n\n $activity->refresh();\n\n return $activity;\n }\n\n private function isActivityApplicable(Activity $activity): bool\n {\n if (! $activity->isTypeConference()) {\n return false;\n }\n\n if (! $this->hasMinimumFilterRequirements($activity)) {\n $this->logger->info('[Hubspot] Fetch related activity - not enough filter data', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n return false;\n }\n\n // The related activity is already linked.\n if ($activity->hasCrmProviderId()) {\n return false;\n }\n\n if (! $activity->getUser()->getProfile() instanceof Profile) {\n $this->logger->info('[Hubspot] Fetch related activity - activity user has no crm profile', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n return false;\n }\n\n return true;\n }\n\n private function hasMinimumFilterRequirements(Activity $activity): bool\n {\n // We need to know when it started to determine when to look for related activity.\n if (! $activity->hasActualStartTime()) {\n return false;\n }\n\n if (! $activity->hasActualEndTime()) {\n return false;\n }\n\n // At least one prospect should be associated to the activity\n if ($activity->hasContact() || $activity->hasAccount()) {\n return true;\n }\n\n return false;\n }\n\n public function fetchRelatedActivity(Activity $activity): array\n {\n $this->logger->info('[Hubspot] Fetch related activity - searching related CRM activity', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n $payloadData = $this->getPayloadFilterData($activity);\n\n try {\n $payload = $this->payloadBuilder->getFindRelatedActivityPayload($payloadData, self::OBJECT_MEETING);\n $response = $this->client->getPaginatedData($payload, self::OBJECT_MEETING);\n $crmActivities = $response['results'];\n } catch (Exception $e) {\n $this->logger->error('[Hubspot] Fetch related activity - failed', [\n 'activityId' => $activity->getUuid(),\n 'reason' => $e->getMessage(),\n ]);\n\n throw $e;\n }\n\n if (empty($crmActivities)) {\n $this->logger->info('[Hubspot] Fetch related activity - no CRM related activity found', [\n 'activityId' => $activity->getUuid(),\n 'payload' => $payload,\n ]);\n\n return [];\n }\n\n $relatedActivity = $this->getBestCrmActivityCandidate($crmActivities, $payloadData);\n if (empty($relatedActivity)) {\n $this->logger->info('[Hubspot] Fetch related activity - no related activity candidate', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n return [];\n }\n\n $this->logger->info('[Hubspot] Fetch related activity - found related CRM activity', [\n 'activityId' => $activity->getUuid(),\n 'crmActivity' => $relatedActivity,\n ]);\n\n return $relatedActivity;\n }\n\n /**\n * @param Activity $activity\n *\n * @return array\n */\n protected function getPayloadFilterData(Activity $activity): array\n {\n [$from, $to] = $this->getActivityTimeFilterRange($activity);\n\n $data = [\n 'from' => $from->getPreciseTimestamp(3),\n 'to' => $to->getPreciseTimestamp(3),\n 'contact' => $activity->hasContact() ? $activity->getContact()->getCrmProviderId() : null,\n 'company' => $activity->hasAccount() ? $activity->getAccount()->getCrmProviderId() : null,\n 'deal' => $activity->hasOpportunity() ? $activity->getOpportunity()->getCrmProviderId() : null,\n 'owner' => $activity->getUser()->getProfile()->getCrmProviderId(),\n 'activity' => $activity->getUuid(),\n ];\n\n return array_filter($data);\n }\n\n private function getActivityTimeFilterRange(Activity $activity): array\n {\n if ($activity->hasScheduledStartTime() && $activity->hasScheduledEndTime()) {\n $calendarEvent = $activity->getCalendarEvent();\n\n if ($calendarEvent instanceof CalendarEvent) {\n $from = $calendarEvent->getStartTime();\n $to = $calendarEvent->getEndTime()->addMinutes(15);\n } else {\n $from = Carbon::instance($activity->getScheduledStartTime());\n $to = Carbon::instance($activity->getScheduledEndTime())->addMinutes(15);\n }\n\n return [$from, $to];\n }\n\n $from = Carbon::instance($activity->getActualStartTime());\n $to = Carbon::instance($activity->getActualEndTime());\n\n return [$from, $to];\n }\n\n /**\n * Determines the best CRM activity candidate based on the number of matching prospects and the owner.\n *\n * @param array $crmActivities List of CRM activities to evaluate.\n * @param array $data Data containing the prospects and owner information.\n *\n * @return array The best CRM activity candidate.\n */\n private function getBestCrmActivityCandidate(array $crmActivities, array $data): array\n {\n $prospectCount = count(array_intersect_key($data, array_flip(['contact', 'company', 'deal'])));\n $bestCandidate = null;\n $maxMatchingProspects = 0;\n $validActivitiesCount = count($crmActivities);\n\n if ($validActivitiesCount > 1) {\n $this->logger->info('[Hubspot] Fetch related activity - determining the best candidate', [\n 'activityId' => $data['activity'],\n 'activitiesCount' => $validActivitiesCount,\n 'crmActivities' => $crmActivities,\n ]);\n }\n\n foreach ($crmActivities as $crmActivity) {\n if ($this->isOwnerMismatch($crmActivity, $data)) {\n $validActivitiesCount--;\n\n continue;\n }\n\n if ($validActivitiesCount === 1) {\n return $crmActivity;\n }\n\n if ($prospectCount > 1) {\n $matchingProspects = $this->countMatchingProspects($crmActivity, $data);\n if ($matchingProspects > $maxMatchingProspects) {\n $maxMatchingProspects = $matchingProspects;\n $bestCandidate = $crmActivity;\n }\n } else {\n return $crmActivity;\n }\n }\n\n return $bestCandidate ?? [];\n }\n\n private function isOwnerMismatch(array $crmActivity, array $data): bool\n {\n if ($crmActivity['properties']['hubspot_owner_id'] !== $data['owner']) {\n $this->logger->info('[Hubspot] Fetch related activity - owner mismatch', [\n 'activityId' => $data['activity'],\n 'crmActivity' => $crmActivity['id'],\n 'jiminnyOwner' => $data['owner'],\n 'hubspotOwner' => $crmActivity['properties']['hubspot_owner_id'],\n ]);\n\n return true;\n }\n\n return false;\n }\n\n private function countMatchingProspects(array $crmActivity, array $data): int\n {\n try {\n $crmActivityWithAssociations = $this->client->getMeeting($crmActivity['id']);\n } catch (ApiException $e) {\n $this->logger->error('[Hubspot] Fetch related activity - failed to get meeting associations', [\n 'activityId' => $data['activity'],\n 'crmActivity' => $crmActivity['id'],\n 'reason' => $e->getMessage(),\n ]);\n\n return 0;\n }\n\n $associations = $crmActivityWithAssociations->getAssociations();\n $matchingProspects = 0;\n\n $matchingProspects += $this->countMatchingAssociation($data, $associations, 'company', 'companies');\n $matchingProspects += $this->countMatchingAssociation($data, $associations, 'contact', 'contacts');\n $matchingProspects += $this->countMatchingAssociation($data, $associations, 'deal', 'deals');\n\n return $matchingProspects;\n }\n\n private function countMatchingAssociation(array $data, array $associations, string $dataKey, string $associationKey): int\n {\n if (isset($data[$dataKey]) && isset($associations[$associationKey])) {\n $associatedItems = $associations[$associationKey]->getResults();\n foreach ($associatedItems as $associatedItem) {\n if ($data[$dataKey] == $associatedItem->getId()) {\n\n return 1;\n }\n }\n }\n\n return 0;\n }\n\n private function getPlaybookCategoryId(Activity $activity, array $crmActivity): ?int\n {\n if (! $activity->hasUser()) {\n $this->logger->info('[Hubspot] Fetch related activity - activity has no user', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n return null;\n }\n\n $playbookResolver = app(PlaybookResolver::class);\n $playbook = $playbookResolver->resolvePlaybookByUser($activity->getUser());\n\n if (! $playbook instanceof Playbook) {\n $this->logger->info('[Hubspot] Fetch related activity - playbook not set for user', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n return null;\n }\n\n if (! $playbook->getActivityField() instanceof Field) {\n $this->logger->info('[Hubspot] Fetch related activity - playbook activity field not set', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n return null;\n }\n\n $playbookCategory = null;\n $activityTypeSource = null;\n\n $playbookActivityFieldName = $playbook->getActivityField()->getCrmProviderId();\n if ($playbookActivityFieldName === 'activityType') {\n $playbookActivityFieldName = self::DEFAULT_ACTIVITY_TYPE;\n }\n\n // First attempt to match playbook category from crm activity\n if (! empty($crmActivity['properties'][$playbookActivityFieldName])) {\n $playbookCategoryRepository = app(PlaybookCategoryRepository::class);\n $playbookCategory = $playbookCategoryRepository->getPlaybookCategoryByName(\n $playbook,\n strval($crmActivity['properties'][$playbookActivityFieldName])\n );\n $activityTypeSource = 'crm';\n }\n\n // If not matched get playbook category from Jiminny activity\n if (! $playbookCategory instanceof PlaybookCategory && $activity->hasActivityType()) {\n $playbookCategory = $activity->getActivityType();\n $activityTypeSource = 'jiminny';\n }\n\n if ($playbookCategory instanceof PlaybookCategory) {\n $this->logger->info('[Hubspot] Fetch related activity - Playbook category matched', [\n 'activityId' => $activity->getUuid(),\n 'playbookCategoryId' => $playbookCategory->getId(),\n 'activityFieldName' => $playbookActivityFieldName,\n 'crmActivity' => $crmActivity['id'],\n 'source' => $activityTypeSource,\n ]);\n\n return $playbookCategory->getId();\n }\n\n $this->logger->info('[Hubspot] Fetch related activity - Playbook category not found', [\n 'activityId' => $activity->getUuid(),\n 'activityFieldName' => $playbookActivityFieldName,\n 'crmActivity' => $crmActivity['id'],\n ]);\n\n return null;\n }\n\n private function updateActivity(Activity $activity, array $data): bool\n {\n $activityRepository = app(ActivityRepository::class);\n\n $alreadyLoggedActivity = $activityRepository->findUserActivityByCrmProviderId(\n $activity->getUser(),\n $data['crm_provider_id']\n );\n\n if ($alreadyLoggedActivity instanceof Activity) {\n $this->logger->info('[Hubspot] Fetch related activity - activity already linked', [\n 'currentActivityId' => $activity->getUuid(),\n 'linkedActivityId' => $alreadyLoggedActivity->getUuid(),\n 'crmActivityId' => $data['crm_provider_id'],\n ]);\n\n // Return false to prevent updating the remote CRM engagement. This can happen when two Jiminny\n // activities share the same calendar event (e.g., rescheduled meetings). Without this, the first\n // activity's summary would be overwritten by the second activity.\n return false;\n }\n\n try {\n $activityRepository->update($activity, $data);\n } catch (Exception $e) {\n $this->logger->error('[Hubspot] Fetch related activity - activity update failed', [\n 'activityId' => $activity->getUuid(),\n 'reason' => $e->getMessage(),\n ]);\n\n return false;\n }\n\n $this->logger->info('[Hubspot] Fetch related activity - activity updated', [\n 'activityId' => $activity->getUuid(),\n 'data' => $data,\n ]);\n\n return true;\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":true,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Project","depth":3,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Project","depth":3,"bounds":{"left":0.011968086,"top":0.047885075,"width":0.024268618,"height":0.024740623},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"New File or Directory…","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Expand Selected","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Collapse All","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Options","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
-2435399166852945830
|
-3377671218453133244
|
click
|
accessibility
|
NULL
|
Project: faVsco.js, menu
JY-20725-handle-HS-search Project: faVsco.js, menu
JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
19
Previous Highlighted Error
Next Highlighted Error
[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {
"headers":{
"Date":["Thu,07 May 2026 14:21:15 GMT"],
"Content-Type":["application/json;charset=utf-8"],
"Transfer-Encoding":["chunked"],
"Connection":["keep-alive"],
"CF-Ray":["9f80deb8db60dc3a-SOF"],
"CF-Cache-Status":["DYNAMIC"],
"Strict-Transport-Security":["max-age=31536000; includeSubDomains; preload"],
"Vary":["origin,
accept-encoding"],
"access-control-allow-credentials":["false"],
"server-timing":["hcid;desc=\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\",
cfr;desc=\"9f80deb8e7c6dc3a-IAD\""],
"x-content-type-options":["nosniff"],
"x-hubspot-correlation-id":["019e02d0-6fd8-7812-bdba-885b7ccb3ee3"],
"Set-Cookie":["__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-[IP_ADDRESS]-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,
07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None"],
"Report-To":["{
\"endpoints\":[{
\"url\":\"https:\\/\\/a.nel.cloudflare.com\\/report\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\"}],
\"group\":\"cf-nel\",
\"max_age\":604800}"],
"NEL":["{
\"success_fraction\":0.01,
\"report_to\":\"cf-nel\",
\"max_age\":604800}"],
"Server":["cloudflare"]}} {
"correlation_id":"95236535-ec98-4541-b92a-adfa73b69eab",
"trace_id":"c7ab8365-903f-46d4-9403-0e5b551e3545"}
Sync Changes
Hide This Notification
Code changed:
Hide
3
11
Previous Highlighted Error
Next Highlighted Error
<?php
declare(strict_types=1);
namespace Jiminny\Services\Crm\Hubspot;
use Carbon\Carbon;
use Exception;
use HubSpot\Client\Crm\Objects\ApiException;
use Jiminny\Integrations\PlaybookResolver;
use Jiminny\Models\Activity;
use Jiminny\Models\Calendar\CalendarEvent;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\Profile;
use Jiminny\Models\Playbook;
use Jiminny\Models\PlaybookCategory;
use Jiminny\Repositories\ActivityRepository;
use Jiminny\Repositories\PlaybookCategoryRepository;
use Psr\Log\LoggerInterface;
class SyncRelatedActivityManager
{
public const string OBJECT_MEETING = 'meetings';
public const string DEFAULT_ACTIVITY_TYPE = 'hs_activity_type';
private RemoteCrmObjectManipulator $crmManipulator;
public function __construct(
private readonly Client $client,
private readonly PayloadBuilder $payloadBuilder,
private readonly LoggerInterface $logger
) {
$this->crmManipulator = app(RemoteCrmObjectManipulator::class, [
'client' => $this->client,
'logger' => $this->logger,
]);
}
public function fetchAndAssociateRelatedActivity(Activity $activity): ?Activity
{
// Validate if the activity is applicable.
if ($this->isActivityApplicable($activity) === false) {
$this->logger->info('[Hubspot] Fetch related activity - not applicable', [
'activityId' => $activity->getUuid(),
]);
return null;
}
// Find and fetch related activity.
try {
$crmActivity = $this->fetchRelatedActivity($activity);
if (empty($crmActivity)) {
return null;
}
} catch (Exception $exception) {
$this->logger->error('[Hubspot] Fetch related activity - failed', [
'activityId' => $activity->getUuid(),
'reason' => $exception->getMessage(),
]);
throw $exception;
}
// get playbook category id
$playbookCategoryId = $this->getPlaybookCategoryId($activity, $crmActivity);
$data = [
'crm_provider_id' => $crmActivity['id'],
'playbook_category_id' => $playbookCategoryId,
];
if (! $this->updateActivity($activity, $data)) {
return null;
}
$this->crmManipulator->updateRelatedCrmActivity($activity, $crmActivity);
$activity->refresh();
return $activity;
}
private function isActivityApplicable(Activity $activity): bool
{
if (! $activity->isTypeConference()) {
return false;
}
if (! $this->hasMinimumFilterRequirements($activity)) {
$this->logger->info('[Hubspot] Fetch related activity - not enough filter data', [
'activityId' => $activity->getUuid(),
]);
return false;
}
// The related activity is already linked.
if ($activity->hasCrmProviderId()) {
return false;
}
if (! $activity->getUser()->getProfile() instanceof Profile) {
$this->logger->info('[Hubspot] Fetch related activity - activity user has no crm profile', [
'activityId' => $activity->getUuid(),
]);
return false;
}
return true;
}
private function hasMinimumFilterRequirements(Activity $activity): bool
{
// We need to know when it started to determine when to look for related activity.
if (! $activity->hasActualStartTime()) {
return false;
}
if (! $activity->hasActualEndTime()) {
return false;
}
// At least one prospect should be associated to the activity
if ($activity->hasContact() || $activity->hasAccount()) {
return true;
}
return false;
}
public function fetchRelatedActivity(Activity $activity): array
{
$this->logger->info('[Hubspot] Fetch related activity - searching related CRM activity', [
'activityId' => $activity->getUuid(),
]);
$payloadData = $this->getPayloadFilterData($activity);
try {
$payload = $this->payloadBuilder->getFindRelatedActivityPayload($payloadData, self::OBJECT_MEETING);
$response = $this->client->getPaginatedData($payload, self::OBJECT_MEETING);
$crmActivities = $response['results'];
} catch (Exception $e) {
$this->logger->error('[Hubspot] Fetch related activity - failed', [
'activityId' => $activity->getUuid(),
'reason' => $e->getMessage(),
]);
throw $e;
}
if (empty($crmActivities)) {
$this->logger->info('[Hubspot] Fetch related activity - no CRM related activity found', [
'activityId' => $activity->getUuid(),
'payload' => $payload,
]);
return [];
}
$relatedActivity = $this->getBestCrmActivityCandidate($crmActivities, $payloadData);
if (empty($relatedActivity)) {
$this->logger->info('[Hubspot] Fetch related activity - no related activity candidate', [
'activityId' => $activity->getUuid(),
]);
return [];
}
$this->logger->info('[Hubspot] Fetch related activity - found related CRM activity', [
'activityId' => $activity->getUuid(),
'crmActivity' => $relatedActivity,
]);
return $relatedActivity;
}
/**
* @param Activity $activity
*
* @return array
*/
protected function getPayloadFilterData(Activity $activity): array
{
[$from, $to] = $this->getActivityTimeFilterRange($activity);
$data = [
'from' => $from->getPreciseTimestamp(3),
'to' => $to->getPreciseTimestamp(3),
'contact' => $activity->hasContact() ? $activity->getContact()->getCrmProviderId() : null,
'company' => $activity->hasAccount() ? $activity->getAccount()->getCrmProviderId() : null,
'deal' => $activity->hasOpportunity() ? $activity->getOpportunity()->getCrmProviderId() : null,
'owner' => $activity->getUser()->getProfile()->getCrmProviderId(),
'activity' => $activity->getUuid(),
];
return array_filter($data);
}
private function getActivityTimeFilterRange(Activity $activity): array
{
if ($activity->hasScheduledStartTime() && $activity->hasScheduledEndTime()) {
$calendarEvent = $activity->getCalendarEvent();
if ($calendarEvent instanceof CalendarEvent) {
$from = $calendarEvent->getStartTime();
$to = $calendarEvent->getEndTime()->addMinutes(15);
} else {
$from = Carbon::instance($activity->getScheduledStartTime());
$to = Carbon::instance($activity->getScheduledEndTime())->addMinutes(15);
}
return [$from, $to];
}
$from = Carbon::instance($activity->getActualStartTime());
$to = Carbon::instance($activity->getActualEndTime());
return [$from, $to];
}
/**
* Determines the best CRM activity candidate based on the number of matching prospects and the owner.
*
* @param array $crmActivities List of CRM activities to evaluate.
* @param array $data Data containing the prospects and owner information.
*
* @return array The best CRM activity candidate.
*/
private function getBestCrmActivityCandidate(array $crmActivities, array $data): array
{
$prospectCount = count(array_intersect_key($data, array_flip(['contact', 'company', 'deal'])));
$bestCandidate = null;
$maxMatchingProspects = 0;
$validActivitiesCount = count($crmActivities);
if ($validActivitiesCount > 1) {
$this->logger->info('[Hubspot] Fetch related activity - determining the best candidate', [
'activityId' => $data['activity'],
'activitiesCount' => $validActivitiesCount,
'crmActivities' => $crmActivities,
]);
}
foreach ($crmActivities as $crmActivity) {
if ($this->isOwnerMismatch($crmActivity, $data)) {
$validActivitiesCount--;
continue;
}
if ($validActivitiesCount === 1) {
return $crmActivity;
}
if ($prospectCount > 1) {
$matchingProspects = $this->countMatchingProspects($crmActivity, $data);
if ($matchingProspects > $maxMatchingProspects) {
$maxMatchingProspects = $matchingProspects;
$bestCandidate = $crmActivity;
}
} else {
return $crmActivity;
}
}
return $bestCandidate ?? [];
}
private function isOwnerMismatch(array $crmActivity, array $data): bool
{
if ($crmActivity['properties']['hubspot_owner_id'] !== $data['owner']) {
$this->logger->info('[Hubspot] Fetch related activity - owner mismatch', [
'activityId' => $data['activity'],
'crmActivity' => $crmActivity['id'],
'jiminnyOwner' => $data['owner'],
'hubspotOwner' => $crmActivity['properties']['hubspot_owner_id'],
]);
return true;
}
return false;
}
private function countMatchingProspects(array $crmActivity, array $data): int
{
try {
$crmActivityWithAssociations = $this->client->getMeeting($crmActivity['id']);
} catch (ApiException $e) {
$this->logger->error('[Hubspot] Fetch related activity - failed to get meeting associations', [
'activityId' => $data['activity'],
'crmActivity' => $crmActivity['id'],
'reason' => $e->getMessage(),
]);
return 0;
}
$associations = $crmActivityWithAssociations->getAssociations();
$matchingProspects = 0;
$matchingProspects += $this->countMatchingAssociation($data, $associations, 'company', 'companies');
$matchingProspects += $this->countMatchingAssociation($data, $associations, 'contact', 'contacts');
$matchingProspects += $this->countMatchingAssociation($data, $associations, 'deal', 'deals');
return $matchingProspects;
}
private function countMatchingAssociation(array $data, array $associations, string $dataKey, string $associationKey): int
{
if (isset($data[$dataKey]) && isset($associations[$associationKey])) {
$associatedItems = $associations[$associationKey]->getResults();
foreach ($associatedItems as $associatedItem) {
if ($data[$dataKey] == $associatedItem->getId()) {
return 1;
}
}
}
return 0;
}
private function getPlaybookCategoryId(Activity $activity, array $crmActivity): ?int
{
if (! $activity->hasUser()) {
$this->logger->info('[Hubspot] Fetch related activity - activity has no user', [
'activityId' => $activity->getUuid(),
]);
return null;
}
$playbookResolver = app(PlaybookResolver::class);
$playbook = $playbookResolver->resolvePlaybookByUser($activity->getUser());
if (! $playbook instanceof Playbook) {
$this->logger->info('[Hubspot] Fetch related activity - playbook not set for user', [
'activityId' => $activity->getUuid(),
]);
return null;
}
if (! $playbook->getActivityField() instanceof Field) {
$this->logger->info('[Hubspot] Fetch related activity - playbook activity field not set', [
'activityId' => $activity->getUuid(),
]);
return null;
}
$playbookCategory = null;
$activityTypeSource = null;
$playbookActivityFieldName = $playbook->getActivityField()->getCrmProviderId();
if ($playbookActivityFieldName === 'activityType') {
$playbookActivityFieldName = self::DEFAULT_ACTIVITY_TYPE;
}
// First attempt to match playbook category from crm activity
if (! empty($crmActivity['properties'][$playbookActivityFieldName])) {
$playbookCategoryRepository = app(PlaybookCategoryRepository::class);
$playbookCategory = $playbookCategoryRepository->getPlaybookCategoryByName(
$playbook,
strval($crmActivity['properties'][$playbookActivityFieldName])
);
$activityTypeSource = 'crm';
}
// If not matched get playbook category from Jiminny activity
if (! $playbookCategory instanceof PlaybookCategory && $activity->hasActivityType()) {
$playbookCategory = $activity->getActivityType();
$activityTypeSource = 'jiminny';
}
if ($playbookCategory instanceof PlaybookCategory) {
$this->logger->info('[Hubspot] Fetch related activity - Playbook category matched', [
'activityId' => $activity->getUuid(),
'playbookCategoryId' => $playbookCategory->getId(),
'activityFieldName' => $playbookActivityFieldName,
'crmActivity' => $crmActivity['id'],
'source' => $activityTypeSource,
]);
return $playbookCategory->getId();
}
$this->logger->info('[Hubspot] Fetch related activity - Playbook category not found', [
'activityId' => $activity->getUuid(),
'activityFieldName' => $playbookActivityFieldName,
'crmActivity' => $crmActivity['id'],
]);
return null;
}
private function updateActivity(Activity $activity, array $data): bool
{
$activityRepository = app(ActivityRepository::class);
$alreadyLoggedActivity = $activityRepository->findUserActivityByCrmProviderId(
$activity->getUser(),
$data['crm_provider_id']
);
if ($alreadyLoggedActivity instanceof Activity) {
$this->logger->info('[Hubspot] Fetch related activity - activity already linked', [
'currentActivityId' => $activity->getUuid(),
'linkedActivityId' => $alreadyLoggedActivity->getUuid(),
'crmActivityId' => $data['crm_provider_id'],
]);
// Return false to prevent updating the remote CRM engagement. This can happen when two Jiminny
// activities share the same calendar event (e.g., rescheduled meetings). Without this, the first
// activity's summary would be overwritten by the second activity.
return false;
}
try {
$activityRepository->update($activity, $data);
} catch (Exception $e) {
$this->logger->error('[Hubspot] Fetch related activity - activity update failed', [
'activityId' => $activity->getUuid(),
'reason' => $e->getMessage(),
]);
return false;
}
$this->logger->info('[Hubspot] Fetch related activity - activity updated', [
'activityId' => $activity->getUuid(),
'data' => $data,
]);
return true;
}
}
Project
Project
New File or Directory…
Expand Selected
Collapse All
Options
Hide...
|
9830
|
NULL
|
NULL
|
NULL
|
|
9831
|
443
|
17
|
2026-05-08T13:37:00.248633+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-08/1778 /Users/lukas/.screenpipe/data/data/2026-05-08/1778247420248_m1.jpg...
|
PhpStorm
|
faVsco.js – SyncRelatedActivityManager.php
|
1
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
JY-20725-handle-HS-search Project: faVsco.js, menu
JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings...
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JY-20725-handle-HS-search-rate-limit, menu","depth":5,"on_screen":true,"help_text":"Git Branch: JY-20725-handle-HS-search-rate-limit","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"AskJiminnyReportActivityServiceTest","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'AskJiminnyReportActivityServiceTest'","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'AskJiminnyReportActivityServiceTest'","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"IDE and Project Settings","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
5988960105550556897
|
-8204420481362449466
|
click
|
hybrid
|
NULL
|
Project: faVsco.js, menu
JY-20725-handle-HS-search Project: faVsco.js, menu
JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
iTerm2Shell Edit ViewSessionScripts|ProfilesWindowHelp‹ $0lahl100% C8APP (-zsh)DOCKER₴1DEV (docker)882JY-20773-fix-automated-reports-user-pilot-trackingJY-20157-AJ-report-not-send-notificationJY-20508-notify-before-AJ-report-expirationJY-20372-ai-reports-promotion-pagesJY-20352-sync-opportunities-without-a-local-owner-user-id-is-nullJY-20738-debug-AJ-tracking-UPAPP (-zsh)-zshJY-18909-automated-reports-ask-jiminnyJY-20692-fix-integration-app-[API_KEY]@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ co -b JY-20725-handle-HS-search-rate-limitSwitched to a new branch 'JY-20725-handle-HS-search-rate-limit'Lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (JY-20725-handle-HS-search-rate-limit) $ I• 84screenpipe*-zshFri 8 May 16:37:01T₴1|₴6APP...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
9830
|
444
|
20
|
2026-05-08T13:36:57.495475+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-08/1778 /Users/lukas/.screenpipe/data/data/2026-05-08/1778247417495_m2.jpg...
|
PhpStorm
|
faVsco.js – SyncRelatedActivityManager.php
|
1
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
JY-20725-handle-HS-search Project: faVsco.js, menu
JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
19
Previous Highlighted Error
Next Highlighted Error
[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {
"headers":{
"Date":["Thu,07 May 2026 14:21:15 GMT"],
"Content-Type":["application/json;charset=utf-8"],
"Transfer-Encoding":["chunked"],
"Connection":["keep-alive"],
"CF-Ray":["9f80deb8db60dc3a-SOF"],
"CF-Cache-Status":["DYNAMIC"],
"Strict-Transport-Security":["max-age=31536000; includeSubDomains; preload"],
"Vary":["origin,
accept-encoding"],
"access-control-allow-credentials":["false"],
"server-timing":["hcid;desc=\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\",
cfr;desc=\"9f80deb8e7c6dc3a-IAD\""],
"x-content-type-options":["nosniff"],
"x-hubspot-correlation-id":["019e02d0-6fd8-7812-bdba-885b7ccb3ee3"],
"Set-Cookie":["__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-[IP_ADDRESS]-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,
07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None"],
"Report-To":["{
\"endpoints\":[{
\"url\":\"https:\\/\\/a.nel.cloudflare.com\\/report\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\"}],
\"group\":\"cf-nel\",
\"max_age\":604800}"],
"NEL":["{
\"success_fraction\":0.01,
\"report_to\":\"cf-nel\",
\"max_age\":604800}"],
"Server":["cloudflare"]}} {
"correlation_id":"95236535-ec98-4541-b92a-adfa73b69eab",
"trace_id":"c7ab8365-903f-46d4-9403-0e5b551e3545"}
Sync Changes
Hide This Notification
Code changed:
Hide
3
11
Previous Highlighted Error...
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"bounds":{"left":0.025930852,"top":0.019952115,"width":0.03856383,"height":0.025538707},"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JY-20725-handle-HS-search-rate-limit, menu","depth":5,"bounds":{"left":0.064494684,"top":0.019952115,"width":0.09541223,"height":0.025538707},"on_screen":true,"help_text":"Git Branch: JY-20725-handle-HS-search-rate-limit","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"bounds":{"left":0.8081782,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"AskJiminnyReportActivityServiceTest","depth":6,"bounds":{"left":0.8234708,"top":0.019952115,"width":0.09208777,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'AskJiminnyReportActivityServiceTest'","depth":6,"bounds":{"left":0.9155585,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'AskJiminnyReportActivityServiceTest'","depth":6,"bounds":{"left":0.9268617,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"bounds":{"left":0.9381649,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"bounds":{"left":0.96609044,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"bounds":{"left":0.9773936,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"IDE and Project Settings","depth":5,"bounds":{"left":0.9886968,"top":0.019952115,"width":0.011303186,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.042220745,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"19","depth":4,"bounds":{"left":0.6615692,"top":0.10055866,"width":0.009640957,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"bounds":{"left":0.67287236,"top":0.09896249,"width":0.00731383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"bounds":{"left":0.68018615,"top":0.09896249,"width":0.006981383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {\n\"headers\":{\n\"Date\":[\"Thu,07 May 2026 14:21:15 GMT\"],\n \"Content-Type\":[\"application/json;charset=utf-8\"],\n \"Transfer-Encoding\":[\"chunked\"],\n \"Connection\":[\"keep-alive\"],\n \"CF-Ray\":[\"9f80deb8db60dc3a-SOF\"],\n \"CF-Cache-Status\":[\"DYNAMIC\"],\n \"Strict-Transport-Security\":[\"max-age=31536000; includeSubDomains; preload\"],\n \"Vary\":[\"origin,\n accept-encoding\"],\n \"access-control-allow-credentials\":[\"false\"],\n \"server-timing\":[\"hcid;desc=\\\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\\\",\n cfr;desc=\\\"9f80deb8e7c6dc3a-IAD\\\"\"],\n \"x-content-type-options\":[\"nosniff\"],\n \"x-hubspot-correlation-id\":[\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\"],\n \"Set-Cookie\":[\"__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-1.0.1.1-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,\n 07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None\"],\n \"Report-To\":[\"{\n\\\"endpoints\\\":[{\n\\\"url\\\":\\\"https:\\\\/\\\\/a.nel.cloudflare.com\\\\/report\\\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\\\"}],\n\\\"group\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"NEL\":[\"{\n\\\"success_fraction\\\":0.01,\n\\\"report_to\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"Server\":[\"cloudflare\"]}} {\n\"correlation_id\":\"95236535-ec98-4541-b92a-adfa73b69eab\",\n\"trace_id\":\"c7ab8365-903f-46d4-9403-0e5b551e3545\"}","depth":4,"bounds":{"left":0.37632978,"top":0.09736632,"width":0.5728058,"height":0.8818835},"on_screen":true,"lines":[{"char_start":207,"char_count":30,"bounds":{"left":0.37632978,"top":0.0,"width":0.07513298,"height":0.014365523}},{"char_start":237,"char_count":36,"bounds":{"left":0.37632978,"top":0.0,"width":0.09075798,"height":0.014365523}},{"char_start":273,"char_count":32,"bounds":{"left":0.37632978,"top":0.0,"width":0.080119684,"height":0.014365523}},{"char_start":305,"char_count":79,"bounds":{"left":0.37632978,"top":0.0,"width":0.20212767,"height":0.014365523}},{"char_start":384,"char_count":18,"bounds":{"left":0.37632978,"top":0.0,"width":0.043882977,"height":0.014365523}},{"char_start":402,"char_count":21,"bounds":{"left":0.37632978,"top":0.0,"width":0.051861703,"height":0.014365523}},{"char_start":423,"char_count":48,"bounds":{"left":0.37632978,"top":0.008778931,"width":0.12167553,"height":0.014365523}},{"char_start":471,"char_count":72,"bounds":{"left":0.37632978,"top":0.026336791,"width":0.18384309,"height":0.014365523}},{"char_start":543,"char_count":40,"bounds":{"left":0.37632978,"top":0.043894652,"width":0.10106383,"height":0.014365523}},{"char_start":583,"char_count":41,"bounds":{"left":0.37632978,"top":0.061452515,"width":0.10372341,"height":0.014365523}},{"char_start":624,"char_count":72,"bounds":{"left":0.37632978,"top":0.079010375,"width":0.18384309,"height":0.014365523}},{"char_start":696,"char_count":219,"bounds":{"left":0.37632978,"top":0.096568234,"width":0.56515956,"height":0.014365523}},{"char_start":915,"char_count":83,"bounds":{"left":0.37632978,"top":0.11412609,"width":0.21243352,"height":0.014365523}},{"char_start":998,"char_count":20,"bounds":{"left":0.37632978,"top":0.13168396,"width":0.04920213,"height":0.014365523}},{"char_start":1018,"char_count":17,"bounds":{"left":0.37632978,"top":0.14924182,"width":0.041223403,"height":0.014365523}},{"char_start":1035,"char_count":203,"bounds":{"left":0.37632978,"top":0.16679968,"width":0.52360374,"height":0.014365523}},{"char_start":1238,"char_count":22,"bounds":{"left":0.37632978,"top":0.18435754,"width":0.05418883,"height":0.014365523}},{"char_start":1260,"char_count":23,"bounds":{"left":0.37632978,"top":0.2019154,"width":0.056848403,"height":0.014365523}},{"char_start":1283,"char_count":10,"bounds":{"left":0.37632978,"top":0.21947326,"width":0.023271276,"height":0.014365523}},{"char_start":1293,"char_count":27,"bounds":{"left":0.37632978,"top":0.23703113,"width":0.06715426,"height":0.014365523}},{"char_start":1320,"char_count":26,"bounds":{"left":0.37632978,"top":0.254589,"width":0.06482713,"height":0.014365523}},{"char_start":1346,"char_count":23,"bounds":{"left":0.37632978,"top":0.27214685,"width":0.056848403,"height":0.014365523}},{"char_start":1369,"char_count":28,"bounds":{"left":0.37632978,"top":0.2897047,"width":0.06981383,"height":0.014365523}},{"char_start":1397,"char_count":57,"bounds":{"left":0.37632978,"top":0.30726257,"width":0.14494681,"height":0.014365523}}],"value":"[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {\n\"headers\":{\n\"Date\":[\"Thu,07 May 2026 14:21:15 GMT\"],\n \"Content-Type\":[\"application/json;charset=utf-8\"],\n \"Transfer-Encoding\":[\"chunked\"],\n \"Connection\":[\"keep-alive\"],\n \"CF-Ray\":[\"9f80deb8db60dc3a-SOF\"],\n \"CF-Cache-Status\":[\"DYNAMIC\"],\n \"Strict-Transport-Security\":[\"max-age=31536000; includeSubDomains; preload\"],\n \"Vary\":[\"origin,\n accept-encoding\"],\n \"access-control-allow-credentials\":[\"false\"],\n \"server-timing\":[\"hcid;desc=\\\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\\\",\n cfr;desc=\\\"9f80deb8e7c6dc3a-IAD\\\"\"],\n \"x-content-type-options\":[\"nosniff\"],\n \"x-hubspot-correlation-id\":[\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\"],\n \"Set-Cookie\":[\"__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-1.0.1.1-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,\n 07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None\"],\n \"Report-To\":[\"{\n\\\"endpoints\\\":[{\n\\\"url\\\":\\\"https:\\\\/\\\\/a.nel.cloudflare.com\\\\/report\\\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\\\"}],\n\\\"group\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"NEL\":[\"{\n\\\"success_fraction\\\":0.01,\n\\\"report_to\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"Server\":[\"cloudflare\"]}} {\n\"correlation_id\":\"95236535-ec98-4541-b92a-adfa73b69eab\",\n\"trace_id\":\"c7ab8365-903f-46d4-9403-0e5b551e3545\"}","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.042220745,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"3","depth":4,"bounds":{"left":0.32214096,"top":0.19952115,"width":0.007978723,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"11","depth":4,"bounds":{"left":0.33211437,"top":0.19952115,"width":0.008976064,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"bounds":{"left":0.34275267,"top":0.19792499,"width":0.00731383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
-8677961674721807739
|
-3048904792944948790
|
click
|
accessibility
|
NULL
|
Project: faVsco.js, menu
JY-20725-handle-HS-search Project: faVsco.js, menu
JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
19
Previous Highlighted Error
Next Highlighted Error
[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {
"headers":{
"Date":["Thu,07 May 2026 14:21:15 GMT"],
"Content-Type":["application/json;charset=utf-8"],
"Transfer-Encoding":["chunked"],
"Connection":["keep-alive"],
"CF-Ray":["9f80deb8db60dc3a-SOF"],
"CF-Cache-Status":["DYNAMIC"],
"Strict-Transport-Security":["max-age=31536000; includeSubDomains; preload"],
"Vary":["origin,
accept-encoding"],
"access-control-allow-credentials":["false"],
"server-timing":["hcid;desc=\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\",
cfr;desc=\"9f80deb8e7c6dc3a-IAD\""],
"x-content-type-options":["nosniff"],
"x-hubspot-correlation-id":["019e02d0-6fd8-7812-bdba-885b7ccb3ee3"],
"Set-Cookie":["__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-[IP_ADDRESS]-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,
07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None"],
"Report-To":["{
\"endpoints\":[{
\"url\":\"https:\\/\\/a.nel.cloudflare.com\\/report\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\"}],
\"group\":\"cf-nel\",
\"max_age\":604800}"],
"NEL":["{
\"success_fraction\":0.01,
\"report_to\":\"cf-nel\",
\"max_age\":604800}"],
"Server":["cloudflare"]}} {
"correlation_id":"95236535-ec98-4541-b92a-adfa73b69eab",
"trace_id":"c7ab8365-903f-46d4-9403-0e5b551e3545"}
Sync Changes
Hide This Notification
Code changed:
Hide
3
11
Previous Highlighted Error...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
9829
|
443
|
16
|
2026-05-08T13:36:41.997896+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-08/1778 /Users/lukas/.screenpipe/data/data/2026-05-08/1778247401997_m1.jpg...
|
PhpStorm
|
faVsco.js – SyncRelatedActivityManager.php
|
1
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
JY-20725-handle-HS-search Project: faVsco.js, menu
JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
19
Previous Highlighted Error
Next Highlighted Error
[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {
"headers":{
"Date":["Thu,07 May 2026 14:21:15 GMT"],
"Content-Type":["application/json;charset=utf-8"],
"Transfer-Encoding":["chunked"],
"Connection":["keep-alive"],
"CF-Ray":["9f80deb8db60dc3a-SOF"],
"CF-Cache-Status":["DYNAMIC"],
"Strict-Transport-Security":["max-age=31536000; includeSubDomains; preload"],
"Vary":["origin,
accept-encoding"],
"access-control-allow-credentials":["false"],
"server-timing":["hcid;desc=\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\",
cfr;desc=\"9f80deb8e7c6dc3a-IAD\""],
"x-content-type-options":["nosniff"],
"x-hubspot-correlation-id":["019e02d0-6fd8-7812-bdba-885b7ccb3ee3"],
"Set-Cookie":["__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-[IP_ADDRESS]-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,
07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None"],
"Report-To":["{
\"endpoints\":[{
\"url\":\"https:\\/\\/a.nel.cloudflare.com\\/report\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\"}],
\"group\":\"cf-nel\",
\"max_age\":604800}"],
"NEL":["{
\"success_fraction\":0.01,
\"report_to\":\"cf-nel\",
\"max_age\":604800}"],
"Server":["cloudflare"]}} {
"correlation_id":"95236535-ec98-4541-b92a-adfa73b69eab",
"trace_id":"c7ab8365-903f-46d4-9403-0e5b551e3545"}
Sync Changes
Hide This Notification
Code changed:
Hide
3
11
Previous Highlighted Error
Next Highlighted Error
<?php
declare(strict_types=1);
namespace Jiminny\Services\Crm\Hubspot;
use Carbon\Carbon;
use Exception;
use HubSpot\Client\Crm\Objects\ApiException;
use Jiminny\Integrations\PlaybookResolver;
use Jiminny\Models\Activity;
use Jiminny\Models\Calendar\CalendarEvent;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\Profile;
use Jiminny\Models\Playbook;
use Jiminny\Models\PlaybookCategory;
use Jiminny\Repositories\ActivityRepository;
use Jiminny\Repositories\PlaybookCategoryRepository;
use Psr\Log\LoggerInterface;
class SyncRelatedActivityManager
{
public const string OBJECT_MEETING = 'meetings';
public const string DEFAULT_ACTIVITY_TYPE = 'hs_activity_type';
private RemoteCrmObjectManipulator $crmManipulator;
public function __construct(
private readonly Client $client,
private readonly PayloadBuilder $payloadBuilder,
private readonly LoggerInterface $logger
) {
$this->crmManipulator = app(RemoteCrmObjectManipulator::class, [
'client' => $this->client,
'logger' => $this->logger,
]);
}
public function fetchAndAssociateRelatedActivity(Activity $activity): ?Activity
{
// Validate if the activity is applicable.
if ($this->isActivityApplicable($activity) === false) {
$this->logger->info('[Hubspot] Fetch related activity - not applicable', [
'activityId' => $activity->getUuid(),
]);
return null;
}
// Find and fetch related activity.
try {
$crmActivity = $this->fetchRelatedActivity($activity);
if (empty($crmActivity)) {
return null;
}
} catch (Exception $exception) {
$this->logger->error('[Hubspot] Fetch related activity - failed', [
'activityId' => $activity->getUuid(),
'reason' => $exception->getMessage(),
]);
throw $exception;
}
// get playbook category id
$playbookCategoryId = $this->getPlaybookCategoryId($activity, $crmActivity);
$data = [
'crm_provider_id' => $crmActivity['id'],
'playbook_category_id' => $playbookCategoryId,
];
if (! $this->updateActivity($activity, $data)) {
return null;
}
$this->crmManipulator->updateRelatedCrmActivity($activity, $crmActivity);
$activity->refresh();
return $activity;
}
private function isActivityApplicable(Activity $activity): bool
{
if (! $activity->isTypeConference()) {
return false;
}
if (! $this->hasMinimumFilterRequirements($activity)) {
$this->logger->info('[Hubspot] Fetch related activity - not enough filter data', [
'activityId' => $activity->getUuid(),
]);
return false;
}
// The related activity is already linked.
if ($activity->hasCrmProviderId()) {
return false;
}
if (! $activity->getUser()->getProfile() instanceof Profile) {
$this->logger->info('[Hubspot] Fetch related activity - activity user has no crm profile', [
'activityId' => $activity->getUuid(),
]);
return false;
}
return true;
}
private function hasMinimumFilterRequirements(Activity $activity): bool
{
// We need to know when it started to determine when to look for related activity.
if (! $activity->hasActualStartTime()) {
return false;
}
if (! $activity->hasActualEndTime()) {
return false;
}
// At least one prospect should be associated to the activity
if ($activity->hasContact() || $activity->hasAccount()) {
return true;
}
return false;
}
public function fetchRelatedActivity(Activity $activity): array
{
$this->logger->info('[Hubspot] Fetch related activity - searching related CRM activity', [
'activityId' => $activity->getUuid(),
]);
$payloadData = $this->getPayloadFilterData($activity);
try {
$payload = $this->payloadBuilder->getFindRelatedActivityPayload($payloadData, self::OBJECT_MEETING);
$response = $this->client->getPaginatedData($payload, self::OBJECT_MEETING);
$crmActivities = $response['results'];
} catch (Exception $e) {
$this->logger->error('[Hubspot] Fetch related activity - failed', [
'activityId' => $activity->getUuid(),
'reason' => $e->getMessage(),
]);
throw $e;
}
if (empty($crmActivities)) {
$this->logger->info('[Hubspot] Fetch related activity - no CRM related activity found', [
'activityId' => $activity->getUuid(),
'payload' => $payload,
]);
return [];
}
$relatedActivity = $this->getBestCrmActivityCandidate($crmActivities, $payloadData);
if (empty($relatedActivity)) {
$this->logger->info('[Hubspot] Fetch related activity - no related activity candidate', [
'activityId' => $activity->getUuid(),
]);
return [];
}
$this->logger->info('[Hubspot] Fetch related activity - found related CRM activity', [
'activityId' => $activity->getUuid(),
'crmActivity' => $relatedActivity,
]);
return $relatedActivity;
}
/**
* @param Activity $activity
*
* @return array
*/
protected function getPayloadFilterData(Activity $activity): array
{
[$from, $to] = $this->getActivityTimeFilterRange($activity);
$data = [
'from' => $from->getPreciseTimestamp(3),
'to' => $to->getPreciseTimestamp(3),
'contact' => $activity->hasContact() ? $activity->getContact()->getCrmProviderId() : null,
'company' => $activity->hasAccount() ? $activity->getAccount()->getCrmProviderId() : null,
'deal' => $activity->hasOpportunity() ? $activity->getOpportunity()->getCrmProviderId() : null,
'owner' => $activity->getUser()->getProfile()->getCrmProviderId(),
'activity' => $activity->getUuid(),
];
return array_filter($data);
}
private function getActivityTimeFilterRange(Activity $activity): array
{
if ($activity->hasScheduledStartTime() && $activity->hasScheduledEndTime()) {
$calendarEvent = $activity->getCalendarEvent();
if ($calendarEvent instanceof CalendarEvent) {
$from = $calendarEvent->getStartTime();
$to = $calendarEvent->getEndTime()->addMinutes(15);
} else {
$from = Carbon::instance($activity->getScheduledStartTime());
$to = Carbon::instance($activity->getScheduledEndTime())->addMinutes(15);
}
return [$from, $to];
}
$from = Carbon::instance($activity->getActualStartTime());
$to = Carbon::instance($activity->getActualEndTime());
return [$from, $to];
}
/**
* Determines the best CRM activity candidate based on the number of matching prospects and the owner.
*
* @param array $crmActivities List of CRM activities to evaluate.
* @param array $data Data containing the prospects and owner information.
*
* @return array The best CRM activity candidate.
*/
private function getBestCrmActivityCandidate(array $crmActivities, array $data): array
{
$prospectCount = count(array_intersect_key($data, array_flip(['contact', 'company', 'deal'])));
$bestCandidate = null;
$maxMatchingProspects = 0;
$validActivitiesCount = count($crmActivities);
if ($validActivitiesCount > 1) {
$this->logger->info('[Hubspot] Fetch related activity - determining the best candidate', [
'activityId' => $data['activity'],
'activitiesCount' => $validActivitiesCount,
'crmActivities' => $crmActivities,
]);
}
foreach ($crmActivities as $crmActivity) {
if ($this->isOwnerMismatch($crmActivity, $data)) {
$validActivitiesCount--;
continue;
}
if ($validActivitiesCount === 1) {
return $crmActivity;
}
if ($prospectCount > 1) {
$matchingProspects = $this->countMatchingProspects($crmActivity, $data);
if ($matchingProspects > $maxMatchingProspects) {
$maxMatchingProspects = $matchingProspects;
$bestCandidate = $crmActivity;
}
} else {
return $crmActivity;
}
}
return $bestCandidate ?? [];
}
private function isOwnerMismatch(array $crmActivity, array $data): bool
{
if ($crmActivity['properties']['hubspot_owner_id'] !== $data['owner']) {
$this->logger->info('[Hubspot] Fetch related activity - owner mismatch', [
'activityId' => $data['activity'],
'crmActivity' => $crmActivity['id'],
'jiminnyOwner' => $data['owner'],
'hubspotOwner' => $crmActivity['properties']['hubspot_owner_id'],
]);
return true;
}
return false;
}
private function countMatchingProspects(array $crmActivity, array $data): int
{
try {
$crmActivityWithAssociations = $this->client->getMeeting($crmActivity['id']);
} catch (ApiException $e) {
$this->logger->error('[Hubspot] Fetch related activity - failed to get meeting associations', [
'activityId' => $data['activity'],
'crmActivity' => $crmActivity['id'],
'reason' => $e->getMessage(),
]);
return 0;
}
$associations = $crmActivityWithAssociations->getAssociations();
$matchingProspects = 0;
$matchingProspects += $this->countMatchingAssociation($data, $associations, 'company', 'companies');
$matchingProspects += $this->countMatchingAssociation($data, $associations, 'contact', 'contacts');
$matchingProspects += $this->countMatchingAssociation($data, $associations, 'deal', 'deals');
return $matchingProspects;
}
private function countMatchingAssociation(array $data, array $associations, string $dataKey, string $associationKey): int
{
if (isset($data[$dataKey]) && isset($associations[$associationKey])) {
$associatedItems = $associations[$associationKey]->getResults();
foreach ($associatedItems as $associatedItem) {
if ($data[$dataKey] == $associatedItem->getId()) {
return 1;
}
}
}
return 0;
}
private function getPlaybookCategoryId(Activity $activity, array $crmActivity): ?int
{
if (! $activity->hasUser()) {
$this->logger->info('[Hubspot] Fetch related activity - activity has no user', [
'activityId' => $activity->getUuid(),
]);
return null;
}
$playbookResolver = app(PlaybookResolver::class);
$playbook = $playbookResolver->resolvePlaybookByUser($activity->getUser());
if (! $playbook instanceof Playbook) {
$this->logger->info('[Hubspot] Fetch related activity - playbook not set for user', [
'activityId' => $activity->getUuid(),
]);
return null;
}
if (! $playbook->getActivityField() instanceof Field) {
$this->logger->info('[Hubspot] Fetch related activity - playbook activity field not set', [
'activityId' => $activity->getUuid(),
]);
return null;
}
$playbookCategory = null;
$activityTypeSource = null;
$playbookActivityFieldName = $playbook->getActivityField()->getCrmProviderId();
if ($playbookActivityFieldName === 'activityType') {
$playbookActivityFieldName = self::DEFAULT_ACTIVITY_TYPE;
}
// First attempt to match playbook category from crm activity
if (! empty($crmActivity['properties'][$playbookActivityFieldName])) {
$playbookCategoryRepository = app(PlaybookCategoryRepository::class);
$playbookCategory = $playbookCategoryRepository->getPlaybookCategoryByName(
$playbook,
strval($crmActivity['properties'][$playbookActivityFieldName])
);
$activityTypeSource = 'crm';
}
// If not matched get playbook category from Jiminny activity
if (! $playbookCategory instanceof PlaybookCategory && $activity->hasActivityType()) {
$playbookCategory = $activity->getActivityType();
$activityTypeSource = 'jiminny';
}
if ($playbookCategory instanceof PlaybookCategory) {
$this->logger->info('[Hubspot] Fetch related activity - Playbook category matched', [
'activityId' => $activity->getUuid(),
'playbookCategoryId' => $playbookCategory->getId(),
'activityFieldName' => $playbookActivityFieldName,
'crmActivity' => $crmActivity['id'],
'source' => $activityTypeSource,
]);
return $playbookCategory->getId();
}
$this->logger->info('[Hubspot] Fetch related activity - Playbook category not found', [
'activityId' => $activity->getUuid(),
'activityFieldName' => $playbookActivityFieldName,
'crmActivity' => $crmActivity['id'],
]);
return null;
}
private function updateActivity(Activity $activity, array $data): bool
{
$activityRepository = app(ActivityRepository::class);
$alreadyLoggedActivity = $activityRepository->findUserActivityByCrmProviderId(
$activity->getUser(),
$data['crm_provider_id']
);
if ($alreadyLoggedActivity instanceof Activity) {
$this->logger->info('[Hubspot] Fetch related activity - activity already linked', [
'currentActivityId' => $activity->getUuid(),
'linkedActivityId' => $alreadyLoggedActivity->getUuid(),
'crmActivityId' => $data['crm_provider_id'],
]);
// Return false to prevent updating the remote CRM engagement. This can happen when two Jiminny
// activities share the same calendar event (e.g., rescheduled meetings). Without this, the first
// activity's summary would be overwritten by the second activity.
return false;
}
try {
$activityRepository->update($activity, $data);
} catch (Exception $e) {
$this->logger->error('[Hubspot] Fetch related activity - activity update failed', [
'activityId' => $activity->getUuid(),
'reason' => $e->getMessage(),
]);
return false;
}
$this->logger->info('[Hubspot] Fetch related activity - activity updated', [
'activityId' => $activity->getUuid(),
'data' => $data,
]);
return true;
}
}
Project
Project
New File or Directory…
Expand Selected
Collapse All
Options
Hide...
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JY-20725-handle-HS-search-rate-limit, menu","depth":5,"on_screen":true,"help_text":"Git Branch: JY-20725-handle-HS-search-rate-limit","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"AskJiminnyReportActivityServiceTest","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'AskJiminnyReportActivityServiceTest'","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'AskJiminnyReportActivityServiceTest'","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"IDE and Project Settings","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.088194445,"height":0.027777778},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"19","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {\n\"headers\":{\n\"Date\":[\"Thu,07 May 2026 14:21:15 GMT\"],\n \"Content-Type\":[\"application/json;charset=utf-8\"],\n \"Transfer-Encoding\":[\"chunked\"],\n \"Connection\":[\"keep-alive\"],\n \"CF-Ray\":[\"9f80deb8db60dc3a-SOF\"],\n \"CF-Cache-Status\":[\"DYNAMIC\"],\n \"Strict-Transport-Security\":[\"max-age=31536000; includeSubDomains; preload\"],\n \"Vary\":[\"origin,\n accept-encoding\"],\n \"access-control-allow-credentials\":[\"false\"],\n \"server-timing\":[\"hcid;desc=\\\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\\\",\n cfr;desc=\\\"9f80deb8e7c6dc3a-IAD\\\"\"],\n \"x-content-type-options\":[\"nosniff\"],\n \"x-hubspot-correlation-id\":[\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\"],\n \"Set-Cookie\":[\"__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-1.0.1.1-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,\n 07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None\"],\n \"Report-To\":[\"{\n\\\"endpoints\\\":[{\n\\\"url\\\":\\\"https:\\\\/\\\\/a.nel.cloudflare.com\\\\/report\\\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\\\"}],\n\\\"group\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"NEL\":[\"{\n\\\"success_fraction\\\":0.01,\n\\\"report_to\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"Server\":[\"cloudflare\"]}} {\n\"correlation_id\":\"95236535-ec98-4541-b92a-adfa73b69eab\",\n\"trace_id\":\"c7ab8365-903f-46d4-9403-0e5b551e3545\"}","depth":4,"on_screen":true,"value":"[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {\n\"headers\":{\n\"Date\":[\"Thu,07 May 2026 14:21:15 GMT\"],\n \"Content-Type\":[\"application/json;charset=utf-8\"],\n \"Transfer-Encoding\":[\"chunked\"],\n \"Connection\":[\"keep-alive\"],\n \"CF-Ray\":[\"9f80deb8db60dc3a-SOF\"],\n \"CF-Cache-Status\":[\"DYNAMIC\"],\n \"Strict-Transport-Security\":[\"max-age=31536000; includeSubDomains; preload\"],\n \"Vary\":[\"origin,\n accept-encoding\"],\n \"access-control-allow-credentials\":[\"false\"],\n \"server-timing\":[\"hcid;desc=\\\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\\\",\n cfr;desc=\\\"9f80deb8e7c6dc3a-IAD\\\"\"],\n \"x-content-type-options\":[\"nosniff\"],\n \"x-hubspot-correlation-id\":[\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\"],\n \"Set-Cookie\":[\"__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-1.0.1.1-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,\n 07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None\"],\n \"Report-To\":[\"{\n\\\"endpoints\\\":[{\n\\\"url\\\":\\\"https:\\\\/\\\\/a.nel.cloudflare.com\\\\/report\\\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\\\"}],\n\\\"group\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"NEL\":[\"{\n\\\"success_fraction\\\":0.01,\n\\\"report_to\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"Server\":[\"cloudflare\"]}} {\n\"correlation_id\":\"95236535-ec98-4541-b92a-adfa73b69eab\",\n\"trace_id\":\"c7ab8365-903f-46d4-9403-0e5b551e3545\"}","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.088194445,"height":0.027777778},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"3","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"11","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\ndeclare(strict_types=1);\n\nnamespace Jiminny\\Services\\Crm\\Hubspot;\n\nuse Carbon\\Carbon;\nuse Exception;\nuse HubSpot\\Client\\Crm\\Objects\\ApiException;\nuse Jiminny\\Integrations\\PlaybookResolver;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Calendar\\CalendarEvent;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Models\\Crm\\Profile;\nuse Jiminny\\Models\\Playbook;\nuse Jiminny\\Models\\PlaybookCategory;\nuse Jiminny\\Repositories\\ActivityRepository;\nuse Jiminny\\Repositories\\PlaybookCategoryRepository;\nuse Psr\\Log\\LoggerInterface;\n\nclass SyncRelatedActivityManager\n{\n public const string OBJECT_MEETING = 'meetings';\n public const string DEFAULT_ACTIVITY_TYPE = 'hs_activity_type';\n private RemoteCrmObjectManipulator $crmManipulator;\n\n public function __construct(\n private readonly Client $client,\n private readonly PayloadBuilder $payloadBuilder,\n private readonly LoggerInterface $logger\n ) {\n $this->crmManipulator = app(RemoteCrmObjectManipulator::class, [\n 'client' => $this->client,\n 'logger' => $this->logger,\n ]);\n }\n\n public function fetchAndAssociateRelatedActivity(Activity $activity): ?Activity\n {\n // Validate if the activity is applicable.\n if ($this->isActivityApplicable($activity) === false) {\n $this->logger->info('[Hubspot] Fetch related activity - not applicable', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n return null;\n }\n\n // Find and fetch related activity.\n try {\n $crmActivity = $this->fetchRelatedActivity($activity);\n if (empty($crmActivity)) {\n return null;\n }\n } catch (Exception $exception) {\n $this->logger->error('[Hubspot] Fetch related activity - failed', [\n 'activityId' => $activity->getUuid(),\n 'reason' => $exception->getMessage(),\n ]);\n\n throw $exception;\n }\n\n // get playbook category id\n $playbookCategoryId = $this->getPlaybookCategoryId($activity, $crmActivity);\n\n $data = [\n 'crm_provider_id' => $crmActivity['id'],\n 'playbook_category_id' => $playbookCategoryId,\n ];\n\n if (! $this->updateActivity($activity, $data)) {\n return null;\n }\n\n $this->crmManipulator->updateRelatedCrmActivity($activity, $crmActivity);\n\n $activity->refresh();\n\n return $activity;\n }\n\n private function isActivityApplicable(Activity $activity): bool\n {\n if (! $activity->isTypeConference()) {\n return false;\n }\n\n if (! $this->hasMinimumFilterRequirements($activity)) {\n $this->logger->info('[Hubspot] Fetch related activity - not enough filter data', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n return false;\n }\n\n // The related activity is already linked.\n if ($activity->hasCrmProviderId()) {\n return false;\n }\n\n if (! $activity->getUser()->getProfile() instanceof Profile) {\n $this->logger->info('[Hubspot] Fetch related activity - activity user has no crm profile', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n return false;\n }\n\n return true;\n }\n\n private function hasMinimumFilterRequirements(Activity $activity): bool\n {\n // We need to know when it started to determine when to look for related activity.\n if (! $activity->hasActualStartTime()) {\n return false;\n }\n\n if (! $activity->hasActualEndTime()) {\n return false;\n }\n\n // At least one prospect should be associated to the activity\n if ($activity->hasContact() || $activity->hasAccount()) {\n return true;\n }\n\n return false;\n }\n\n public function fetchRelatedActivity(Activity $activity): array\n {\n $this->logger->info('[Hubspot] Fetch related activity - searching related CRM activity', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n $payloadData = $this->getPayloadFilterData($activity);\n\n try {\n $payload = $this->payloadBuilder->getFindRelatedActivityPayload($payloadData, self::OBJECT_MEETING);\n $response = $this->client->getPaginatedData($payload, self::OBJECT_MEETING);\n $crmActivities = $response['results'];\n } catch (Exception $e) {\n $this->logger->error('[Hubspot] Fetch related activity - failed', [\n 'activityId' => $activity->getUuid(),\n 'reason' => $e->getMessage(),\n ]);\n\n throw $e;\n }\n\n if (empty($crmActivities)) {\n $this->logger->info('[Hubspot] Fetch related activity - no CRM related activity found', [\n 'activityId' => $activity->getUuid(),\n 'payload' => $payload,\n ]);\n\n return [];\n }\n\n $relatedActivity = $this->getBestCrmActivityCandidate($crmActivities, $payloadData);\n if (empty($relatedActivity)) {\n $this->logger->info('[Hubspot] Fetch related activity - no related activity candidate', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n return [];\n }\n\n $this->logger->info('[Hubspot] Fetch related activity - found related CRM activity', [\n 'activityId' => $activity->getUuid(),\n 'crmActivity' => $relatedActivity,\n ]);\n\n return $relatedActivity;\n }\n\n /**\n * @param Activity $activity\n *\n * @return array\n */\n protected function getPayloadFilterData(Activity $activity): array\n {\n [$from, $to] = $this->getActivityTimeFilterRange($activity);\n\n $data = [\n 'from' => $from->getPreciseTimestamp(3),\n 'to' => $to->getPreciseTimestamp(3),\n 'contact' => $activity->hasContact() ? $activity->getContact()->getCrmProviderId() : null,\n 'company' => $activity->hasAccount() ? $activity->getAccount()->getCrmProviderId() : null,\n 'deal' => $activity->hasOpportunity() ? $activity->getOpportunity()->getCrmProviderId() : null,\n 'owner' => $activity->getUser()->getProfile()->getCrmProviderId(),\n 'activity' => $activity->getUuid(),\n ];\n\n return array_filter($data);\n }\n\n private function getActivityTimeFilterRange(Activity $activity): array\n {\n if ($activity->hasScheduledStartTime() && $activity->hasScheduledEndTime()) {\n $calendarEvent = $activity->getCalendarEvent();\n\n if ($calendarEvent instanceof CalendarEvent) {\n $from = $calendarEvent->getStartTime();\n $to = $calendarEvent->getEndTime()->addMinutes(15);\n } else {\n $from = Carbon::instance($activity->getScheduledStartTime());\n $to = Carbon::instance($activity->getScheduledEndTime())->addMinutes(15);\n }\n\n return [$from, $to];\n }\n\n $from = Carbon::instance($activity->getActualStartTime());\n $to = Carbon::instance($activity->getActualEndTime());\n\n return [$from, $to];\n }\n\n /**\n * Determines the best CRM activity candidate based on the number of matching prospects and the owner.\n *\n * @param array $crmActivities List of CRM activities to evaluate.\n * @param array $data Data containing the prospects and owner information.\n *\n * @return array The best CRM activity candidate.\n */\n private function getBestCrmActivityCandidate(array $crmActivities, array $data): array\n {\n $prospectCount = count(array_intersect_key($data, array_flip(['contact', 'company', 'deal'])));\n $bestCandidate = null;\n $maxMatchingProspects = 0;\n $validActivitiesCount = count($crmActivities);\n\n if ($validActivitiesCount > 1) {\n $this->logger->info('[Hubspot] Fetch related activity - determining the best candidate', [\n 'activityId' => $data['activity'],\n 'activitiesCount' => $validActivitiesCount,\n 'crmActivities' => $crmActivities,\n ]);\n }\n\n foreach ($crmActivities as $crmActivity) {\n if ($this->isOwnerMismatch($crmActivity, $data)) {\n $validActivitiesCount--;\n\n continue;\n }\n\n if ($validActivitiesCount === 1) {\n return $crmActivity;\n }\n\n if ($prospectCount > 1) {\n $matchingProspects = $this->countMatchingProspects($crmActivity, $data);\n if ($matchingProspects > $maxMatchingProspects) {\n $maxMatchingProspects = $matchingProspects;\n $bestCandidate = $crmActivity;\n }\n } else {\n return $crmActivity;\n }\n }\n\n return $bestCandidate ?? [];\n }\n\n private function isOwnerMismatch(array $crmActivity, array $data): bool\n {\n if ($crmActivity['properties']['hubspot_owner_id'] !== $data['owner']) {\n $this->logger->info('[Hubspot] Fetch related activity - owner mismatch', [\n 'activityId' => $data['activity'],\n 'crmActivity' => $crmActivity['id'],\n 'jiminnyOwner' => $data['owner'],\n 'hubspotOwner' => $crmActivity['properties']['hubspot_owner_id'],\n ]);\n\n return true;\n }\n\n return false;\n }\n\n private function countMatchingProspects(array $crmActivity, array $data): int\n {\n try {\n $crmActivityWithAssociations = $this->client->getMeeting($crmActivity['id']);\n } catch (ApiException $e) {\n $this->logger->error('[Hubspot] Fetch related activity - failed to get meeting associations', [\n 'activityId' => $data['activity'],\n 'crmActivity' => $crmActivity['id'],\n 'reason' => $e->getMessage(),\n ]);\n\n return 0;\n }\n\n $associations = $crmActivityWithAssociations->getAssociations();\n $matchingProspects = 0;\n\n $matchingProspects += $this->countMatchingAssociation($data, $associations, 'company', 'companies');\n $matchingProspects += $this->countMatchingAssociation($data, $associations, 'contact', 'contacts');\n $matchingProspects += $this->countMatchingAssociation($data, $associations, 'deal', 'deals');\n\n return $matchingProspects;\n }\n\n private function countMatchingAssociation(array $data, array $associations, string $dataKey, string $associationKey): int\n {\n if (isset($data[$dataKey]) && isset($associations[$associationKey])) {\n $associatedItems = $associations[$associationKey]->getResults();\n foreach ($associatedItems as $associatedItem) {\n if ($data[$dataKey] == $associatedItem->getId()) {\n\n return 1;\n }\n }\n }\n\n return 0;\n }\n\n private function getPlaybookCategoryId(Activity $activity, array $crmActivity): ?int\n {\n if (! $activity->hasUser()) {\n $this->logger->info('[Hubspot] Fetch related activity - activity has no user', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n return null;\n }\n\n $playbookResolver = app(PlaybookResolver::class);\n $playbook = $playbookResolver->resolvePlaybookByUser($activity->getUser());\n\n if (! $playbook instanceof Playbook) {\n $this->logger->info('[Hubspot] Fetch related activity - playbook not set for user', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n return null;\n }\n\n if (! $playbook->getActivityField() instanceof Field) {\n $this->logger->info('[Hubspot] Fetch related activity - playbook activity field not set', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n return null;\n }\n\n $playbookCategory = null;\n $activityTypeSource = null;\n\n $playbookActivityFieldName = $playbook->getActivityField()->getCrmProviderId();\n if ($playbookActivityFieldName === 'activityType') {\n $playbookActivityFieldName = self::DEFAULT_ACTIVITY_TYPE;\n }\n\n // First attempt to match playbook category from crm activity\n if (! empty($crmActivity['properties'][$playbookActivityFieldName])) {\n $playbookCategoryRepository = app(PlaybookCategoryRepository::class);\n $playbookCategory = $playbookCategoryRepository->getPlaybookCategoryByName(\n $playbook,\n strval($crmActivity['properties'][$playbookActivityFieldName])\n );\n $activityTypeSource = 'crm';\n }\n\n // If not matched get playbook category from Jiminny activity\n if (! $playbookCategory instanceof PlaybookCategory && $activity->hasActivityType()) {\n $playbookCategory = $activity->getActivityType();\n $activityTypeSource = 'jiminny';\n }\n\n if ($playbookCategory instanceof PlaybookCategory) {\n $this->logger->info('[Hubspot] Fetch related activity - Playbook category matched', [\n 'activityId' => $activity->getUuid(),\n 'playbookCategoryId' => $playbookCategory->getId(),\n 'activityFieldName' => $playbookActivityFieldName,\n 'crmActivity' => $crmActivity['id'],\n 'source' => $activityTypeSource,\n ]);\n\n return $playbookCategory->getId();\n }\n\n $this->logger->info('[Hubspot] Fetch related activity - Playbook category not found', [\n 'activityId' => $activity->getUuid(),\n 'activityFieldName' => $playbookActivityFieldName,\n 'crmActivity' => $crmActivity['id'],\n ]);\n\n return null;\n }\n\n private function updateActivity(Activity $activity, array $data): bool\n {\n $activityRepository = app(ActivityRepository::class);\n\n $alreadyLoggedActivity = $activityRepository->findUserActivityByCrmProviderId(\n $activity->getUser(),\n $data['crm_provider_id']\n );\n\n if ($alreadyLoggedActivity instanceof Activity) {\n $this->logger->info('[Hubspot] Fetch related activity - activity already linked', [\n 'currentActivityId' => $activity->getUuid(),\n 'linkedActivityId' => $alreadyLoggedActivity->getUuid(),\n 'crmActivityId' => $data['crm_provider_id'],\n ]);\n\n // Return false to prevent updating the remote CRM engagement. This can happen when two Jiminny\n // activities share the same calendar event (e.g., rescheduled meetings). Without this, the first\n // activity's summary would be overwritten by the second activity.\n return false;\n }\n\n try {\n $activityRepository->update($activity, $data);\n } catch (Exception $e) {\n $this->logger->error('[Hubspot] Fetch related activity - activity update failed', [\n 'activityId' => $activity->getUuid(),\n 'reason' => $e->getMessage(),\n ]);\n\n return false;\n }\n\n $this->logger->info('[Hubspot] Fetch related activity - activity updated', [\n 'activityId' => $activity->getUuid(),\n 'data' => $data,\n ]);\n\n return true;\n }\n}","depth":4,"on_screen":true,"value":"<?php\n\ndeclare(strict_types=1);\n\nnamespace Jiminny\\Services\\Crm\\Hubspot;\n\nuse Carbon\\Carbon;\nuse Exception;\nuse HubSpot\\Client\\Crm\\Objects\\ApiException;\nuse Jiminny\\Integrations\\PlaybookResolver;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Calendar\\CalendarEvent;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Models\\Crm\\Profile;\nuse Jiminny\\Models\\Playbook;\nuse Jiminny\\Models\\PlaybookCategory;\nuse Jiminny\\Repositories\\ActivityRepository;\nuse Jiminny\\Repositories\\PlaybookCategoryRepository;\nuse Psr\\Log\\LoggerInterface;\n\nclass SyncRelatedActivityManager\n{\n public const string OBJECT_MEETING = 'meetings';\n public const string DEFAULT_ACTIVITY_TYPE = 'hs_activity_type';\n private RemoteCrmObjectManipulator $crmManipulator;\n\n public function __construct(\n private readonly Client $client,\n private readonly PayloadBuilder $payloadBuilder,\n private readonly LoggerInterface $logger\n ) {\n $this->crmManipulator = app(RemoteCrmObjectManipulator::class, [\n 'client' => $this->client,\n 'logger' => $this->logger,\n ]);\n }\n\n public function fetchAndAssociateRelatedActivity(Activity $activity): ?Activity\n {\n // Validate if the activity is applicable.\n if ($this->isActivityApplicable($activity) === false) {\n $this->logger->info('[Hubspot] Fetch related activity - not applicable', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n return null;\n }\n\n // Find and fetch related activity.\n try {\n $crmActivity = $this->fetchRelatedActivity($activity);\n if (empty($crmActivity)) {\n return null;\n }\n } catch (Exception $exception) {\n $this->logger->error('[Hubspot] Fetch related activity - failed', [\n 'activityId' => $activity->getUuid(),\n 'reason' => $exception->getMessage(),\n ]);\n\n throw $exception;\n }\n\n // get playbook category id\n $playbookCategoryId = $this->getPlaybookCategoryId($activity, $crmActivity);\n\n $data = [\n 'crm_provider_id' => $crmActivity['id'],\n 'playbook_category_id' => $playbookCategoryId,\n ];\n\n if (! $this->updateActivity($activity, $data)) {\n return null;\n }\n\n $this->crmManipulator->updateRelatedCrmActivity($activity, $crmActivity);\n\n $activity->refresh();\n\n return $activity;\n }\n\n private function isActivityApplicable(Activity $activity): bool\n {\n if (! $activity->isTypeConference()) {\n return false;\n }\n\n if (! $this->hasMinimumFilterRequirements($activity)) {\n $this->logger->info('[Hubspot] Fetch related activity - not enough filter data', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n return false;\n }\n\n // The related activity is already linked.\n if ($activity->hasCrmProviderId()) {\n return false;\n }\n\n if (! $activity->getUser()->getProfile() instanceof Profile) {\n $this->logger->info('[Hubspot] Fetch related activity - activity user has no crm profile', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n return false;\n }\n\n return true;\n }\n\n private function hasMinimumFilterRequirements(Activity $activity): bool\n {\n // We need to know when it started to determine when to look for related activity.\n if (! $activity->hasActualStartTime()) {\n return false;\n }\n\n if (! $activity->hasActualEndTime()) {\n return false;\n }\n\n // At least one prospect should be associated to the activity\n if ($activity->hasContact() || $activity->hasAccount()) {\n return true;\n }\n\n return false;\n }\n\n public function fetchRelatedActivity(Activity $activity): array\n {\n $this->logger->info('[Hubspot] Fetch related activity - searching related CRM activity', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n $payloadData = $this->getPayloadFilterData($activity);\n\n try {\n $payload = $this->payloadBuilder->getFindRelatedActivityPayload($payloadData, self::OBJECT_MEETING);\n $response = $this->client->getPaginatedData($payload, self::OBJECT_MEETING);\n $crmActivities = $response['results'];\n } catch (Exception $e) {\n $this->logger->error('[Hubspot] Fetch related activity - failed', [\n 'activityId' => $activity->getUuid(),\n 'reason' => $e->getMessage(),\n ]);\n\n throw $e;\n }\n\n if (empty($crmActivities)) {\n $this->logger->info('[Hubspot] Fetch related activity - no CRM related activity found', [\n 'activityId' => $activity->getUuid(),\n 'payload' => $payload,\n ]);\n\n return [];\n }\n\n $relatedActivity = $this->getBestCrmActivityCandidate($crmActivities, $payloadData);\n if (empty($relatedActivity)) {\n $this->logger->info('[Hubspot] Fetch related activity - no related activity candidate', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n return [];\n }\n\n $this->logger->info('[Hubspot] Fetch related activity - found related CRM activity', [\n 'activityId' => $activity->getUuid(),\n 'crmActivity' => $relatedActivity,\n ]);\n\n return $relatedActivity;\n }\n\n /**\n * @param Activity $activity\n *\n * @return array\n */\n protected function getPayloadFilterData(Activity $activity): array\n {\n [$from, $to] = $this->getActivityTimeFilterRange($activity);\n\n $data = [\n 'from' => $from->getPreciseTimestamp(3),\n 'to' => $to->getPreciseTimestamp(3),\n 'contact' => $activity->hasContact() ? $activity->getContact()->getCrmProviderId() : null,\n 'company' => $activity->hasAccount() ? $activity->getAccount()->getCrmProviderId() : null,\n 'deal' => $activity->hasOpportunity() ? $activity->getOpportunity()->getCrmProviderId() : null,\n 'owner' => $activity->getUser()->getProfile()->getCrmProviderId(),\n 'activity' => $activity->getUuid(),\n ];\n\n return array_filter($data);\n }\n\n private function getActivityTimeFilterRange(Activity $activity): array\n {\n if ($activity->hasScheduledStartTime() && $activity->hasScheduledEndTime()) {\n $calendarEvent = $activity->getCalendarEvent();\n\n if ($calendarEvent instanceof CalendarEvent) {\n $from = $calendarEvent->getStartTime();\n $to = $calendarEvent->getEndTime()->addMinutes(15);\n } else {\n $from = Carbon::instance($activity->getScheduledStartTime());\n $to = Carbon::instance($activity->getScheduledEndTime())->addMinutes(15);\n }\n\n return [$from, $to];\n }\n\n $from = Carbon::instance($activity->getActualStartTime());\n $to = Carbon::instance($activity->getActualEndTime());\n\n return [$from, $to];\n }\n\n /**\n * Determines the best CRM activity candidate based on the number of matching prospects and the owner.\n *\n * @param array $crmActivities List of CRM activities to evaluate.\n * @param array $data Data containing the prospects and owner information.\n *\n * @return array The best CRM activity candidate.\n */\n private function getBestCrmActivityCandidate(array $crmActivities, array $data): array\n {\n $prospectCount = count(array_intersect_key($data, array_flip(['contact', 'company', 'deal'])));\n $bestCandidate = null;\n $maxMatchingProspects = 0;\n $validActivitiesCount = count($crmActivities);\n\n if ($validActivitiesCount > 1) {\n $this->logger->info('[Hubspot] Fetch related activity - determining the best candidate', [\n 'activityId' => $data['activity'],\n 'activitiesCount' => $validActivitiesCount,\n 'crmActivities' => $crmActivities,\n ]);\n }\n\n foreach ($crmActivities as $crmActivity) {\n if ($this->isOwnerMismatch($crmActivity, $data)) {\n $validActivitiesCount--;\n\n continue;\n }\n\n if ($validActivitiesCount === 1) {\n return $crmActivity;\n }\n\n if ($prospectCount > 1) {\n $matchingProspects = $this->countMatchingProspects($crmActivity, $data);\n if ($matchingProspects > $maxMatchingProspects) {\n $maxMatchingProspects = $matchingProspects;\n $bestCandidate = $crmActivity;\n }\n } else {\n return $crmActivity;\n }\n }\n\n return $bestCandidate ?? [];\n }\n\n private function isOwnerMismatch(array $crmActivity, array $data): bool\n {\n if ($crmActivity['properties']['hubspot_owner_id'] !== $data['owner']) {\n $this->logger->info('[Hubspot] Fetch related activity - owner mismatch', [\n 'activityId' => $data['activity'],\n 'crmActivity' => $crmActivity['id'],\n 'jiminnyOwner' => $data['owner'],\n 'hubspotOwner' => $crmActivity['properties']['hubspot_owner_id'],\n ]);\n\n return true;\n }\n\n return false;\n }\n\n private function countMatchingProspects(array $crmActivity, array $data): int\n {\n try {\n $crmActivityWithAssociations = $this->client->getMeeting($crmActivity['id']);\n } catch (ApiException $e) {\n $this->logger->error('[Hubspot] Fetch related activity - failed to get meeting associations', [\n 'activityId' => $data['activity'],\n 'crmActivity' => $crmActivity['id'],\n 'reason' => $e->getMessage(),\n ]);\n\n return 0;\n }\n\n $associations = $crmActivityWithAssociations->getAssociations();\n $matchingProspects = 0;\n\n $matchingProspects += $this->countMatchingAssociation($data, $associations, 'company', 'companies');\n $matchingProspects += $this->countMatchingAssociation($data, $associations, 'contact', 'contacts');\n $matchingProspects += $this->countMatchingAssociation($data, $associations, 'deal', 'deals');\n\n return $matchingProspects;\n }\n\n private function countMatchingAssociation(array $data, array $associations, string $dataKey, string $associationKey): int\n {\n if (isset($data[$dataKey]) && isset($associations[$associationKey])) {\n $associatedItems = $associations[$associationKey]->getResults();\n foreach ($associatedItems as $associatedItem) {\n if ($data[$dataKey] == $associatedItem->getId()) {\n\n return 1;\n }\n }\n }\n\n return 0;\n }\n\n private function getPlaybookCategoryId(Activity $activity, array $crmActivity): ?int\n {\n if (! $activity->hasUser()) {\n $this->logger->info('[Hubspot] Fetch related activity - activity has no user', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n return null;\n }\n\n $playbookResolver = app(PlaybookResolver::class);\n $playbook = $playbookResolver->resolvePlaybookByUser($activity->getUser());\n\n if (! $playbook instanceof Playbook) {\n $this->logger->info('[Hubspot] Fetch related activity - playbook not set for user', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n return null;\n }\n\n if (! $playbook->getActivityField() instanceof Field) {\n $this->logger->info('[Hubspot] Fetch related activity - playbook activity field not set', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n return null;\n }\n\n $playbookCategory = null;\n $activityTypeSource = null;\n\n $playbookActivityFieldName = $playbook->getActivityField()->getCrmProviderId();\n if ($playbookActivityFieldName === 'activityType') {\n $playbookActivityFieldName = self::DEFAULT_ACTIVITY_TYPE;\n }\n\n // First attempt to match playbook category from crm activity\n if (! empty($crmActivity['properties'][$playbookActivityFieldName])) {\n $playbookCategoryRepository = app(PlaybookCategoryRepository::class);\n $playbookCategory = $playbookCategoryRepository->getPlaybookCategoryByName(\n $playbook,\n strval($crmActivity['properties'][$playbookActivityFieldName])\n );\n $activityTypeSource = 'crm';\n }\n\n // If not matched get playbook category from Jiminny activity\n if (! $playbookCategory instanceof PlaybookCategory && $activity->hasActivityType()) {\n $playbookCategory = $activity->getActivityType();\n $activityTypeSource = 'jiminny';\n }\n\n if ($playbookCategory instanceof PlaybookCategory) {\n $this->logger->info('[Hubspot] Fetch related activity - Playbook category matched', [\n 'activityId' => $activity->getUuid(),\n 'playbookCategoryId' => $playbookCategory->getId(),\n 'activityFieldName' => $playbookActivityFieldName,\n 'crmActivity' => $crmActivity['id'],\n 'source' => $activityTypeSource,\n ]);\n\n return $playbookCategory->getId();\n }\n\n $this->logger->info('[Hubspot] Fetch related activity - Playbook category not found', [\n 'activityId' => $activity->getUuid(),\n 'activityFieldName' => $playbookActivityFieldName,\n 'crmActivity' => $crmActivity['id'],\n ]);\n\n return null;\n }\n\n private function updateActivity(Activity $activity, array $data): bool\n {\n $activityRepository = app(ActivityRepository::class);\n\n $alreadyLoggedActivity = $activityRepository->findUserActivityByCrmProviderId(\n $activity->getUser(),\n $data['crm_provider_id']\n );\n\n if ($alreadyLoggedActivity instanceof Activity) {\n $this->logger->info('[Hubspot] Fetch related activity - activity already linked', [\n 'currentActivityId' => $activity->getUuid(),\n 'linkedActivityId' => $alreadyLoggedActivity->getUuid(),\n 'crmActivityId' => $data['crm_provider_id'],\n ]);\n\n // Return false to prevent updating the remote CRM engagement. This can happen when two Jiminny\n // activities share the same calendar event (e.g., rescheduled meetings). Without this, the first\n // activity's summary would be overwritten by the second activity.\n return false;\n }\n\n try {\n $activityRepository->update($activity, $data);\n } catch (Exception $e) {\n $this->logger->error('[Hubspot] Fetch related activity - activity update failed', [\n 'activityId' => $activity->getUuid(),\n 'reason' => $e->getMessage(),\n ]);\n\n return false;\n }\n\n $this->logger->info('[Hubspot] Fetch related activity - activity updated', [\n 'activityId' => $activity->getUuid(),\n 'data' => $data,\n ]);\n\n return true;\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":true,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Project","depth":3,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Project","depth":3,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"New File or Directory…","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Expand Selected","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Collapse All","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Options","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
-2435399166852945830
|
-3377671218453133244
|
click
|
accessibility
|
NULL
|
Project: faVsco.js, menu
JY-20725-handle-HS-search Project: faVsco.js, menu
JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
19
Previous Highlighted Error
Next Highlighted Error
[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {
"headers":{
"Date":["Thu,07 May 2026 14:21:15 GMT"],
"Content-Type":["application/json;charset=utf-8"],
"Transfer-Encoding":["chunked"],
"Connection":["keep-alive"],
"CF-Ray":["9f80deb8db60dc3a-SOF"],
"CF-Cache-Status":["DYNAMIC"],
"Strict-Transport-Security":["max-age=31536000; includeSubDomains; preload"],
"Vary":["origin,
accept-encoding"],
"access-control-allow-credentials":["false"],
"server-timing":["hcid;desc=\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\",
cfr;desc=\"9f80deb8e7c6dc3a-IAD\""],
"x-content-type-options":["nosniff"],
"x-hubspot-correlation-id":["019e02d0-6fd8-7812-bdba-885b7ccb3ee3"],
"Set-Cookie":["__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-[IP_ADDRESS]-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,
07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None"],
"Report-To":["{
\"endpoints\":[{
\"url\":\"https:\\/\\/a.nel.cloudflare.com\\/report\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\"}],
\"group\":\"cf-nel\",
\"max_age\":604800}"],
"NEL":["{
\"success_fraction\":0.01,
\"report_to\":\"cf-nel\",
\"max_age\":604800}"],
"Server":["cloudflare"]}} {
"correlation_id":"95236535-ec98-4541-b92a-adfa73b69eab",
"trace_id":"c7ab8365-903f-46d4-9403-0e5b551e3545"}
Sync Changes
Hide This Notification
Code changed:
Hide
3
11
Previous Highlighted Error
Next Highlighted Error
<?php
declare(strict_types=1);
namespace Jiminny\Services\Crm\Hubspot;
use Carbon\Carbon;
use Exception;
use HubSpot\Client\Crm\Objects\ApiException;
use Jiminny\Integrations\PlaybookResolver;
use Jiminny\Models\Activity;
use Jiminny\Models\Calendar\CalendarEvent;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\Profile;
use Jiminny\Models\Playbook;
use Jiminny\Models\PlaybookCategory;
use Jiminny\Repositories\ActivityRepository;
use Jiminny\Repositories\PlaybookCategoryRepository;
use Psr\Log\LoggerInterface;
class SyncRelatedActivityManager
{
public const string OBJECT_MEETING = 'meetings';
public const string DEFAULT_ACTIVITY_TYPE = 'hs_activity_type';
private RemoteCrmObjectManipulator $crmManipulator;
public function __construct(
private readonly Client $client,
private readonly PayloadBuilder $payloadBuilder,
private readonly LoggerInterface $logger
) {
$this->crmManipulator = app(RemoteCrmObjectManipulator::class, [
'client' => $this->client,
'logger' => $this->logger,
]);
}
public function fetchAndAssociateRelatedActivity(Activity $activity): ?Activity
{
// Validate if the activity is applicable.
if ($this->isActivityApplicable($activity) === false) {
$this->logger->info('[Hubspot] Fetch related activity - not applicable', [
'activityId' => $activity->getUuid(),
]);
return null;
}
// Find and fetch related activity.
try {
$crmActivity = $this->fetchRelatedActivity($activity);
if (empty($crmActivity)) {
return null;
}
} catch (Exception $exception) {
$this->logger->error('[Hubspot] Fetch related activity - failed', [
'activityId' => $activity->getUuid(),
'reason' => $exception->getMessage(),
]);
throw $exception;
}
// get playbook category id
$playbookCategoryId = $this->getPlaybookCategoryId($activity, $crmActivity);
$data = [
'crm_provider_id' => $crmActivity['id'],
'playbook_category_id' => $playbookCategoryId,
];
if (! $this->updateActivity($activity, $data)) {
return null;
}
$this->crmManipulator->updateRelatedCrmActivity($activity, $crmActivity);
$activity->refresh();
return $activity;
}
private function isActivityApplicable(Activity $activity): bool
{
if (! $activity->isTypeConference()) {
return false;
}
if (! $this->hasMinimumFilterRequirements($activity)) {
$this->logger->info('[Hubspot] Fetch related activity - not enough filter data', [
'activityId' => $activity->getUuid(),
]);
return false;
}
// The related activity is already linked.
if ($activity->hasCrmProviderId()) {
return false;
}
if (! $activity->getUser()->getProfile() instanceof Profile) {
$this->logger->info('[Hubspot] Fetch related activity - activity user has no crm profile', [
'activityId' => $activity->getUuid(),
]);
return false;
}
return true;
}
private function hasMinimumFilterRequirements(Activity $activity): bool
{
// We need to know when it started to determine when to look for related activity.
if (! $activity->hasActualStartTime()) {
return false;
}
if (! $activity->hasActualEndTime()) {
return false;
}
// At least one prospect should be associated to the activity
if ($activity->hasContact() || $activity->hasAccount()) {
return true;
}
return false;
}
public function fetchRelatedActivity(Activity $activity): array
{
$this->logger->info('[Hubspot] Fetch related activity - searching related CRM activity', [
'activityId' => $activity->getUuid(),
]);
$payloadData = $this->getPayloadFilterData($activity);
try {
$payload = $this->payloadBuilder->getFindRelatedActivityPayload($payloadData, self::OBJECT_MEETING);
$response = $this->client->getPaginatedData($payload, self::OBJECT_MEETING);
$crmActivities = $response['results'];
} catch (Exception $e) {
$this->logger->error('[Hubspot] Fetch related activity - failed', [
'activityId' => $activity->getUuid(),
'reason' => $e->getMessage(),
]);
throw $e;
}
if (empty($crmActivities)) {
$this->logger->info('[Hubspot] Fetch related activity - no CRM related activity found', [
'activityId' => $activity->getUuid(),
'payload' => $payload,
]);
return [];
}
$relatedActivity = $this->getBestCrmActivityCandidate($crmActivities, $payloadData);
if (empty($relatedActivity)) {
$this->logger->info('[Hubspot] Fetch related activity - no related activity candidate', [
'activityId' => $activity->getUuid(),
]);
return [];
}
$this->logger->info('[Hubspot] Fetch related activity - found related CRM activity', [
'activityId' => $activity->getUuid(),
'crmActivity' => $relatedActivity,
]);
return $relatedActivity;
}
/**
* @param Activity $activity
*
* @return array
*/
protected function getPayloadFilterData(Activity $activity): array
{
[$from, $to] = $this->getActivityTimeFilterRange($activity);
$data = [
'from' => $from->getPreciseTimestamp(3),
'to' => $to->getPreciseTimestamp(3),
'contact' => $activity->hasContact() ? $activity->getContact()->getCrmProviderId() : null,
'company' => $activity->hasAccount() ? $activity->getAccount()->getCrmProviderId() : null,
'deal' => $activity->hasOpportunity() ? $activity->getOpportunity()->getCrmProviderId() : null,
'owner' => $activity->getUser()->getProfile()->getCrmProviderId(),
'activity' => $activity->getUuid(),
];
return array_filter($data);
}
private function getActivityTimeFilterRange(Activity $activity): array
{
if ($activity->hasScheduledStartTime() && $activity->hasScheduledEndTime()) {
$calendarEvent = $activity->getCalendarEvent();
if ($calendarEvent instanceof CalendarEvent) {
$from = $calendarEvent->getStartTime();
$to = $calendarEvent->getEndTime()->addMinutes(15);
} else {
$from = Carbon::instance($activity->getScheduledStartTime());
$to = Carbon::instance($activity->getScheduledEndTime())->addMinutes(15);
}
return [$from, $to];
}
$from = Carbon::instance($activity->getActualStartTime());
$to = Carbon::instance($activity->getActualEndTime());
return [$from, $to];
}
/**
* Determines the best CRM activity candidate based on the number of matching prospects and the owner.
*
* @param array $crmActivities List of CRM activities to evaluate.
* @param array $data Data containing the prospects and owner information.
*
* @return array The best CRM activity candidate.
*/
private function getBestCrmActivityCandidate(array $crmActivities, array $data): array
{
$prospectCount = count(array_intersect_key($data, array_flip(['contact', 'company', 'deal'])));
$bestCandidate = null;
$maxMatchingProspects = 0;
$validActivitiesCount = count($crmActivities);
if ($validActivitiesCount > 1) {
$this->logger->info('[Hubspot] Fetch related activity - determining the best candidate', [
'activityId' => $data['activity'],
'activitiesCount' => $validActivitiesCount,
'crmActivities' => $crmActivities,
]);
}
foreach ($crmActivities as $crmActivity) {
if ($this->isOwnerMismatch($crmActivity, $data)) {
$validActivitiesCount--;
continue;
}
if ($validActivitiesCount === 1) {
return $crmActivity;
}
if ($prospectCount > 1) {
$matchingProspects = $this->countMatchingProspects($crmActivity, $data);
if ($matchingProspects > $maxMatchingProspects) {
$maxMatchingProspects = $matchingProspects;
$bestCandidate = $crmActivity;
}
} else {
return $crmActivity;
}
}
return $bestCandidate ?? [];
}
private function isOwnerMismatch(array $crmActivity, array $data): bool
{
if ($crmActivity['properties']['hubspot_owner_id'] !== $data['owner']) {
$this->logger->info('[Hubspot] Fetch related activity - owner mismatch', [
'activityId' => $data['activity'],
'crmActivity' => $crmActivity['id'],
'jiminnyOwner' => $data['owner'],
'hubspotOwner' => $crmActivity['properties']['hubspot_owner_id'],
]);
return true;
}
return false;
}
private function countMatchingProspects(array $crmActivity, array $data): int
{
try {
$crmActivityWithAssociations = $this->client->getMeeting($crmActivity['id']);
} catch (ApiException $e) {
$this->logger->error('[Hubspot] Fetch related activity - failed to get meeting associations', [
'activityId' => $data['activity'],
'crmActivity' => $crmActivity['id'],
'reason' => $e->getMessage(),
]);
return 0;
}
$associations = $crmActivityWithAssociations->getAssociations();
$matchingProspects = 0;
$matchingProspects += $this->countMatchingAssociation($data, $associations, 'company', 'companies');
$matchingProspects += $this->countMatchingAssociation($data, $associations, 'contact', 'contacts');
$matchingProspects += $this->countMatchingAssociation($data, $associations, 'deal', 'deals');
return $matchingProspects;
}
private function countMatchingAssociation(array $data, array $associations, string $dataKey, string $associationKey): int
{
if (isset($data[$dataKey]) && isset($associations[$associationKey])) {
$associatedItems = $associations[$associationKey]->getResults();
foreach ($associatedItems as $associatedItem) {
if ($data[$dataKey] == $associatedItem->getId()) {
return 1;
}
}
}
return 0;
}
private function getPlaybookCategoryId(Activity $activity, array $crmActivity): ?int
{
if (! $activity->hasUser()) {
$this->logger->info('[Hubspot] Fetch related activity - activity has no user', [
'activityId' => $activity->getUuid(),
]);
return null;
}
$playbookResolver = app(PlaybookResolver::class);
$playbook = $playbookResolver->resolvePlaybookByUser($activity->getUser());
if (! $playbook instanceof Playbook) {
$this->logger->info('[Hubspot] Fetch related activity - playbook not set for user', [
'activityId' => $activity->getUuid(),
]);
return null;
}
if (! $playbook->getActivityField() instanceof Field) {
$this->logger->info('[Hubspot] Fetch related activity - playbook activity field not set', [
'activityId' => $activity->getUuid(),
]);
return null;
}
$playbookCategory = null;
$activityTypeSource = null;
$playbookActivityFieldName = $playbook->getActivityField()->getCrmProviderId();
if ($playbookActivityFieldName === 'activityType') {
$playbookActivityFieldName = self::DEFAULT_ACTIVITY_TYPE;
}
// First attempt to match playbook category from crm activity
if (! empty($crmActivity['properties'][$playbookActivityFieldName])) {
$playbookCategoryRepository = app(PlaybookCategoryRepository::class);
$playbookCategory = $playbookCategoryRepository->getPlaybookCategoryByName(
$playbook,
strval($crmActivity['properties'][$playbookActivityFieldName])
);
$activityTypeSource = 'crm';
}
// If not matched get playbook category from Jiminny activity
if (! $playbookCategory instanceof PlaybookCategory && $activity->hasActivityType()) {
$playbookCategory = $activity->getActivityType();
$activityTypeSource = 'jiminny';
}
if ($playbookCategory instanceof PlaybookCategory) {
$this->logger->info('[Hubspot] Fetch related activity - Playbook category matched', [
'activityId' => $activity->getUuid(),
'playbookCategoryId' => $playbookCategory->getId(),
'activityFieldName' => $playbookActivityFieldName,
'crmActivity' => $crmActivity['id'],
'source' => $activityTypeSource,
]);
return $playbookCategory->getId();
}
$this->logger->info('[Hubspot] Fetch related activity - Playbook category not found', [
'activityId' => $activity->getUuid(),
'activityFieldName' => $playbookActivityFieldName,
'crmActivity' => $crmActivity['id'],
]);
return null;
}
private function updateActivity(Activity $activity, array $data): bool
{
$activityRepository = app(ActivityRepository::class);
$alreadyLoggedActivity = $activityRepository->findUserActivityByCrmProviderId(
$activity->getUser(),
$data['crm_provider_id']
);
if ($alreadyLoggedActivity instanceof Activity) {
$this->logger->info('[Hubspot] Fetch related activity - activity already linked', [
'currentActivityId' => $activity->getUuid(),
'linkedActivityId' => $alreadyLoggedActivity->getUuid(),
'crmActivityId' => $data['crm_provider_id'],
]);
// Return false to prevent updating the remote CRM engagement. This can happen when two Jiminny
// activities share the same calendar event (e.g., rescheduled meetings). Without this, the first
// activity's summary would be overwritten by the second activity.
return false;
}
try {
$activityRepository->update($activity, $data);
} catch (Exception $e) {
$this->logger->error('[Hubspot] Fetch related activity - activity update failed', [
'activityId' => $activity->getUuid(),
'reason' => $e->getMessage(),
]);
return false;
}
$this->logger->info('[Hubspot] Fetch related activity - activity updated', [
'activityId' => $activity->getUuid(),
'data' => $data,
]);
return true;
}
}
Project
Project
New File or Directory…
Expand Selected
Collapse All
Options
Hide...
|
9828
|
NULL
|
NULL
|
NULL
|
|
9828
|
443
|
15
|
2026-05-08T13:36:37.641421+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-08/1778 /Users/lukas/.screenpipe/data/data/2026-05-08/1778247397641_m1.jpg...
|
PhpStorm
|
faVsco.js – SyncRelatedActivityManager.php
|
1
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
JY-20725-handle-HS-search Project: faVsco.js, menu
JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings...
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JY-20725-handle-HS-search-rate-limit, menu","depth":5,"on_screen":true,"help_text":"Git Branch: JY-20725-handle-HS-search-rate-limit","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"AskJiminnyReportActivityServiceTest","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'AskJiminnyReportActivityServiceTest'","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'AskJiminnyReportActivityServiceTest'","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"IDE and Project Settings","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
5988960105550556897
|
-8204420481362449466
|
click
|
hybrid
|
NULL
|
Project: faVsco.js, menu
JY-20725-handle-HS-search Project: faVsco.js, menu
JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
iTerm2Shell Edit ViewSessionScripts|ProfilesWindowHelp‹ $0ladl100% C8APP (-zsh)DOCKERDEV (docker)882JY-20773-fix-automated-reports-user-pilot-trackingJY-20157-AJ-report-not-send-notificationJY-20508-notify-before-AJ-report-expirationJY-20372-ai-reports-promotion-pagesJY-20352-sync-opportunities-without-a-local-owner-user-id-is-nullJY-20738-debug-AJ-tracking-UPAPP (-zsh)-zshJY-18909-automated-reports-ask-jiminnyJY-20692-fix-integration-app-[API_KEY]@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ co -b JY-20725-handle-HS-search-rate-limitSwitched to a new branch 'JY-20725-handle-HS-search-rate-limit'Lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (JY-20725-handle-HS-search-rate-limit) $ I• 84|screenpipe*•$5-zshFri 8 May 16:36:38T81₴6APP...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
9827
|
444
|
19
|
2026-05-08T13:36:31.638983+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-08/1778 /Users/lukas/.screenpipe/data/data/2026-05-08/1778247391638_m2.jpg...
|
PhpStorm
|
faVsco.js – SyncRelatedActivityManager.php
|
1
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
JY-20725-handle-HS-search Project: faVsco.js, menu
JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
19
Previous Highlighted Error
Next Highlighted Error
[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {
"headers":{
"Date":["Thu,07 May 2026 14:21:15 GMT"],
"Content-Type":["application/json;charset=utf-8"],
"Transfer-Encoding":["chunked"],
"Connection":["keep-alive"],
"CF-Ray":["9f80deb8db60dc3a-SOF"],
"CF-Cache-Status":["DYNAMIC"],
"Strict-Transport-Security":["max-age=31536000; includeSubDomains; preload"],
"Vary":["origin,
accept-encoding"],
"access-control-allow-credentials":["false"],
"server-timing":["hcid;desc=\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\",
cfr;desc=\"9f80deb8e7c6dc3a-IAD\""],
"x-content-type-options":["nosniff"],
"x-hubspot-correlation-id":["019e02d0-6fd8-7812-bdba-885b7ccb3ee3"],
"Set-Cookie":["__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-[IP_ADDRESS]-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,
07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None"],
"Report-To":["{
\"endpoints\":[{
\"url\":\"https:\\/\\/a.nel.cloudflare.com\\/report\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\"}],
\"group\":\"cf-nel\",
\"max_age\":604800}"],
"NEL":["{
\"success_fraction\":0.01,
\"report_to\":\"cf-nel\",
\"max_age\":604800}"],
"Server":["cloudflare"]}} {
"correlation_id":"95236535-ec98-4541-b92a-adfa73b69eab",
"trace_id":"c7ab8365-903f-46d4-9403-0e5b551e3545"}
Sync Changes
Hide This Notification
Code changed:
Hide
3
11
Previous Highlighted Error
Next Highlighted Error
<?php
declare(strict_types=1);
namespace Jiminny\Services\Crm\Hubspot;
use Carbon\Carbon;
use Exception;
use HubSpot\Client\Crm\Objects\ApiException;
use Jiminny\Integrations\PlaybookResolver;
use Jiminny\Models\Activity;
use Jiminny\Models\Calendar\CalendarEvent;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\Profile;
use Jiminny\Models\Playbook;
use Jiminny\Models\PlaybookCategory;
use Jiminny\Repositories\ActivityRepository;
use Jiminny\Repositories\PlaybookCategoryRepository;
use Psr\Log\LoggerInterface;
class SyncRelatedActivityManager
{
public const string OBJECT_MEETING = 'meetings';
public const string DEFAULT_ACTIVITY_TYPE = 'hs_activity_type';
private RemoteCrmObjectManipulator $crmManipulator;
public function __construct(
private readonly Client $client,
private readonly PayloadBuilder $payloadBuilder,
private readonly LoggerInterface $logger
) {
$this->crmManipulator = app(RemoteCrmObjectManipulator::class, [
'client' => $this->client,
'logger' => $this->logger,
]);
}
public function fetchAndAssociateRelatedActivity(Activity $activity): ?Activity
{
// Validate if the activity is applicable.
if ($this->isActivityApplicable($activity) === false) {
$this->logger->info('[Hubspot] Fetch related activity - not applicable', [
'activityId' => $activity->getUuid(),
]);
return null;
}
// Find and fetch related activity.
try {
$crmActivity = $this->fetchRelatedActivity($activity);
if (empty($crmActivity)) {
return null;
}
} catch (Exception $exception) {
$this->logger->error('[Hubspot] Fetch related activity - failed', [
'activityId' => $activity->getUuid(),
'reason' => $exception->getMessage(),
]);
throw $exception;
}
// get playbook category id
$playbookCategoryId = $this->getPlaybookCategoryId($activity, $crmActivity);
$data = [
'crm_provider_id' => $crmActivity['id'],
'playbook_category_id' => $playbookCategoryId,
];
if (! $this->updateActivity($activity, $data)) {
return null;
}
$this->crmManipulator->updateRelatedCrmActivity($activity, $crmActivity);
$activity->refresh();
return $activity;
}
private function isActivityApplicable(Activity $activity): bool
{
if (! $activity->isTypeConference()) {
return false;
}
if (! $this->hasMinimumFilterRequirements($activity)) {
$this->logger->info('[Hubspot] Fetch related activity - not enough filter data', [
'activityId' => $activity->getUuid(),
]);
return false;
}
// The related activity is already linked.
if ($activity->hasCrmProviderId()) {
return false;
}
if (! $activity->getUser()->getProfile() instanceof Profile) {
$this->logger->info('[Hubspot] Fetch related activity - activity user has no crm profile', [
'activityId' => $activity->getUuid(),
]);
return false;
}
return true;
}
private function hasMinimumFilterRequirements(Activity $activity): bool
{
// We need to know when it started to determine when to look for related activity.
if (! $activity->hasActualStartTime()) {
return false;
}
if (! $activity->hasActualEndTime()) {
return false;
}
// At least one prospect should be associated to the activity
if ($activity->hasContact() || $activity->hasAccount()) {
return true;
}
return false;
}
public function fetchRelatedActivity(Activity $activity): array
{
$this->logger->info('[Hubspot] Fetch related activity - searching related CRM activity', [
'activityId' => $activity->getUuid(),
]);
$payloadData = $this->getPayloadFilterData($activity);
try {
$payload = $this->payloadBuilder->getFindRelatedActivityPayload($payloadData, self::OBJECT_MEETING);
$response = $this->client->getPaginatedData($payload, self::OBJECT_MEETING);
$crmActivities = $response['results'];
} catch (Exception $e) {
$this->logger->error('[Hubspot] Fetch related activity - failed', [
'activityId' => $activity->getUuid(),
'reason' => $e->getMessage(),
]);
throw $e;
}
if (empty($crmActivities)) {
$this->logger->info('[Hubspot] Fetch related activity - no CRM related activity found', [
'activityId' => $activity->getUuid(),
'payload' => $payload,
]);
return [];
}
$relatedActivity = $this->getBestCrmActivityCandidate($crmActivities, $payloadData);
if (empty($relatedActivity)) {
$this->logger->info('[Hubspot] Fetch related activity - no related activity candidate', [
'activityId' => $activity->getUuid(),
]);
return [];
}
$this->logger->info('[Hubspot] Fetch related activity - found related CRM activity', [
'activityId' => $activity->getUuid(),
'crmActivity' => $relatedActivity,
]);
return $relatedActivity;
}
/**
* @param Activity $activity
*
* @return array
*/
protected function getPayloadFilterData(Activity $activity): array
{
[$from, $to] = $this->getActivityTimeFilterRange($activity);
$data = [
'from' => $from->getPreciseTimestamp(3),
'to' => $to->getPreciseTimestamp(3),
'contact' => $activity->hasContact() ? $activity->getContact()->getCrmProviderId() : null,
'company' => $activity->hasAccount() ? $activity->getAccount()->getCrmProviderId() : null,
'deal' => $activity->hasOpportunity() ? $activity->getOpportunity()->getCrmProviderId() : null,
'owner' => $activity->getUser()->getProfile()->getCrmProviderId(),
'activity' => $activity->getUuid(),
];
return array_filter($data);
}
private function getActivityTimeFilterRange(Activity $activity): array
{
if ($activity->hasScheduledStartTime() && $activity->hasScheduledEndTime()) {
$calendarEvent = $activity->getCalendarEvent();
if ($calendarEvent instanceof CalendarEvent) {
$from = $calendarEvent->getStartTime();
$to = $calendarEvent->getEndTime()->addMinutes(15);
} else {
$from = Carbon::instance($activity->getScheduledStartTime());
$to = Carbon::instance($activity->getScheduledEndTime())->addMinutes(15);
}
return [$from, $to];
}
$from = Carbon::instance($activity->getActualStartTime());
$to = Carbon::instance($activity->getActualEndTime());
return [$from, $to];
}
/**
* Determines the best CRM activity candidate based on the number of matching prospects and the owner.
*
* @param array $crmActivities List of CRM activities to evaluate.
* @param array $data Data containing the prospects and owner information.
*
* @return array The best CRM activity candidate.
*/
private function getBestCrmActivityCandidate(array $crmActivities, array $data): array
{
$prospectCount = count(array_intersect_key($data, array_flip(['contact', 'company', 'deal'])));
$bestCandidate = null;
$maxMatchingProspects = 0;
$validActivitiesCount = count($crmActivities);
if ($validActivitiesCount > 1) {
$this->logger->info('[Hubspot] Fetch related activity - determining the best candidate', [
'activityId' => $data['activity'],
'activitiesCount' => $validActivitiesCount,
'crmActivities' => $crmActivities,
]);
}
foreach ($crmActivities as $crmActivity) {
if ($this->isOwnerMismatch($crmActivity, $data)) {
$validActivitiesCount--;
continue;
}
if ($validActivitiesCount === 1) {
return $crmActivity;
}
if ($prospectCount > 1) {
$matchingProspects = $this->countMatchingProspects($crmActivity, $data);
if ($matchingProspects > $maxMatchingProspects) {
$maxMatchingProspects = $matchingProspects;
$bestCandidate = $crmActivity;
}
} else {
return $crmActivity;
}
}
return $bestCandidate ?? [];
}
private function isOwnerMismatch(array $crmActivity, array $data): bool
{
if ($crmActivity['properties']['hubspot_owner_id'] !== $data['owner']) {
$this->logger->info('[Hubspot] Fetch related activity - owner mismatch', [
'activityId' => $data['activity'],
'crmActivity' => $crmActivity['id'],
'jiminnyOwner' => $data['owner'],
'hubspotOwner' => $crmActivity['properties']['hubspot_owner_id'],
]);
return true;
}
return false;
}
private function countMatchingProspects(array $crmActivity, array $data): int
{
try {
$crmActivityWithAssociations = $this->client->getMeeting($crmActivity['id']);
} catch (ApiException $e) {
$this->logger->error('[Hubspot] Fetch related activity - failed to get meeting associations', [
'activityId' => $data['activity'],
'crmActivity' => $crmActivity['id'],
'reason' => $e->getMessage(),
]);
return 0;
}
$associations = $crmActivityWithAssociations->getAssociations();
$matchingProspects = 0;
$matchingProspects += $this->countMatchingAssociation($data, $associations, 'company', 'companies');
$matchingProspects += $this->countMatchingAssociation($data, $associations, 'contact', 'contacts');
$matchingProspects += $this->countMatchingAssociation($data, $associations, 'deal', 'deals');
return $matchingProspects;
}
private function countMatchingAssociation(array $data, array $associations, string $dataKey, string $associationKey): int
{
if (isset($data[$dataKey]) && isset($associations[$associationKey])) {
$associatedItems = $associations[$associationKey]->getResults();
foreach ($associatedItems as $associatedItem) {
if ($data[$dataKey] == $associatedItem->getId()) {
return 1;
}
}
}
return 0;
}
private function getPlaybookCategoryId(Activity $activity, array $crmActivity): ?int
{
if (! $activity->hasUser()) {
$this->logger->info('[Hubspot] Fetch related activity - activity has no user', [
'activityId' => $activity->getUuid(),
]);
return null;
}
$playbookResolver = app(PlaybookResolver::class);
$playbook = $playbookResolver->resolvePlaybookByUser($activity->getUser());
if (! $playbook instanceof Playbook) {
$this->logger->info('[Hubspot] Fetch related activity - playbook not set for user', [
'activityId' => $activity->getUuid(),
]);
return null;
}
if (! $playbook->getActivityField() instanceof Field) {
$this->logger->info('[Hubspot] Fetch related activity - playbook activity field not set', [
'activityId' => $activity->getUuid(),
]);
return null;
}
$playbookCategory = null;
$activityTypeSource = null;
$playbookActivityFieldName = $playbook->getActivityField()->getCrmProviderId();
if ($playbookActivityFieldName === 'activityType') {
$playbookActivityFieldName = self::DEFAULT_ACTIVITY_TYPE;
}
// First attempt to match playbook category from crm activity
if (! empty($crmActivity['properties'][$playbookActivityFieldName])) {
$playbookCategoryRepository = app(PlaybookCategoryRepository::class);
$playbookCategory = $playbookCategoryRepository->getPlaybookCategoryByName(
$playbook,
strval($crmActivity['properties'][$playbookActivityFieldName])
);
$activityTypeSource = 'crm';
}
// If not matched get playbook category from Jiminny activity
if (! $playbookCategory instanceof PlaybookCategory && $activity->hasActivityType()) {
$playbookCategory = $activity->getActivityType();
$activityTypeSource = 'jiminny';
}
if ($playbookCategory instanceof PlaybookCategory) {
$this->logger->info('[Hubspot] Fetch related activity - Playbook category matched', [
'activityId' => $activity->getUuid(),
'playbookCategoryId' => $playbookCategory->getId(),
'activityFieldName' => $playbookActivityFieldName,
'crmActivity' => $crmActivity['id'],
'source' => $activityTypeSource,
]);
return $playbookCategory->getId();
}
$this->logger->info('[Hubspot] Fetch related activity - Playbook category not found', [
'activityId' => $activity->getUuid(),
'activityFieldName' => $playbookActivityFieldName,
'crmActivity' => $crmActivity['id'],
]);
return null;
}
private function updateActivity(Activity $activity, array $data): bool
{
$activityRepository = app(ActivityRepository::class);
$alreadyLoggedActivity = $activityRepository->findUserActivityByCrmProviderId(
$activity->getUser(),
$data['crm_provider_id']
);
if ($alreadyLoggedActivity instanceof Activity) {
$this->logger->info('[Hubspot] Fetch related activity - activity already linked', [
'currentActivityId' => $activity->getUuid(),
'linkedActivityId' => $alreadyLoggedActivity->getUuid(),
'crmActivityId' => $data['crm_provider_id'],
]);
// Return false to prevent updating the remote CRM engagement. This can happen when two Jiminny
// activities share the same calendar event (e.g., rescheduled meetings). Without this, the first
// activity's summary would be overwritten by the second activity.
return false;
}
try {
$activityRepository->update($activity, $data);
} catch (Exception $e) {
$this->logger->error('[Hubspot] Fetch related activity - activity update failed', [
'activityId' => $activity->getUuid(),
'reason' => $e->getMessage(),
]);
return false;
}
$this->logger->info('[Hubspot] Fetch related activity - activity updated', [
'activityId' => $activity->getUuid(),
'data' => $data,
]);
return true;
}
}
Project
Project
New File or Directory…
Expand Selected
Collapse All
Options
Hide...
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"bounds":{"left":0.025930852,"top":0.019952115,"width":0.03856383,"height":0.025538707},"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JY-20725-handle-HS-search-rate-limit, menu","depth":5,"bounds":{"left":0.064494684,"top":0.019952115,"width":0.09541223,"height":0.025538707},"on_screen":true,"help_text":"Git Branch: JY-20725-handle-HS-search-rate-limit","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"bounds":{"left":0.8081782,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"AskJiminnyReportActivityServiceTest","depth":6,"bounds":{"left":0.8234708,"top":0.019952115,"width":0.09208777,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'AskJiminnyReportActivityServiceTest'","depth":6,"bounds":{"left":0.9155585,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'AskJiminnyReportActivityServiceTest'","depth":6,"bounds":{"left":0.9268617,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"bounds":{"left":0.9381649,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"bounds":{"left":0.96609044,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"bounds":{"left":0.9773936,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"IDE and Project Settings","depth":5,"bounds":{"left":0.9886968,"top":0.019952115,"width":0.011303186,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.042220745,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"19","depth":4,"bounds":{"left":0.6615692,"top":0.10055866,"width":0.009640957,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"bounds":{"left":0.67287236,"top":0.09896249,"width":0.00731383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"bounds":{"left":0.68018615,"top":0.09896249,"width":0.006981383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {\n\"headers\":{\n\"Date\":[\"Thu,07 May 2026 14:21:15 GMT\"],\n \"Content-Type\":[\"application/json;charset=utf-8\"],\n \"Transfer-Encoding\":[\"chunked\"],\n \"Connection\":[\"keep-alive\"],\n \"CF-Ray\":[\"9f80deb8db60dc3a-SOF\"],\n \"CF-Cache-Status\":[\"DYNAMIC\"],\n \"Strict-Transport-Security\":[\"max-age=31536000; includeSubDomains; preload\"],\n \"Vary\":[\"origin,\n accept-encoding\"],\n \"access-control-allow-credentials\":[\"false\"],\n \"server-timing\":[\"hcid;desc=\\\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\\\",\n cfr;desc=\\\"9f80deb8e7c6dc3a-IAD\\\"\"],\n \"x-content-type-options\":[\"nosniff\"],\n \"x-hubspot-correlation-id\":[\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\"],\n \"Set-Cookie\":[\"__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-1.0.1.1-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,\n 07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None\"],\n \"Report-To\":[\"{\n\\\"endpoints\\\":[{\n\\\"url\\\":\\\"https:\\\\/\\\\/a.nel.cloudflare.com\\\\/report\\\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\\\"}],\n\\\"group\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"NEL\":[\"{\n\\\"success_fraction\\\":0.01,\n\\\"report_to\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"Server\":[\"cloudflare\"]}} {\n\"correlation_id\":\"95236535-ec98-4541-b92a-adfa73b69eab\",\n\"trace_id\":\"c7ab8365-903f-46d4-9403-0e5b551e3545\"}","depth":4,"bounds":{"left":0.37632978,"top":0.09736632,"width":0.5728058,"height":0.8818835},"on_screen":true,"lines":[{"char_start":207,"char_count":30,"bounds":{"left":0.37632978,"top":0.0,"width":0.07513298,"height":0.014365523}},{"char_start":237,"char_count":36,"bounds":{"left":0.37632978,"top":0.0,"width":0.09075798,"height":0.014365523}},{"char_start":273,"char_count":32,"bounds":{"left":0.37632978,"top":0.0,"width":0.080119684,"height":0.014365523}},{"char_start":305,"char_count":79,"bounds":{"left":0.37632978,"top":0.0,"width":0.20212767,"height":0.014365523}},{"char_start":384,"char_count":18,"bounds":{"left":0.37632978,"top":0.0,"width":0.043882977,"height":0.014365523}},{"char_start":402,"char_count":21,"bounds":{"left":0.37632978,"top":0.0,"width":0.051861703,"height":0.014365523}},{"char_start":423,"char_count":48,"bounds":{"left":0.37632978,"top":0.008778931,"width":0.12167553,"height":0.014365523}},{"char_start":471,"char_count":72,"bounds":{"left":0.37632978,"top":0.026336791,"width":0.18384309,"height":0.014365523}},{"char_start":543,"char_count":40,"bounds":{"left":0.37632978,"top":0.043894652,"width":0.10106383,"height":0.014365523}},{"char_start":583,"char_count":41,"bounds":{"left":0.37632978,"top":0.061452515,"width":0.10372341,"height":0.014365523}},{"char_start":624,"char_count":72,"bounds":{"left":0.37632978,"top":0.079010375,"width":0.18384309,"height":0.014365523}},{"char_start":696,"char_count":219,"bounds":{"left":0.37632978,"top":0.096568234,"width":0.56515956,"height":0.014365523}},{"char_start":915,"char_count":83,"bounds":{"left":0.37632978,"top":0.11412609,"width":0.21243352,"height":0.014365523}},{"char_start":998,"char_count":20,"bounds":{"left":0.37632978,"top":0.13168396,"width":0.04920213,"height":0.014365523}},{"char_start":1018,"char_count":17,"bounds":{"left":0.37632978,"top":0.14924182,"width":0.041223403,"height":0.014365523}},{"char_start":1035,"char_count":203,"bounds":{"left":0.37632978,"top":0.16679968,"width":0.52360374,"height":0.014365523}},{"char_start":1238,"char_count":22,"bounds":{"left":0.37632978,"top":0.18435754,"width":0.05418883,"height":0.014365523}},{"char_start":1260,"char_count":23,"bounds":{"left":0.37632978,"top":0.2019154,"width":0.056848403,"height":0.014365523}},{"char_start":1283,"char_count":10,"bounds":{"left":0.37632978,"top":0.21947326,"width":0.023271276,"height":0.014365523}},{"char_start":1293,"char_count":27,"bounds":{"left":0.37632978,"top":0.23703113,"width":0.06715426,"height":0.014365523}},{"char_start":1320,"char_count":26,"bounds":{"left":0.37632978,"top":0.254589,"width":0.06482713,"height":0.014365523}},{"char_start":1346,"char_count":23,"bounds":{"left":0.37632978,"top":0.27214685,"width":0.056848403,"height":0.014365523}},{"char_start":1369,"char_count":28,"bounds":{"left":0.37632978,"top":0.2897047,"width":0.06981383,"height":0.014365523}},{"char_start":1397,"char_count":57,"bounds":{"left":0.37632978,"top":0.30726257,"width":0.14494681,"height":0.014365523}}],"value":"[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {\n\"headers\":{\n\"Date\":[\"Thu,07 May 2026 14:21:15 GMT\"],\n \"Content-Type\":[\"application/json;charset=utf-8\"],\n \"Transfer-Encoding\":[\"chunked\"],\n \"Connection\":[\"keep-alive\"],\n \"CF-Ray\":[\"9f80deb8db60dc3a-SOF\"],\n \"CF-Cache-Status\":[\"DYNAMIC\"],\n \"Strict-Transport-Security\":[\"max-age=31536000; includeSubDomains; preload\"],\n \"Vary\":[\"origin,\n accept-encoding\"],\n \"access-control-allow-credentials\":[\"false\"],\n \"server-timing\":[\"hcid;desc=\\\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\\\",\n cfr;desc=\\\"9f80deb8e7c6dc3a-IAD\\\"\"],\n \"x-content-type-options\":[\"nosniff\"],\n \"x-hubspot-correlation-id\":[\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\"],\n \"Set-Cookie\":[\"__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-1.0.1.1-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,\n 07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None\"],\n \"Report-To\":[\"{\n\\\"endpoints\\\":[{\n\\\"url\\\":\\\"https:\\\\/\\\\/a.nel.cloudflare.com\\\\/report\\\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\\\"}],\n\\\"group\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"NEL\":[\"{\n\\\"success_fraction\\\":0.01,\n\\\"report_to\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"Server\":[\"cloudflare\"]}} {\n\"correlation_id\":\"95236535-ec98-4541-b92a-adfa73b69eab\",\n\"trace_id\":\"c7ab8365-903f-46d4-9403-0e5b551e3545\"}","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.042220745,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"3","depth":4,"bounds":{"left":0.32214096,"top":0.19952115,"width":0.007978723,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"11","depth":4,"bounds":{"left":0.33211437,"top":0.19952115,"width":0.008976064,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"bounds":{"left":0.34275267,"top":0.19792499,"width":0.00731383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"bounds":{"left":0.35006648,"top":0.19792499,"width":0.006981383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\ndeclare(strict_types=1);\n\nnamespace Jiminny\\Services\\Crm\\Hubspot;\n\nuse Carbon\\Carbon;\nuse Exception;\nuse HubSpot\\Client\\Crm\\Objects\\ApiException;\nuse Jiminny\\Integrations\\PlaybookResolver;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Calendar\\CalendarEvent;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Models\\Crm\\Profile;\nuse Jiminny\\Models\\Playbook;\nuse Jiminny\\Models\\PlaybookCategory;\nuse Jiminny\\Repositories\\ActivityRepository;\nuse Jiminny\\Repositories\\PlaybookCategoryRepository;\nuse Psr\\Log\\LoggerInterface;\n\nclass SyncRelatedActivityManager\n{\n public const string OBJECT_MEETING = 'meetings';\n public const string DEFAULT_ACTIVITY_TYPE = 'hs_activity_type';\n private RemoteCrmObjectManipulator $crmManipulator;\n\n public function __construct(\n private readonly Client $client,\n private readonly PayloadBuilder $payloadBuilder,\n private readonly LoggerInterface $logger\n ) {\n $this->crmManipulator = app(RemoteCrmObjectManipulator::class, [\n 'client' => $this->client,\n 'logger' => $this->logger,\n ]);\n }\n\n public function fetchAndAssociateRelatedActivity(Activity $activity): ?Activity\n {\n // Validate if the activity is applicable.\n if ($this->isActivityApplicable($activity) === false) {\n $this->logger->info('[Hubspot] Fetch related activity - not applicable', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n return null;\n }\n\n // Find and fetch related activity.\n try {\n $crmActivity = $this->fetchRelatedActivity($activity);\n if (empty($crmActivity)) {\n return null;\n }\n } catch (Exception $exception) {\n $this->logger->error('[Hubspot] Fetch related activity - failed', [\n 'activityId' => $activity->getUuid(),\n 'reason' => $exception->getMessage(),\n ]);\n\n throw $exception;\n }\n\n // get playbook category id\n $playbookCategoryId = $this->getPlaybookCategoryId($activity, $crmActivity);\n\n $data = [\n 'crm_provider_id' => $crmActivity['id'],\n 'playbook_category_id' => $playbookCategoryId,\n ];\n\n if (! $this->updateActivity($activity, $data)) {\n return null;\n }\n\n $this->crmManipulator->updateRelatedCrmActivity($activity, $crmActivity);\n\n $activity->refresh();\n\n return $activity;\n }\n\n private function isActivityApplicable(Activity $activity): bool\n {\n if (! $activity->isTypeConference()) {\n return false;\n }\n\n if (! $this->hasMinimumFilterRequirements($activity)) {\n $this->logger->info('[Hubspot] Fetch related activity - not enough filter data', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n return false;\n }\n\n // The related activity is already linked.\n if ($activity->hasCrmProviderId()) {\n return false;\n }\n\n if (! $activity->getUser()->getProfile() instanceof Profile) {\n $this->logger->info('[Hubspot] Fetch related activity - activity user has no crm profile', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n return false;\n }\n\n return true;\n }\n\n private function hasMinimumFilterRequirements(Activity $activity): bool\n {\n // We need to know when it started to determine when to look for related activity.\n if (! $activity->hasActualStartTime()) {\n return false;\n }\n\n if (! $activity->hasActualEndTime()) {\n return false;\n }\n\n // At least one prospect should be associated to the activity\n if ($activity->hasContact() || $activity->hasAccount()) {\n return true;\n }\n\n return false;\n }\n\n public function fetchRelatedActivity(Activity $activity): array\n {\n $this->logger->info('[Hubspot] Fetch related activity - searching related CRM activity', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n $payloadData = $this->getPayloadFilterData($activity);\n\n try {\n $payload = $this->payloadBuilder->getFindRelatedActivityPayload($payloadData, self::OBJECT_MEETING);\n $response = $this->client->getPaginatedData($payload, self::OBJECT_MEETING);\n $crmActivities = $response['results'];\n } catch (Exception $e) {\n $this->logger->error('[Hubspot] Fetch related activity - failed', [\n 'activityId' => $activity->getUuid(),\n 'reason' => $e->getMessage(),\n ]);\n\n throw $e;\n }\n\n if (empty($crmActivities)) {\n $this->logger->info('[Hubspot] Fetch related activity - no CRM related activity found', [\n 'activityId' => $activity->getUuid(),\n 'payload' => $payload,\n ]);\n\n return [];\n }\n\n $relatedActivity = $this->getBestCrmActivityCandidate($crmActivities, $payloadData);\n if (empty($relatedActivity)) {\n $this->logger->info('[Hubspot] Fetch related activity - no related activity candidate', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n return [];\n }\n\n $this->logger->info('[Hubspot] Fetch related activity - found related CRM activity', [\n 'activityId' => $activity->getUuid(),\n 'crmActivity' => $relatedActivity,\n ]);\n\n return $relatedActivity;\n }\n\n /**\n * @param Activity $activity\n *\n * @return array\n */\n protected function getPayloadFilterData(Activity $activity): array\n {\n [$from, $to] = $this->getActivityTimeFilterRange($activity);\n\n $data = [\n 'from' => $from->getPreciseTimestamp(3),\n 'to' => $to->getPreciseTimestamp(3),\n 'contact' => $activity->hasContact() ? $activity->getContact()->getCrmProviderId() : null,\n 'company' => $activity->hasAccount() ? $activity->getAccount()->getCrmProviderId() : null,\n 'deal' => $activity->hasOpportunity() ? $activity->getOpportunity()->getCrmProviderId() : null,\n 'owner' => $activity->getUser()->getProfile()->getCrmProviderId(),\n 'activity' => $activity->getUuid(),\n ];\n\n return array_filter($data);\n }\n\n private function getActivityTimeFilterRange(Activity $activity): array\n {\n if ($activity->hasScheduledStartTime() && $activity->hasScheduledEndTime()) {\n $calendarEvent = $activity->getCalendarEvent();\n\n if ($calendarEvent instanceof CalendarEvent) {\n $from = $calendarEvent->getStartTime();\n $to = $calendarEvent->getEndTime()->addMinutes(15);\n } else {\n $from = Carbon::instance($activity->getScheduledStartTime());\n $to = Carbon::instance($activity->getScheduledEndTime())->addMinutes(15);\n }\n\n return [$from, $to];\n }\n\n $from = Carbon::instance($activity->getActualStartTime());\n $to = Carbon::instance($activity->getActualEndTime());\n\n return [$from, $to];\n }\n\n /**\n * Determines the best CRM activity candidate based on the number of matching prospects and the owner.\n *\n * @param array $crmActivities List of CRM activities to evaluate.\n * @param array $data Data containing the prospects and owner information.\n *\n * @return array The best CRM activity candidate.\n */\n private function getBestCrmActivityCandidate(array $crmActivities, array $data): array\n {\n $prospectCount = count(array_intersect_key($data, array_flip(['contact', 'company', 'deal'])));\n $bestCandidate = null;\n $maxMatchingProspects = 0;\n $validActivitiesCount = count($crmActivities);\n\n if ($validActivitiesCount > 1) {\n $this->logger->info('[Hubspot] Fetch related activity - determining the best candidate', [\n 'activityId' => $data['activity'],\n 'activitiesCount' => $validActivitiesCount,\n 'crmActivities' => $crmActivities,\n ]);\n }\n\n foreach ($crmActivities as $crmActivity) {\n if ($this->isOwnerMismatch($crmActivity, $data)) {\n $validActivitiesCount--;\n\n continue;\n }\n\n if ($validActivitiesCount === 1) {\n return $crmActivity;\n }\n\n if ($prospectCount > 1) {\n $matchingProspects = $this->countMatchingProspects($crmActivity, $data);\n if ($matchingProspects > $maxMatchingProspects) {\n $maxMatchingProspects = $matchingProspects;\n $bestCandidate = $crmActivity;\n }\n } else {\n return $crmActivity;\n }\n }\n\n return $bestCandidate ?? [];\n }\n\n private function isOwnerMismatch(array $crmActivity, array $data): bool\n {\n if ($crmActivity['properties']['hubspot_owner_id'] !== $data['owner']) {\n $this->logger->info('[Hubspot] Fetch related activity - owner mismatch', [\n 'activityId' => $data['activity'],\n 'crmActivity' => $crmActivity['id'],\n 'jiminnyOwner' => $data['owner'],\n 'hubspotOwner' => $crmActivity['properties']['hubspot_owner_id'],\n ]);\n\n return true;\n }\n\n return false;\n }\n\n private function countMatchingProspects(array $crmActivity, array $data): int\n {\n try {\n $crmActivityWithAssociations = $this->client->getMeeting($crmActivity['id']);\n } catch (ApiException $e) {\n $this->logger->error('[Hubspot] Fetch related activity - failed to get meeting associations', [\n 'activityId' => $data['activity'],\n 'crmActivity' => $crmActivity['id'],\n 'reason' => $e->getMessage(),\n ]);\n\n return 0;\n }\n\n $associations = $crmActivityWithAssociations->getAssociations();\n $matchingProspects = 0;\n\n $matchingProspects += $this->countMatchingAssociation($data, $associations, 'company', 'companies');\n $matchingProspects += $this->countMatchingAssociation($data, $associations, 'contact', 'contacts');\n $matchingProspects += $this->countMatchingAssociation($data, $associations, 'deal', 'deals');\n\n return $matchingProspects;\n }\n\n private function countMatchingAssociation(array $data, array $associations, string $dataKey, string $associationKey): int\n {\n if (isset($data[$dataKey]) && isset($associations[$associationKey])) {\n $associatedItems = $associations[$associationKey]->getResults();\n foreach ($associatedItems as $associatedItem) {\n if ($data[$dataKey] == $associatedItem->getId()) {\n\n return 1;\n }\n }\n }\n\n return 0;\n }\n\n private function getPlaybookCategoryId(Activity $activity, array $crmActivity): ?int\n {\n if (! $activity->hasUser()) {\n $this->logger->info('[Hubspot] Fetch related activity - activity has no user', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n return null;\n }\n\n $playbookResolver = app(PlaybookResolver::class);\n $playbook = $playbookResolver->resolvePlaybookByUser($activity->getUser());\n\n if (! $playbook instanceof Playbook) {\n $this->logger->info('[Hubspot] Fetch related activity - playbook not set for user', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n return null;\n }\n\n if (! $playbook->getActivityField() instanceof Field) {\n $this->logger->info('[Hubspot] Fetch related activity - playbook activity field not set', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n return null;\n }\n\n $playbookCategory = null;\n $activityTypeSource = null;\n\n $playbookActivityFieldName = $playbook->getActivityField()->getCrmProviderId();\n if ($playbookActivityFieldName === 'activityType') {\n $playbookActivityFieldName = self::DEFAULT_ACTIVITY_TYPE;\n }\n\n // First attempt to match playbook category from crm activity\n if (! empty($crmActivity['properties'][$playbookActivityFieldName])) {\n $playbookCategoryRepository = app(PlaybookCategoryRepository::class);\n $playbookCategory = $playbookCategoryRepository->getPlaybookCategoryByName(\n $playbook,\n strval($crmActivity['properties'][$playbookActivityFieldName])\n );\n $activityTypeSource = 'crm';\n }\n\n // If not matched get playbook category from Jiminny activity\n if (! $playbookCategory instanceof PlaybookCategory && $activity->hasActivityType()) {\n $playbookCategory = $activity->getActivityType();\n $activityTypeSource = 'jiminny';\n }\n\n if ($playbookCategory instanceof PlaybookCategory) {\n $this->logger->info('[Hubspot] Fetch related activity - Playbook category matched', [\n 'activityId' => $activity->getUuid(),\n 'playbookCategoryId' => $playbookCategory->getId(),\n 'activityFieldName' => $playbookActivityFieldName,\n 'crmActivity' => $crmActivity['id'],\n 'source' => $activityTypeSource,\n ]);\n\n return $playbookCategory->getId();\n }\n\n $this->logger->info('[Hubspot] Fetch related activity - Playbook category not found', [\n 'activityId' => $activity->getUuid(),\n 'activityFieldName' => $playbookActivityFieldName,\n 'crmActivity' => $crmActivity['id'],\n ]);\n\n return null;\n }\n\n private function updateActivity(Activity $activity, array $data): bool\n {\n $activityRepository = app(ActivityRepository::class);\n\n $alreadyLoggedActivity = $activityRepository->findUserActivityByCrmProviderId(\n $activity->getUser(),\n $data['crm_provider_id']\n );\n\n if ($alreadyLoggedActivity instanceof Activity) {\n $this->logger->info('[Hubspot] Fetch related activity - activity already linked', [\n 'currentActivityId' => $activity->getUuid(),\n 'linkedActivityId' => $alreadyLoggedActivity->getUuid(),\n 'crmActivityId' => $data['crm_provider_id'],\n ]);\n\n // Return false to prevent updating the remote CRM engagement. This can happen when two Jiminny\n // activities share the same calendar event (e.g., rescheduled meetings). Without this, the first\n // activity's summary would be overwritten by the second activity.\n return false;\n }\n\n try {\n $activityRepository->update($activity, $data);\n } catch (Exception $e) {\n $this->logger->error('[Hubspot] Fetch related activity - activity update failed', [\n 'activityId' => $activity->getUuid(),\n 'reason' => $e->getMessage(),\n ]);\n\n return false;\n }\n\n $this->logger->info('[Hubspot] Fetch related activity - activity updated', [\n 'activityId' => $activity->getUuid(),\n 'data' => $data,\n ]);\n\n return true;\n }\n}","depth":4,"on_screen":true,"value":"<?php\n\ndeclare(strict_types=1);\n\nnamespace Jiminny\\Services\\Crm\\Hubspot;\n\nuse Carbon\\Carbon;\nuse Exception;\nuse HubSpot\\Client\\Crm\\Objects\\ApiException;\nuse Jiminny\\Integrations\\PlaybookResolver;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Calendar\\CalendarEvent;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Models\\Crm\\Profile;\nuse Jiminny\\Models\\Playbook;\nuse Jiminny\\Models\\PlaybookCategory;\nuse Jiminny\\Repositories\\ActivityRepository;\nuse Jiminny\\Repositories\\PlaybookCategoryRepository;\nuse Psr\\Log\\LoggerInterface;\n\nclass SyncRelatedActivityManager\n{\n public const string OBJECT_MEETING = 'meetings';\n public const string DEFAULT_ACTIVITY_TYPE = 'hs_activity_type';\n private RemoteCrmObjectManipulator $crmManipulator;\n\n public function __construct(\n private readonly Client $client,\n private readonly PayloadBuilder $payloadBuilder,\n private readonly LoggerInterface $logger\n ) {\n $this->crmManipulator = app(RemoteCrmObjectManipulator::class, [\n 'client' => $this->client,\n 'logger' => $this->logger,\n ]);\n }\n\n public function fetchAndAssociateRelatedActivity(Activity $activity): ?Activity\n {\n // Validate if the activity is applicable.\n if ($this->isActivityApplicable($activity) === false) {\n $this->logger->info('[Hubspot] Fetch related activity - not applicable', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n return null;\n }\n\n // Find and fetch related activity.\n try {\n $crmActivity = $this->fetchRelatedActivity($activity);\n if (empty($crmActivity)) {\n return null;\n }\n } catch (Exception $exception) {\n $this->logger->error('[Hubspot] Fetch related activity - failed', [\n 'activityId' => $activity->getUuid(),\n 'reason' => $exception->getMessage(),\n ]);\n\n throw $exception;\n }\n\n // get playbook category id\n $playbookCategoryId = $this->getPlaybookCategoryId($activity, $crmActivity);\n\n $data = [\n 'crm_provider_id' => $crmActivity['id'],\n 'playbook_category_id' => $playbookCategoryId,\n ];\n\n if (! $this->updateActivity($activity, $data)) {\n return null;\n }\n\n $this->crmManipulator->updateRelatedCrmActivity($activity, $crmActivity);\n\n $activity->refresh();\n\n return $activity;\n }\n\n private function isActivityApplicable(Activity $activity): bool\n {\n if (! $activity->isTypeConference()) {\n return false;\n }\n\n if (! $this->hasMinimumFilterRequirements($activity)) {\n $this->logger->info('[Hubspot] Fetch related activity - not enough filter data', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n return false;\n }\n\n // The related activity is already linked.\n if ($activity->hasCrmProviderId()) {\n return false;\n }\n\n if (! $activity->getUser()->getProfile() instanceof Profile) {\n $this->logger->info('[Hubspot] Fetch related activity - activity user has no crm profile', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n return false;\n }\n\n return true;\n }\n\n private function hasMinimumFilterRequirements(Activity $activity): bool\n {\n // We need to know when it started to determine when to look for related activity.\n if (! $activity->hasActualStartTime()) {\n return false;\n }\n\n if (! $activity->hasActualEndTime()) {\n return false;\n }\n\n // At least one prospect should be associated to the activity\n if ($activity->hasContact() || $activity->hasAccount()) {\n return true;\n }\n\n return false;\n }\n\n public function fetchRelatedActivity(Activity $activity): array\n {\n $this->logger->info('[Hubspot] Fetch related activity - searching related CRM activity', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n $payloadData = $this->getPayloadFilterData($activity);\n\n try {\n $payload = $this->payloadBuilder->getFindRelatedActivityPayload($payloadData, self::OBJECT_MEETING);\n $response = $this->client->getPaginatedData($payload, self::OBJECT_MEETING);\n $crmActivities = $response['results'];\n } catch (Exception $e) {\n $this->logger->error('[Hubspot] Fetch related activity - failed', [\n 'activityId' => $activity->getUuid(),\n 'reason' => $e->getMessage(),\n ]);\n\n throw $e;\n }\n\n if (empty($crmActivities)) {\n $this->logger->info('[Hubspot] Fetch related activity - no CRM related activity found', [\n 'activityId' => $activity->getUuid(),\n 'payload' => $payload,\n ]);\n\n return [];\n }\n\n $relatedActivity = $this->getBestCrmActivityCandidate($crmActivities, $payloadData);\n if (empty($relatedActivity)) {\n $this->logger->info('[Hubspot] Fetch related activity - no related activity candidate', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n return [];\n }\n\n $this->logger->info('[Hubspot] Fetch related activity - found related CRM activity', [\n 'activityId' => $activity->getUuid(),\n 'crmActivity' => $relatedActivity,\n ]);\n\n return $relatedActivity;\n }\n\n /**\n * @param Activity $activity\n *\n * @return array\n */\n protected function getPayloadFilterData(Activity $activity): array\n {\n [$from, $to] = $this->getActivityTimeFilterRange($activity);\n\n $data = [\n 'from' => $from->getPreciseTimestamp(3),\n 'to' => $to->getPreciseTimestamp(3),\n 'contact' => $activity->hasContact() ? $activity->getContact()->getCrmProviderId() : null,\n 'company' => $activity->hasAccount() ? $activity->getAccount()->getCrmProviderId() : null,\n 'deal' => $activity->hasOpportunity() ? $activity->getOpportunity()->getCrmProviderId() : null,\n 'owner' => $activity->getUser()->getProfile()->getCrmProviderId(),\n 'activity' => $activity->getUuid(),\n ];\n\n return array_filter($data);\n }\n\n private function getActivityTimeFilterRange(Activity $activity): array\n {\n if ($activity->hasScheduledStartTime() && $activity->hasScheduledEndTime()) {\n $calendarEvent = $activity->getCalendarEvent();\n\n if ($calendarEvent instanceof CalendarEvent) {\n $from = $calendarEvent->getStartTime();\n $to = $calendarEvent->getEndTime()->addMinutes(15);\n } else {\n $from = Carbon::instance($activity->getScheduledStartTime());\n $to = Carbon::instance($activity->getScheduledEndTime())->addMinutes(15);\n }\n\n return [$from, $to];\n }\n\n $from = Carbon::instance($activity->getActualStartTime());\n $to = Carbon::instance($activity->getActualEndTime());\n\n return [$from, $to];\n }\n\n /**\n * Determines the best CRM activity candidate based on the number of matching prospects and the owner.\n *\n * @param array $crmActivities List of CRM activities to evaluate.\n * @param array $data Data containing the prospects and owner information.\n *\n * @return array The best CRM activity candidate.\n */\n private function getBestCrmActivityCandidate(array $crmActivities, array $data): array\n {\n $prospectCount = count(array_intersect_key($data, array_flip(['contact', 'company', 'deal'])));\n $bestCandidate = null;\n $maxMatchingProspects = 0;\n $validActivitiesCount = count($crmActivities);\n\n if ($validActivitiesCount > 1) {\n $this->logger->info('[Hubspot] Fetch related activity - determining the best candidate', [\n 'activityId' => $data['activity'],\n 'activitiesCount' => $validActivitiesCount,\n 'crmActivities' => $crmActivities,\n ]);\n }\n\n foreach ($crmActivities as $crmActivity) {\n if ($this->isOwnerMismatch($crmActivity, $data)) {\n $validActivitiesCount--;\n\n continue;\n }\n\n if ($validActivitiesCount === 1) {\n return $crmActivity;\n }\n\n if ($prospectCount > 1) {\n $matchingProspects = $this->countMatchingProspects($crmActivity, $data);\n if ($matchingProspects > $maxMatchingProspects) {\n $maxMatchingProspects = $matchingProspects;\n $bestCandidate = $crmActivity;\n }\n } else {\n return $crmActivity;\n }\n }\n\n return $bestCandidate ?? [];\n }\n\n private function isOwnerMismatch(array $crmActivity, array $data): bool\n {\n if ($crmActivity['properties']['hubspot_owner_id'] !== $data['owner']) {\n $this->logger->info('[Hubspot] Fetch related activity - owner mismatch', [\n 'activityId' => $data['activity'],\n 'crmActivity' => $crmActivity['id'],\n 'jiminnyOwner' => $data['owner'],\n 'hubspotOwner' => $crmActivity['properties']['hubspot_owner_id'],\n ]);\n\n return true;\n }\n\n return false;\n }\n\n private function countMatchingProspects(array $crmActivity, array $data): int\n {\n try {\n $crmActivityWithAssociations = $this->client->getMeeting($crmActivity['id']);\n } catch (ApiException $e) {\n $this->logger->error('[Hubspot] Fetch related activity - failed to get meeting associations', [\n 'activityId' => $data['activity'],\n 'crmActivity' => $crmActivity['id'],\n 'reason' => $e->getMessage(),\n ]);\n\n return 0;\n }\n\n $associations = $crmActivityWithAssociations->getAssociations();\n $matchingProspects = 0;\n\n $matchingProspects += $this->countMatchingAssociation($data, $associations, 'company', 'companies');\n $matchingProspects += $this->countMatchingAssociation($data, $associations, 'contact', 'contacts');\n $matchingProspects += $this->countMatchingAssociation($data, $associations, 'deal', 'deals');\n\n return $matchingProspects;\n }\n\n private function countMatchingAssociation(array $data, array $associations, string $dataKey, string $associationKey): int\n {\n if (isset($data[$dataKey]) && isset($associations[$associationKey])) {\n $associatedItems = $associations[$associationKey]->getResults();\n foreach ($associatedItems as $associatedItem) {\n if ($data[$dataKey] == $associatedItem->getId()) {\n\n return 1;\n }\n }\n }\n\n return 0;\n }\n\n private function getPlaybookCategoryId(Activity $activity, array $crmActivity): ?int\n {\n if (! $activity->hasUser()) {\n $this->logger->info('[Hubspot] Fetch related activity - activity has no user', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n return null;\n }\n\n $playbookResolver = app(PlaybookResolver::class);\n $playbook = $playbookResolver->resolvePlaybookByUser($activity->getUser());\n\n if (! $playbook instanceof Playbook) {\n $this->logger->info('[Hubspot] Fetch related activity - playbook not set for user', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n return null;\n }\n\n if (! $playbook->getActivityField() instanceof Field) {\n $this->logger->info('[Hubspot] Fetch related activity - playbook activity field not set', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n return null;\n }\n\n $playbookCategory = null;\n $activityTypeSource = null;\n\n $playbookActivityFieldName = $playbook->getActivityField()->getCrmProviderId();\n if ($playbookActivityFieldName === 'activityType') {\n $playbookActivityFieldName = self::DEFAULT_ACTIVITY_TYPE;\n }\n\n // First attempt to match playbook category from crm activity\n if (! empty($crmActivity['properties'][$playbookActivityFieldName])) {\n $playbookCategoryRepository = app(PlaybookCategoryRepository::class);\n $playbookCategory = $playbookCategoryRepository->getPlaybookCategoryByName(\n $playbook,\n strval($crmActivity['properties'][$playbookActivityFieldName])\n );\n $activityTypeSource = 'crm';\n }\n\n // If not matched get playbook category from Jiminny activity\n if (! $playbookCategory instanceof PlaybookCategory && $activity->hasActivityType()) {\n $playbookCategory = $activity->getActivityType();\n $activityTypeSource = 'jiminny';\n }\n\n if ($playbookCategory instanceof PlaybookCategory) {\n $this->logger->info('[Hubspot] Fetch related activity - Playbook category matched', [\n 'activityId' => $activity->getUuid(),\n 'playbookCategoryId' => $playbookCategory->getId(),\n 'activityFieldName' => $playbookActivityFieldName,\n 'crmActivity' => $crmActivity['id'],\n 'source' => $activityTypeSource,\n ]);\n\n return $playbookCategory->getId();\n }\n\n $this->logger->info('[Hubspot] Fetch related activity - Playbook category not found', [\n 'activityId' => $activity->getUuid(),\n 'activityFieldName' => $playbookActivityFieldName,\n 'crmActivity' => $crmActivity['id'],\n ]);\n\n return null;\n }\n\n private function updateActivity(Activity $activity, array $data): bool\n {\n $activityRepository = app(ActivityRepository::class);\n\n $alreadyLoggedActivity = $activityRepository->findUserActivityByCrmProviderId(\n $activity->getUser(),\n $data['crm_provider_id']\n );\n\n if ($alreadyLoggedActivity instanceof Activity) {\n $this->logger->info('[Hubspot] Fetch related activity - activity already linked', [\n 'currentActivityId' => $activity->getUuid(),\n 'linkedActivityId' => $alreadyLoggedActivity->getUuid(),\n 'crmActivityId' => $data['crm_provider_id'],\n ]);\n\n // Return false to prevent updating the remote CRM engagement. This can happen when two Jiminny\n // activities share the same calendar event (e.g., rescheduled meetings). Without this, the first\n // activity's summary would be overwritten by the second activity.\n return false;\n }\n\n try {\n $activityRepository->update($activity, $data);\n } catch (Exception $e) {\n $this->logger->error('[Hubspot] Fetch related activity - activity update failed', [\n 'activityId' => $activity->getUuid(),\n 'reason' => $e->getMessage(),\n ]);\n\n return false;\n }\n\n $this->logger->info('[Hubspot] Fetch related activity - activity updated', [\n 'activityId' => $activity->getUuid(),\n 'data' => $data,\n ]);\n\n return true;\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":true,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Project","depth":3,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Project","depth":3,"bounds":{"left":0.011968086,"top":0.047885075,"width":0.024268618,"height":0.024740623},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"New File or Directory…","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Expand Selected","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Collapse All","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Options","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
-2435399166852945830
|
-3377671218453133244
|
click
|
accessibility
|
NULL
|
Project: faVsco.js, menu
JY-20725-handle-HS-search Project: faVsco.js, menu
JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
19
Previous Highlighted Error
Next Highlighted Error
[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {
"headers":{
"Date":["Thu,07 May 2026 14:21:15 GMT"],
"Content-Type":["application/json;charset=utf-8"],
"Transfer-Encoding":["chunked"],
"Connection":["keep-alive"],
"CF-Ray":["9f80deb8db60dc3a-SOF"],
"CF-Cache-Status":["DYNAMIC"],
"Strict-Transport-Security":["max-age=31536000; includeSubDomains; preload"],
"Vary":["origin,
accept-encoding"],
"access-control-allow-credentials":["false"],
"server-timing":["hcid;desc=\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\",
cfr;desc=\"9f80deb8e7c6dc3a-IAD\""],
"x-content-type-options":["nosniff"],
"x-hubspot-correlation-id":["019e02d0-6fd8-7812-bdba-885b7ccb3ee3"],
"Set-Cookie":["__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-[IP_ADDRESS]-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,
07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None"],
"Report-To":["{
\"endpoints\":[{
\"url\":\"https:\\/\\/a.nel.cloudflare.com\\/report\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\"}],
\"group\":\"cf-nel\",
\"max_age\":604800}"],
"NEL":["{
\"success_fraction\":0.01,
\"report_to\":\"cf-nel\",
\"max_age\":604800}"],
"Server":["cloudflare"]}} {
"correlation_id":"95236535-ec98-4541-b92a-adfa73b69eab",
"trace_id":"c7ab8365-903f-46d4-9403-0e5b551e3545"}
Sync Changes
Hide This Notification
Code changed:
Hide
3
11
Previous Highlighted Error
Next Highlighted Error
<?php
declare(strict_types=1);
namespace Jiminny\Services\Crm\Hubspot;
use Carbon\Carbon;
use Exception;
use HubSpot\Client\Crm\Objects\ApiException;
use Jiminny\Integrations\PlaybookResolver;
use Jiminny\Models\Activity;
use Jiminny\Models\Calendar\CalendarEvent;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\Profile;
use Jiminny\Models\Playbook;
use Jiminny\Models\PlaybookCategory;
use Jiminny\Repositories\ActivityRepository;
use Jiminny\Repositories\PlaybookCategoryRepository;
use Psr\Log\LoggerInterface;
class SyncRelatedActivityManager
{
public const string OBJECT_MEETING = 'meetings';
public const string DEFAULT_ACTIVITY_TYPE = 'hs_activity_type';
private RemoteCrmObjectManipulator $crmManipulator;
public function __construct(
private readonly Client $client,
private readonly PayloadBuilder $payloadBuilder,
private readonly LoggerInterface $logger
) {
$this->crmManipulator = app(RemoteCrmObjectManipulator::class, [
'client' => $this->client,
'logger' => $this->logger,
]);
}
public function fetchAndAssociateRelatedActivity(Activity $activity): ?Activity
{
// Validate if the activity is applicable.
if ($this->isActivityApplicable($activity) === false) {
$this->logger->info('[Hubspot] Fetch related activity - not applicable', [
'activityId' => $activity->getUuid(),
]);
return null;
}
// Find and fetch related activity.
try {
$crmActivity = $this->fetchRelatedActivity($activity);
if (empty($crmActivity)) {
return null;
}
} catch (Exception $exception) {
$this->logger->error('[Hubspot] Fetch related activity - failed', [
'activityId' => $activity->getUuid(),
'reason' => $exception->getMessage(),
]);
throw $exception;
}
// get playbook category id
$playbookCategoryId = $this->getPlaybookCategoryId($activity, $crmActivity);
$data = [
'crm_provider_id' => $crmActivity['id'],
'playbook_category_id' => $playbookCategoryId,
];
if (! $this->updateActivity($activity, $data)) {
return null;
}
$this->crmManipulator->updateRelatedCrmActivity($activity, $crmActivity);
$activity->refresh();
return $activity;
}
private function isActivityApplicable(Activity $activity): bool
{
if (! $activity->isTypeConference()) {
return false;
}
if (! $this->hasMinimumFilterRequirements($activity)) {
$this->logger->info('[Hubspot] Fetch related activity - not enough filter data', [
'activityId' => $activity->getUuid(),
]);
return false;
}
// The related activity is already linked.
if ($activity->hasCrmProviderId()) {
return false;
}
if (! $activity->getUser()->getProfile() instanceof Profile) {
$this->logger->info('[Hubspot] Fetch related activity - activity user has no crm profile', [
'activityId' => $activity->getUuid(),
]);
return false;
}
return true;
}
private function hasMinimumFilterRequirements(Activity $activity): bool
{
// We need to know when it started to determine when to look for related activity.
if (! $activity->hasActualStartTime()) {
return false;
}
if (! $activity->hasActualEndTime()) {
return false;
}
// At least one prospect should be associated to the activity
if ($activity->hasContact() || $activity->hasAccount()) {
return true;
}
return false;
}
public function fetchRelatedActivity(Activity $activity): array
{
$this->logger->info('[Hubspot] Fetch related activity - searching related CRM activity', [
'activityId' => $activity->getUuid(),
]);
$payloadData = $this->getPayloadFilterData($activity);
try {
$payload = $this->payloadBuilder->getFindRelatedActivityPayload($payloadData, self::OBJECT_MEETING);
$response = $this->client->getPaginatedData($payload, self::OBJECT_MEETING);
$crmActivities = $response['results'];
} catch (Exception $e) {
$this->logger->error('[Hubspot] Fetch related activity - failed', [
'activityId' => $activity->getUuid(),
'reason' => $e->getMessage(),
]);
throw $e;
}
if (empty($crmActivities)) {
$this->logger->info('[Hubspot] Fetch related activity - no CRM related activity found', [
'activityId' => $activity->getUuid(),
'payload' => $payload,
]);
return [];
}
$relatedActivity = $this->getBestCrmActivityCandidate($crmActivities, $payloadData);
if (empty($relatedActivity)) {
$this->logger->info('[Hubspot] Fetch related activity - no related activity candidate', [
'activityId' => $activity->getUuid(),
]);
return [];
}
$this->logger->info('[Hubspot] Fetch related activity - found related CRM activity', [
'activityId' => $activity->getUuid(),
'crmActivity' => $relatedActivity,
]);
return $relatedActivity;
}
/**
* @param Activity $activity
*
* @return array
*/
protected function getPayloadFilterData(Activity $activity): array
{
[$from, $to] = $this->getActivityTimeFilterRange($activity);
$data = [
'from' => $from->getPreciseTimestamp(3),
'to' => $to->getPreciseTimestamp(3),
'contact' => $activity->hasContact() ? $activity->getContact()->getCrmProviderId() : null,
'company' => $activity->hasAccount() ? $activity->getAccount()->getCrmProviderId() : null,
'deal' => $activity->hasOpportunity() ? $activity->getOpportunity()->getCrmProviderId() : null,
'owner' => $activity->getUser()->getProfile()->getCrmProviderId(),
'activity' => $activity->getUuid(),
];
return array_filter($data);
}
private function getActivityTimeFilterRange(Activity $activity): array
{
if ($activity->hasScheduledStartTime() && $activity->hasScheduledEndTime()) {
$calendarEvent = $activity->getCalendarEvent();
if ($calendarEvent instanceof CalendarEvent) {
$from = $calendarEvent->getStartTime();
$to = $calendarEvent->getEndTime()->addMinutes(15);
} else {
$from = Carbon::instance($activity->getScheduledStartTime());
$to = Carbon::instance($activity->getScheduledEndTime())->addMinutes(15);
}
return [$from, $to];
}
$from = Carbon::instance($activity->getActualStartTime());
$to = Carbon::instance($activity->getActualEndTime());
return [$from, $to];
}
/**
* Determines the best CRM activity candidate based on the number of matching prospects and the owner.
*
* @param array $crmActivities List of CRM activities to evaluate.
* @param array $data Data containing the prospects and owner information.
*
* @return array The best CRM activity candidate.
*/
private function getBestCrmActivityCandidate(array $crmActivities, array $data): array
{
$prospectCount = count(array_intersect_key($data, array_flip(['contact', 'company', 'deal'])));
$bestCandidate = null;
$maxMatchingProspects = 0;
$validActivitiesCount = count($crmActivities);
if ($validActivitiesCount > 1) {
$this->logger->info('[Hubspot] Fetch related activity - determining the best candidate', [
'activityId' => $data['activity'],
'activitiesCount' => $validActivitiesCount,
'crmActivities' => $crmActivities,
]);
}
foreach ($crmActivities as $crmActivity) {
if ($this->isOwnerMismatch($crmActivity, $data)) {
$validActivitiesCount--;
continue;
}
if ($validActivitiesCount === 1) {
return $crmActivity;
}
if ($prospectCount > 1) {
$matchingProspects = $this->countMatchingProspects($crmActivity, $data);
if ($matchingProspects > $maxMatchingProspects) {
$maxMatchingProspects = $matchingProspects;
$bestCandidate = $crmActivity;
}
} else {
return $crmActivity;
}
}
return $bestCandidate ?? [];
}
private function isOwnerMismatch(array $crmActivity, array $data): bool
{
if ($crmActivity['properties']['hubspot_owner_id'] !== $data['owner']) {
$this->logger->info('[Hubspot] Fetch related activity - owner mismatch', [
'activityId' => $data['activity'],
'crmActivity' => $crmActivity['id'],
'jiminnyOwner' => $data['owner'],
'hubspotOwner' => $crmActivity['properties']['hubspot_owner_id'],
]);
return true;
}
return false;
}
private function countMatchingProspects(array $crmActivity, array $data): int
{
try {
$crmActivityWithAssociations = $this->client->getMeeting($crmActivity['id']);
} catch (ApiException $e) {
$this->logger->error('[Hubspot] Fetch related activity - failed to get meeting associations', [
'activityId' => $data['activity'],
'crmActivity' => $crmActivity['id'],
'reason' => $e->getMessage(),
]);
return 0;
}
$associations = $crmActivityWithAssociations->getAssociations();
$matchingProspects = 0;
$matchingProspects += $this->countMatchingAssociation($data, $associations, 'company', 'companies');
$matchingProspects += $this->countMatchingAssociation($data, $associations, 'contact', 'contacts');
$matchingProspects += $this->countMatchingAssociation($data, $associations, 'deal', 'deals');
return $matchingProspects;
}
private function countMatchingAssociation(array $data, array $associations, string $dataKey, string $associationKey): int
{
if (isset($data[$dataKey]) && isset($associations[$associationKey])) {
$associatedItems = $associations[$associationKey]->getResults();
foreach ($associatedItems as $associatedItem) {
if ($data[$dataKey] == $associatedItem->getId()) {
return 1;
}
}
}
return 0;
}
private function getPlaybookCategoryId(Activity $activity, array $crmActivity): ?int
{
if (! $activity->hasUser()) {
$this->logger->info('[Hubspot] Fetch related activity - activity has no user', [
'activityId' => $activity->getUuid(),
]);
return null;
}
$playbookResolver = app(PlaybookResolver::class);
$playbook = $playbookResolver->resolvePlaybookByUser($activity->getUser());
if (! $playbook instanceof Playbook) {
$this->logger->info('[Hubspot] Fetch related activity - playbook not set for user', [
'activityId' => $activity->getUuid(),
]);
return null;
}
if (! $playbook->getActivityField() instanceof Field) {
$this->logger->info('[Hubspot] Fetch related activity - playbook activity field not set', [
'activityId' => $activity->getUuid(),
]);
return null;
}
$playbookCategory = null;
$activityTypeSource = null;
$playbookActivityFieldName = $playbook->getActivityField()->getCrmProviderId();
if ($playbookActivityFieldName === 'activityType') {
$playbookActivityFieldName = self::DEFAULT_ACTIVITY_TYPE;
}
// First attempt to match playbook category from crm activity
if (! empty($crmActivity['properties'][$playbookActivityFieldName])) {
$playbookCategoryRepository = app(PlaybookCategoryRepository::class);
$playbookCategory = $playbookCategoryRepository->getPlaybookCategoryByName(
$playbook,
strval($crmActivity['properties'][$playbookActivityFieldName])
);
$activityTypeSource = 'crm';
}
// If not matched get playbook category from Jiminny activity
if (! $playbookCategory instanceof PlaybookCategory && $activity->hasActivityType()) {
$playbookCategory = $activity->getActivityType();
$activityTypeSource = 'jiminny';
}
if ($playbookCategory instanceof PlaybookCategory) {
$this->logger->info('[Hubspot] Fetch related activity - Playbook category matched', [
'activityId' => $activity->getUuid(),
'playbookCategoryId' => $playbookCategory->getId(),
'activityFieldName' => $playbookActivityFieldName,
'crmActivity' => $crmActivity['id'],
'source' => $activityTypeSource,
]);
return $playbookCategory->getId();
}
$this->logger->info('[Hubspot] Fetch related activity - Playbook category not found', [
'activityId' => $activity->getUuid(),
'activityFieldName' => $playbookActivityFieldName,
'crmActivity' => $crmActivity['id'],
]);
return null;
}
private function updateActivity(Activity $activity, array $data): bool
{
$activityRepository = app(ActivityRepository::class);
$alreadyLoggedActivity = $activityRepository->findUserActivityByCrmProviderId(
$activity->getUser(),
$data['crm_provider_id']
);
if ($alreadyLoggedActivity instanceof Activity) {
$this->logger->info('[Hubspot] Fetch related activity - activity already linked', [
'currentActivityId' => $activity->getUuid(),
'linkedActivityId' => $alreadyLoggedActivity->getUuid(),
'crmActivityId' => $data['crm_provider_id'],
]);
// Return false to prevent updating the remote CRM engagement. This can happen when two Jiminny
// activities share the same calendar event (e.g., rescheduled meetings). Without this, the first
// activity's summary would be overwritten by the second activity.
return false;
}
try {
$activityRepository->update($activity, $data);
} catch (Exception $e) {
$this->logger->error('[Hubspot] Fetch related activity - activity update failed', [
'activityId' => $activity->getUuid(),
'reason' => $e->getMessage(),
]);
return false;
}
$this->logger->info('[Hubspot] Fetch related activity - activity updated', [
'activityId' => $activity->getUuid(),
'data' => $data,
]);
return true;
}
}
Project
Project
New File or Directory…
Expand Selected
Collapse All
Options
Hide...
|
9824
|
NULL
|
NULL
|
NULL
|
|
9826
|
443
|
14
|
2026-05-08T13:36:29.654124+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-08/1778 /Users/lukas/.screenpipe/data/data/2026-05-08/1778247389654_m1.jpg...
|
PhpStorm
|
faVsco.js – SyncRelatedActivityManager.php
|
1
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
JY-20725-handle-HS-search Project: faVsco.js, menu
JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification...
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JY-20725-handle-HS-search-rate-limit, menu","depth":5,"on_screen":true,"help_text":"Git Branch: JY-20725-handle-HS-search-rate-limit","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"AskJiminnyReportActivityServiceTest","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'AskJiminnyReportActivityServiceTest'","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'AskJiminnyReportActivityServiceTest'","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"IDE and Project Settings","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
-9140679751598560133
|
-8132362818571490362
|
click
|
hybrid
|
NULL
|
Project: faVsco.js, menu
JY-20725-handle-HS-search Project: faVsco.js, menu
JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
iTerm2Shell Edit ViewSessionScripts|ProfilesWindowHelp‹ >0 lobl100% C8APP (-zsh)DOCKERDEV (docker)882JY-20773-fix-automated-reports-user-pilot-trackingJY-20157-AJ-report-not-send-notificationJY-20508-notify-before-AJ-report-expirationJY-20372-ai-reports-promotion-pagesJY-20352-sync-opportunities-without-a-local-owner-user-id-is-nullJY-20738-debug-AJ-tracking-UPAPP (-zsh)-zshJY-18909-automated-reports-ask-jiminnyJY-20692-fix-integration-app-[API_KEY]@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ co -b JY-20725-handle-HS-search-rate-limitSwitched to a new branch 'JY-20725-handle-HS-search-rate-limit'Lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (JY-20725-handle-HS-search-rate-limit) $ I• 84screenpipe*•$5-zshFri 8 May 16:36:32T₴1|₴6APP...
|
9825
|
NULL
|
NULL
|
NULL
|
|
9825
|
443
|
13
|
2026-05-08T13:36:22.386462+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-08/1778 /Users/lukas/.screenpipe/data/data/2026-05-08/1778247382386_m1.jpg...
|
PhpStorm
|
faVsco.js – HubspotPaginationService.php
|
1
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
JY-20725-handle-HS-search Project: faVsco.js, menu
JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
19
Previous Highlighted Error
Next Highlighted Error
[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {
"headers":{
"Date":["Thu,07 May 2026 14:21:15 GMT"],
"Content-Type":["application/json;charset=utf-8"],
"Transfer-Encoding":["chunked"],
"Connection":["keep-alive"],
"CF-Ray":["9f80deb8db60dc3a-SOF"],
"CF-Cache-Status":["DYNAMIC"],
"Strict-Transport-Security":["max-age=31536000; includeSubDomains; preload"],
"Vary":["origin,
accept-encoding"],
"access-control-allow-credentials":["false"],
"server-timing":["hcid;desc=\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\",
cfr;desc=\"9f80deb8e7c6dc3a-IAD\""],
"x-content-type-options":["nosniff"],
"x-hubspot-correlation-id":["019e02d0-6fd8-7812-bdba-885b7ccb3ee3"],
"Set-Cookie":["__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-[IP_ADDRESS]-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,
07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None"],
"Report-To":["{
\"endpoints\":[{
\"url\":\"https:\\/\\/a.nel.cloudflare.com\\/report\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\"}],
\"group\":\"cf-nel\",
\"max_age\":604800}"],
"NEL":["{
\"success_fraction\":0.01,
\"report_to\":\"cf-nel\",
\"max_age\":604800}"],
"Server":["cloudflare"]}} {
"correlation_id":"95236535-ec98-4541-b92a-adfa73b69eab",
"trace_id":"c7ab8365-903f-46d4-9403-0e5b551e3545"}
Sync Changes
Hide This Notification
Code changed:
Hide
12
Previous Highlighted Error
Next Highlighted Error
<?php
declare(strict_types=1);
namespace Jiminny\Services\Crm\Hubspot\Pagination;
use Jiminny\Services\Crm\Hubspot\Client;
use Jiminny\Services\Crm\Hubspot\PayloadBuilder;
use Psr\Log\LoggerInterface;
use SevenShores\Hubspot\Exceptions\BadRequest;
use SevenShores\Hubspot\Exceptions\HubspotException;
use Jiminny\Exceptions\SocialAccountTokenInvalidException;
class HubspotPaginationService
{
public function __construct(
private LoggerInterface $logger
) {
}
/**
* @throws HubspotException
* @throws SocialAccountTokenInvalidException
* @throws BadRequest
*/
public function getPaginatedDataGenerator(
Client $client,
array $payload,
string $type,
int $offset = 0,
int &$total = 0,
?string &$lastRecordId = null
): \Generator {
$state = new PaginationState(offset: $offset);
$endpoint = Client::BASE_URL . "/crm/v3/objects/{$type}/search";
$defaultFilter = $payload['filters'] ?? [];
$resultsPerPage = PayloadBuilder::MAX_SEARCH_REQUEST_LIMIT;
$teamId = $client->getConfig()->getTeam()->getId();
$delay = $this->calculateDelayInMicroseconds();
do {
if ($this->shouldStopPagination($state, $teamId)) {
break;
}
$payload = $this->handlePaginationStrategy($payload, $defaultFilter, $state, $resultsPerPage, $teamId);
$this->validateTokenIfNeeded($client, $state);
if ($state->requestCount > 0) {
usleep($delay);
}
$page = $this->executeSearchRequest($client, $type, $payload, $state);
$state->setTotal($page['total'] ?? 0);
$this->updateLastRecordId($page, $state);
// Safely iterate over results with null check
$results = $page['results'] ?? [];
foreach ($results as $row) {
$state->incrementTotalRecords();
yield $row;
}
$state->setOffset($this->getNextOffset($page));
$state->incrementRequestCount();
$this->logPaginationProgress($state, $teamId, $endpoint);
} while ($state->offset && ! empty($page['results']));
// Log final pagination completion stats
$this->logger->info('[Hubspot] Pagination completed', [
'team_id' => $teamId,
'endpoint' => $endpoint,
'total_requests' => $state->requestCount,
'total_records_fetched' => $state->totalRecords,
'total_elapsed_seconds' => round($state->getElapsedSeconds(), 2),
'average_seconds_per_request' => $state->requestCount > 0 ? round($state->getElapsedSeconds() / $state->requestCount, 2) : 0,
]);
// Update reference parameters
$total = $state->total;
$lastRecordId = $state->lastRecordId;
}
private function shouldStopPagination(PaginationState $state, int $teamId): bool
{
if ($state->hasReachedSafetyLimit()) {
$this->logger->warning('[Hubspot] Reached maximum request limit during pagination', [
'team_id' => $teamId,
'safety_limit' => PaginationConfig::LOOP_SAFETY_LIMIT,
'total_fetched' => $state->totalRecords,
]);
return true;
}
return false;
}
private function handlePaginationStrategy(
array $payload,
array $defaultFilter,
PaginationState $state,
int $resultsPerPage,
int $teamId
): array {
if ($this->shouldSwitchToKeysetPagination($state, $resultsPerPage)) {
$payload['filters'] = $defaultFilter;
$payload['filters'][] = [
'propertyName' => 'hs_object_id',
'operator' => 'LT',
'value' => $state->lastRecordId,
];
$this->logger->info('[Hubspot] Search keyset pagination request', [
'team_id' => $teamId,
'sequence' => $state->requestCount,
'itemsPerPage' => $resultsPerPage,
'payload' => $payload,
'total' => $state->total,
]);
unset($payload['after']);
$state->setOffset(0);
}
if ($state->offset) {
$payload['after'] = $state->offset;
}
return $payload;
}
private function shouldSwitchToKeysetPagination(PaginationState $state, int $resultsPerPage): bool
{
// Check if we've hit the offset limit
$shouldSwitch = $state->requestCount > 0 && ($state->offset + $resultsPerPage) > PaginationConfig::TOTAL_QUERY_LIMIT;
if ($shouldSwitch && $state->lastRecordId === null) {
$this->logger->warning('[Hubspot] Cannot switch to keyset pagination: lastRecordId is null', [
'request_count' => $state->requestCount,
'current_offset' => $state->offset,
'results_per_page' => $resultsPerPage,
'total_query_limit' => PaginationConfig::TOTAL_QUERY_LIMIT,
]);
return false; // Continue with offset pagination
}
return $shouldSwitch;
}
private function validateTokenIfNeeded(Client $client, PaginationState $state): void
{
if ($state->shouldValidateToken()) {
$client->ensureValidToken();
$state->updateLastTokenCheck();
}
}
private function executeSearchRequest(Client $client, string $objectType, array $payload, PaginationState $state): array
{
try {
return $client->search($objectType, $payload);
} catch (\Exception $e) {
if ($client->isUnauthorizedException($e)) {
$this->logger->warning('[Hubspot] Got 401 during pagination, attempting token refresh', [
'team_id' => $client->getConfig()->getTeam()->getId(),
'error' => $e->getMessage(),
]);
$client->ensureValidToken();
$state->updateLastTokenCheck();
try {
$result = $client->search($objectType, $payload);
$this->logger->info('[Hubspot] Token refresh and retry successful', [
'team_id' => $client->getConfig()->getTeam()->getId(),
]);
return $result;
} catch (\Exception $retryException) {
$this->logger->error('[Hubspot] Retry request failed after token refresh', [
'team_id' => $client->getConfig()->getTeam()->getId(),
'original_error' => $e->getMessage(),
'retry_error' => $retryException->getMessage(),
]);
throw $retryException;
}
}
// RateLimitException and other exceptions are re-thrown as-is
throw $e;
}
}
private function updateLastRecordId(array $page, PaginationState $state): void
{
$lastRecord = ! empty($page['results']) ? end($page['results']) : null;
$lastRecordId = $lastRecord['id'] ?? null;
$state->updateLastRecordId($lastRecordId);
}
private function getNextOffset(array $page): int
{
return isset($page['paging']['next']['after']) ? (int) $page['paging']['next']['after'] : 0;
}
private function logPaginationProgress(PaginationState $state, int $teamId, string $endpoint): void
{
if ($state->shouldLogProgress()) {
$this->logger->info('[Hubspot] Pagination progress log', [
'team_id' => $teamId,
'endpoint' => $endpoint,
'requests_made' => $state->requestCount,
'records_fetched' => $state->totalRecords,
'elapsed_seconds' => $state->getElapsedSeconds(),
]);
}
}
private function calculateDelayInMicroseconds(): int
{
return (int) (1 / PaginationConfig::SEARCH_RPS_LIMIT * 1000000);
}
}
Project
Project
New File or Directory…
Expand Selected
Collapse All
Options
Hide...
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JY-20725-handle-HS-search-rate-limit, menu","depth":5,"on_screen":true,"help_text":"Git Branch: JY-20725-handle-HS-search-rate-limit","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"AskJiminnyReportActivityServiceTest","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'AskJiminnyReportActivityServiceTest'","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'AskJiminnyReportActivityServiceTest'","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"IDE and Project Settings","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.088194445,"height":0.027777778},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"19","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {\n\"headers\":{\n\"Date\":[\"Thu,07 May 2026 14:21:15 GMT\"],\n \"Content-Type\":[\"application/json;charset=utf-8\"],\n \"Transfer-Encoding\":[\"chunked\"],\n \"Connection\":[\"keep-alive\"],\n \"CF-Ray\":[\"9f80deb8db60dc3a-SOF\"],\n \"CF-Cache-Status\":[\"DYNAMIC\"],\n \"Strict-Transport-Security\":[\"max-age=31536000; includeSubDomains; preload\"],\n \"Vary\":[\"origin,\n accept-encoding\"],\n \"access-control-allow-credentials\":[\"false\"],\n \"server-timing\":[\"hcid;desc=\\\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\\\",\n cfr;desc=\\\"9f80deb8e7c6dc3a-IAD\\\"\"],\n \"x-content-type-options\":[\"nosniff\"],\n \"x-hubspot-correlation-id\":[\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\"],\n \"Set-Cookie\":[\"__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-1.0.1.1-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,\n 07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None\"],\n \"Report-To\":[\"{\n\\\"endpoints\\\":[{\n\\\"url\\\":\\\"https:\\\\/\\\\/a.nel.cloudflare.com\\\\/report\\\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\\\"}],\n\\\"group\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"NEL\":[\"{\n\\\"success_fraction\\\":0.01,\n\\\"report_to\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"Server\":[\"cloudflare\"]}} {\n\"correlation_id\":\"95236535-ec98-4541-b92a-adfa73b69eab\",\n\"trace_id\":\"c7ab8365-903f-46d4-9403-0e5b551e3545\"}","depth":4,"on_screen":true,"value":"[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {\n\"headers\":{\n\"Date\":[\"Thu,07 May 2026 14:21:15 GMT\"],\n \"Content-Type\":[\"application/json;charset=utf-8\"],\n \"Transfer-Encoding\":[\"chunked\"],\n \"Connection\":[\"keep-alive\"],\n \"CF-Ray\":[\"9f80deb8db60dc3a-SOF\"],\n \"CF-Cache-Status\":[\"DYNAMIC\"],\n \"Strict-Transport-Security\":[\"max-age=31536000; includeSubDomains; preload\"],\n \"Vary\":[\"origin,\n accept-encoding\"],\n \"access-control-allow-credentials\":[\"false\"],\n \"server-timing\":[\"hcid;desc=\\\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\\\",\n cfr;desc=\\\"9f80deb8e7c6dc3a-IAD\\\"\"],\n \"x-content-type-options\":[\"nosniff\"],\n \"x-hubspot-correlation-id\":[\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\"],\n \"Set-Cookie\":[\"__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-1.0.1.1-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,\n 07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None\"],\n \"Report-To\":[\"{\n\\\"endpoints\\\":[{\n\\\"url\\\":\\\"https:\\\\/\\\\/a.nel.cloudflare.com\\\\/report\\\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\\\"}],\n\\\"group\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"NEL\":[\"{\n\\\"success_fraction\\\":0.01,\n\\\"report_to\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"Server\":[\"cloudflare\"]}} {\n\"correlation_id\":\"95236535-ec98-4541-b92a-adfa73b69eab\",\n\"trace_id\":\"c7ab8365-903f-46d4-9403-0e5b551e3545\"}","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.088194445,"height":0.027777778},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"12","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\ndeclare(strict_types=1);\n\nnamespace Jiminny\\Services\\Crm\\Hubspot\\Pagination;\n\nuse Jiminny\\Services\\Crm\\Hubspot\\Client;\nuse Jiminny\\Services\\Crm\\Hubspot\\PayloadBuilder;\nuse Psr\\Log\\LoggerInterface;\nuse SevenShores\\Hubspot\\Exceptions\\BadRequest;\nuse SevenShores\\Hubspot\\Exceptions\\HubspotException;\nuse Jiminny\\Exceptions\\SocialAccountTokenInvalidException;\n\nclass HubspotPaginationService\n{\n public function __construct(\n private LoggerInterface $logger\n ) {\n }\n\n /**\n * @throws HubspotException\n * @throws SocialAccountTokenInvalidException\n * @throws BadRequest\n */\n public function getPaginatedDataGenerator(\n Client $client,\n array $payload,\n string $type,\n int $offset = 0,\n int &$total = 0,\n ?string &$lastRecordId = null\n ): \\Generator {\n $state = new PaginationState(offset: $offset);\n $endpoint = Client::BASE_URL . \"/crm/v3/objects/{$type}/search\";\n $defaultFilter = $payload['filters'] ?? [];\n $resultsPerPage = PayloadBuilder::MAX_SEARCH_REQUEST_LIMIT;\n $teamId = $client->getConfig()->getTeam()->getId();\n $delay = $this->calculateDelayInMicroseconds();\n\n do {\n if ($this->shouldStopPagination($state, $teamId)) {\n break;\n }\n\n $payload = $this->handlePaginationStrategy($payload, $defaultFilter, $state, $resultsPerPage, $teamId);\n\n $this->validateTokenIfNeeded($client, $state);\n if ($state->requestCount > 0) {\n usleep($delay);\n }\n\n $page = $this->executeSearchRequest($client, $type, $payload, $state);\n\n $state->setTotal($page['total'] ?? 0);\n $this->updateLastRecordId($page, $state);\n\n // Safely iterate over results with null check\n $results = $page['results'] ?? [];\n foreach ($results as $row) {\n $state->incrementTotalRecords();\n yield $row;\n }\n\n $state->setOffset($this->getNextOffset($page));\n $state->incrementRequestCount();\n\n $this->logPaginationProgress($state, $teamId, $endpoint);\n } while ($state->offset && ! empty($page['results']));\n\n // Log final pagination completion stats\n $this->logger->info('[Hubspot] Pagination completed', [\n 'team_id' => $teamId,\n 'endpoint' => $endpoint,\n 'total_requests' => $state->requestCount,\n 'total_records_fetched' => $state->totalRecords,\n 'total_elapsed_seconds' => round($state->getElapsedSeconds(), 2),\n 'average_seconds_per_request' => $state->requestCount > 0 ? round($state->getElapsedSeconds() / $state->requestCount, 2) : 0,\n ]);\n\n // Update reference parameters\n $total = $state->total;\n $lastRecordId = $state->lastRecordId;\n }\n\n private function shouldStopPagination(PaginationState $state, int $teamId): bool\n {\n if ($state->hasReachedSafetyLimit()) {\n $this->logger->warning('[Hubspot] Reached maximum request limit during pagination', [\n 'team_id' => $teamId,\n 'safety_limit' => PaginationConfig::LOOP_SAFETY_LIMIT,\n 'total_fetched' => $state->totalRecords,\n ]);\n\n return true;\n }\n\n return false;\n }\n\n private function handlePaginationStrategy(\n array $payload,\n array $defaultFilter,\n PaginationState $state,\n int $resultsPerPage,\n int $teamId\n ): array {\n if ($this->shouldSwitchToKeysetPagination($state, $resultsPerPage)) {\n $payload['filters'] = $defaultFilter;\n $payload['filters'][] = [\n 'propertyName' => 'hs_object_id',\n 'operator' => 'LT',\n 'value' => $state->lastRecordId,\n ];\n\n $this->logger->info('[Hubspot] Search keyset pagination request', [\n 'team_id' => $teamId,\n 'sequence' => $state->requestCount,\n 'itemsPerPage' => $resultsPerPage,\n 'payload' => $payload,\n 'total' => $state->total,\n ]);\n\n unset($payload['after']);\n $state->setOffset(0);\n }\n\n if ($state->offset) {\n $payload['after'] = $state->offset;\n }\n\n return $payload;\n }\n\n private function shouldSwitchToKeysetPagination(PaginationState $state, int $resultsPerPage): bool\n {\n // Check if we've hit the offset limit\n $shouldSwitch = $state->requestCount > 0 && ($state->offset + $resultsPerPage) > PaginationConfig::TOTAL_QUERY_LIMIT;\n\n if ($shouldSwitch && $state->lastRecordId === null) {\n $this->logger->warning('[Hubspot] Cannot switch to keyset pagination: lastRecordId is null', [\n 'request_count' => $state->requestCount,\n 'current_offset' => $state->offset,\n 'results_per_page' => $resultsPerPage,\n 'total_query_limit' => PaginationConfig::TOTAL_QUERY_LIMIT,\n ]);\n\n return false; // Continue with offset pagination\n }\n\n return $shouldSwitch;\n }\n\n private function validateTokenIfNeeded(Client $client, PaginationState $state): void\n {\n if ($state->shouldValidateToken()) {\n $client->ensureValidToken();\n $state->updateLastTokenCheck();\n }\n }\n\n private function executeSearchRequest(Client $client, string $objectType, array $payload, PaginationState $state): array\n {\n try {\n return $client->search($objectType, $payload);\n } catch (\\Exception $e) {\n if ($client->isUnauthorizedException($e)) {\n $this->logger->warning('[Hubspot] Got 401 during pagination, attempting token refresh', [\n 'team_id' => $client->getConfig()->getTeam()->getId(),\n 'error' => $e->getMessage(),\n ]);\n\n $client->ensureValidToken();\n $state->updateLastTokenCheck();\n\n try {\n $result = $client->search($objectType, $payload);\n\n $this->logger->info('[Hubspot] Token refresh and retry successful', [\n 'team_id' => $client->getConfig()->getTeam()->getId(),\n ]);\n\n return $result;\n } catch (\\Exception $retryException) {\n $this->logger->error('[Hubspot] Retry request failed after token refresh', [\n 'team_id' => $client->getConfig()->getTeam()->getId(),\n 'original_error' => $e->getMessage(),\n 'retry_error' => $retryException->getMessage(),\n ]);\n\n throw $retryException;\n }\n }\n\n // RateLimitException and other exceptions are re-thrown as-is\n throw $e;\n }\n }\n\n private function updateLastRecordId(array $page, PaginationState $state): void\n {\n $lastRecord = ! empty($page['results']) ? end($page['results']) : null;\n $lastRecordId = $lastRecord['id'] ?? null;\n $state->updateLastRecordId($lastRecordId);\n }\n\n private function getNextOffset(array $page): int\n {\n return isset($page['paging']['next']['after']) ? (int) $page['paging']['next']['after'] : 0;\n }\n\n private function logPaginationProgress(PaginationState $state, int $teamId, string $endpoint): void\n {\n if ($state->shouldLogProgress()) {\n $this->logger->info('[Hubspot] Pagination progress log', [\n 'team_id' => $teamId,\n 'endpoint' => $endpoint,\n 'requests_made' => $state->requestCount,\n 'records_fetched' => $state->totalRecords,\n 'elapsed_seconds' => $state->getElapsedSeconds(),\n ]);\n }\n }\n\n private function calculateDelayInMicroseconds(): int\n {\n return (int) (1 / PaginationConfig::SEARCH_RPS_LIMIT * 1000000);\n }\n}","depth":4,"on_screen":true,"value":"<?php\n\ndeclare(strict_types=1);\n\nnamespace Jiminny\\Services\\Crm\\Hubspot\\Pagination;\n\nuse Jiminny\\Services\\Crm\\Hubspot\\Client;\nuse Jiminny\\Services\\Crm\\Hubspot\\PayloadBuilder;\nuse Psr\\Log\\LoggerInterface;\nuse SevenShores\\Hubspot\\Exceptions\\BadRequest;\nuse SevenShores\\Hubspot\\Exceptions\\HubspotException;\nuse Jiminny\\Exceptions\\SocialAccountTokenInvalidException;\n\nclass HubspotPaginationService\n{\n public function __construct(\n private LoggerInterface $logger\n ) {\n }\n\n /**\n * @throws HubspotException\n * @throws SocialAccountTokenInvalidException\n * @throws BadRequest\n */\n public function getPaginatedDataGenerator(\n Client $client,\n array $payload,\n string $type,\n int $offset = 0,\n int &$total = 0,\n ?string &$lastRecordId = null\n ): \\Generator {\n $state = new PaginationState(offset: $offset);\n $endpoint = Client::BASE_URL . \"/crm/v3/objects/{$type}/search\";\n $defaultFilter = $payload['filters'] ?? [];\n $resultsPerPage = PayloadBuilder::MAX_SEARCH_REQUEST_LIMIT;\n $teamId = $client->getConfig()->getTeam()->getId();\n $delay = $this->calculateDelayInMicroseconds();\n\n do {\n if ($this->shouldStopPagination($state, $teamId)) {\n break;\n }\n\n $payload = $this->handlePaginationStrategy($payload, $defaultFilter, $state, $resultsPerPage, $teamId);\n\n $this->validateTokenIfNeeded($client, $state);\n if ($state->requestCount > 0) {\n usleep($delay);\n }\n\n $page = $this->executeSearchRequest($client, $type, $payload, $state);\n\n $state->setTotal($page['total'] ?? 0);\n $this->updateLastRecordId($page, $state);\n\n // Safely iterate over results with null check\n $results = $page['results'] ?? [];\n foreach ($results as $row) {\n $state->incrementTotalRecords();\n yield $row;\n }\n\n $state->setOffset($this->getNextOffset($page));\n $state->incrementRequestCount();\n\n $this->logPaginationProgress($state, $teamId, $endpoint);\n } while ($state->offset && ! empty($page['results']));\n\n // Log final pagination completion stats\n $this->logger->info('[Hubspot] Pagination completed', [\n 'team_id' => $teamId,\n 'endpoint' => $endpoint,\n 'total_requests' => $state->requestCount,\n 'total_records_fetched' => $state->totalRecords,\n 'total_elapsed_seconds' => round($state->getElapsedSeconds(), 2),\n 'average_seconds_per_request' => $state->requestCount > 0 ? round($state->getElapsedSeconds() / $state->requestCount, 2) : 0,\n ]);\n\n // Update reference parameters\n $total = $state->total;\n $lastRecordId = $state->lastRecordId;\n }\n\n private function shouldStopPagination(PaginationState $state, int $teamId): bool\n {\n if ($state->hasReachedSafetyLimit()) {\n $this->logger->warning('[Hubspot] Reached maximum request limit during pagination', [\n 'team_id' => $teamId,\n 'safety_limit' => PaginationConfig::LOOP_SAFETY_LIMIT,\n 'total_fetched' => $state->totalRecords,\n ]);\n\n return true;\n }\n\n return false;\n }\n\n private function handlePaginationStrategy(\n array $payload,\n array $defaultFilter,\n PaginationState $state,\n int $resultsPerPage,\n int $teamId\n ): array {\n if ($this->shouldSwitchToKeysetPagination($state, $resultsPerPage)) {\n $payload['filters'] = $defaultFilter;\n $payload['filters'][] = [\n 'propertyName' => 'hs_object_id',\n 'operator' => 'LT',\n 'value' => $state->lastRecordId,\n ];\n\n $this->logger->info('[Hubspot] Search keyset pagination request', [\n 'team_id' => $teamId,\n 'sequence' => $state->requestCount,\n 'itemsPerPage' => $resultsPerPage,\n 'payload' => $payload,\n 'total' => $state->total,\n ]);\n\n unset($payload['after']);\n $state->setOffset(0);\n }\n\n if ($state->offset) {\n $payload['after'] = $state->offset;\n }\n\n return $payload;\n }\n\n private function shouldSwitchToKeysetPagination(PaginationState $state, int $resultsPerPage): bool\n {\n // Check if we've hit the offset limit\n $shouldSwitch = $state->requestCount > 0 && ($state->offset + $resultsPerPage) > PaginationConfig::TOTAL_QUERY_LIMIT;\n\n if ($shouldSwitch && $state->lastRecordId === null) {\n $this->logger->warning('[Hubspot] Cannot switch to keyset pagination: lastRecordId is null', [\n 'request_count' => $state->requestCount,\n 'current_offset' => $state->offset,\n 'results_per_page' => $resultsPerPage,\n 'total_query_limit' => PaginationConfig::TOTAL_QUERY_LIMIT,\n ]);\n\n return false; // Continue with offset pagination\n }\n\n return $shouldSwitch;\n }\n\n private function validateTokenIfNeeded(Client $client, PaginationState $state): void\n {\n if ($state->shouldValidateToken()) {\n $client->ensureValidToken();\n $state->updateLastTokenCheck();\n }\n }\n\n private function executeSearchRequest(Client $client, string $objectType, array $payload, PaginationState $state): array\n {\n try {\n return $client->search($objectType, $payload);\n } catch (\\Exception $e) {\n if ($client->isUnauthorizedException($e)) {\n $this->logger->warning('[Hubspot] Got 401 during pagination, attempting token refresh', [\n 'team_id' => $client->getConfig()->getTeam()->getId(),\n 'error' => $e->getMessage(),\n ]);\n\n $client->ensureValidToken();\n $state->updateLastTokenCheck();\n\n try {\n $result = $client->search($objectType, $payload);\n\n $this->logger->info('[Hubspot] Token refresh and retry successful', [\n 'team_id' => $client->getConfig()->getTeam()->getId(),\n ]);\n\n return $result;\n } catch (\\Exception $retryException) {\n $this->logger->error('[Hubspot] Retry request failed after token refresh', [\n 'team_id' => $client->getConfig()->getTeam()->getId(),\n 'original_error' => $e->getMessage(),\n 'retry_error' => $retryException->getMessage(),\n ]);\n\n throw $retryException;\n }\n }\n\n // RateLimitException and other exceptions are re-thrown as-is\n throw $e;\n }\n }\n\n private function updateLastRecordId(array $page, PaginationState $state): void\n {\n $lastRecord = ! empty($page['results']) ? end($page['results']) : null;\n $lastRecordId = $lastRecord['id'] ?? null;\n $state->updateLastRecordId($lastRecordId);\n }\n\n private function getNextOffset(array $page): int\n {\n return isset($page['paging']['next']['after']) ? (int) $page['paging']['next']['after'] : 0;\n }\n\n private function logPaginationProgress(PaginationState $state, int $teamId, string $endpoint): void\n {\n if ($state->shouldLogProgress()) {\n $this->logger->info('[Hubspot] Pagination progress log', [\n 'team_id' => $teamId,\n 'endpoint' => $endpoint,\n 'requests_made' => $state->requestCount,\n 'records_fetched' => $state->totalRecords,\n 'elapsed_seconds' => $state->getElapsedSeconds(),\n ]);\n }\n }\n\n private function calculateDelayInMicroseconds(): int\n {\n return (int) (1 / PaginationConfig::SEARCH_RPS_LIMIT * 1000000);\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Project","depth":3,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Project","depth":3,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"New File or Directory…","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Expand Selected","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Collapse All","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Options","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
-1447358068432733832
|
-1122008763557830165
|
click
|
accessibility
|
NULL
|
Project: faVsco.js, menu
JY-20725-handle-HS-search Project: faVsco.js, menu
JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
19
Previous Highlighted Error
Next Highlighted Error
[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {
"headers":{
"Date":["Thu,07 May 2026 14:21:15 GMT"],
"Content-Type":["application/json;charset=utf-8"],
"Transfer-Encoding":["chunked"],
"Connection":["keep-alive"],
"CF-Ray":["9f80deb8db60dc3a-SOF"],
"CF-Cache-Status":["DYNAMIC"],
"Strict-Transport-Security":["max-age=31536000; includeSubDomains; preload"],
"Vary":["origin,
accept-encoding"],
"access-control-allow-credentials":["false"],
"server-timing":["hcid;desc=\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\",
cfr;desc=\"9f80deb8e7c6dc3a-IAD\""],
"x-content-type-options":["nosniff"],
"x-hubspot-correlation-id":["019e02d0-6fd8-7812-bdba-885b7ccb3ee3"],
"Set-Cookie":["__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-[IP_ADDRESS]-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,
07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None"],
"Report-To":["{
\"endpoints\":[{
\"url\":\"https:\\/\\/a.nel.cloudflare.com\\/report\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\"}],
\"group\":\"cf-nel\",
\"max_age\":604800}"],
"NEL":["{
\"success_fraction\":0.01,
\"report_to\":\"cf-nel\",
\"max_age\":604800}"],
"Server":["cloudflare"]}} {
"correlation_id":"95236535-ec98-4541-b92a-adfa73b69eab",
"trace_id":"c7ab8365-903f-46d4-9403-0e5b551e3545"}
Sync Changes
Hide This Notification
Code changed:
Hide
12
Previous Highlighted Error
Next Highlighted Error
<?php
declare(strict_types=1);
namespace Jiminny\Services\Crm\Hubspot\Pagination;
use Jiminny\Services\Crm\Hubspot\Client;
use Jiminny\Services\Crm\Hubspot\PayloadBuilder;
use Psr\Log\LoggerInterface;
use SevenShores\Hubspot\Exceptions\BadRequest;
use SevenShores\Hubspot\Exceptions\HubspotException;
use Jiminny\Exceptions\SocialAccountTokenInvalidException;
class HubspotPaginationService
{
public function __construct(
private LoggerInterface $logger
) {
}
/**
* @throws HubspotException
* @throws SocialAccountTokenInvalidException
* @throws BadRequest
*/
public function getPaginatedDataGenerator(
Client $client,
array $payload,
string $type,
int $offset = 0,
int &$total = 0,
?string &$lastRecordId = null
): \Generator {
$state = new PaginationState(offset: $offset);
$endpoint = Client::BASE_URL . "/crm/v3/objects/{$type}/search";
$defaultFilter = $payload['filters'] ?? [];
$resultsPerPage = PayloadBuilder::MAX_SEARCH_REQUEST_LIMIT;
$teamId = $client->getConfig()->getTeam()->getId();
$delay = $this->calculateDelayInMicroseconds();
do {
if ($this->shouldStopPagination($state, $teamId)) {
break;
}
$payload = $this->handlePaginationStrategy($payload, $defaultFilter, $state, $resultsPerPage, $teamId);
$this->validateTokenIfNeeded($client, $state);
if ($state->requestCount > 0) {
usleep($delay);
}
$page = $this->executeSearchRequest($client, $type, $payload, $state);
$state->setTotal($page['total'] ?? 0);
$this->updateLastRecordId($page, $state);
// Safely iterate over results with null check
$results = $page['results'] ?? [];
foreach ($results as $row) {
$state->incrementTotalRecords();
yield $row;
}
$state->setOffset($this->getNextOffset($page));
$state->incrementRequestCount();
$this->logPaginationProgress($state, $teamId, $endpoint);
} while ($state->offset && ! empty($page['results']));
// Log final pagination completion stats
$this->logger->info('[Hubspot] Pagination completed', [
'team_id' => $teamId,
'endpoint' => $endpoint,
'total_requests' => $state->requestCount,
'total_records_fetched' => $state->totalRecords,
'total_elapsed_seconds' => round($state->getElapsedSeconds(), 2),
'average_seconds_per_request' => $state->requestCount > 0 ? round($state->getElapsedSeconds() / $state->requestCount, 2) : 0,
]);
// Update reference parameters
$total = $state->total;
$lastRecordId = $state->lastRecordId;
}
private function shouldStopPagination(PaginationState $state, int $teamId): bool
{
if ($state->hasReachedSafetyLimit()) {
$this->logger->warning('[Hubspot] Reached maximum request limit during pagination', [
'team_id' => $teamId,
'safety_limit' => PaginationConfig::LOOP_SAFETY_LIMIT,
'total_fetched' => $state->totalRecords,
]);
return true;
}
return false;
}
private function handlePaginationStrategy(
array $payload,
array $defaultFilter,
PaginationState $state,
int $resultsPerPage,
int $teamId
): array {
if ($this->shouldSwitchToKeysetPagination($state, $resultsPerPage)) {
$payload['filters'] = $defaultFilter;
$payload['filters'][] = [
'propertyName' => 'hs_object_id',
'operator' => 'LT',
'value' => $state->lastRecordId,
];
$this->logger->info('[Hubspot] Search keyset pagination request', [
'team_id' => $teamId,
'sequence' => $state->requestCount,
'itemsPerPage' => $resultsPerPage,
'payload' => $payload,
'total' => $state->total,
]);
unset($payload['after']);
$state->setOffset(0);
}
if ($state->offset) {
$payload['after'] = $state->offset;
}
return $payload;
}
private function shouldSwitchToKeysetPagination(PaginationState $state, int $resultsPerPage): bool
{
// Check if we've hit the offset limit
$shouldSwitch = $state->requestCount > 0 && ($state->offset + $resultsPerPage) > PaginationConfig::TOTAL_QUERY_LIMIT;
if ($shouldSwitch && $state->lastRecordId === null) {
$this->logger->warning('[Hubspot] Cannot switch to keyset pagination: lastRecordId is null', [
'request_count' => $state->requestCount,
'current_offset' => $state->offset,
'results_per_page' => $resultsPerPage,
'total_query_limit' => PaginationConfig::TOTAL_QUERY_LIMIT,
]);
return false; // Continue with offset pagination
}
return $shouldSwitch;
}
private function validateTokenIfNeeded(Client $client, PaginationState $state): void
{
if ($state->shouldValidateToken()) {
$client->ensureValidToken();
$state->updateLastTokenCheck();
}
}
private function executeSearchRequest(Client $client, string $objectType, array $payload, PaginationState $state): array
{
try {
return $client->search($objectType, $payload);
} catch (\Exception $e) {
if ($client->isUnauthorizedException($e)) {
$this->logger->warning('[Hubspot] Got 401 during pagination, attempting token refresh', [
'team_id' => $client->getConfig()->getTeam()->getId(),
'error' => $e->getMessage(),
]);
$client->ensureValidToken();
$state->updateLastTokenCheck();
try {
$result = $client->search($objectType, $payload);
$this->logger->info('[Hubspot] Token refresh and retry successful', [
'team_id' => $client->getConfig()->getTeam()->getId(),
]);
return $result;
} catch (\Exception $retryException) {
$this->logger->error('[Hubspot] Retry request failed after token refresh', [
'team_id' => $client->getConfig()->getTeam()->getId(),
'original_error' => $e->getMessage(),
'retry_error' => $retryException->getMessage(),
]);
throw $retryException;
}
}
// RateLimitException and other exceptions are re-thrown as-is
throw $e;
}
}
private function updateLastRecordId(array $page, PaginationState $state): void
{
$lastRecord = ! empty($page['results']) ? end($page['results']) : null;
$lastRecordId = $lastRecord['id'] ?? null;
$state->updateLastRecordId($lastRecordId);
}
private function getNextOffset(array $page): int
{
return isset($page['paging']['next']['after']) ? (int) $page['paging']['next']['after'] : 0;
}
private function logPaginationProgress(PaginationState $state, int $teamId, string $endpoint): void
{
if ($state->shouldLogProgress()) {
$this->logger->info('[Hubspot] Pagination progress log', [
'team_id' => $teamId,
'endpoint' => $endpoint,
'requests_made' => $state->requestCount,
'records_fetched' => $state->totalRecords,
'elapsed_seconds' => $state->getElapsedSeconds(),
]);
}
}
private function calculateDelayInMicroseconds(): int
{
return (int) (1 / PaginationConfig::SEARCH_RPS_LIMIT * 1000000);
}
}
Project
Project
New File or Directory…
Expand Selected
Collapse All
Options
Hide...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
9824
|
444
|
18
|
2026-05-08T13:36:21.829136+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-08/1778 /Users/lukas/.screenpipe/data/data/2026-05-08/1778247381829_m2.jpg...
|
PhpStorm
|
faVsco.js – HubspotPaginationService.php
|
1
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
JY-20725-handle-HS-search Project: faVsco.js, menu
JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
19
Previous Highlighted Error
Next Highlighted Error
[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {
"headers":{
"Date":["Thu,07 May 2026 14:21:15 GMT"],
"Content-Type":["application/json;charset=utf-8"],
"Transfer-Encoding":["chunked"],
"Connection":["keep-alive"],
"CF-Ray":["9f80deb8db60dc3a-SOF"],
"CF-Cache-Status":["DYNAMIC"],
"Strict-Transport-Security":["max-age=31536000; includeSubDomains; preload"],
"Vary":["origin,
accept-encoding"],
"access-control-allow-credentials":["false"],
"server-timing":["hcid;desc=\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\",
cfr;desc=\"9f80deb8e7c6dc3a-IAD\""],
"x-content-type-options":["nosniff"],
"x-hubspot-correlation-id":["019e02d0-6fd8-7812-bdba-885b7ccb3ee3"],
"Set-Cookie":["__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-[IP_ADDRESS]-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,
07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None"],
"Report-To":["{
\"endpoints\":[{
\"url\":\"https:\\/\\/a.nel.cloudflare.com\\/report\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\"}],
\"group\":\"cf-nel\",
\"max_age\":604800}"],
"NEL":["{
\"success_fraction\":0.01,
\"report_to\":\"cf-nel\",
\"max_age\":604800}"],
"Server":["cloudflare"]}} {
"correlation_id":"95236535-ec98-4541-b92a-adfa73b69eab",
"trace_id":"c7ab8365-903f-46d4-9403-0e5b551e3545"}
Sync Changes
Hide This Notification
Code changed:
Hide
12
Previous Highlighted Error
Next Highlighted Error
<?php
declare(strict_types=1);
namespace Jiminny\Services\Crm\Hubspot\Pagination;
use Jiminny\Services\Crm\Hubspot\Client;
use Jiminny\Services\Crm\Hubspot\PayloadBuilder;
use Psr\Log\LoggerInterface;
use SevenShores\Hubspot\Exceptions\BadRequest;
use SevenShores\Hubspot\Exceptions\HubspotException;
use Jiminny\Exceptions\SocialAccountTokenInvalidException;
class HubspotPaginationService
{
public function __construct(
private LoggerInterface $logger
) {
}
/**
* @throws HubspotException
* @throws SocialAccountTokenInvalidException
* @throws BadRequest
*/
public function getPaginatedDataGenerator(
Client $client,
array $payload,
string $type,
int $offset = 0,
int &$total = 0,
?string &$lastRecordId = null
): \Generator {
$state = new PaginationState(offset: $offset);
$endpoint = Client::BASE_URL . "/crm/v3/objects/{$type}/search";
$defaultFilter = $payload['filters'] ?? [];
$resultsPerPage = PayloadBuilder::MAX_SEARCH_REQUEST_LIMIT;
$teamId = $client->getConfig()->getTeam()->getId();
$delay = $this->calculateDelayInMicroseconds();
do {
if ($this->shouldStopPagination($state, $teamId)) {
break;
}
$payload = $this->handlePaginationStrategy($payload, $defaultFilter, $state, $resultsPerPage, $teamId);
$this->validateTokenIfNeeded($client, $state);
if ($state->requestCount > 0) {
usleep($delay);
}
$page = $this->executeSearchRequest($client, $type, $payload, $state);
$state->setTotal($page['total'] ?? 0);
$this->updateLastRecordId($page, $state);
// Safely iterate over results with null check
$results = $page['results'] ?? [];
foreach ($results as $row) {
$state->incrementTotalRecords();
yield $row;
}
$state->setOffset($this->getNextOffset($page));
$state->incrementRequestCount();
$this->logPaginationProgress($state, $teamId, $endpoint);
} while ($state->offset && ! empty($page['results']));
// Log final pagination completion stats
$this->logger->info('[Hubspot] Pagination completed', [
'team_id' => $teamId,
'endpoint' => $endpoint,
'total_requests' => $state->requestCount,
'total_records_fetched' => $state->totalRecords,
'total_elapsed_seconds' => round($state->getElapsedSeconds(), 2),
'average_seconds_per_request' => $state->requestCount > 0 ? round($state->getElapsedSeconds() / $state->requestCount, 2) : 0,
]);
// Update reference parameters
$total = $state->total;
$lastRecordId = $state->lastRecordId;
}
private function shouldStopPagination(PaginationState $state, int $teamId): bool
{
if ($state->hasReachedSafetyLimit()) {
$this->logger->warning('[Hubspot] Reached maximum request limit during pagination', [
'team_id' => $teamId,
'safety_limit' => PaginationConfig::LOOP_SAFETY_LIMIT,
'total_fetched' => $state->totalRecords,
]);
return true;
}
return false;
}
private function handlePaginationStrategy(
array $payload,
array $defaultFilter,
PaginationState $state,
int $resultsPerPage,
int $teamId
): array {
if ($this->shouldSwitchToKeysetPagination($state, $resultsPerPage)) {
$payload['filters'] = $defaultFilter;
$payload['filters'][] = [
'propertyName' => 'hs_object_id',
'operator' => 'LT',
'value' => $state->lastRecordId,
];
$this->logger->info('[Hubspot] Search keyset pagination request', [
'team_id' => $teamId,
'sequence' => $state->requestCount,
'itemsPerPage' => $resultsPerPage,
'payload' => $payload,
'total' => $state->total,
]);
unset($payload['after']);
$state->setOffset(0);
}
if ($state->offset) {
$payload['after'] = $state->offset;
}
return $payload;
}
private function shouldSwitchToKeysetPagination(PaginationState $state, int $resultsPerPage): bool
{
// Check if we've hit the offset limit
$shouldSwitch = $state->requestCount > 0 && ($state->offset + $resultsPerPage) > PaginationConfig::TOTAL_QUERY_LIMIT;
if ($shouldSwitch && $state->lastRecordId === null) {
$this->logger->warning('[Hubspot] Cannot switch to keyset pagination: lastRecordId is null', [
'request_count' => $state->requestCount,
'current_offset' => $state->offset,
'results_per_page' => $resultsPerPage,
'total_query_limit' => PaginationConfig::TOTAL_QUERY_LIMIT,
]);
return false; // Continue with offset pagination
}
return $shouldSwitch;
}
private function validateTokenIfNeeded(Client $client, PaginationState $state): void
{
if ($state->shouldValidateToken()) {
$client->ensureValidToken();
$state->updateLastTokenCheck();
}
}
private function executeSearchRequest(Client $client, string $objectType, array $payload, PaginationState $state): array
{
try {
return $client->search($objectType, $payload);
} catch (\Exception $e) {
if ($client->isUnauthorizedException($e)) {
$this->logger->warning('[Hubspot] Got 401 during pagination, attempting token refresh', [
'team_id' => $client->getConfig()->getTeam()->getId(),
'error' => $e->getMessage(),
]);
$client->ensureValidToken();
$state->updateLastTokenCheck();
try {
$result = $client->search($objectType, $payload);
$this->logger->info('[Hubspot] Token refresh and retry successful', [
'team_id' => $client->getConfig()->getTeam()->getId(),
]);
return $result;
} catch (\Exception $retryException) {
$this->logger->error('[Hubspot] Retry request failed after token refresh', [
'team_id' => $client->getConfig()->getTeam()->getId(),
'original_error' => $e->getMessage(),
'retry_error' => $retryException->getMessage(),
]);
throw $retryException;
}
}
// RateLimitException and other exceptions are re-thrown as-is
throw $e;
}
}
private function updateLastRecordId(array $page, PaginationState $state): void
{
$lastRecord = ! empty($page['results']) ? end($page['results']) : null;
$lastRecordId = $lastRecord['id'] ?? null;
$state->updateLastRecordId($lastRecordId);
}
private function getNextOffset(array $page): int
{
return isset($page['paging']['next']['after']) ? (int) $page['paging']['next']['after'] : 0;
}
private function logPaginationProgress(PaginationState $state, int $teamId, string $endpoint): void
{
if ($state->shouldLogProgress()) {
$this->logger->info('[Hubspot] Pagination progress log', [
'team_id' => $teamId,
'endpoint' => $endpoint,
'requests_made' => $state->requestCount,
'records_fetched' => $state->totalRecords,
'elapsed_seconds' => $state->getElapsedSeconds(),
]);
}
}
private function calculateDelayInMicroseconds(): int
{
return (int) (1 / PaginationConfig::SEARCH_RPS_LIMIT * 1000000);
}
}
Project
Project
New File or Directory…
Expand Selected
Collapse All
Options
Hide...
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"bounds":{"left":0.025930852,"top":0.019952115,"width":0.03856383,"height":0.025538707},"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JY-20725-handle-HS-search-rate-limit, menu","depth":5,"bounds":{"left":0.064494684,"top":0.019952115,"width":0.09541223,"height":0.025538707},"on_screen":true,"help_text":"Git Branch: JY-20725-handle-HS-search-rate-limit","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"bounds":{"left":0.8081782,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"AskJiminnyReportActivityServiceTest","depth":6,"bounds":{"left":0.8234708,"top":0.019952115,"width":0.09208777,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'AskJiminnyReportActivityServiceTest'","depth":6,"bounds":{"left":0.9155585,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'AskJiminnyReportActivityServiceTest'","depth":6,"bounds":{"left":0.9268617,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"bounds":{"left":0.9381649,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"bounds":{"left":0.96609044,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"bounds":{"left":0.9773936,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"IDE and Project Settings","depth":5,"bounds":{"left":0.9886968,"top":0.019952115,"width":0.011303186,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.042220745,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"19","depth":4,"bounds":{"left":0.6615692,"top":0.10055866,"width":0.009640957,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"bounds":{"left":0.67287236,"top":0.09896249,"width":0.00731383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"bounds":{"left":0.68018615,"top":0.09896249,"width":0.006981383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {\n\"headers\":{\n\"Date\":[\"Thu,07 May 2026 14:21:15 GMT\"],\n \"Content-Type\":[\"application/json;charset=utf-8\"],\n \"Transfer-Encoding\":[\"chunked\"],\n \"Connection\":[\"keep-alive\"],\n \"CF-Ray\":[\"9f80deb8db60dc3a-SOF\"],\n \"CF-Cache-Status\":[\"DYNAMIC\"],\n \"Strict-Transport-Security\":[\"max-age=31536000; includeSubDomains; preload\"],\n \"Vary\":[\"origin,\n accept-encoding\"],\n \"access-control-allow-credentials\":[\"false\"],\n \"server-timing\":[\"hcid;desc=\\\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\\\",\n cfr;desc=\\\"9f80deb8e7c6dc3a-IAD\\\"\"],\n \"x-content-type-options\":[\"nosniff\"],\n \"x-hubspot-correlation-id\":[\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\"],\n \"Set-Cookie\":[\"__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-1.0.1.1-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,\n 07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None\"],\n \"Report-To\":[\"{\n\\\"endpoints\\\":[{\n\\\"url\\\":\\\"https:\\\\/\\\\/a.nel.cloudflare.com\\\\/report\\\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\\\"}],\n\\\"group\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"NEL\":[\"{\n\\\"success_fraction\\\":0.01,\n\\\"report_to\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"Server\":[\"cloudflare\"]}} {\n\"correlation_id\":\"95236535-ec98-4541-b92a-adfa73b69eab\",\n\"trace_id\":\"c7ab8365-903f-46d4-9403-0e5b551e3545\"}","depth":4,"bounds":{"left":0.37632978,"top":0.09736632,"width":0.5728058,"height":0.8818835},"on_screen":true,"lines":[{"char_start":207,"char_count":30,"bounds":{"left":0.37632978,"top":0.0,"width":0.07513298,"height":0.014365523}},{"char_start":237,"char_count":36,"bounds":{"left":0.37632978,"top":0.0,"width":0.09075798,"height":0.014365523}},{"char_start":273,"char_count":32,"bounds":{"left":0.37632978,"top":0.0,"width":0.080119684,"height":0.014365523}},{"char_start":305,"char_count":79,"bounds":{"left":0.37632978,"top":0.0,"width":0.20212767,"height":0.014365523}},{"char_start":384,"char_count":18,"bounds":{"left":0.37632978,"top":0.0,"width":0.043882977,"height":0.014365523}},{"char_start":402,"char_count":21,"bounds":{"left":0.37632978,"top":0.0,"width":0.051861703,"height":0.014365523}},{"char_start":423,"char_count":48,"bounds":{"left":0.37632978,"top":0.008778931,"width":0.12167553,"height":0.014365523}},{"char_start":471,"char_count":72,"bounds":{"left":0.37632978,"top":0.026336791,"width":0.18384309,"height":0.014365523}},{"char_start":543,"char_count":40,"bounds":{"left":0.37632978,"top":0.043894652,"width":0.10106383,"height":0.014365523}},{"char_start":583,"char_count":41,"bounds":{"left":0.37632978,"top":0.061452515,"width":0.10372341,"height":0.014365523}},{"char_start":624,"char_count":72,"bounds":{"left":0.37632978,"top":0.079010375,"width":0.18384309,"height":0.014365523}},{"char_start":696,"char_count":219,"bounds":{"left":0.37632978,"top":0.096568234,"width":0.56515956,"height":0.014365523}},{"char_start":915,"char_count":83,"bounds":{"left":0.37632978,"top":0.11412609,"width":0.21243352,"height":0.014365523}},{"char_start":998,"char_count":20,"bounds":{"left":0.37632978,"top":0.13168396,"width":0.04920213,"height":0.014365523}},{"char_start":1018,"char_count":17,"bounds":{"left":0.37632978,"top":0.14924182,"width":0.041223403,"height":0.014365523}},{"char_start":1035,"char_count":203,"bounds":{"left":0.37632978,"top":0.16679968,"width":0.52360374,"height":0.014365523}},{"char_start":1238,"char_count":22,"bounds":{"left":0.37632978,"top":0.18435754,"width":0.05418883,"height":0.014365523}},{"char_start":1260,"char_count":23,"bounds":{"left":0.37632978,"top":0.2019154,"width":0.056848403,"height":0.014365523}},{"char_start":1283,"char_count":10,"bounds":{"left":0.37632978,"top":0.21947326,"width":0.023271276,"height":0.014365523}},{"char_start":1293,"char_count":27,"bounds":{"left":0.37632978,"top":0.23703113,"width":0.06715426,"height":0.014365523}},{"char_start":1320,"char_count":26,"bounds":{"left":0.37632978,"top":0.254589,"width":0.06482713,"height":0.014365523}},{"char_start":1346,"char_count":23,"bounds":{"left":0.37632978,"top":0.27214685,"width":0.056848403,"height":0.014365523}},{"char_start":1369,"char_count":28,"bounds":{"left":0.37632978,"top":0.2897047,"width":0.06981383,"height":0.014365523}},{"char_start":1397,"char_count":57,"bounds":{"left":0.37632978,"top":0.30726257,"width":0.14494681,"height":0.014365523}}],"value":"[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {\n\"headers\":{\n\"Date\":[\"Thu,07 May 2026 14:21:15 GMT\"],\n \"Content-Type\":[\"application/json;charset=utf-8\"],\n \"Transfer-Encoding\":[\"chunked\"],\n \"Connection\":[\"keep-alive\"],\n \"CF-Ray\":[\"9f80deb8db60dc3a-SOF\"],\n \"CF-Cache-Status\":[\"DYNAMIC\"],\n \"Strict-Transport-Security\":[\"max-age=31536000; includeSubDomains; preload\"],\n \"Vary\":[\"origin,\n accept-encoding\"],\n \"access-control-allow-credentials\":[\"false\"],\n \"server-timing\":[\"hcid;desc=\\\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\\\",\n cfr;desc=\\\"9f80deb8e7c6dc3a-IAD\\\"\"],\n \"x-content-type-options\":[\"nosniff\"],\n \"x-hubspot-correlation-id\":[\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\"],\n \"Set-Cookie\":[\"__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-1.0.1.1-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,\n 07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None\"],\n \"Report-To\":[\"{\n\\\"endpoints\\\":[{\n\\\"url\\\":\\\"https:\\\\/\\\\/a.nel.cloudflare.com\\\\/report\\\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\\\"}],\n\\\"group\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"NEL\":[\"{\n\\\"success_fraction\\\":0.01,\n\\\"report_to\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"Server\":[\"cloudflare\"]}} {\n\"correlation_id\":\"95236535-ec98-4541-b92a-adfa73b69eab\",\n\"trace_id\":\"c7ab8365-903f-46d4-9403-0e5b551e3545\"}","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.042220745,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"12","depth":4,"bounds":{"left":0.33144948,"top":0.19952115,"width":0.009640957,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"bounds":{"left":0.34275267,"top":0.19792499,"width":0.00731383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"bounds":{"left":0.35006648,"top":0.19792499,"width":0.006981383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\ndeclare(strict_types=1);\n\nnamespace Jiminny\\Services\\Crm\\Hubspot\\Pagination;\n\nuse Jiminny\\Services\\Crm\\Hubspot\\Client;\nuse Jiminny\\Services\\Crm\\Hubspot\\PayloadBuilder;\nuse Psr\\Log\\LoggerInterface;\nuse SevenShores\\Hubspot\\Exceptions\\BadRequest;\nuse SevenShores\\Hubspot\\Exceptions\\HubspotException;\nuse Jiminny\\Exceptions\\SocialAccountTokenInvalidException;\n\nclass HubspotPaginationService\n{\n public function __construct(\n private LoggerInterface $logger\n ) {\n }\n\n /**\n * @throws HubspotException\n * @throws SocialAccountTokenInvalidException\n * @throws BadRequest\n */\n public function getPaginatedDataGenerator(\n Client $client,\n array $payload,\n string $type,\n int $offset = 0,\n int &$total = 0,\n ?string &$lastRecordId = null\n ): \\Generator {\n $state = new PaginationState(offset: $offset);\n $endpoint = Client::BASE_URL . \"/crm/v3/objects/{$type}/search\";\n $defaultFilter = $payload['filters'] ?? [];\n $resultsPerPage = PayloadBuilder::MAX_SEARCH_REQUEST_LIMIT;\n $teamId = $client->getConfig()->getTeam()->getId();\n $delay = $this->calculateDelayInMicroseconds();\n\n do {\n if ($this->shouldStopPagination($state, $teamId)) {\n break;\n }\n\n $payload = $this->handlePaginationStrategy($payload, $defaultFilter, $state, $resultsPerPage, $teamId);\n\n $this->validateTokenIfNeeded($client, $state);\n if ($state->requestCount > 0) {\n usleep($delay);\n }\n\n $page = $this->executeSearchRequest($client, $type, $payload, $state);\n\n $state->setTotal($page['total'] ?? 0);\n $this->updateLastRecordId($page, $state);\n\n // Safely iterate over results with null check\n $results = $page['results'] ?? [];\n foreach ($results as $row) {\n $state->incrementTotalRecords();\n yield $row;\n }\n\n $state->setOffset($this->getNextOffset($page));\n $state->incrementRequestCount();\n\n $this->logPaginationProgress($state, $teamId, $endpoint);\n } while ($state->offset && ! empty($page['results']));\n\n // Log final pagination completion stats\n $this->logger->info('[Hubspot] Pagination completed', [\n 'team_id' => $teamId,\n 'endpoint' => $endpoint,\n 'total_requests' => $state->requestCount,\n 'total_records_fetched' => $state->totalRecords,\n 'total_elapsed_seconds' => round($state->getElapsedSeconds(), 2),\n 'average_seconds_per_request' => $state->requestCount > 0 ? round($state->getElapsedSeconds() / $state->requestCount, 2) : 0,\n ]);\n\n // Update reference parameters\n $total = $state->total;\n $lastRecordId = $state->lastRecordId;\n }\n\n private function shouldStopPagination(PaginationState $state, int $teamId): bool\n {\n if ($state->hasReachedSafetyLimit()) {\n $this->logger->warning('[Hubspot] Reached maximum request limit during pagination', [\n 'team_id' => $teamId,\n 'safety_limit' => PaginationConfig::LOOP_SAFETY_LIMIT,\n 'total_fetched' => $state->totalRecords,\n ]);\n\n return true;\n }\n\n return false;\n }\n\n private function handlePaginationStrategy(\n array $payload,\n array $defaultFilter,\n PaginationState $state,\n int $resultsPerPage,\n int $teamId\n ): array {\n if ($this->shouldSwitchToKeysetPagination($state, $resultsPerPage)) {\n $payload['filters'] = $defaultFilter;\n $payload['filters'][] = [\n 'propertyName' => 'hs_object_id',\n 'operator' => 'LT',\n 'value' => $state->lastRecordId,\n ];\n\n $this->logger->info('[Hubspot] Search keyset pagination request', [\n 'team_id' => $teamId,\n 'sequence' => $state->requestCount,\n 'itemsPerPage' => $resultsPerPage,\n 'payload' => $payload,\n 'total' => $state->total,\n ]);\n\n unset($payload['after']);\n $state->setOffset(0);\n }\n\n if ($state->offset) {\n $payload['after'] = $state->offset;\n }\n\n return $payload;\n }\n\n private function shouldSwitchToKeysetPagination(PaginationState $state, int $resultsPerPage): bool\n {\n // Check if we've hit the offset limit\n $shouldSwitch = $state->requestCount > 0 && ($state->offset + $resultsPerPage) > PaginationConfig::TOTAL_QUERY_LIMIT;\n\n if ($shouldSwitch && $state->lastRecordId === null) {\n $this->logger->warning('[Hubspot] Cannot switch to keyset pagination: lastRecordId is null', [\n 'request_count' => $state->requestCount,\n 'current_offset' => $state->offset,\n 'results_per_page' => $resultsPerPage,\n 'total_query_limit' => PaginationConfig::TOTAL_QUERY_LIMIT,\n ]);\n\n return false; // Continue with offset pagination\n }\n\n return $shouldSwitch;\n }\n\n private function validateTokenIfNeeded(Client $client, PaginationState $state): void\n {\n if ($state->shouldValidateToken()) {\n $client->ensureValidToken();\n $state->updateLastTokenCheck();\n }\n }\n\n private function executeSearchRequest(Client $client, string $objectType, array $payload, PaginationState $state): array\n {\n try {\n return $client->search($objectType, $payload);\n } catch (\\Exception $e) {\n if ($client->isUnauthorizedException($e)) {\n $this->logger->warning('[Hubspot] Got 401 during pagination, attempting token refresh', [\n 'team_id' => $client->getConfig()->getTeam()->getId(),\n 'error' => $e->getMessage(),\n ]);\n\n $client->ensureValidToken();\n $state->updateLastTokenCheck();\n\n try {\n $result = $client->search($objectType, $payload);\n\n $this->logger->info('[Hubspot] Token refresh and retry successful', [\n 'team_id' => $client->getConfig()->getTeam()->getId(),\n ]);\n\n return $result;\n } catch (\\Exception $retryException) {\n $this->logger->error('[Hubspot] Retry request failed after token refresh', [\n 'team_id' => $client->getConfig()->getTeam()->getId(),\n 'original_error' => $e->getMessage(),\n 'retry_error' => $retryException->getMessage(),\n ]);\n\n throw $retryException;\n }\n }\n\n // RateLimitException and other exceptions are re-thrown as-is\n throw $e;\n }\n }\n\n private function updateLastRecordId(array $page, PaginationState $state): void\n {\n $lastRecord = ! empty($page['results']) ? end($page['results']) : null;\n $lastRecordId = $lastRecord['id'] ?? null;\n $state->updateLastRecordId($lastRecordId);\n }\n\n private function getNextOffset(array $page): int\n {\n return isset($page['paging']['next']['after']) ? (int) $page['paging']['next']['after'] : 0;\n }\n\n private function logPaginationProgress(PaginationState $state, int $teamId, string $endpoint): void\n {\n if ($state->shouldLogProgress()) {\n $this->logger->info('[Hubspot] Pagination progress log', [\n 'team_id' => $teamId,\n 'endpoint' => $endpoint,\n 'requests_made' => $state->requestCount,\n 'records_fetched' => $state->totalRecords,\n 'elapsed_seconds' => $state->getElapsedSeconds(),\n ]);\n }\n }\n\n private function calculateDelayInMicroseconds(): int\n {\n return (int) (1 / PaginationConfig::SEARCH_RPS_LIMIT * 1000000);\n }\n}","depth":4,"bounds":{"left":0.122340426,"top":0.1963288,"width":0.39960107,"height":0.8036712},"on_screen":true,"value":"<?php\n\ndeclare(strict_types=1);\n\nnamespace Jiminny\\Services\\Crm\\Hubspot\\Pagination;\n\nuse Jiminny\\Services\\Crm\\Hubspot\\Client;\nuse Jiminny\\Services\\Crm\\Hubspot\\PayloadBuilder;\nuse Psr\\Log\\LoggerInterface;\nuse SevenShores\\Hubspot\\Exceptions\\BadRequest;\nuse SevenShores\\Hubspot\\Exceptions\\HubspotException;\nuse Jiminny\\Exceptions\\SocialAccountTokenInvalidException;\n\nclass HubspotPaginationService\n{\n public function __construct(\n private LoggerInterface $logger\n ) {\n }\n\n /**\n * @throws HubspotException\n * @throws SocialAccountTokenInvalidException\n * @throws BadRequest\n */\n public function getPaginatedDataGenerator(\n Client $client,\n array $payload,\n string $type,\n int $offset = 0,\n int &$total = 0,\n ?string &$lastRecordId = null\n ): \\Generator {\n $state = new PaginationState(offset: $offset);\n $endpoint = Client::BASE_URL . \"/crm/v3/objects/{$type}/search\";\n $defaultFilter = $payload['filters'] ?? [];\n $resultsPerPage = PayloadBuilder::MAX_SEARCH_REQUEST_LIMIT;\n $teamId = $client->getConfig()->getTeam()->getId();\n $delay = $this->calculateDelayInMicroseconds();\n\n do {\n if ($this->shouldStopPagination($state, $teamId)) {\n break;\n }\n\n $payload = $this->handlePaginationStrategy($payload, $defaultFilter, $state, $resultsPerPage, $teamId);\n\n $this->validateTokenIfNeeded($client, $state);\n if ($state->requestCount > 0) {\n usleep($delay);\n }\n\n $page = $this->executeSearchRequest($client, $type, $payload, $state);\n\n $state->setTotal($page['total'] ?? 0);\n $this->updateLastRecordId($page, $state);\n\n // Safely iterate over results with null check\n $results = $page['results'] ?? [];\n foreach ($results as $row) {\n $state->incrementTotalRecords();\n yield $row;\n }\n\n $state->setOffset($this->getNextOffset($page));\n $state->incrementRequestCount();\n\n $this->logPaginationProgress($state, $teamId, $endpoint);\n } while ($state->offset && ! empty($page['results']));\n\n // Log final pagination completion stats\n $this->logger->info('[Hubspot] Pagination completed', [\n 'team_id' => $teamId,\n 'endpoint' => $endpoint,\n 'total_requests' => $state->requestCount,\n 'total_records_fetched' => $state->totalRecords,\n 'total_elapsed_seconds' => round($state->getElapsedSeconds(), 2),\n 'average_seconds_per_request' => $state->requestCount > 0 ? round($state->getElapsedSeconds() / $state->requestCount, 2) : 0,\n ]);\n\n // Update reference parameters\n $total = $state->total;\n $lastRecordId = $state->lastRecordId;\n }\n\n private function shouldStopPagination(PaginationState $state, int $teamId): bool\n {\n if ($state->hasReachedSafetyLimit()) {\n $this->logger->warning('[Hubspot] Reached maximum request limit during pagination', [\n 'team_id' => $teamId,\n 'safety_limit' => PaginationConfig::LOOP_SAFETY_LIMIT,\n 'total_fetched' => $state->totalRecords,\n ]);\n\n return true;\n }\n\n return false;\n }\n\n private function handlePaginationStrategy(\n array $payload,\n array $defaultFilter,\n PaginationState $state,\n int $resultsPerPage,\n int $teamId\n ): array {\n if ($this->shouldSwitchToKeysetPagination($state, $resultsPerPage)) {\n $payload['filters'] = $defaultFilter;\n $payload['filters'][] = [\n 'propertyName' => 'hs_object_id',\n 'operator' => 'LT',\n 'value' => $state->lastRecordId,\n ];\n\n $this->logger->info('[Hubspot] Search keyset pagination request', [\n 'team_id' => $teamId,\n 'sequence' => $state->requestCount,\n 'itemsPerPage' => $resultsPerPage,\n 'payload' => $payload,\n 'total' => $state->total,\n ]);\n\n unset($payload['after']);\n $state->setOffset(0);\n }\n\n if ($state->offset) {\n $payload['after'] = $state->offset;\n }\n\n return $payload;\n }\n\n private function shouldSwitchToKeysetPagination(PaginationState $state, int $resultsPerPage): bool\n {\n // Check if we've hit the offset limit\n $shouldSwitch = $state->requestCount > 0 && ($state->offset + $resultsPerPage) > PaginationConfig::TOTAL_QUERY_LIMIT;\n\n if ($shouldSwitch && $state->lastRecordId === null) {\n $this->logger->warning('[Hubspot] Cannot switch to keyset pagination: lastRecordId is null', [\n 'request_count' => $state->requestCount,\n 'current_offset' => $state->offset,\n 'results_per_page' => $resultsPerPage,\n 'total_query_limit' => PaginationConfig::TOTAL_QUERY_LIMIT,\n ]);\n\n return false; // Continue with offset pagination\n }\n\n return $shouldSwitch;\n }\n\n private function validateTokenIfNeeded(Client $client, PaginationState $state): void\n {\n if ($state->shouldValidateToken()) {\n $client->ensureValidToken();\n $state->updateLastTokenCheck();\n }\n }\n\n private function executeSearchRequest(Client $client, string $objectType, array $payload, PaginationState $state): array\n {\n try {\n return $client->search($objectType, $payload);\n } catch (\\Exception $e) {\n if ($client->isUnauthorizedException($e)) {\n $this->logger->warning('[Hubspot] Got 401 during pagination, attempting token refresh', [\n 'team_id' => $client->getConfig()->getTeam()->getId(),\n 'error' => $e->getMessage(),\n ]);\n\n $client->ensureValidToken();\n $state->updateLastTokenCheck();\n\n try {\n $result = $client->search($objectType, $payload);\n\n $this->logger->info('[Hubspot] Token refresh and retry successful', [\n 'team_id' => $client->getConfig()->getTeam()->getId(),\n ]);\n\n return $result;\n } catch (\\Exception $retryException) {\n $this->logger->error('[Hubspot] Retry request failed after token refresh', [\n 'team_id' => $client->getConfig()->getTeam()->getId(),\n 'original_error' => $e->getMessage(),\n 'retry_error' => $retryException->getMessage(),\n ]);\n\n throw $retryException;\n }\n }\n\n // RateLimitException and other exceptions are re-thrown as-is\n throw $e;\n }\n }\n\n private function updateLastRecordId(array $page, PaginationState $state): void\n {\n $lastRecord = ! empty($page['results']) ? end($page['results']) : null;\n $lastRecordId = $lastRecord['id'] ?? null;\n $state->updateLastRecordId($lastRecordId);\n }\n\n private function getNextOffset(array $page): int\n {\n return isset($page['paging']['next']['after']) ? (int) $page['paging']['next']['after'] : 0;\n }\n\n private function logPaginationProgress(PaginationState $state, int $teamId, string $endpoint): void\n {\n if ($state->shouldLogProgress()) {\n $this->logger->info('[Hubspot] Pagination progress log', [\n 'team_id' => $teamId,\n 'endpoint' => $endpoint,\n 'requests_made' => $state->requestCount,\n 'records_fetched' => $state->totalRecords,\n 'elapsed_seconds' => $state->getElapsedSeconds(),\n ]);\n }\n }\n\n private function calculateDelayInMicroseconds(): int\n {\n return (int) (1 / PaginationConfig::SEARCH_RPS_LIMIT * 1000000);\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Project","depth":3,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Project","depth":3,"bounds":{"left":0.011968086,"top":0.047885075,"width":0.024268618,"height":0.024740623},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"New File or Directory…","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Expand Selected","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Collapse All","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Options","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
-1447358068432733832
|
-1122008763557830165
|
visual_change
|
accessibility
|
NULL
|
Project: faVsco.js, menu
JY-20725-handle-HS-search Project: faVsco.js, menu
JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
19
Previous Highlighted Error
Next Highlighted Error
[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {
"headers":{
"Date":["Thu,07 May 2026 14:21:15 GMT"],
"Content-Type":["application/json;charset=utf-8"],
"Transfer-Encoding":["chunked"],
"Connection":["keep-alive"],
"CF-Ray":["9f80deb8db60dc3a-SOF"],
"CF-Cache-Status":["DYNAMIC"],
"Strict-Transport-Security":["max-age=31536000; includeSubDomains; preload"],
"Vary":["origin,
accept-encoding"],
"access-control-allow-credentials":["false"],
"server-timing":["hcid;desc=\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\",
cfr;desc=\"9f80deb8e7c6dc3a-IAD\""],
"x-content-type-options":["nosniff"],
"x-hubspot-correlation-id":["019e02d0-6fd8-7812-bdba-885b7ccb3ee3"],
"Set-Cookie":["__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-[IP_ADDRESS]-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,
07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None"],
"Report-To":["{
\"endpoints\":[{
\"url\":\"https:\\/\\/a.nel.cloudflare.com\\/report\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\"}],
\"group\":\"cf-nel\",
\"max_age\":604800}"],
"NEL":["{
\"success_fraction\":0.01,
\"report_to\":\"cf-nel\",
\"max_age\":604800}"],
"Server":["cloudflare"]}} {
"correlation_id":"95236535-ec98-4541-b92a-adfa73b69eab",
"trace_id":"c7ab8365-903f-46d4-9403-0e5b551e3545"}
Sync Changes
Hide This Notification
Code changed:
Hide
12
Previous Highlighted Error
Next Highlighted Error
<?php
declare(strict_types=1);
namespace Jiminny\Services\Crm\Hubspot\Pagination;
use Jiminny\Services\Crm\Hubspot\Client;
use Jiminny\Services\Crm\Hubspot\PayloadBuilder;
use Psr\Log\LoggerInterface;
use SevenShores\Hubspot\Exceptions\BadRequest;
use SevenShores\Hubspot\Exceptions\HubspotException;
use Jiminny\Exceptions\SocialAccountTokenInvalidException;
class HubspotPaginationService
{
public function __construct(
private LoggerInterface $logger
) {
}
/**
* @throws HubspotException
* @throws SocialAccountTokenInvalidException
* @throws BadRequest
*/
public function getPaginatedDataGenerator(
Client $client,
array $payload,
string $type,
int $offset = 0,
int &$total = 0,
?string &$lastRecordId = null
): \Generator {
$state = new PaginationState(offset: $offset);
$endpoint = Client::BASE_URL . "/crm/v3/objects/{$type}/search";
$defaultFilter = $payload['filters'] ?? [];
$resultsPerPage = PayloadBuilder::MAX_SEARCH_REQUEST_LIMIT;
$teamId = $client->getConfig()->getTeam()->getId();
$delay = $this->calculateDelayInMicroseconds();
do {
if ($this->shouldStopPagination($state, $teamId)) {
break;
}
$payload = $this->handlePaginationStrategy($payload, $defaultFilter, $state, $resultsPerPage, $teamId);
$this->validateTokenIfNeeded($client, $state);
if ($state->requestCount > 0) {
usleep($delay);
}
$page = $this->executeSearchRequest($client, $type, $payload, $state);
$state->setTotal($page['total'] ?? 0);
$this->updateLastRecordId($page, $state);
// Safely iterate over results with null check
$results = $page['results'] ?? [];
foreach ($results as $row) {
$state->incrementTotalRecords();
yield $row;
}
$state->setOffset($this->getNextOffset($page));
$state->incrementRequestCount();
$this->logPaginationProgress($state, $teamId, $endpoint);
} while ($state->offset && ! empty($page['results']));
// Log final pagination completion stats
$this->logger->info('[Hubspot] Pagination completed', [
'team_id' => $teamId,
'endpoint' => $endpoint,
'total_requests' => $state->requestCount,
'total_records_fetched' => $state->totalRecords,
'total_elapsed_seconds' => round($state->getElapsedSeconds(), 2),
'average_seconds_per_request' => $state->requestCount > 0 ? round($state->getElapsedSeconds() / $state->requestCount, 2) : 0,
]);
// Update reference parameters
$total = $state->total;
$lastRecordId = $state->lastRecordId;
}
private function shouldStopPagination(PaginationState $state, int $teamId): bool
{
if ($state->hasReachedSafetyLimit()) {
$this->logger->warning('[Hubspot] Reached maximum request limit during pagination', [
'team_id' => $teamId,
'safety_limit' => PaginationConfig::LOOP_SAFETY_LIMIT,
'total_fetched' => $state->totalRecords,
]);
return true;
}
return false;
}
private function handlePaginationStrategy(
array $payload,
array $defaultFilter,
PaginationState $state,
int $resultsPerPage,
int $teamId
): array {
if ($this->shouldSwitchToKeysetPagination($state, $resultsPerPage)) {
$payload['filters'] = $defaultFilter;
$payload['filters'][] = [
'propertyName' => 'hs_object_id',
'operator' => 'LT',
'value' => $state->lastRecordId,
];
$this->logger->info('[Hubspot] Search keyset pagination request', [
'team_id' => $teamId,
'sequence' => $state->requestCount,
'itemsPerPage' => $resultsPerPage,
'payload' => $payload,
'total' => $state->total,
]);
unset($payload['after']);
$state->setOffset(0);
}
if ($state->offset) {
$payload['after'] = $state->offset;
}
return $payload;
}
private function shouldSwitchToKeysetPagination(PaginationState $state, int $resultsPerPage): bool
{
// Check if we've hit the offset limit
$shouldSwitch = $state->requestCount > 0 && ($state->offset + $resultsPerPage) > PaginationConfig::TOTAL_QUERY_LIMIT;
if ($shouldSwitch && $state->lastRecordId === null) {
$this->logger->warning('[Hubspot] Cannot switch to keyset pagination: lastRecordId is null', [
'request_count' => $state->requestCount,
'current_offset' => $state->offset,
'results_per_page' => $resultsPerPage,
'total_query_limit' => PaginationConfig::TOTAL_QUERY_LIMIT,
]);
return false; // Continue with offset pagination
}
return $shouldSwitch;
}
private function validateTokenIfNeeded(Client $client, PaginationState $state): void
{
if ($state->shouldValidateToken()) {
$client->ensureValidToken();
$state->updateLastTokenCheck();
}
}
private function executeSearchRequest(Client $client, string $objectType, array $payload, PaginationState $state): array
{
try {
return $client->search($objectType, $payload);
} catch (\Exception $e) {
if ($client->isUnauthorizedException($e)) {
$this->logger->warning('[Hubspot] Got 401 during pagination, attempting token refresh', [
'team_id' => $client->getConfig()->getTeam()->getId(),
'error' => $e->getMessage(),
]);
$client->ensureValidToken();
$state->updateLastTokenCheck();
try {
$result = $client->search($objectType, $payload);
$this->logger->info('[Hubspot] Token refresh and retry successful', [
'team_id' => $client->getConfig()->getTeam()->getId(),
]);
return $result;
} catch (\Exception $retryException) {
$this->logger->error('[Hubspot] Retry request failed after token refresh', [
'team_id' => $client->getConfig()->getTeam()->getId(),
'original_error' => $e->getMessage(),
'retry_error' => $retryException->getMessage(),
]);
throw $retryException;
}
}
// RateLimitException and other exceptions are re-thrown as-is
throw $e;
}
}
private function updateLastRecordId(array $page, PaginationState $state): void
{
$lastRecord = ! empty($page['results']) ? end($page['results']) : null;
$lastRecordId = $lastRecord['id'] ?? null;
$state->updateLastRecordId($lastRecordId);
}
private function getNextOffset(array $page): int
{
return isset($page['paging']['next']['after']) ? (int) $page['paging']['next']['after'] : 0;
}
private function logPaginationProgress(PaginationState $state, int $teamId, string $endpoint): void
{
if ($state->shouldLogProgress()) {
$this->logger->info('[Hubspot] Pagination progress log', [
'team_id' => $teamId,
'endpoint' => $endpoint,
'requests_made' => $state->requestCount,
'records_fetched' => $state->totalRecords,
'elapsed_seconds' => $state->getElapsedSeconds(),
]);
}
}
private function calculateDelayInMicroseconds(): int
{
return (int) (1 / PaginationConfig::SEARCH_RPS_LIMIT * 1000000);
}
}
Project
Project
New File or Directory…
Expand Selected
Collapse All
Options
Hide...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
9823
|
443
|
12
|
2026-05-08T13:36:03.283804+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-08/1778 /Users/lukas/.screenpipe/data/data/2026-05-08/1778247363283_m1.jpg...
|
PhpStorm
|
faVsco.js – HubspotPaginationService.php
|
1
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
JY-20725-handle-HS-search Project: faVsco.js, menu
JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
19
Previous Highlighted Error
Next Highlighted Error
[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {
"headers":{
"Date":["Thu,07 May 2026 14:21:15 GMT"],
"Content-Type":["application/json;charset=utf-8"],
"Transfer-Encoding":["chunked"],
"Connection":["keep-alive"],
"CF-Ray":["9f80deb8db60dc3a-SOF"],
"CF-Cache-Status":["DYNAMIC"],
"Strict-Transport-Security":["max-age=31536000; includeSubDomains; preload"],
"Vary":["origin,
accept-encoding"],
"access-control-allow-credentials":["false"],
"server-timing":["hcid;desc=\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\",
cfr;desc=\"9f80deb8e7c6dc3a-IAD\""],
"x-content-type-options":["nosniff"],
"x-hubspot-correlation-id":["019e02d0-6fd8-7812-bdba-885b7ccb3ee3"],
"Set-Cookie":["__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-[IP_ADDRESS]-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,
07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None"],
"Report-To":["{
\"endpoints\":[{
\"url\":\"https:\\/\\/a.nel.cloudflare.com\\/report\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\"}],
\"group\":\"cf-nel\",
\"max_age\":604800}"],
"NEL":["{
\"success_fraction\":0.01,
\"report_to\":\"cf-nel\",
\"max_age\":604800}"],
"Server":["cloudflare"]}} {
"correlation_id":"95236535-ec98-4541-b92a-adfa73b69eab",
"trace_id":"c7ab8365-903f-46d4-9403-0e5b551e3545"}
Sync Changes
Hide This Notification
Code changed:
Hide
13
Previous Highlighted Error
Next Highlighted Error
<?php
declare(strict_types=1);
namespace Jiminny\Services\Crm\Hubspot\Pagination;
use Jiminny\Exceptions\RateLimitException;
use Jiminny\Services\Crm\Hubspot\Client;
use Jiminny\Services\Crm\Hubspot\PayloadBuilder;
use Psr\Log\LoggerInterface;
use SevenShores\Hubspot\Exceptions\BadRequest;
use SevenShores\Hubspot\Exceptions\HubspotException;
use Jiminny\Exceptions\SocialAccountTokenInvalidException;
class HubspotPaginationService
{
public function __construct(
private LoggerInterface $logger
) {
}
/**
* @throws HubspotException
* @throws SocialAccountTokenInvalidException
* @throws BadRequest
*/
public function getPaginatedDataGenerator(
Client $client,
array $payload,
string $type,
int $offset = 0,
int &$total = 0,
?string &$lastRecordId = null
): \Generator {
$state = new PaginationState(offset: $offset);
$endpoint = Client::BASE_URL . "/crm/v3/objects/{$type}/search";
$defaultFilter = $payload['filters'] ?? [];
$resultsPerPage = PayloadBuilder::MAX_SEARCH_REQUEST_LIMIT;
$teamId = $client->getConfig()->getTeam()->getId();
$delay = $this->calculateDelayInMicroseconds();
do {
if ($this->shouldStopPagination($state, $teamId)) {
break;
}
$payload = $this->handlePaginationStrategy($payload, $defaultFilter, $state, $resultsPerPage, $teamId);
$this->validateTokenIfNeeded($client, $state);
if ($state->requestCount > 0) {
usleep($delay);
}
$page = $this->executeSearchRequest($client, $type, $payload, $state);
$state->setTotal($page['total'] ?? 0);
$this->updateLastRecordId($page, $state);
// Safely iterate over results with null check
$results = $page['results'] ?? [];
foreach ($results as $row) {
$state->incrementTotalRecords();
yield $row;
}
$state->setOffset($this->getNextOffset($page));
$state->incrementRequestCount();
$this->logPaginationProgress($state, $teamId, $endpoint);
} while ($state->offset && ! empty($page['results']));
// Log final pagination completion stats
$this->logger->info('[Hubspot] Pagination completed', [
'team_id' => $teamId,
'endpoint' => $endpoint,
'total_requests' => $state->requestCount,
'total_records_fetched' => $state->totalRecords,
'total_elapsed_seconds' => round($state->getElapsedSeconds(), 2),
'average_seconds_per_request' => $state->requestCount > 0 ? round($state->getElapsedSeconds() / $state->requestCount, 2) : 0,
]);
// Update reference parameters
$total = $state->total;
$lastRecordId = $state->lastRecordId;
}
private function shouldStopPagination(PaginationState $state, int $teamId): bool
{
if ($state->hasReachedSafetyLimit()) {
$this->logger->warning('[Hubspot] Reached maximum request limit during pagination', [
'team_id' => $teamId,
'safety_limit' => PaginationConfig::LOOP_SAFETY_LIMIT,
'total_fetched' => $state->totalRecords,
]);
return true;
}
return false;
}
private function handlePaginationStrategy(
array $payload,
array $defaultFilter,
PaginationState $state,
int $resultsPerPage,
int $teamId
): array {
if ($this->shouldSwitchToKeysetPagination($state, $resultsPerPage)) {
$payload['filters'] = $defaultFilter;
$payload['filters'][] = [
'propertyName' => 'hs_object_id',
'operator' => 'LT',
'value' => $state->lastRecordId,
];
$this->logger->info('[Hubspot] Search keyset pagination request', [
'team_id' => $teamId,
'sequence' => $state->requestCount,
'itemsPerPage' => $resultsPerPage,
'payload' => $payload,
'total' => $state->total,
]);
unset($payload['after']);
$state->setOffset(0);
}
if ($state->offset) {
$payload['after'] = $state->offset;
}
return $payload;
}
private function shouldSwitchToKeysetPagination(PaginationState $state, int $resultsPerPage): bool
{
// Check if we've hit the offset limit
$shouldSwitch = $state->requestCount > 0 && ($state->offset + $resultsPerPage) > PaginationConfig::TOTAL_QUERY_LIMIT;
if ($shouldSwitch && $state->lastRecordId === null) {
$this->logger->warning('[Hubspot] Cannot switch to keyset pagination: lastRecordId is null', [
'request_count' => $state->requestCount,
'current_offset' => $state->offset,
'results_per_page' => $resultsPerPage,
'total_query_limit' => PaginationConfig::TOTAL_QUERY_LIMIT,
]);
return false; // Continue with offset pagination
}
return $shouldSwitch;
}
private function validateTokenIfNeeded(Client $client, PaginationState $state): void
{
if ($state->shouldValidateToken()) {
$client->ensureValidToken();
$state->updateLastTokenCheck();
}
}
private function executeSearchRequest(Client $client, string $objectType, array $payload, PaginationState $state): array
{
try {
return $client->search($objectType, $payload);
} catch (\Exception $e) {
if ($client->isUnauthorizedException($e)) {
$this->logger->warning('[Hubspot] Got 401 during pagination, attempting token refresh', [
'team_id' => $client->getConfig()->getTeam()->getId(),
'error' => $e->getMessage(),
]);
$client->ensureValidToken();
$state->updateLastTokenCheck();
try {
$result = $client->search($objectType, $payload);
$this->logger->info('[Hubspot] Token refresh and retry successful', [
'team_id' => $client->getConfig()->getTeam()->getId(),
]);
return $result;
} catch (\Exception $retryException) {
$this->logger->error('[Hubspot] Retry request failed after token refresh', [
'team_id' => $client->getConfig()->getTeam()->getId(),
'original_error' => $e->getMessage(),
'retry_error' => $retryException->getMessage(),
]);
throw $retryException;
}
}
// RateLimitException and other exceptions are re-thrown as-is
throw $e;
}
}
private function updateLastRecordId(array $page, PaginationState $state): void
{
$lastRecord = ! empty($page['results']) ? end($page['results']) : null;
$lastRecordId = $lastRecord['id'] ?? null;
$state->updateLastRecordId($lastRecordId);
}
private function getNextOffset(array $page): int
{
return isset($page['paging']['next']['after']) ? (int) $page['paging']['next']['after'] : 0;
}
private function logPaginationProgress(PaginationState $state, int $teamId, string $endpoint): void
{
if ($state->shouldLogProgress()) {
$this->logger->info('[Hubspot] Pagination progress log', [
'team_id' => $teamId,
'endpoint' => $endpoint,
'requests_made' => $state->requestCount,
'records_fetched' => $state->totalRecords,
'elapsed_seconds' => $state->getElapsedSeconds(),
]);
}
}
private function calculateDelayInMicroseconds(): int
{
return (int) (1 / PaginationConfig::SEARCH_RPS_LIMIT * 1000000);
}
}
Project
Project
New File or Directory…
Expand Selected
Collapse All
Options
Hide...
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JY-20725-handle-HS-search-rate-limit, menu","depth":5,"on_screen":true,"help_text":"Git Branch: JY-20725-handle-HS-search-rate-limit","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"AskJiminnyReportActivityServiceTest","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'AskJiminnyReportActivityServiceTest'","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'AskJiminnyReportActivityServiceTest'","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"IDE and Project Settings","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.088194445,"height":0.027777778},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"19","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {\n\"headers\":{\n\"Date\":[\"Thu,07 May 2026 14:21:15 GMT\"],\n \"Content-Type\":[\"application/json;charset=utf-8\"],\n \"Transfer-Encoding\":[\"chunked\"],\n \"Connection\":[\"keep-alive\"],\n \"CF-Ray\":[\"9f80deb8db60dc3a-SOF\"],\n \"CF-Cache-Status\":[\"DYNAMIC\"],\n \"Strict-Transport-Security\":[\"max-age=31536000; includeSubDomains; preload\"],\n \"Vary\":[\"origin,\n accept-encoding\"],\n \"access-control-allow-credentials\":[\"false\"],\n \"server-timing\":[\"hcid;desc=\\\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\\\",\n cfr;desc=\\\"9f80deb8e7c6dc3a-IAD\\\"\"],\n \"x-content-type-options\":[\"nosniff\"],\n \"x-hubspot-correlation-id\":[\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\"],\n \"Set-Cookie\":[\"__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-1.0.1.1-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,\n 07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None\"],\n \"Report-To\":[\"{\n\\\"endpoints\\\":[{\n\\\"url\\\":\\\"https:\\\\/\\\\/a.nel.cloudflare.com\\\\/report\\\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\\\"}],\n\\\"group\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"NEL\":[\"{\n\\\"success_fraction\\\":0.01,\n\\\"report_to\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"Server\":[\"cloudflare\"]}} {\n\"correlation_id\":\"95236535-ec98-4541-b92a-adfa73b69eab\",\n\"trace_id\":\"c7ab8365-903f-46d4-9403-0e5b551e3545\"}","depth":4,"on_screen":true,"value":"[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {\n\"headers\":{\n\"Date\":[\"Thu,07 May 2026 14:21:15 GMT\"],\n \"Content-Type\":[\"application/json;charset=utf-8\"],\n \"Transfer-Encoding\":[\"chunked\"],\n \"Connection\":[\"keep-alive\"],\n \"CF-Ray\":[\"9f80deb8db60dc3a-SOF\"],\n \"CF-Cache-Status\":[\"DYNAMIC\"],\n \"Strict-Transport-Security\":[\"max-age=31536000; includeSubDomains; preload\"],\n \"Vary\":[\"origin,\n accept-encoding\"],\n \"access-control-allow-credentials\":[\"false\"],\n \"server-timing\":[\"hcid;desc=\\\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\\\",\n cfr;desc=\\\"9f80deb8e7c6dc3a-IAD\\\"\"],\n \"x-content-type-options\":[\"nosniff\"],\n \"x-hubspot-correlation-id\":[\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\"],\n \"Set-Cookie\":[\"__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-1.0.1.1-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,\n 07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None\"],\n \"Report-To\":[\"{\n\\\"endpoints\\\":[{\n\\\"url\\\":\\\"https:\\\\/\\\\/a.nel.cloudflare.com\\\\/report\\\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\\\"}],\n\\\"group\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"NEL\":[\"{\n\\\"success_fraction\\\":0.01,\n\\\"report_to\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"Server\":[\"cloudflare\"]}} {\n\"correlation_id\":\"95236535-ec98-4541-b92a-adfa73b69eab\",\n\"trace_id\":\"c7ab8365-903f-46d4-9403-0e5b551e3545\"}","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.088194445,"height":0.027777778},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"13","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\ndeclare(strict_types=1);\n\nnamespace Jiminny\\Services\\Crm\\Hubspot\\Pagination;\n\nuse Jiminny\\Exceptions\\RateLimitException;\nuse Jiminny\\Services\\Crm\\Hubspot\\Client;\nuse Jiminny\\Services\\Crm\\Hubspot\\PayloadBuilder;\nuse Psr\\Log\\LoggerInterface;\nuse SevenShores\\Hubspot\\Exceptions\\BadRequest;\nuse SevenShores\\Hubspot\\Exceptions\\HubspotException;\nuse Jiminny\\Exceptions\\SocialAccountTokenInvalidException;\n\nclass HubspotPaginationService\n{\n public function __construct(\n private LoggerInterface $logger\n ) {\n }\n\n /**\n * @throws HubspotException\n * @throws SocialAccountTokenInvalidException\n * @throws BadRequest\n */\n public function getPaginatedDataGenerator(\n Client $client,\n array $payload,\n string $type,\n int $offset = 0,\n int &$total = 0,\n ?string &$lastRecordId = null\n ): \\Generator {\n $state = new PaginationState(offset: $offset);\n $endpoint = Client::BASE_URL . \"/crm/v3/objects/{$type}/search\";\n $defaultFilter = $payload['filters'] ?? [];\n $resultsPerPage = PayloadBuilder::MAX_SEARCH_REQUEST_LIMIT;\n $teamId = $client->getConfig()->getTeam()->getId();\n $delay = $this->calculateDelayInMicroseconds();\n\n do {\n if ($this->shouldStopPagination($state, $teamId)) {\n break;\n }\n\n $payload = $this->handlePaginationStrategy($payload, $defaultFilter, $state, $resultsPerPage, $teamId);\n\n $this->validateTokenIfNeeded($client, $state);\n if ($state->requestCount > 0) {\n usleep($delay);\n }\n\n $page = $this->executeSearchRequest($client, $type, $payload, $state);\n\n $state->setTotal($page['total'] ?? 0);\n $this->updateLastRecordId($page, $state);\n\n // Safely iterate over results with null check\n $results = $page['results'] ?? [];\n foreach ($results as $row) {\n $state->incrementTotalRecords();\n yield $row;\n }\n\n $state->setOffset($this->getNextOffset($page));\n $state->incrementRequestCount();\n\n $this->logPaginationProgress($state, $teamId, $endpoint);\n } while ($state->offset && ! empty($page['results']));\n\n // Log final pagination completion stats\n $this->logger->info('[Hubspot] Pagination completed', [\n 'team_id' => $teamId,\n 'endpoint' => $endpoint,\n 'total_requests' => $state->requestCount,\n 'total_records_fetched' => $state->totalRecords,\n 'total_elapsed_seconds' => round($state->getElapsedSeconds(), 2),\n 'average_seconds_per_request' => $state->requestCount > 0 ? round($state->getElapsedSeconds() / $state->requestCount, 2) : 0,\n ]);\n\n // Update reference parameters\n $total = $state->total;\n $lastRecordId = $state->lastRecordId;\n }\n\n private function shouldStopPagination(PaginationState $state, int $teamId): bool\n {\n if ($state->hasReachedSafetyLimit()) {\n $this->logger->warning('[Hubspot] Reached maximum request limit during pagination', [\n 'team_id' => $teamId,\n 'safety_limit' => PaginationConfig::LOOP_SAFETY_LIMIT,\n 'total_fetched' => $state->totalRecords,\n ]);\n\n return true;\n }\n\n return false;\n }\n\n private function handlePaginationStrategy(\n array $payload,\n array $defaultFilter,\n PaginationState $state,\n int $resultsPerPage,\n int $teamId\n ): array {\n if ($this->shouldSwitchToKeysetPagination($state, $resultsPerPage)) {\n $payload['filters'] = $defaultFilter;\n $payload['filters'][] = [\n 'propertyName' => 'hs_object_id',\n 'operator' => 'LT',\n 'value' => $state->lastRecordId,\n ];\n\n $this->logger->info('[Hubspot] Search keyset pagination request', [\n 'team_id' => $teamId,\n 'sequence' => $state->requestCount,\n 'itemsPerPage' => $resultsPerPage,\n 'payload' => $payload,\n 'total' => $state->total,\n ]);\n\n unset($payload['after']);\n $state->setOffset(0);\n }\n\n if ($state->offset) {\n $payload['after'] = $state->offset;\n }\n\n return $payload;\n }\n\n private function shouldSwitchToKeysetPagination(PaginationState $state, int $resultsPerPage): bool\n {\n // Check if we've hit the offset limit\n $shouldSwitch = $state->requestCount > 0 && ($state->offset + $resultsPerPage) > PaginationConfig::TOTAL_QUERY_LIMIT;\n\n if ($shouldSwitch && $state->lastRecordId === null) {\n $this->logger->warning('[Hubspot] Cannot switch to keyset pagination: lastRecordId is null', [\n 'request_count' => $state->requestCount,\n 'current_offset' => $state->offset,\n 'results_per_page' => $resultsPerPage,\n 'total_query_limit' => PaginationConfig::TOTAL_QUERY_LIMIT,\n ]);\n\n return false; // Continue with offset pagination\n }\n\n return $shouldSwitch;\n }\n\n private function validateTokenIfNeeded(Client $client, PaginationState $state): void\n {\n if ($state->shouldValidateToken()) {\n $client->ensureValidToken();\n $state->updateLastTokenCheck();\n }\n }\n\n private function executeSearchRequest(Client $client, string $objectType, array $payload, PaginationState $state): array\n {\n try {\n return $client->search($objectType, $payload);\n } catch (\\Exception $e) {\n if ($client->isUnauthorizedException($e)) {\n $this->logger->warning('[Hubspot] Got 401 during pagination, attempting token refresh', [\n 'team_id' => $client->getConfig()->getTeam()->getId(),\n 'error' => $e->getMessage(),\n ]);\n\n $client->ensureValidToken();\n $state->updateLastTokenCheck();\n\n try {\n $result = $client->search($objectType, $payload);\n\n $this->logger->info('[Hubspot] Token refresh and retry successful', [\n 'team_id' => $client->getConfig()->getTeam()->getId(),\n ]);\n\n return $result;\n } catch (\\Exception $retryException) {\n $this->logger->error('[Hubspot] Retry request failed after token refresh', [\n 'team_id' => $client->getConfig()->getTeam()->getId(),\n 'original_error' => $e->getMessage(),\n 'retry_error' => $retryException->getMessage(),\n ]);\n\n throw $retryException;\n }\n }\n\n // RateLimitException and other exceptions are re-thrown as-is\n throw $e;\n }\n }\n\n private function updateLastRecordId(array $page, PaginationState $state): void\n {\n $lastRecord = ! empty($page['results']) ? end($page['results']) : null;\n $lastRecordId = $lastRecord['id'] ?? null;\n $state->updateLastRecordId($lastRecordId);\n }\n\n private function getNextOffset(array $page): int\n {\n return isset($page['paging']['next']['after']) ? (int) $page['paging']['next']['after'] : 0;\n }\n\n private function logPaginationProgress(PaginationState $state, int $teamId, string $endpoint): void\n {\n if ($state->shouldLogProgress()) {\n $this->logger->info('[Hubspot] Pagination progress log', [\n 'team_id' => $teamId,\n 'endpoint' => $endpoint,\n 'requests_made' => $state->requestCount,\n 'records_fetched' => $state->totalRecords,\n 'elapsed_seconds' => $state->getElapsedSeconds(),\n ]);\n }\n }\n\n private function calculateDelayInMicroseconds(): int\n {\n return (int) (1 / PaginationConfig::SEARCH_RPS_LIMIT * 1000000);\n }\n}","depth":4,"on_screen":true,"value":"<?php\n\ndeclare(strict_types=1);\n\nnamespace Jiminny\\Services\\Crm\\Hubspot\\Pagination;\n\nuse Jiminny\\Exceptions\\RateLimitException;\nuse Jiminny\\Services\\Crm\\Hubspot\\Client;\nuse Jiminny\\Services\\Crm\\Hubspot\\PayloadBuilder;\nuse Psr\\Log\\LoggerInterface;\nuse SevenShores\\Hubspot\\Exceptions\\BadRequest;\nuse SevenShores\\Hubspot\\Exceptions\\HubspotException;\nuse Jiminny\\Exceptions\\SocialAccountTokenInvalidException;\n\nclass HubspotPaginationService\n{\n public function __construct(\n private LoggerInterface $logger\n ) {\n }\n\n /**\n * @throws HubspotException\n * @throws SocialAccountTokenInvalidException\n * @throws BadRequest\n */\n public function getPaginatedDataGenerator(\n Client $client,\n array $payload,\n string $type,\n int $offset = 0,\n int &$total = 0,\n ?string &$lastRecordId = null\n ): \\Generator {\n $state = new PaginationState(offset: $offset);\n $endpoint = Client::BASE_URL . \"/crm/v3/objects/{$type}/search\";\n $defaultFilter = $payload['filters'] ?? [];\n $resultsPerPage = PayloadBuilder::MAX_SEARCH_REQUEST_LIMIT;\n $teamId = $client->getConfig()->getTeam()->getId();\n $delay = $this->calculateDelayInMicroseconds();\n\n do {\n if ($this->shouldStopPagination($state, $teamId)) {\n break;\n }\n\n $payload = $this->handlePaginationStrategy($payload, $defaultFilter, $state, $resultsPerPage, $teamId);\n\n $this->validateTokenIfNeeded($client, $state);\n if ($state->requestCount > 0) {\n usleep($delay);\n }\n\n $page = $this->executeSearchRequest($client, $type, $payload, $state);\n\n $state->setTotal($page['total'] ?? 0);\n $this->updateLastRecordId($page, $state);\n\n // Safely iterate over results with null check\n $results = $page['results'] ?? [];\n foreach ($results as $row) {\n $state->incrementTotalRecords();\n yield $row;\n }\n\n $state->setOffset($this->getNextOffset($page));\n $state->incrementRequestCount();\n\n $this->logPaginationProgress($state, $teamId, $endpoint);\n } while ($state->offset && ! empty($page['results']));\n\n // Log final pagination completion stats\n $this->logger->info('[Hubspot] Pagination completed', [\n 'team_id' => $teamId,\n 'endpoint' => $endpoint,\n 'total_requests' => $state->requestCount,\n 'total_records_fetched' => $state->totalRecords,\n 'total_elapsed_seconds' => round($state->getElapsedSeconds(), 2),\n 'average_seconds_per_request' => $state->requestCount > 0 ? round($state->getElapsedSeconds() / $state->requestCount, 2) : 0,\n ]);\n\n // Update reference parameters\n $total = $state->total;\n $lastRecordId = $state->lastRecordId;\n }\n\n private function shouldStopPagination(PaginationState $state, int $teamId): bool\n {\n if ($state->hasReachedSafetyLimit()) {\n $this->logger->warning('[Hubspot] Reached maximum request limit during pagination', [\n 'team_id' => $teamId,\n 'safety_limit' => PaginationConfig::LOOP_SAFETY_LIMIT,\n 'total_fetched' => $state->totalRecords,\n ]);\n\n return true;\n }\n\n return false;\n }\n\n private function handlePaginationStrategy(\n array $payload,\n array $defaultFilter,\n PaginationState $state,\n int $resultsPerPage,\n int $teamId\n ): array {\n if ($this->shouldSwitchToKeysetPagination($state, $resultsPerPage)) {\n $payload['filters'] = $defaultFilter;\n $payload['filters'][] = [\n 'propertyName' => 'hs_object_id',\n 'operator' => 'LT',\n 'value' => $state->lastRecordId,\n ];\n\n $this->logger->info('[Hubspot] Search keyset pagination request', [\n 'team_id' => $teamId,\n 'sequence' => $state->requestCount,\n 'itemsPerPage' => $resultsPerPage,\n 'payload' => $payload,\n 'total' => $state->total,\n ]);\n\n unset($payload['after']);\n $state->setOffset(0);\n }\n\n if ($state->offset) {\n $payload['after'] = $state->offset;\n }\n\n return $payload;\n }\n\n private function shouldSwitchToKeysetPagination(PaginationState $state, int $resultsPerPage): bool\n {\n // Check if we've hit the offset limit\n $shouldSwitch = $state->requestCount > 0 && ($state->offset + $resultsPerPage) > PaginationConfig::TOTAL_QUERY_LIMIT;\n\n if ($shouldSwitch && $state->lastRecordId === null) {\n $this->logger->warning('[Hubspot] Cannot switch to keyset pagination: lastRecordId is null', [\n 'request_count' => $state->requestCount,\n 'current_offset' => $state->offset,\n 'results_per_page' => $resultsPerPage,\n 'total_query_limit' => PaginationConfig::TOTAL_QUERY_LIMIT,\n ]);\n\n return false; // Continue with offset pagination\n }\n\n return $shouldSwitch;\n }\n\n private function validateTokenIfNeeded(Client $client, PaginationState $state): void\n {\n if ($state->shouldValidateToken()) {\n $client->ensureValidToken();\n $state->updateLastTokenCheck();\n }\n }\n\n private function executeSearchRequest(Client $client, string $objectType, array $payload, PaginationState $state): array\n {\n try {\n return $client->search($objectType, $payload);\n } catch (\\Exception $e) {\n if ($client->isUnauthorizedException($e)) {\n $this->logger->warning('[Hubspot] Got 401 during pagination, attempting token refresh', [\n 'team_id' => $client->getConfig()->getTeam()->getId(),\n 'error' => $e->getMessage(),\n ]);\n\n $client->ensureValidToken();\n $state->updateLastTokenCheck();\n\n try {\n $result = $client->search($objectType, $payload);\n\n $this->logger->info('[Hubspot] Token refresh and retry successful', [\n 'team_id' => $client->getConfig()->getTeam()->getId(),\n ]);\n\n return $result;\n } catch (\\Exception $retryException) {\n $this->logger->error('[Hubspot] Retry request failed after token refresh', [\n 'team_id' => $client->getConfig()->getTeam()->getId(),\n 'original_error' => $e->getMessage(),\n 'retry_error' => $retryException->getMessage(),\n ]);\n\n throw $retryException;\n }\n }\n\n // RateLimitException and other exceptions are re-thrown as-is\n throw $e;\n }\n }\n\n private function updateLastRecordId(array $page, PaginationState $state): void\n {\n $lastRecord = ! empty($page['results']) ? end($page['results']) : null;\n $lastRecordId = $lastRecord['id'] ?? null;\n $state->updateLastRecordId($lastRecordId);\n }\n\n private function getNextOffset(array $page): int\n {\n return isset($page['paging']['next']['after']) ? (int) $page['paging']['next']['after'] : 0;\n }\n\n private function logPaginationProgress(PaginationState $state, int $teamId, string $endpoint): void\n {\n if ($state->shouldLogProgress()) {\n $this->logger->info('[Hubspot] Pagination progress log', [\n 'team_id' => $teamId,\n 'endpoint' => $endpoint,\n 'requests_made' => $state->requestCount,\n 'records_fetched' => $state->totalRecords,\n 'elapsed_seconds' => $state->getElapsedSeconds(),\n ]);\n }\n }\n\n private function calculateDelayInMicroseconds(): int\n {\n return (int) (1 / PaginationConfig::SEARCH_RPS_LIMIT * 1000000);\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":true,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Project","depth":3,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Project","depth":3,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"New File or Directory…","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Expand Selected","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Collapse All","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Options","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
-8710375623998314330
|
-5589570795668856337
|
click
|
accessibility
|
NULL
|
Project: faVsco.js, menu
JY-20725-handle-HS-search Project: faVsco.js, menu
JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
19
Previous Highlighted Error
Next Highlighted Error
[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {
"headers":{
"Date":["Thu,07 May 2026 14:21:15 GMT"],
"Content-Type":["application/json;charset=utf-8"],
"Transfer-Encoding":["chunked"],
"Connection":["keep-alive"],
"CF-Ray":["9f80deb8db60dc3a-SOF"],
"CF-Cache-Status":["DYNAMIC"],
"Strict-Transport-Security":["max-age=31536000; includeSubDomains; preload"],
"Vary":["origin,
accept-encoding"],
"access-control-allow-credentials":["false"],
"server-timing":["hcid;desc=\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\",
cfr;desc=\"9f80deb8e7c6dc3a-IAD\""],
"x-content-type-options":["nosniff"],
"x-hubspot-correlation-id":["019e02d0-6fd8-7812-bdba-885b7ccb3ee3"],
"Set-Cookie":["__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-[IP_ADDRESS]-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,
07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None"],
"Report-To":["{
\"endpoints\":[{
\"url\":\"https:\\/\\/a.nel.cloudflare.com\\/report\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\"}],
\"group\":\"cf-nel\",
\"max_age\":604800}"],
"NEL":["{
\"success_fraction\":0.01,
\"report_to\":\"cf-nel\",
\"max_age\":604800}"],
"Server":["cloudflare"]}} {
"correlation_id":"95236535-ec98-4541-b92a-adfa73b69eab",
"trace_id":"c7ab8365-903f-46d4-9403-0e5b551e3545"}
Sync Changes
Hide This Notification
Code changed:
Hide
13
Previous Highlighted Error
Next Highlighted Error
<?php
declare(strict_types=1);
namespace Jiminny\Services\Crm\Hubspot\Pagination;
use Jiminny\Exceptions\RateLimitException;
use Jiminny\Services\Crm\Hubspot\Client;
use Jiminny\Services\Crm\Hubspot\PayloadBuilder;
use Psr\Log\LoggerInterface;
use SevenShores\Hubspot\Exceptions\BadRequest;
use SevenShores\Hubspot\Exceptions\HubspotException;
use Jiminny\Exceptions\SocialAccountTokenInvalidException;
class HubspotPaginationService
{
public function __construct(
private LoggerInterface $logger
) {
}
/**
* @throws HubspotException
* @throws SocialAccountTokenInvalidException
* @throws BadRequest
*/
public function getPaginatedDataGenerator(
Client $client,
array $payload,
string $type,
int $offset = 0,
int &$total = 0,
?string &$lastRecordId = null
): \Generator {
$state = new PaginationState(offset: $offset);
$endpoint = Client::BASE_URL . "/crm/v3/objects/{$type}/search";
$defaultFilter = $payload['filters'] ?? [];
$resultsPerPage = PayloadBuilder::MAX_SEARCH_REQUEST_LIMIT;
$teamId = $client->getConfig()->getTeam()->getId();
$delay = $this->calculateDelayInMicroseconds();
do {
if ($this->shouldStopPagination($state, $teamId)) {
break;
}
$payload = $this->handlePaginationStrategy($payload, $defaultFilter, $state, $resultsPerPage, $teamId);
$this->validateTokenIfNeeded($client, $state);
if ($state->requestCount > 0) {
usleep($delay);
}
$page = $this->executeSearchRequest($client, $type, $payload, $state);
$state->setTotal($page['total'] ?? 0);
$this->updateLastRecordId($page, $state);
// Safely iterate over results with null check
$results = $page['results'] ?? [];
foreach ($results as $row) {
$state->incrementTotalRecords();
yield $row;
}
$state->setOffset($this->getNextOffset($page));
$state->incrementRequestCount();
$this->logPaginationProgress($state, $teamId, $endpoint);
} while ($state->offset && ! empty($page['results']));
// Log final pagination completion stats
$this->logger->info('[Hubspot] Pagination completed', [
'team_id' => $teamId,
'endpoint' => $endpoint,
'total_requests' => $state->requestCount,
'total_records_fetched' => $state->totalRecords,
'total_elapsed_seconds' => round($state->getElapsedSeconds(), 2),
'average_seconds_per_request' => $state->requestCount > 0 ? round($state->getElapsedSeconds() / $state->requestCount, 2) : 0,
]);
// Update reference parameters
$total = $state->total;
$lastRecordId = $state->lastRecordId;
}
private function shouldStopPagination(PaginationState $state, int $teamId): bool
{
if ($state->hasReachedSafetyLimit()) {
$this->logger->warning('[Hubspot] Reached maximum request limit during pagination', [
'team_id' => $teamId,
'safety_limit' => PaginationConfig::LOOP_SAFETY_LIMIT,
'total_fetched' => $state->totalRecords,
]);
return true;
}
return false;
}
private function handlePaginationStrategy(
array $payload,
array $defaultFilter,
PaginationState $state,
int $resultsPerPage,
int $teamId
): array {
if ($this->shouldSwitchToKeysetPagination($state, $resultsPerPage)) {
$payload['filters'] = $defaultFilter;
$payload['filters'][] = [
'propertyName' => 'hs_object_id',
'operator' => 'LT',
'value' => $state->lastRecordId,
];
$this->logger->info('[Hubspot] Search keyset pagination request', [
'team_id' => $teamId,
'sequence' => $state->requestCount,
'itemsPerPage' => $resultsPerPage,
'payload' => $payload,
'total' => $state->total,
]);
unset($payload['after']);
$state->setOffset(0);
}
if ($state->offset) {
$payload['after'] = $state->offset;
}
return $payload;
}
private function shouldSwitchToKeysetPagination(PaginationState $state, int $resultsPerPage): bool
{
// Check if we've hit the offset limit
$shouldSwitch = $state->requestCount > 0 && ($state->offset + $resultsPerPage) > PaginationConfig::TOTAL_QUERY_LIMIT;
if ($shouldSwitch && $state->lastRecordId === null) {
$this->logger->warning('[Hubspot] Cannot switch to keyset pagination: lastRecordId is null', [
'request_count' => $state->requestCount,
'current_offset' => $state->offset,
'results_per_page' => $resultsPerPage,
'total_query_limit' => PaginationConfig::TOTAL_QUERY_LIMIT,
]);
return false; // Continue with offset pagination
}
return $shouldSwitch;
}
private function validateTokenIfNeeded(Client $client, PaginationState $state): void
{
if ($state->shouldValidateToken()) {
$client->ensureValidToken();
$state->updateLastTokenCheck();
}
}
private function executeSearchRequest(Client $client, string $objectType, array $payload, PaginationState $state): array
{
try {
return $client->search($objectType, $payload);
} catch (\Exception $e) {
if ($client->isUnauthorizedException($e)) {
$this->logger->warning('[Hubspot] Got 401 during pagination, attempting token refresh', [
'team_id' => $client->getConfig()->getTeam()->getId(),
'error' => $e->getMessage(),
]);
$client->ensureValidToken();
$state->updateLastTokenCheck();
try {
$result = $client->search($objectType, $payload);
$this->logger->info('[Hubspot] Token refresh and retry successful', [
'team_id' => $client->getConfig()->getTeam()->getId(),
]);
return $result;
} catch (\Exception $retryException) {
$this->logger->error('[Hubspot] Retry request failed after token refresh', [
'team_id' => $client->getConfig()->getTeam()->getId(),
'original_error' => $e->getMessage(),
'retry_error' => $retryException->getMessage(),
]);
throw $retryException;
}
}
// RateLimitException and other exceptions are re-thrown as-is
throw $e;
}
}
private function updateLastRecordId(array $page, PaginationState $state): void
{
$lastRecord = ! empty($page['results']) ? end($page['results']) : null;
$lastRecordId = $lastRecord['id'] ?? null;
$state->updateLastRecordId($lastRecordId);
}
private function getNextOffset(array $page): int
{
return isset($page['paging']['next']['after']) ? (int) $page['paging']['next']['after'] : 0;
}
private function logPaginationProgress(PaginationState $state, int $teamId, string $endpoint): void
{
if ($state->shouldLogProgress()) {
$this->logger->info('[Hubspot] Pagination progress log', [
'team_id' => $teamId,
'endpoint' => $endpoint,
'requests_made' => $state->requestCount,
'records_fetched' => $state->totalRecords,
'elapsed_seconds' => $state->getElapsedSeconds(),
]);
}
}
private function calculateDelayInMicroseconds(): int
{
return (int) (1 / PaginationConfig::SEARCH_RPS_LIMIT * 1000000);
}
}
Project
Project
New File or Directory…
Expand Selected
Collapse All
Options
Hide...
|
9821
|
NULL
|
NULL
|
NULL
|
|
9822
|
444
|
17
|
2026-05-08T13:36:03.181821+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-08/1778 /Users/lukas/.screenpipe/data/data/2026-05-08/1778247363181_m2.jpg...
|
PhpStorm
|
faVsco.js – HubspotPaginationService.php
|
1
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
JY-20725-handle-HS-search Project: faVsco.js, menu
JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
19
Previous Highlighted Error
Next Highlighted Error
[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {
"headers":{
"Date":["Thu,07 May 2026 14:21:15 GMT"],
"Content-Type":["application/json;charset=utf-8"],
"Transfer-Encoding":["chunked"],
"Connection":["keep-alive"],
"CF-Ray":["9f80deb8db60dc3a-SOF"],
"CF-Cache-Status":["DYNAMIC"],
"Strict-Transport-Security":["max-age=31536000; includeSubDomains; preload"],
"Vary":["origin,
accept-encoding"],
"access-control-allow-credentials":["false"],
"server-timing":["hcid;desc=\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\",
cfr;desc=\"9f80deb8e7c6dc3a-IAD\""],
"x-content-type-options":["nosniff"],
"x-hubspot-correlation-id":["019e02d0-6fd8-7812-bdba-885b7ccb3ee3"],
"Set-Cookie":["__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-[IP_ADDRESS]-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,
07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None"],
"Report-To":["{
\"endpoints\":[{
\"url\":\"https:\\/\\/a.nel.cloudflare.com\\/report\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\"}],
\"group\":\"cf-nel\",
\"max_age\":604800}"],
"NEL":["{
\"success_fraction\":0.01,
\"report_to\":\"cf-nel\",
\"max_age\":604800}"],
"Server":["cloudflare"]}} {
"correlation_id":"95236535-ec98-4541-b92a-adfa73b69eab",
"trace_id":"c7ab8365-903f-46d4-9403-0e5b551e3545"}
Sync Changes
Hide This Notification
Code changed:
Hide
13
Previous Highlighted Error
Next Highlighted Error
<?php
declare(strict_types=1);
namespace Jiminny\Services\Crm\Hubspot\Pagination;
use Jiminny\Exceptions\RateLimitException;
use Jiminny\Services\Crm\Hubspot\Client;
use Jiminny\Services\Crm\Hubspot\PayloadBuilder;
use Psr\Log\LoggerInterface;
use SevenShores\Hubspot\Exceptions\BadRequest;
use SevenShores\Hubspot\Exceptions\HubspotException;
use Jiminny\Exceptions\SocialAccountTokenInvalidException;
class HubspotPaginationService
{
public function __construct(
private LoggerInterface $logger
) {
}
/**
* @throws HubspotException
* @throws SocialAccountTokenInvalidException
* @throws BadRequest
*/
public function getPaginatedDataGenerator(
Client $client,
array $payload,
string $type,
int $offset = 0,
int &$total = 0,
?string &$lastRecordId = null
): \Generator {
$state = new PaginationState(offset: $offset);
$endpoint = Client::BASE_URL . "/crm/v3/objects/{$type}/search";
$defaultFilter = $payload['filters'] ?? [];
$resultsPerPage = PayloadBuilder::MAX_SEARCH_REQUEST_LIMIT;
$teamId = $client->getConfig()->getTeam()->getId();
$delay = $this->calculateDelayInMicroseconds();
do {
if ($this->shouldStopPagination($state, $teamId)) {
break;
}
$payload = $this->handlePaginationStrategy($payload, $defaultFilter, $state, $resultsPerPage, $teamId);
$this->validateTokenIfNeeded($client, $state);
if ($state->requestCount > 0) {
usleep($delay);
}
$page = $this->executeSearchRequest($client, $type, $payload, $state);
$state->setTotal($page['total'] ?? 0);
$this->updateLastRecordId($page, $state);
// Safely iterate over results with null check
$results = $page['results'] ?? [];
foreach ($results as $row) {
$state->incrementTotalRecords();
yield $row;
}
$state->setOffset($this->getNextOffset($page));
$state->incrementRequestCount();
$this->logPaginationProgress($state, $teamId, $endpoint);
} while ($state->offset && ! empty($page['results']));
// Log final pagination completion stats
$this->logger->info('[Hubspot] Pagination completed', [
'team_id' => $teamId,
'endpoint' => $endpoint,
'total_requests' => $state->requestCount,
'total_records_fetched' => $state->totalRecords,
'total_elapsed_seconds' => round($state->getElapsedSeconds(), 2),
'average_seconds_per_request' => $state->requestCount > 0 ? round($state->getElapsedSeconds() / $state->requestCount, 2) : 0,
]);
// Update reference parameters
$total = $state->total;
$lastRecordId = $state->lastRecordId;
}
private function shouldStopPagination(PaginationState $state, int $teamId): bool
{
if ($state->hasReachedSafetyLimit()) {
$this->logger->warning('[Hubspot] Reached maximum request limit during pagination', [
'team_id' => $teamId,
'safety_limit' => PaginationConfig::LOOP_SAFETY_LIMIT,
'total_fetched' => $state->totalRecords,
]);
return true;
}
return false;
}
private function handlePaginationStrategy(
array $payload,
array $defaultFilter,
PaginationState $state,
int $resultsPerPage,
int $teamId
): array {
if ($this->shouldSwitchToKeysetPagination($state, $resultsPerPage)) {
$payload['filters'] = $defaultFilter;
$payload['filters'][] = [
'propertyName' => 'hs_object_id',
'operator' => 'LT',
'value' => $state->lastRecordId,
];
$this->logger->info('[Hubspot] Search keyset pagination request', [
'team_id' => $teamId,
'sequence' => $state->requestCount,
'itemsPerPage' => $resultsPerPage,
'payload' => $payload,
'total' => $state->total,
]);
unset($payload['after']);
$state->setOffset(0);
}
if ($state->offset) {
$payload['after'] = $state->offset;
}
return $payload;
}
private function shouldSwitchToKeysetPagination(PaginationState $state, int $resultsPerPage): bool
{
// Check if we've hit the offset limit
$shouldSwitch = $state->requestCount > 0 && ($state->offset + $resultsPerPage) > PaginationConfig::TOTAL_QUERY_LIMIT;
if ($shouldSwitch && $state->lastRecordId === null) {
$this->logger->warning('[Hubspot] Cannot switch to keyset pagination: lastRecordId is null', [
'request_count' => $state->requestCount,
'current_offset' => $state->offset,
'results_per_page' => $resultsPerPage,
'total_query_limit' => PaginationConfig::TOTAL_QUERY_LIMIT,
]);
return false; // Continue with offset pagination
}
return $shouldSwitch;
}
private function validateTokenIfNeeded(Client $client, PaginationState $state): void
{
if ($state->shouldValidateToken()) {
$client->ensureValidToken();
$state->updateLastTokenCheck();
}
}
private function executeSearchRequest(Client $client, string $objectType, array $payload, PaginationState $state): array
{
try {
return $client->search($objectType, $payload);
} catch (\Exception $e) {
if ($client->isUnauthorizedException($e)) {
$this->logger->warning('[Hubspot] Got 401 during pagination, attempting token refresh', [
'team_id' => $client->getConfig()->getTeam()->getId(),
'error' => $e->getMessage(),
]);
$client->ensureValidToken();
$state->updateLastTokenCheck();
try {
$result = $client->search($objectType, $payload);
$this->logger->info('[Hubspot] Token refresh and retry successful', [
'team_id' => $client->getConfig()->getTeam()->getId(),
]);
return $result;
} catch (\Exception $retryException) {
$this->logger->error('[Hubspot] Retry request failed after token refresh', [
'team_id' => $client->getConfig()->getTeam()->getId(),
'original_error' => $e->getMessage(),
'retry_error' => $retryException->getMessage(),
]);
throw $retryException;
}
}
// RateLimitException and other exceptions are re-thrown as-is
throw $e;
}
}
private function updateLastRecordId(array $page, PaginationState $state): void
{
$lastRecord = ! empty($page['results']) ? end($page['results']) : null;
$lastRecordId = $lastRecord['id'] ?? null;
$state->updateLastRecordId($lastRecordId);
}
private function getNextOffset(array $page): int
{
return isset($page['paging']['next']['after']) ? (int) $page['paging']['next']['after'] : 0;
}
private function logPaginationProgress(PaginationState $state, int $teamId, string $endpoint): void
{
if ($state->shouldLogProgress()) {
$this->logger->info('[Hubspot] Pagination progress log', [
'team_id' => $teamId,
'endpoint' => $endpoint,
'requests_made' => $state->requestCount,
'records_fetched' => $state->totalRecords,
'elapsed_seconds' => $state->getElapsedSeconds(),
]);
}
}
private function calculateDelayInMicroseconds(): int
{
return (int) (1 / PaginationConfig::SEARCH_RPS_LIMIT * 1000000);
}
}
Project
Project
New File or Directory…
Expand Selected
Collapse All
Options
Hide...
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"bounds":{"left":0.025930852,"top":0.019952115,"width":0.03856383,"height":0.025538707},"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JY-20725-handle-HS-search-rate-limit, menu","depth":5,"bounds":{"left":0.064494684,"top":0.019952115,"width":0.09541223,"height":0.025538707},"on_screen":true,"help_text":"Git Branch: JY-20725-handle-HS-search-rate-limit","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"bounds":{"left":0.8081782,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"AskJiminnyReportActivityServiceTest","depth":6,"bounds":{"left":0.8234708,"top":0.019952115,"width":0.09208777,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'AskJiminnyReportActivityServiceTest'","depth":6,"bounds":{"left":0.9155585,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'AskJiminnyReportActivityServiceTest'","depth":6,"bounds":{"left":0.9268617,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"bounds":{"left":0.9381649,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"bounds":{"left":0.96609044,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"bounds":{"left":0.9773936,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"IDE and Project Settings","depth":5,"bounds":{"left":0.9886968,"top":0.019952115,"width":0.011303186,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.042220745,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"19","depth":4,"bounds":{"left":0.6615692,"top":0.10055866,"width":0.009640957,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"bounds":{"left":0.67287236,"top":0.09896249,"width":0.00731383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"bounds":{"left":0.68018615,"top":0.09896249,"width":0.006981383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {\n\"headers\":{\n\"Date\":[\"Thu,07 May 2026 14:21:15 GMT\"],\n \"Content-Type\":[\"application/json;charset=utf-8\"],\n \"Transfer-Encoding\":[\"chunked\"],\n \"Connection\":[\"keep-alive\"],\n \"CF-Ray\":[\"9f80deb8db60dc3a-SOF\"],\n \"CF-Cache-Status\":[\"DYNAMIC\"],\n \"Strict-Transport-Security\":[\"max-age=31536000; includeSubDomains; preload\"],\n \"Vary\":[\"origin,\n accept-encoding\"],\n \"access-control-allow-credentials\":[\"false\"],\n \"server-timing\":[\"hcid;desc=\\\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\\\",\n cfr;desc=\\\"9f80deb8e7c6dc3a-IAD\\\"\"],\n \"x-content-type-options\":[\"nosniff\"],\n \"x-hubspot-correlation-id\":[\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\"],\n \"Set-Cookie\":[\"__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-1.0.1.1-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,\n 07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None\"],\n \"Report-To\":[\"{\n\\\"endpoints\\\":[{\n\\\"url\\\":\\\"https:\\\\/\\\\/a.nel.cloudflare.com\\\\/report\\\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\\\"}],\n\\\"group\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"NEL\":[\"{\n\\\"success_fraction\\\":0.01,\n\\\"report_to\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"Server\":[\"cloudflare\"]}} {\n\"correlation_id\":\"95236535-ec98-4541-b92a-adfa73b69eab\",\n\"trace_id\":\"c7ab8365-903f-46d4-9403-0e5b551e3545\"}","depth":4,"bounds":{"left":0.37632978,"top":0.09736632,"width":0.5728058,"height":0.8818835},"on_screen":true,"lines":[{"char_start":207,"char_count":30,"bounds":{"left":0.37632978,"top":0.0,"width":0.07513298,"height":0.014365523}},{"char_start":237,"char_count":36,"bounds":{"left":0.37632978,"top":0.0,"width":0.09075798,"height":0.014365523}},{"char_start":273,"char_count":32,"bounds":{"left":0.37632978,"top":0.0,"width":0.080119684,"height":0.014365523}},{"char_start":305,"char_count":79,"bounds":{"left":0.37632978,"top":0.0,"width":0.20212767,"height":0.014365523}},{"char_start":384,"char_count":18,"bounds":{"left":0.37632978,"top":0.0,"width":0.043882977,"height":0.014365523}},{"char_start":402,"char_count":21,"bounds":{"left":0.37632978,"top":0.0,"width":0.051861703,"height":0.014365523}},{"char_start":423,"char_count":48,"bounds":{"left":0.37632978,"top":0.008778931,"width":0.12167553,"height":0.014365523}},{"char_start":471,"char_count":72,"bounds":{"left":0.37632978,"top":0.026336791,"width":0.18384309,"height":0.014365523}},{"char_start":543,"char_count":40,"bounds":{"left":0.37632978,"top":0.043894652,"width":0.10106383,"height":0.014365523}},{"char_start":583,"char_count":41,"bounds":{"left":0.37632978,"top":0.061452515,"width":0.10372341,"height":0.014365523}},{"char_start":624,"char_count":72,"bounds":{"left":0.37632978,"top":0.079010375,"width":0.18384309,"height":0.014365523}},{"char_start":696,"char_count":219,"bounds":{"left":0.37632978,"top":0.096568234,"width":0.56515956,"height":0.014365523}},{"char_start":915,"char_count":83,"bounds":{"left":0.37632978,"top":0.11412609,"width":0.21243352,"height":0.014365523}},{"char_start":998,"char_count":20,"bounds":{"left":0.37632978,"top":0.13168396,"width":0.04920213,"height":0.014365523}},{"char_start":1018,"char_count":17,"bounds":{"left":0.37632978,"top":0.14924182,"width":0.041223403,"height":0.014365523}},{"char_start":1035,"char_count":203,"bounds":{"left":0.37632978,"top":0.16679968,"width":0.52360374,"height":0.014365523}},{"char_start":1238,"char_count":22,"bounds":{"left":0.37632978,"top":0.18435754,"width":0.05418883,"height":0.014365523}},{"char_start":1260,"char_count":23,"bounds":{"left":0.37632978,"top":0.2019154,"width":0.056848403,"height":0.014365523}},{"char_start":1283,"char_count":10,"bounds":{"left":0.37632978,"top":0.21947326,"width":0.023271276,"height":0.014365523}},{"char_start":1293,"char_count":27,"bounds":{"left":0.37632978,"top":0.23703113,"width":0.06715426,"height":0.014365523}},{"char_start":1320,"char_count":26,"bounds":{"left":0.37632978,"top":0.254589,"width":0.06482713,"height":0.014365523}},{"char_start":1346,"char_count":23,"bounds":{"left":0.37632978,"top":0.27214685,"width":0.056848403,"height":0.014365523}},{"char_start":1369,"char_count":28,"bounds":{"left":0.37632978,"top":0.2897047,"width":0.06981383,"height":0.014365523}},{"char_start":1397,"char_count":57,"bounds":{"left":0.37632978,"top":0.30726257,"width":0.14494681,"height":0.014365523}}],"value":"[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {\n\"headers\":{\n\"Date\":[\"Thu,07 May 2026 14:21:15 GMT\"],\n \"Content-Type\":[\"application/json;charset=utf-8\"],\n \"Transfer-Encoding\":[\"chunked\"],\n \"Connection\":[\"keep-alive\"],\n \"CF-Ray\":[\"9f80deb8db60dc3a-SOF\"],\n \"CF-Cache-Status\":[\"DYNAMIC\"],\n \"Strict-Transport-Security\":[\"max-age=31536000; includeSubDomains; preload\"],\n \"Vary\":[\"origin,\n accept-encoding\"],\n \"access-control-allow-credentials\":[\"false\"],\n \"server-timing\":[\"hcid;desc=\\\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\\\",\n cfr;desc=\\\"9f80deb8e7c6dc3a-IAD\\\"\"],\n \"x-content-type-options\":[\"nosniff\"],\n \"x-hubspot-correlation-id\":[\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\"],\n \"Set-Cookie\":[\"__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-1.0.1.1-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,\n 07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None\"],\n \"Report-To\":[\"{\n\\\"endpoints\\\":[{\n\\\"url\\\":\\\"https:\\\\/\\\\/a.nel.cloudflare.com\\\\/report\\\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\\\"}],\n\\\"group\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"NEL\":[\"{\n\\\"success_fraction\\\":0.01,\n\\\"report_to\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"Server\":[\"cloudflare\"]}} {\n\"correlation_id\":\"95236535-ec98-4541-b92a-adfa73b69eab\",\n\"trace_id\":\"c7ab8365-903f-46d4-9403-0e5b551e3545\"}","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.042220745,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"13","depth":4,"bounds":{"left":0.33144948,"top":0.19952115,"width":0.009640957,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"bounds":{"left":0.34275267,"top":0.19792499,"width":0.00731383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"bounds":{"left":0.35006648,"top":0.19792499,"width":0.006981383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\ndeclare(strict_types=1);\n\nnamespace Jiminny\\Services\\Crm\\Hubspot\\Pagination;\n\nuse Jiminny\\Exceptions\\RateLimitException;\nuse Jiminny\\Services\\Crm\\Hubspot\\Client;\nuse Jiminny\\Services\\Crm\\Hubspot\\PayloadBuilder;\nuse Psr\\Log\\LoggerInterface;\nuse SevenShores\\Hubspot\\Exceptions\\BadRequest;\nuse SevenShores\\Hubspot\\Exceptions\\HubspotException;\nuse Jiminny\\Exceptions\\SocialAccountTokenInvalidException;\n\nclass HubspotPaginationService\n{\n public function __construct(\n private LoggerInterface $logger\n ) {\n }\n\n /**\n * @throws HubspotException\n * @throws SocialAccountTokenInvalidException\n * @throws BadRequest\n */\n public function getPaginatedDataGenerator(\n Client $client,\n array $payload,\n string $type,\n int $offset = 0,\n int &$total = 0,\n ?string &$lastRecordId = null\n ): \\Generator {\n $state = new PaginationState(offset: $offset);\n $endpoint = Client::BASE_URL . \"/crm/v3/objects/{$type}/search\";\n $defaultFilter = $payload['filters'] ?? [];\n $resultsPerPage = PayloadBuilder::MAX_SEARCH_REQUEST_LIMIT;\n $teamId = $client->getConfig()->getTeam()->getId();\n $delay = $this->calculateDelayInMicroseconds();\n\n do {\n if ($this->shouldStopPagination($state, $teamId)) {\n break;\n }\n\n $payload = $this->handlePaginationStrategy($payload, $defaultFilter, $state, $resultsPerPage, $teamId);\n\n $this->validateTokenIfNeeded($client, $state);\n if ($state->requestCount > 0) {\n usleep($delay);\n }\n\n $page = $this->executeSearchRequest($client, $type, $payload, $state);\n\n $state->setTotal($page['total'] ?? 0);\n $this->updateLastRecordId($page, $state);\n\n // Safely iterate over results with null check\n $results = $page['results'] ?? [];\n foreach ($results as $row) {\n $state->incrementTotalRecords();\n yield $row;\n }\n\n $state->setOffset($this->getNextOffset($page));\n $state->incrementRequestCount();\n\n $this->logPaginationProgress($state, $teamId, $endpoint);\n } while ($state->offset && ! empty($page['results']));\n\n // Log final pagination completion stats\n $this->logger->info('[Hubspot] Pagination completed', [\n 'team_id' => $teamId,\n 'endpoint' => $endpoint,\n 'total_requests' => $state->requestCount,\n 'total_records_fetched' => $state->totalRecords,\n 'total_elapsed_seconds' => round($state->getElapsedSeconds(), 2),\n 'average_seconds_per_request' => $state->requestCount > 0 ? round($state->getElapsedSeconds() / $state->requestCount, 2) : 0,\n ]);\n\n // Update reference parameters\n $total = $state->total;\n $lastRecordId = $state->lastRecordId;\n }\n\n private function shouldStopPagination(PaginationState $state, int $teamId): bool\n {\n if ($state->hasReachedSafetyLimit()) {\n $this->logger->warning('[Hubspot] Reached maximum request limit during pagination', [\n 'team_id' => $teamId,\n 'safety_limit' => PaginationConfig::LOOP_SAFETY_LIMIT,\n 'total_fetched' => $state->totalRecords,\n ]);\n\n return true;\n }\n\n return false;\n }\n\n private function handlePaginationStrategy(\n array $payload,\n array $defaultFilter,\n PaginationState $state,\n int $resultsPerPage,\n int $teamId\n ): array {\n if ($this->shouldSwitchToKeysetPagination($state, $resultsPerPage)) {\n $payload['filters'] = $defaultFilter;\n $payload['filters'][] = [\n 'propertyName' => 'hs_object_id',\n 'operator' => 'LT',\n 'value' => $state->lastRecordId,\n ];\n\n $this->logger->info('[Hubspot] Search keyset pagination request', [\n 'team_id' => $teamId,\n 'sequence' => $state->requestCount,\n 'itemsPerPage' => $resultsPerPage,\n 'payload' => $payload,\n 'total' => $state->total,\n ]);\n\n unset($payload['after']);\n $state->setOffset(0);\n }\n\n if ($state->offset) {\n $payload['after'] = $state->offset;\n }\n\n return $payload;\n }\n\n private function shouldSwitchToKeysetPagination(PaginationState $state, int $resultsPerPage): bool\n {\n // Check if we've hit the offset limit\n $shouldSwitch = $state->requestCount > 0 && ($state->offset + $resultsPerPage) > PaginationConfig::TOTAL_QUERY_LIMIT;\n\n if ($shouldSwitch && $state->lastRecordId === null) {\n $this->logger->warning('[Hubspot] Cannot switch to keyset pagination: lastRecordId is null', [\n 'request_count' => $state->requestCount,\n 'current_offset' => $state->offset,\n 'results_per_page' => $resultsPerPage,\n 'total_query_limit' => PaginationConfig::TOTAL_QUERY_LIMIT,\n ]);\n\n return false; // Continue with offset pagination\n }\n\n return $shouldSwitch;\n }\n\n private function validateTokenIfNeeded(Client $client, PaginationState $state): void\n {\n if ($state->shouldValidateToken()) {\n $client->ensureValidToken();\n $state->updateLastTokenCheck();\n }\n }\n\n private function executeSearchRequest(Client $client, string $objectType, array $payload, PaginationState $state): array\n {\n try {\n return $client->search($objectType, $payload);\n } catch (\\Exception $e) {\n if ($client->isUnauthorizedException($e)) {\n $this->logger->warning('[Hubspot] Got 401 during pagination, attempting token refresh', [\n 'team_id' => $client->getConfig()->getTeam()->getId(),\n 'error' => $e->getMessage(),\n ]);\n\n $client->ensureValidToken();\n $state->updateLastTokenCheck();\n\n try {\n $result = $client->search($objectType, $payload);\n\n $this->logger->info('[Hubspot] Token refresh and retry successful', [\n 'team_id' => $client->getConfig()->getTeam()->getId(),\n ]);\n\n return $result;\n } catch (\\Exception $retryException) {\n $this->logger->error('[Hubspot] Retry request failed after token refresh', [\n 'team_id' => $client->getConfig()->getTeam()->getId(),\n 'original_error' => $e->getMessage(),\n 'retry_error' => $retryException->getMessage(),\n ]);\n\n throw $retryException;\n }\n }\n\n // RateLimitException and other exceptions are re-thrown as-is\n throw $e;\n }\n }\n\n private function updateLastRecordId(array $page, PaginationState $state): void\n {\n $lastRecord = ! empty($page['results']) ? end($page['results']) : null;\n $lastRecordId = $lastRecord['id'] ?? null;\n $state->updateLastRecordId($lastRecordId);\n }\n\n private function getNextOffset(array $page): int\n {\n return isset($page['paging']['next']['after']) ? (int) $page['paging']['next']['after'] : 0;\n }\n\n private function logPaginationProgress(PaginationState $state, int $teamId, string $endpoint): void\n {\n if ($state->shouldLogProgress()) {\n $this->logger->info('[Hubspot] Pagination progress log', [\n 'team_id' => $teamId,\n 'endpoint' => $endpoint,\n 'requests_made' => $state->requestCount,\n 'records_fetched' => $state->totalRecords,\n 'elapsed_seconds' => $state->getElapsedSeconds(),\n ]);\n }\n }\n\n private function calculateDelayInMicroseconds(): int\n {\n return (int) (1 / PaginationConfig::SEARCH_RPS_LIMIT * 1000000);\n }\n}","depth":4,"bounds":{"left":0.122340426,"top":0.1963288,"width":0.39960107,"height":0.8036712},"on_screen":true,"value":"<?php\n\ndeclare(strict_types=1);\n\nnamespace Jiminny\\Services\\Crm\\Hubspot\\Pagination;\n\nuse Jiminny\\Exceptions\\RateLimitException;\nuse Jiminny\\Services\\Crm\\Hubspot\\Client;\nuse Jiminny\\Services\\Crm\\Hubspot\\PayloadBuilder;\nuse Psr\\Log\\LoggerInterface;\nuse SevenShores\\Hubspot\\Exceptions\\BadRequest;\nuse SevenShores\\Hubspot\\Exceptions\\HubspotException;\nuse Jiminny\\Exceptions\\SocialAccountTokenInvalidException;\n\nclass HubspotPaginationService\n{\n public function __construct(\n private LoggerInterface $logger\n ) {\n }\n\n /**\n * @throws HubspotException\n * @throws SocialAccountTokenInvalidException\n * @throws BadRequest\n */\n public function getPaginatedDataGenerator(\n Client $client,\n array $payload,\n string $type,\n int $offset = 0,\n int &$total = 0,\n ?string &$lastRecordId = null\n ): \\Generator {\n $state = new PaginationState(offset: $offset);\n $endpoint = Client::BASE_URL . \"/crm/v3/objects/{$type}/search\";\n $defaultFilter = $payload['filters'] ?? [];\n $resultsPerPage = PayloadBuilder::MAX_SEARCH_REQUEST_LIMIT;\n $teamId = $client->getConfig()->getTeam()->getId();\n $delay = $this->calculateDelayInMicroseconds();\n\n do {\n if ($this->shouldStopPagination($state, $teamId)) {\n break;\n }\n\n $payload = $this->handlePaginationStrategy($payload, $defaultFilter, $state, $resultsPerPage, $teamId);\n\n $this->validateTokenIfNeeded($client, $state);\n if ($state->requestCount > 0) {\n usleep($delay);\n }\n\n $page = $this->executeSearchRequest($client, $type, $payload, $state);\n\n $state->setTotal($page['total'] ?? 0);\n $this->updateLastRecordId($page, $state);\n\n // Safely iterate over results with null check\n $results = $page['results'] ?? [];\n foreach ($results as $row) {\n $state->incrementTotalRecords();\n yield $row;\n }\n\n $state->setOffset($this->getNextOffset($page));\n $state->incrementRequestCount();\n\n $this->logPaginationProgress($state, $teamId, $endpoint);\n } while ($state->offset && ! empty($page['results']));\n\n // Log final pagination completion stats\n $this->logger->info('[Hubspot] Pagination completed', [\n 'team_id' => $teamId,\n 'endpoint' => $endpoint,\n 'total_requests' => $state->requestCount,\n 'total_records_fetched' => $state->totalRecords,\n 'total_elapsed_seconds' => round($state->getElapsedSeconds(), 2),\n 'average_seconds_per_request' => $state->requestCount > 0 ? round($state->getElapsedSeconds() / $state->requestCount, 2) : 0,\n ]);\n\n // Update reference parameters\n $total = $state->total;\n $lastRecordId = $state->lastRecordId;\n }\n\n private function shouldStopPagination(PaginationState $state, int $teamId): bool\n {\n if ($state->hasReachedSafetyLimit()) {\n $this->logger->warning('[Hubspot] Reached maximum request limit during pagination', [\n 'team_id' => $teamId,\n 'safety_limit' => PaginationConfig::LOOP_SAFETY_LIMIT,\n 'total_fetched' => $state->totalRecords,\n ]);\n\n return true;\n }\n\n return false;\n }\n\n private function handlePaginationStrategy(\n array $payload,\n array $defaultFilter,\n PaginationState $state,\n int $resultsPerPage,\n int $teamId\n ): array {\n if ($this->shouldSwitchToKeysetPagination($state, $resultsPerPage)) {\n $payload['filters'] = $defaultFilter;\n $payload['filters'][] = [\n 'propertyName' => 'hs_object_id',\n 'operator' => 'LT',\n 'value' => $state->lastRecordId,\n ];\n\n $this->logger->info('[Hubspot] Search keyset pagination request', [\n 'team_id' => $teamId,\n 'sequence' => $state->requestCount,\n 'itemsPerPage' => $resultsPerPage,\n 'payload' => $payload,\n 'total' => $state->total,\n ]);\n\n unset($payload['after']);\n $state->setOffset(0);\n }\n\n if ($state->offset) {\n $payload['after'] = $state->offset;\n }\n\n return $payload;\n }\n\n private function shouldSwitchToKeysetPagination(PaginationState $state, int $resultsPerPage): bool\n {\n // Check if we've hit the offset limit\n $shouldSwitch = $state->requestCount > 0 && ($state->offset + $resultsPerPage) > PaginationConfig::TOTAL_QUERY_LIMIT;\n\n if ($shouldSwitch && $state->lastRecordId === null) {\n $this->logger->warning('[Hubspot] Cannot switch to keyset pagination: lastRecordId is null', [\n 'request_count' => $state->requestCount,\n 'current_offset' => $state->offset,\n 'results_per_page' => $resultsPerPage,\n 'total_query_limit' => PaginationConfig::TOTAL_QUERY_LIMIT,\n ]);\n\n return false; // Continue with offset pagination\n }\n\n return $shouldSwitch;\n }\n\n private function validateTokenIfNeeded(Client $client, PaginationState $state): void\n {\n if ($state->shouldValidateToken()) {\n $client->ensureValidToken();\n $state->updateLastTokenCheck();\n }\n }\n\n private function executeSearchRequest(Client $client, string $objectType, array $payload, PaginationState $state): array\n {\n try {\n return $client->search($objectType, $payload);\n } catch (\\Exception $e) {\n if ($client->isUnauthorizedException($e)) {\n $this->logger->warning('[Hubspot] Got 401 during pagination, attempting token refresh', [\n 'team_id' => $client->getConfig()->getTeam()->getId(),\n 'error' => $e->getMessage(),\n ]);\n\n $client->ensureValidToken();\n $state->updateLastTokenCheck();\n\n try {\n $result = $client->search($objectType, $payload);\n\n $this->logger->info('[Hubspot] Token refresh and retry successful', [\n 'team_id' => $client->getConfig()->getTeam()->getId(),\n ]);\n\n return $result;\n } catch (\\Exception $retryException) {\n $this->logger->error('[Hubspot] Retry request failed after token refresh', [\n 'team_id' => $client->getConfig()->getTeam()->getId(),\n 'original_error' => $e->getMessage(),\n 'retry_error' => $retryException->getMessage(),\n ]);\n\n throw $retryException;\n }\n }\n\n // RateLimitException and other exceptions are re-thrown as-is\n throw $e;\n }\n }\n\n private function updateLastRecordId(array $page, PaginationState $state): void\n {\n $lastRecord = ! empty($page['results']) ? end($page['results']) : null;\n $lastRecordId = $lastRecord['id'] ?? null;\n $state->updateLastRecordId($lastRecordId);\n }\n\n private function getNextOffset(array $page): int\n {\n return isset($page['paging']['next']['after']) ? (int) $page['paging']['next']['after'] : 0;\n }\n\n private function logPaginationProgress(PaginationState $state, int $teamId, string $endpoint): void\n {\n if ($state->shouldLogProgress()) {\n $this->logger->info('[Hubspot] Pagination progress log', [\n 'team_id' => $teamId,\n 'endpoint' => $endpoint,\n 'requests_made' => $state->requestCount,\n 'records_fetched' => $state->totalRecords,\n 'elapsed_seconds' => $state->getElapsedSeconds(),\n ]);\n }\n }\n\n private function calculateDelayInMicroseconds(): int\n {\n return (int) (1 / PaginationConfig::SEARCH_RPS_LIMIT * 1000000);\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":true,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Project","depth":3,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Project","depth":3,"bounds":{"left":0.011968086,"top":0.047885075,"width":0.024268618,"height":0.024740623},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"New File or Directory…","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Expand Selected","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Collapse All","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Options","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
-8710375623998314330
|
-5589570795668856337
|
click
|
accessibility
|
NULL
|
Project: faVsco.js, menu
JY-20725-handle-HS-search Project: faVsco.js, menu
JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
19
Previous Highlighted Error
Next Highlighted Error
[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {
"headers":{
"Date":["Thu,07 May 2026 14:21:15 GMT"],
"Content-Type":["application/json;charset=utf-8"],
"Transfer-Encoding":["chunked"],
"Connection":["keep-alive"],
"CF-Ray":["9f80deb8db60dc3a-SOF"],
"CF-Cache-Status":["DYNAMIC"],
"Strict-Transport-Security":["max-age=31536000; includeSubDomains; preload"],
"Vary":["origin,
accept-encoding"],
"access-control-allow-credentials":["false"],
"server-timing":["hcid;desc=\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\",
cfr;desc=\"9f80deb8e7c6dc3a-IAD\""],
"x-content-type-options":["nosniff"],
"x-hubspot-correlation-id":["019e02d0-6fd8-7812-bdba-885b7ccb3ee3"],
"Set-Cookie":["__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-[IP_ADDRESS]-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,
07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None"],
"Report-To":["{
\"endpoints\":[{
\"url\":\"https:\\/\\/a.nel.cloudflare.com\\/report\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\"}],
\"group\":\"cf-nel\",
\"max_age\":604800}"],
"NEL":["{
\"success_fraction\":0.01,
\"report_to\":\"cf-nel\",
\"max_age\":604800}"],
"Server":["cloudflare"]}} {
"correlation_id":"95236535-ec98-4541-b92a-adfa73b69eab",
"trace_id":"c7ab8365-903f-46d4-9403-0e5b551e3545"}
Sync Changes
Hide This Notification
Code changed:
Hide
13
Previous Highlighted Error
Next Highlighted Error
<?php
declare(strict_types=1);
namespace Jiminny\Services\Crm\Hubspot\Pagination;
use Jiminny\Exceptions\RateLimitException;
use Jiminny\Services\Crm\Hubspot\Client;
use Jiminny\Services\Crm\Hubspot\PayloadBuilder;
use Psr\Log\LoggerInterface;
use SevenShores\Hubspot\Exceptions\BadRequest;
use SevenShores\Hubspot\Exceptions\HubspotException;
use Jiminny\Exceptions\SocialAccountTokenInvalidException;
class HubspotPaginationService
{
public function __construct(
private LoggerInterface $logger
) {
}
/**
* @throws HubspotException
* @throws SocialAccountTokenInvalidException
* @throws BadRequest
*/
public function getPaginatedDataGenerator(
Client $client,
array $payload,
string $type,
int $offset = 0,
int &$total = 0,
?string &$lastRecordId = null
): \Generator {
$state = new PaginationState(offset: $offset);
$endpoint = Client::BASE_URL . "/crm/v3/objects/{$type}/search";
$defaultFilter = $payload['filters'] ?? [];
$resultsPerPage = PayloadBuilder::MAX_SEARCH_REQUEST_LIMIT;
$teamId = $client->getConfig()->getTeam()->getId();
$delay = $this->calculateDelayInMicroseconds();
do {
if ($this->shouldStopPagination($state, $teamId)) {
break;
}
$payload = $this->handlePaginationStrategy($payload, $defaultFilter, $state, $resultsPerPage, $teamId);
$this->validateTokenIfNeeded($client, $state);
if ($state->requestCount > 0) {
usleep($delay);
}
$page = $this->executeSearchRequest($client, $type, $payload, $state);
$state->setTotal($page['total'] ?? 0);
$this->updateLastRecordId($page, $state);
// Safely iterate over results with null check
$results = $page['results'] ?? [];
foreach ($results as $row) {
$state->incrementTotalRecords();
yield $row;
}
$state->setOffset($this->getNextOffset($page));
$state->incrementRequestCount();
$this->logPaginationProgress($state, $teamId, $endpoint);
} while ($state->offset && ! empty($page['results']));
// Log final pagination completion stats
$this->logger->info('[Hubspot] Pagination completed', [
'team_id' => $teamId,
'endpoint' => $endpoint,
'total_requests' => $state->requestCount,
'total_records_fetched' => $state->totalRecords,
'total_elapsed_seconds' => round($state->getElapsedSeconds(), 2),
'average_seconds_per_request' => $state->requestCount > 0 ? round($state->getElapsedSeconds() / $state->requestCount, 2) : 0,
]);
// Update reference parameters
$total = $state->total;
$lastRecordId = $state->lastRecordId;
}
private function shouldStopPagination(PaginationState $state, int $teamId): bool
{
if ($state->hasReachedSafetyLimit()) {
$this->logger->warning('[Hubspot] Reached maximum request limit during pagination', [
'team_id' => $teamId,
'safety_limit' => PaginationConfig::LOOP_SAFETY_LIMIT,
'total_fetched' => $state->totalRecords,
]);
return true;
}
return false;
}
private function handlePaginationStrategy(
array $payload,
array $defaultFilter,
PaginationState $state,
int $resultsPerPage,
int $teamId
): array {
if ($this->shouldSwitchToKeysetPagination($state, $resultsPerPage)) {
$payload['filters'] = $defaultFilter;
$payload['filters'][] = [
'propertyName' => 'hs_object_id',
'operator' => 'LT',
'value' => $state->lastRecordId,
];
$this->logger->info('[Hubspot] Search keyset pagination request', [
'team_id' => $teamId,
'sequence' => $state->requestCount,
'itemsPerPage' => $resultsPerPage,
'payload' => $payload,
'total' => $state->total,
]);
unset($payload['after']);
$state->setOffset(0);
}
if ($state->offset) {
$payload['after'] = $state->offset;
}
return $payload;
}
private function shouldSwitchToKeysetPagination(PaginationState $state, int $resultsPerPage): bool
{
// Check if we've hit the offset limit
$shouldSwitch = $state->requestCount > 0 && ($state->offset + $resultsPerPage) > PaginationConfig::TOTAL_QUERY_LIMIT;
if ($shouldSwitch && $state->lastRecordId === null) {
$this->logger->warning('[Hubspot] Cannot switch to keyset pagination: lastRecordId is null', [
'request_count' => $state->requestCount,
'current_offset' => $state->offset,
'results_per_page' => $resultsPerPage,
'total_query_limit' => PaginationConfig::TOTAL_QUERY_LIMIT,
]);
return false; // Continue with offset pagination
}
return $shouldSwitch;
}
private function validateTokenIfNeeded(Client $client, PaginationState $state): void
{
if ($state->shouldValidateToken()) {
$client->ensureValidToken();
$state->updateLastTokenCheck();
}
}
private function executeSearchRequest(Client $client, string $objectType, array $payload, PaginationState $state): array
{
try {
return $client->search($objectType, $payload);
} catch (\Exception $e) {
if ($client->isUnauthorizedException($e)) {
$this->logger->warning('[Hubspot] Got 401 during pagination, attempting token refresh', [
'team_id' => $client->getConfig()->getTeam()->getId(),
'error' => $e->getMessage(),
]);
$client->ensureValidToken();
$state->updateLastTokenCheck();
try {
$result = $client->search($objectType, $payload);
$this->logger->info('[Hubspot] Token refresh and retry successful', [
'team_id' => $client->getConfig()->getTeam()->getId(),
]);
return $result;
} catch (\Exception $retryException) {
$this->logger->error('[Hubspot] Retry request failed after token refresh', [
'team_id' => $client->getConfig()->getTeam()->getId(),
'original_error' => $e->getMessage(),
'retry_error' => $retryException->getMessage(),
]);
throw $retryException;
}
}
// RateLimitException and other exceptions are re-thrown as-is
throw $e;
}
}
private function updateLastRecordId(array $page, PaginationState $state): void
{
$lastRecord = ! empty($page['results']) ? end($page['results']) : null;
$lastRecordId = $lastRecord['id'] ?? null;
$state->updateLastRecordId($lastRecordId);
}
private function getNextOffset(array $page): int
{
return isset($page['paging']['next']['after']) ? (int) $page['paging']['next']['after'] : 0;
}
private function logPaginationProgress(PaginationState $state, int $teamId, string $endpoint): void
{
if ($state->shouldLogProgress()) {
$this->logger->info('[Hubspot] Pagination progress log', [
'team_id' => $teamId,
'endpoint' => $endpoint,
'requests_made' => $state->requestCount,
'records_fetched' => $state->totalRecords,
'elapsed_seconds' => $state->getElapsedSeconds(),
]);
}
}
private function calculateDelayInMicroseconds(): int
{
return (int) (1 / PaginationConfig::SEARCH_RPS_LIMIT * 1000000);
}
}
Project
Project
New File or Directory…
Expand Selected
Collapse All
Options
Hide...
|
9820
|
NULL
|
NULL
|
NULL
|
|
9821
|
443
|
11
|
2026-05-08T13:35:41.510444+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-08/1778 /Users/lukas/.screenpipe/data/data/2026-05-08/1778247341510_m1.jpg...
|
PhpStorm
|
faVsco.js – HubspotPaginationService.php
|
1
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
JY-20725-handle-HS-search Project: faVsco.js, menu
JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
19
Previous Highlighted Error
Next Highlighted Error
[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {
"headers":{
"Date":["Thu,07 May 2026 14:21:15 GMT"],
"Content-Type":["application/json;charset=utf-8"],
"Transfer-Encoding":["chunked"],
"Connection":["keep-alive"],
"CF-Ray":["9f80deb8db60dc3a-SOF"],
"CF-Cache-Status":["DYNAMIC"],
"Strict-Transport-Security":["max-age=31536000; includeSubDomains; preload"],
"Vary":["origin,
accept-encoding"],
"access-control-allow-credentials":["false"],
"server-timing":["hcid;desc=\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\",
cfr;desc=\"9f80deb8e7c6dc3a-IAD\""],
"x-content-type-options":["nosniff"],
"x-hubspot-correlation-id":["019e02d0-6fd8-7812-bdba-885b7ccb3ee3"],
"Set-Cookie":["__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-[IP_ADDRESS]-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,
07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None"],
"Report-To":["{
\"endpoints\":[{
\"url\":\"https:\\/\\/a.nel.cloudflare.com\\/report\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\"}],
\"group\":\"cf-nel\",
\"max_age\":604800}"],
"NEL":["{
\"success_fraction\":0.01,
\"report_to\":\"cf-nel\",
\"max_age\":604800}"],
"Server":["cloudflare"]}} {
"correlation_id":"95236535-ec98-4541-b92a-adfa73b69eab",
"trace_id":"c7ab8365-903f-46d4-9403-0e5b551e3545"}...
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JY-20725-handle-HS-search-rate-limit, menu","depth":5,"on_screen":true,"help_text":"Git Branch: JY-20725-handle-HS-search-rate-limit","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"AskJiminnyReportActivityServiceTest","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'AskJiminnyReportActivityServiceTest'","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'AskJiminnyReportActivityServiceTest'","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"IDE and Project Settings","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.088194445,"height":0.027777778},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"19","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {\n\"headers\":{\n\"Date\":[\"Thu,07 May 2026 14:21:15 GMT\"],\n \"Content-Type\":[\"application/json;charset=utf-8\"],\n \"Transfer-Encoding\":[\"chunked\"],\n \"Connection\":[\"keep-alive\"],\n \"CF-Ray\":[\"9f80deb8db60dc3a-SOF\"],\n \"CF-Cache-Status\":[\"DYNAMIC\"],\n \"Strict-Transport-Security\":[\"max-age=31536000; includeSubDomains; preload\"],\n \"Vary\":[\"origin,\n accept-encoding\"],\n \"access-control-allow-credentials\":[\"false\"],\n \"server-timing\":[\"hcid;desc=\\\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\\\",\n cfr;desc=\\\"9f80deb8e7c6dc3a-IAD\\\"\"],\n \"x-content-type-options\":[\"nosniff\"],\n \"x-hubspot-correlation-id\":[\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\"],\n \"Set-Cookie\":[\"__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-1.0.1.1-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,\n 07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None\"],\n \"Report-To\":[\"{\n\\\"endpoints\\\":[{\n\\\"url\\\":\\\"https:\\\\/\\\\/a.nel.cloudflare.com\\\\/report\\\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\\\"}],\n\\\"group\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"NEL\":[\"{\n\\\"success_fraction\\\":0.01,\n\\\"report_to\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"Server\":[\"cloudflare\"]}} {\n\"correlation_id\":\"95236535-ec98-4541-b92a-adfa73b69eab\",\n\"trace_id\":\"c7ab8365-903f-46d4-9403-0e5b551e3545\"}","depth":4,"on_screen":true,"value":"[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {\n\"headers\":{\n\"Date\":[\"Thu,07 May 2026 14:21:15 GMT\"],\n \"Content-Type\":[\"application/json;charset=utf-8\"],\n \"Transfer-Encoding\":[\"chunked\"],\n \"Connection\":[\"keep-alive\"],\n \"CF-Ray\":[\"9f80deb8db60dc3a-SOF\"],\n \"CF-Cache-Status\":[\"DYNAMIC\"],\n \"Strict-Transport-Security\":[\"max-age=31536000; includeSubDomains; preload\"],\n \"Vary\":[\"origin,\n accept-encoding\"],\n \"access-control-allow-credentials\":[\"false\"],\n \"server-timing\":[\"hcid;desc=\\\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\\\",\n cfr;desc=\\\"9f80deb8e7c6dc3a-IAD\\\"\"],\n \"x-content-type-options\":[\"nosniff\"],\n \"x-hubspot-correlation-id\":[\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\"],\n \"Set-Cookie\":[\"__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-1.0.1.1-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,\n 07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None\"],\n \"Report-To\":[\"{\n\\\"endpoints\\\":[{\n\\\"url\\\":\\\"https:\\\\/\\\\/a.nel.cloudflare.com\\\\/report\\\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\\\"}],\n\\\"group\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"NEL\":[\"{\n\\\"success_fraction\\\":0.01,\n\\\"report_to\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"Server\":[\"cloudflare\"]}} {\n\"correlation_id\":\"95236535-ec98-4541-b92a-adfa73b69eab\",\n\"trace_id\":\"c7ab8365-903f-46d4-9403-0e5b551e3545\"}","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
6337657672782761712
|
-3048904810292590134
|
click
|
accessibility
|
NULL
|
Project: faVsco.js, menu
JY-20725-handle-HS-search Project: faVsco.js, menu
JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
19
Previous Highlighted Error
Next Highlighted Error
[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {
"headers":{
"Date":["Thu,07 May 2026 14:21:15 GMT"],
"Content-Type":["application/json;charset=utf-8"],
"Transfer-Encoding":["chunked"],
"Connection":["keep-alive"],
"CF-Ray":["9f80deb8db60dc3a-SOF"],
"CF-Cache-Status":["DYNAMIC"],
"Strict-Transport-Security":["max-age=31536000; includeSubDomains; preload"],
"Vary":["origin,
accept-encoding"],
"access-control-allow-credentials":["false"],
"server-timing":["hcid;desc=\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\",
cfr;desc=\"9f80deb8e7c6dc3a-IAD\""],
"x-content-type-options":["nosniff"],
"x-hubspot-correlation-id":["019e02d0-6fd8-7812-bdba-885b7ccb3ee3"],
"Set-Cookie":["__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-[IP_ADDRESS]-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,
07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None"],
"Report-To":["{
\"endpoints\":[{
\"url\":\"https:\\/\\/a.nel.cloudflare.com\\/report\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\"}],
\"group\":\"cf-nel\",
\"max_age\":604800}"],
"NEL":["{
\"success_fraction\":0.01,
\"report_to\":\"cf-nel\",
\"max_age\":604800}"],
"Server":["cloudflare"]}} {
"correlation_id":"95236535-ec98-4541-b92a-adfa73b69eab",
"trace_id":"c7ab8365-903f-46d4-9403-0e5b551e3545"}...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
9820
|
444
|
16
|
2026-05-08T13:35:41.423742+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-08/1778 /Users/lukas/.screenpipe/data/data/2026-05-08/1778247341423_m2.jpg...
|
PhpStorm
|
|
1
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
PhostormINavigarecode°9 JY-20725-handle-HS-search- PhostormINavigarecode°9 JY-20725-handle-HS-search-rateProletey>@ OpportunitySyncStraC) CachedCrmServiceDecorator.ongJiminnyDeougcommand.pngv D Pagination© CheckAndRetryRemoteMatch.pnpc Paginationcontig.© RateLimitException.phpC) Cllent.phg© PaginationState.p© ProviderRateLimiter.php x_ Prospectsearchstrat>0 Redis(C)PaqinationContia.phgv → ServiceTraitsroho+Opportunitysynct+ SynccrmEntitiestdeclare(strict tvoes=1)):TSvncFieldstrait.pT.Writecrmtrait.ohrnamespace Jiminny Comoonent Utilitv Service:•Weonook?Ust ..C) BatchSvncCollector.r 1aC) BatchSvncRedisServi 11class ProvidenPatel imiter(C) Client.oho(C) ClosedDealStadesSe 13procecred kaceLimiter sracelimicer.@ DoalFieldeService. oh 1/© DecorateActivity.php 1(C) SioldDefinitions nhnpublic function __construct(RateLimiter SrateLimiter)(...}© FieldTypeConverter., 20public function canMakeRequest(RateLimited Sprovider): bool(0 HubsnotClientinterfa. 21© HubspotTokenManac 22/** @var RateLimitInterface SrateLimit */Payloadbullder.ongforeach (Sprovider->getRateLimits() as SrateLimit) {© RemoteCrmObjectMª 24d PesnonseNormalize., 25Skey = SrateLim1t->qetkeyorc service.onoif (Sthis->rateLimiter->tooManvAttemots(Skev. SrateLimit->qetOuota0)) {© SyncFieldAction.php 27return talse:© SyncRelatedActivitvß 28© WebhookSyncBatchF 29v D IntegrationApp>_ Accessors>DADI→ Contianublic function requestAvailableIn(RateLimited Sorovider): intf...?>D FiltersM.lobsnublic function incrementRequestCountRateLimited Sorovider): voio> ProsoectSearchStrat 45ServiceTraits** Avan Ratel imitIntenface Sratelimit */C) Dataclient.nholforeach (Sprovider-›getRateLimits as SrateLimit) ‹(C) DecorateActivitv. nho 19Sthis->rateLimiter->hit($rateLimit->getKey, SrateLimit->getWindow0):© LocalSearch.php(0 LocalSearchinterface sa© RemoteSearch.php(C) Service nhrv Ml istenerc(e Convortl ond Activitiae Duraol ookunGncherM Motadatal> 0 Migration= custom.log x= laravel.logA SF [jiminny@localhost4 HS_local (jiminny@localhost)# console [PKobJA console leu)# console [SlAvING[2026-05-07 14:21:15] local.INF0: [Hubspot] DEBUG Getting headers {M X19 A V"neaders".?"Vace".L"Inu,or May 2020 14.21.15 6Ml"J"Loncent-lvpe". applicacionson-charser=utt-o"Transter-Encodinq":"chunked").."CF-Ray":"9t80deb8dbo0dcsa-s0F","CF-Cache-Status":L"DYNAMIC"J,"Strict-Transport-Secur1ty":"max-aqe=31536000* 1ncludeSubDomains: preload")"server-timing": ["hcid;desc=|"019e02d0-6fd8-7812-bdba-885b7ccb3ee3)",cfr;desc=|"9f80deb8e7c6dc3a-1AD\""],'x-hubspot-correlation-id":["019e02d0-6fd8-7812-bdba-885b7ccb3ee3"],"Set-Cookie":["__cf_bm=S1UrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-[IP_ADDRESS]-rI.ZggtDKxTge5zr8_2gbBfW100.ufZ07-May-26 14:51:15 GMT; domain=.hubapi.com; Http0nly; Secure; SameSite=None"],"Renont-To"•r"s"endnoints"."urz\":\"https:|\/\V/a.nel.cloudflare.com\/report\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZ1zoYdxI%2BIxVpHmsKn\"group\":\"cf-nel\",\"max_age\":604800}"],"NEL":["{\"success_fraction\":0.01,reporc to. "cr-nel,"max age":604800}"]."Server": ["cloudflare"]}} {"correlation_1d":"95256555-ec98-4541-b9za-adta/sb6Yeab",I"trace 1d":"C/ab8565-905t-4604-9405-0e5b551e5545"100% LzCascadeHubspot Rate LimitingHubSpot Rate Limit ReInvestigating Rate LinNew CascadeHubspot erm call Reclient->getInstance()->objectProperties('task').alt()geroojeccrields) line ~85client->aetinstance@->obiectProoerties(Stvoe.aet(Sid)imoortObiectFieldo Kline ~232)OpportunitySyncStrateay/HubspotSyncStrateqyBase.phpclient->qetPaqinatedDatagenerator(Soavload, 'deals')OpportunitySyncStrateay/HubspotSingleSyncStrateqy.phpMethod contexiclient->getOpportunityByIdoSyncRe latedAct 1vitvmanager.ohoclient->aetPaginatedData(Spayload, 'meeting')fetchRelatedMeetinas line ~142untMatchinaProspects (line ~290)Remotecrmob1ectman1pulator.phpMethod contextclient->updateEngagementupdateCrmActivityFromLocal line ~73rielas/spec1alrielabecoraror.onoMethod contextgetEngagementoptions) line ~39)getEngagementoptions) line ~42)getcngagementoptions) (uine ~40)client→>retchopportunztyP1pelinestagesgetOpportunityOptions() (line ~54)client→>retchopportun1tyP1pelinesgetOpportunityOptions() (line ~57)Summarv bv CatedorvCearch-rate calls (5 RPS shared limit) — most sensitive:•getPaginatedDataGenerator /getPaginatedData - used for all bulk syncs (deals, contacts, companies, meetings, tasks, calls)•tindo — contact/comoany name search 2 search calls per invocation))• handlePhoneSearchRequest() — up to 3 search calls per phone lookup•searchcal sBvPeriodo, searchcal BvRecordinqURLTokeno, aetcauisolBurst-rate calls — all read/write sinale or batch obiect oberations, enaagements, properties, oipelines, associations. owners.Fri 8 May 16:35:42+0 ..Rate LimitSEARCHSEARCHRate LimitBURSTRate LimitBURSTBURSTBURSTBURSTRLIDCTAsk anvthing (&+-bC° Adantive44-20UTE.Rfo 4 spaces...
|
NULL
|
-2095657153719652807
|
NULL
|
click
|
ocr
|
NULL
|
PhostormINavigarecode°9 JY-20725-handle-HS-search- PhostormINavigarecode°9 JY-20725-handle-HS-search-rateProletey>@ OpportunitySyncStraC) CachedCrmServiceDecorator.ongJiminnyDeougcommand.pngv D Pagination© CheckAndRetryRemoteMatch.pnpc Paginationcontig.© RateLimitException.phpC) Cllent.phg© PaginationState.p© ProviderRateLimiter.php x_ Prospectsearchstrat>0 Redis(C)PaqinationContia.phgv → ServiceTraitsroho+Opportunitysynct+ SynccrmEntitiestdeclare(strict tvoes=1)):TSvncFieldstrait.pT.Writecrmtrait.ohrnamespace Jiminny Comoonent Utilitv Service:•Weonook?Ust ..C) BatchSvncCollector.r 1aC) BatchSvncRedisServi 11class ProvidenPatel imiter(C) Client.oho(C) ClosedDealStadesSe 13procecred kaceLimiter sracelimicer.@ DoalFieldeService. oh 1/© DecorateActivity.php 1(C) SioldDefinitions nhnpublic function __construct(RateLimiter SrateLimiter)(...}© FieldTypeConverter., 20public function canMakeRequest(RateLimited Sprovider): bool(0 HubsnotClientinterfa. 21© HubspotTokenManac 22/** @var RateLimitInterface SrateLimit */Payloadbullder.ongforeach (Sprovider->getRateLimits() as SrateLimit) {© RemoteCrmObjectMª 24d PesnonseNormalize., 25Skey = SrateLim1t->qetkeyorc service.onoif (Sthis->rateLimiter->tooManvAttemots(Skev. SrateLimit->qetOuota0)) {© SyncFieldAction.php 27return talse:© SyncRelatedActivitvß 28© WebhookSyncBatchF 29v D IntegrationApp>_ Accessors>DADI→ Contianublic function requestAvailableIn(RateLimited Sorovider): intf...?>D FiltersM.lobsnublic function incrementRequestCountRateLimited Sorovider): voio> ProsoectSearchStrat 45ServiceTraits** Avan Ratel imitIntenface Sratelimit */C) Dataclient.nholforeach (Sprovider-›getRateLimits as SrateLimit) ‹(C) DecorateActivitv. nho 19Sthis->rateLimiter->hit($rateLimit->getKey, SrateLimit->getWindow0):© LocalSearch.php(0 LocalSearchinterface sa© RemoteSearch.php(C) Service nhrv Ml istenerc(e Convortl ond Activitiae Duraol ookunGncherM Motadatal> 0 Migration= custom.log x= laravel.logA SF [jiminny@localhost4 HS_local (jiminny@localhost)# console [PKobJA console leu)# console [SlAvING[2026-05-07 14:21:15] local.INF0: [Hubspot] DEBUG Getting headers {M X19 A V"neaders".?"Vace".L"Inu,or May 2020 14.21.15 6Ml"J"Loncent-lvpe". applicacionson-charser=utt-o"Transter-Encodinq":"chunked").."CF-Ray":"9t80deb8dbo0dcsa-s0F","CF-Cache-Status":L"DYNAMIC"J,"Strict-Transport-Secur1ty":"max-aqe=31536000* 1ncludeSubDomains: preload")"server-timing": ["hcid;desc=|"019e02d0-6fd8-7812-bdba-885b7ccb3ee3)",cfr;desc=|"9f80deb8e7c6dc3a-1AD\""],'x-hubspot-correlation-id":["019e02d0-6fd8-7812-bdba-885b7ccb3ee3"],"Set-Cookie":["__cf_bm=S1UrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-[IP_ADDRESS]-rI.ZggtDKxTge5zr8_2gbBfW100.ufZ07-May-26 14:51:15 GMT; domain=.hubapi.com; Http0nly; Secure; SameSite=None"],"Renont-To"•r"s"endnoints"."urz\":\"https:|\/\V/a.nel.cloudflare.com\/report\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZ1zoYdxI%2BIxVpHmsKn\"group\":\"cf-nel\",\"max_age\":604800}"],"NEL":["{\"success_fraction\":0.01,reporc to. "cr-nel,"max age":604800}"]."Server": ["cloudflare"]}} {"correlation_1d":"95256555-ec98-4541-b9za-adta/sb6Yeab",I"trace 1d":"C/ab8565-905t-4604-9405-0e5b551e5545"100% LzCascadeHubspot Rate LimitingHubSpot Rate Limit ReInvestigating Rate LinNew CascadeHubspot erm call Reclient->getInstance()->objectProperties('task').alt()geroojeccrields) line ~85client->aetinstance@->obiectProoerties(Stvoe.aet(Sid)imoortObiectFieldo Kline ~232)OpportunitySyncStrateay/HubspotSyncStrateqyBase.phpclient->qetPaqinatedDatagenerator(Soavload, 'deals')OpportunitySyncStrateay/HubspotSingleSyncStrateqy.phpMethod contexiclient->getOpportunityByIdoSyncRe latedAct 1vitvmanager.ohoclient->aetPaginatedData(Spayload, 'meeting')fetchRelatedMeetinas line ~142untMatchinaProspects (line ~290)Remotecrmob1ectman1pulator.phpMethod contextclient->updateEngagementupdateCrmActivityFromLocal line ~73rielas/spec1alrielabecoraror.onoMethod contextgetEngagementoptions) line ~39)getEngagementoptions) line ~42)getcngagementoptions) (uine ~40)client→>retchopportunztyP1pelinestagesgetOpportunityOptions() (line ~54)client→>retchopportun1tyP1pelinesgetOpportunityOptions() (line ~57)Summarv bv CatedorvCearch-rate calls (5 RPS shared limit) — most sensitive:•getPaginatedDataGenerator /getPaginatedData - used for all bulk syncs (deals, contacts, companies, meetings, tasks, calls)•tindo — contact/comoany name search 2 search calls per invocation))• handlePhoneSearchRequest() — up to 3 search calls per phone lookup•searchcal sBvPeriodo, searchcal BvRecordinqURLTokeno, aetcauisolBurst-rate calls — all read/write sinale or batch obiect oberations, enaagements, properties, oipelines, associations. owners.Fri 8 May 16:35:42+0 ..Rate LimitSEARCHSEARCHRate LimitBURSTRate LimitBURSTBURSTBURSTBURSTRLIDCTAsk anvthing (&+-bC° Adantive44-20UTE.Rfo 4 spaces...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
9819
|
443
|
10
|
2026-05-08T13:35:28.008419+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-08/1778 /Users/lukas/.screenpipe/data/data/2026-05-08/1778247328008_m1.jpg...
|
PhpStorm
|
faVsco.js – HubspotPaginationService.php
|
1
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
JY-20725-handle-HS-search Project: faVsco.js, menu
JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
19
Previous Highlighted Error
Next Highlighted Error
[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {
"headers":{
"Date":["Thu,07 May 2026 14:21:15 GMT"],
"Content-Type":["application/json;charset=utf-8"],
"Transfer-Encoding":["chunked"],
"Connection":["keep-alive"],
"CF-Ray":["9f80deb8db60dc3a-SOF"],
"CF-Cache-Status":["DYNAMIC"],
"Strict-Transport-Security":["max-age=31536000; includeSubDomains; preload"],
"Vary":["origin,
accept-encoding"],
"access-control-allow-credentials":["false"],
"server-timing":["hcid;desc=\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\",
cfr;desc=\"9f80deb8e7c6dc3a-IAD\""],
"x-content-type-options":["nosniff"],
"x-hubspot-correlation-id":["019e02d0-6fd8-7812-bdba-885b7ccb3ee3"],
"Set-Cookie":["__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-[IP_ADDRESS]-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,
07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None"],
"Report-To":["{
\"endpoints\":[{
\"url\":\"https:\\/\\/a.nel.cloudflare.com\\/report\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\"}],
\"group\":\"cf-nel\",
\"max_age\":604800}"],
"NEL":["{
\"success_fraction\":0.01,
\"report_to\":\"cf-nel\",
\"max_age\":604800}"],
"Server":["cloudflare"]}} {
"correlation_id":"95236535-ec98-4541-b92a-adfa73b69eab",
"trace_id":"c7ab8365-903f-46d4-9403-0e5b551e3545"}
Sync Changes
Hide This Notification
Code changed:
Hide
13
Previous Highlighted Error
Next Highlighted Error
<?php
declare(strict_types=1);
namespace Jiminny\Services\Crm\Hubspot\Pagination;
use Jiminny\Exceptions\RateLimitException;
use Jiminny\Services\Crm\Hubspot\Client;
use Jiminny\Services\Crm\Hubspot\PayloadBuilder;
use Psr\Log\LoggerInterface;
use SevenShores\Hubspot\Exceptions\BadRequest;
use SevenShores\Hubspot\Exceptions\HubspotException;
use Jiminny\Exceptions\SocialAccountTokenInvalidException;
class HubspotPaginationService
{
public function __construct(
private LoggerInterface $logger
) {
}
/**
* @throws HubspotException
* @throws SocialAccountTokenInvalidException
* @throws BadRequest
*/
public function getPaginatedDataGenerator(
Client $client,
array $payload,
string $type,
int $offset = 0,
int &$total = 0,
?string &$lastRecordId = null
): \Generator {
$state = new PaginationState(offset: $offset);
$endpoint = Client::BASE_URL . "/crm/v3/objects/{$type}/search";
$defaultFilter = $payload['filters'] ?? [];
$resultsPerPage = PayloadBuilder::MAX_SEARCH_REQUEST_LIMIT;
$teamId = $client->getConfig()->getTeam()->getId();
$delay = $this->calculateDelayInMicroseconds();
do {
if ($this->shouldStopPagination($state, $teamId)) {
break;
}
$payload = $this->handlePaginationStrategy($payload, $defaultFilter, $state, $resultsPerPage, $teamId);
$this->validateTokenIfNeeded($client, $state);
if ($state->requestCount > 0) {
usleep($delay);
}
$page = $this->executeSearchRequest($client, $type, $payload, $state);
$state->setTotal($page['total'] ?? 0);
$this->updateLastRecordId($page, $state);
// Safely iterate over results with null check
$results = $page['results'] ?? [];
foreach ($results as $row) {
$state->incrementTotalRecords();
yield $row;
}
$state->setOffset($this->getNextOffset($page));
$state->incrementRequestCount();
$this->logPaginationProgress($state, $teamId, $endpoint);
} while ($state->offset && ! empty($page['results']));
// Log final pagination completion stats
$this->logger->info('[Hubspot] Pagination completed', [
'team_id' => $teamId,
'endpoint' => $endpoint,
'total_requests' => $state->requestCount,
'total_records_fetched' => $state->totalRecords,
'total_elapsed_seconds' => round($state->getElapsedSeconds(), 2),
'average_seconds_per_request' => $state->requestCount > 0 ? round($state->getElapsedSeconds() / $state->requestCount, 2) : 0,
]);
// Update reference parameters
$total = $state->total;
$lastRecordId = $state->lastRecordId;
}
private function shouldStopPagination(PaginationState $state, int $teamId): bool
{
if ($state->hasReachedSafetyLimit()) {
$this->logger->warning('[Hubspot] Reached maximum request limit during pagination', [
'team_id' => $teamId,
'safety_limit' => PaginationConfig::LOOP_SAFETY_LIMIT,
'total_fetched' => $state->totalRecords,
]);
return true;
}
return false;
}
private function handlePaginationStrategy(
array $payload,
array $defaultFilter,
PaginationState $state,
int $resultsPerPage,
int $teamId
): array {
if ($this->shouldSwitchToKeysetPagination($state, $resultsPerPage)) {
$payload['filters'] = $defaultFilter;
$payload['filters'][] = [
'propertyName' => 'hs_object_id',
'operator' => 'LT',
'value' => $state->lastRecordId,
];
$this->logger->info('[Hubspot] Search keyset pagination request', [
'team_id' => $teamId,
'sequence' => $state->requestCount,
'itemsPerPage' => $resultsPerPage,
'payload' => $payload,
'total' => $state->total,
]);
unset($payload['after']);
$state->setOffset(0);
}
if ($state->offset) {
$payload['after'] = $state->offset;
}
return $payload;
}
private function shouldSwitchToKeysetPagination(PaginationState $state, int $resultsPerPage): bool
{
// Check if we've hit the offset limit
$shouldSwitch = $state->requestCount > 0 && ($state->offset + $resultsPerPage) > PaginationConfig::TOTAL_QUERY_LIMIT;
if ($shouldSwitch && $state->lastRecordId === null) {
$this->logger->warning('[Hubspot] Cannot switch to keyset pagination: lastRecordId is null', [
'request_count' => $state->requestCount,
'current_offset' => $state->offset,
'results_per_page' => $resultsPerPage,
'total_query_limit' => PaginationConfig::TOTAL_QUERY_LIMIT,
]);
return false; // Continue with offset pagination
}
return $shouldSwitch;
}
private function validateTokenIfNeeded(Client $client, PaginationState $state): void
{
if ($state->shouldValidateToken()) {
$client->ensureValidToken();
$state->updateLastTokenCheck();
}
}
private function executeSearchRequest(Client $client, string $objectType, array $payload, PaginationState $state): array
{
try {
return $client->search($objectType, $payload);
} catch (\Exception $e) {
if ($client->isUnauthorizedException($e)) {
$this->logger->warning('[Hubspot] Got 401 during pagination, attempting token refresh', [
'team_id' => $client->getConfig()->getTeam()->getId(),
'error' => $e->getMessage(),
]);
$client->ensureValidToken();
$state->updateLastTokenCheck();
try {
$result = $client->search($objectType, $payload);
$this->logger->info('[Hubspot] Token refresh and retry successful', [
'team_id' => $client->getConfig()->getTeam()->getId(),
]);
return $result;
} catch (\Exception $retryException) {
$this->logger->error('[Hubspot] Retry request failed after token refresh', [
'team_id' => $client->getConfig()->getTeam()->getId(),
'original_error' => $e->getMessage(),
'retry_error' => $retryException->getMessage(),
]);
throw $retryException;
}
}
// RateLimitException and other exceptions are re-thrown as-is
throw $e;
}
}
private function updateLastRecordId(array $page, PaginationState $state): void
{
$lastRecord = ! empty($page['results']) ? end($page['results']) : null;
$lastRecordId = $lastRecord['id'] ?? null;
$state->updateLastRecordId($lastRecordId);
}
private function getNextOffset(array $page): int
{
return isset($page['paging']['next']['after']) ? (int) $page['paging']['next']['after'] : 0;
}
private function logPaginationProgress(PaginationState $state, int $teamId, string $endpoint): void
{
if ($state->shouldLogProgress()) {
$this->logger->info('[Hubspot] Pagination progress log', [
'team_id' => $teamId,
'endpoint' => $endpoint,
'requests_made' => $state->requestCount,
'records_fetched' => $state->totalRecords,
'elapsed_seconds' => $state->getElapsedSeconds(),
]);
}
}
private function calculateDelayInMicroseconds(): int
{
return (int) (1 / PaginationConfig::SEARCH_RPS_LIMIT * 1000000);
}
}
Project
Project
New File or Directory…
Expand Selected
Collapse All
Options
Hide...
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JY-20725-handle-HS-search-rate-limit, menu","depth":5,"on_screen":true,"help_text":"Git Branch: JY-20725-handle-HS-search-rate-limit","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"AskJiminnyReportActivityServiceTest","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'AskJiminnyReportActivityServiceTest'","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'AskJiminnyReportActivityServiceTest'","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"IDE and Project Settings","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.088194445,"height":0.027777778},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"19","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {\n\"headers\":{\n\"Date\":[\"Thu,07 May 2026 14:21:15 GMT\"],\n \"Content-Type\":[\"application/json;charset=utf-8\"],\n \"Transfer-Encoding\":[\"chunked\"],\n \"Connection\":[\"keep-alive\"],\n \"CF-Ray\":[\"9f80deb8db60dc3a-SOF\"],\n \"CF-Cache-Status\":[\"DYNAMIC\"],\n \"Strict-Transport-Security\":[\"max-age=31536000; includeSubDomains; preload\"],\n \"Vary\":[\"origin,\n accept-encoding\"],\n \"access-control-allow-credentials\":[\"false\"],\n \"server-timing\":[\"hcid;desc=\\\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\\\",\n cfr;desc=\\\"9f80deb8e7c6dc3a-IAD\\\"\"],\n \"x-content-type-options\":[\"nosniff\"],\n \"x-hubspot-correlation-id\":[\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\"],\n \"Set-Cookie\":[\"__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-1.0.1.1-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,\n 07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None\"],\n \"Report-To\":[\"{\n\\\"endpoints\\\":[{\n\\\"url\\\":\\\"https:\\\\/\\\\/a.nel.cloudflare.com\\\\/report\\\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\\\"}],\n\\\"group\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"NEL\":[\"{\n\\\"success_fraction\\\":0.01,\n\\\"report_to\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"Server\":[\"cloudflare\"]}} {\n\"correlation_id\":\"95236535-ec98-4541-b92a-adfa73b69eab\",\n\"trace_id\":\"c7ab8365-903f-46d4-9403-0e5b551e3545\"}","depth":4,"on_screen":true,"value":"[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {\n\"headers\":{\n\"Date\":[\"Thu,07 May 2026 14:21:15 GMT\"],\n \"Content-Type\":[\"application/json;charset=utf-8\"],\n \"Transfer-Encoding\":[\"chunked\"],\n \"Connection\":[\"keep-alive\"],\n \"CF-Ray\":[\"9f80deb8db60dc3a-SOF\"],\n \"CF-Cache-Status\":[\"DYNAMIC\"],\n \"Strict-Transport-Security\":[\"max-age=31536000; includeSubDomains; preload\"],\n \"Vary\":[\"origin,\n accept-encoding\"],\n \"access-control-allow-credentials\":[\"false\"],\n \"server-timing\":[\"hcid;desc=\\\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\\\",\n cfr;desc=\\\"9f80deb8e7c6dc3a-IAD\\\"\"],\n \"x-content-type-options\":[\"nosniff\"],\n \"x-hubspot-correlation-id\":[\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\"],\n \"Set-Cookie\":[\"__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-1.0.1.1-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,\n 07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None\"],\n \"Report-To\":[\"{\n\\\"endpoints\\\":[{\n\\\"url\\\":\\\"https:\\\\/\\\\/a.nel.cloudflare.com\\\\/report\\\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\\\"}],\n\\\"group\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"NEL\":[\"{\n\\\"success_fraction\\\":0.01,\n\\\"report_to\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"Server\":[\"cloudflare\"]}} {\n\"correlation_id\":\"95236535-ec98-4541-b92a-adfa73b69eab\",\n\"trace_id\":\"c7ab8365-903f-46d4-9403-0e5b551e3545\"}","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.088194445,"height":0.027777778},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"13","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\ndeclare(strict_types=1);\n\nnamespace Jiminny\\Services\\Crm\\Hubspot\\Pagination;\n\nuse Jiminny\\Exceptions\\RateLimitException;\nuse Jiminny\\Services\\Crm\\Hubspot\\Client;\nuse Jiminny\\Services\\Crm\\Hubspot\\PayloadBuilder;\nuse Psr\\Log\\LoggerInterface;\nuse SevenShores\\Hubspot\\Exceptions\\BadRequest;\nuse SevenShores\\Hubspot\\Exceptions\\HubspotException;\nuse Jiminny\\Exceptions\\SocialAccountTokenInvalidException;\n\nclass HubspotPaginationService\n{\n public function __construct(\n private LoggerInterface $logger\n ) {\n }\n\n /**\n * @throws HubspotException\n * @throws SocialAccountTokenInvalidException\n * @throws BadRequest\n */\n public function getPaginatedDataGenerator(\n Client $client,\n array $payload,\n string $type,\n int $offset = 0,\n int &$total = 0,\n ?string &$lastRecordId = null\n ): \\Generator {\n $state = new PaginationState(offset: $offset);\n $endpoint = Client::BASE_URL . \"/crm/v3/objects/{$type}/search\";\n $defaultFilter = $payload['filters'] ?? [];\n $resultsPerPage = PayloadBuilder::MAX_SEARCH_REQUEST_LIMIT;\n $teamId = $client->getConfig()->getTeam()->getId();\n $delay = $this->calculateDelayInMicroseconds();\n\n do {\n if ($this->shouldStopPagination($state, $teamId)) {\n break;\n }\n\n $payload = $this->handlePaginationStrategy($payload, $defaultFilter, $state, $resultsPerPage, $teamId);\n\n $this->validateTokenIfNeeded($client, $state);\n if ($state->requestCount > 0) {\n usleep($delay);\n }\n\n $page = $this->executeSearchRequest($client, $type, $payload, $state);\n\n $state->setTotal($page['total'] ?? 0);\n $this->updateLastRecordId($page, $state);\n\n // Safely iterate over results with null check\n $results = $page['results'] ?? [];\n foreach ($results as $row) {\n $state->incrementTotalRecords();\n yield $row;\n }\n\n $state->setOffset($this->getNextOffset($page));\n $state->incrementRequestCount();\n\n $this->logPaginationProgress($state, $teamId, $endpoint);\n } while ($state->offset && ! empty($page['results']));\n\n // Log final pagination completion stats\n $this->logger->info('[Hubspot] Pagination completed', [\n 'team_id' => $teamId,\n 'endpoint' => $endpoint,\n 'total_requests' => $state->requestCount,\n 'total_records_fetched' => $state->totalRecords,\n 'total_elapsed_seconds' => round($state->getElapsedSeconds(), 2),\n 'average_seconds_per_request' => $state->requestCount > 0 ? round($state->getElapsedSeconds() / $state->requestCount, 2) : 0,\n ]);\n\n // Update reference parameters\n $total = $state->total;\n $lastRecordId = $state->lastRecordId;\n }\n\n private function shouldStopPagination(PaginationState $state, int $teamId): bool\n {\n if ($state->hasReachedSafetyLimit()) {\n $this->logger->warning('[Hubspot] Reached maximum request limit during pagination', [\n 'team_id' => $teamId,\n 'safety_limit' => PaginationConfig::LOOP_SAFETY_LIMIT,\n 'total_fetched' => $state->totalRecords,\n ]);\n\n return true;\n }\n\n return false;\n }\n\n private function handlePaginationStrategy(\n array $payload,\n array $defaultFilter,\n PaginationState $state,\n int $resultsPerPage,\n int $teamId\n ): array {\n if ($this->shouldSwitchToKeysetPagination($state, $resultsPerPage)) {\n $payload['filters'] = $defaultFilter;\n $payload['filters'][] = [\n 'propertyName' => 'hs_object_id',\n 'operator' => 'LT',\n 'value' => $state->lastRecordId,\n ];\n\n $this->logger->info('[Hubspot] Search keyset pagination request', [\n 'team_id' => $teamId,\n 'sequence' => $state->requestCount,\n 'itemsPerPage' => $resultsPerPage,\n 'payload' => $payload,\n 'total' => $state->total,\n ]);\n\n unset($payload['after']);\n $state->setOffset(0);\n }\n\n if ($state->offset) {\n $payload['after'] = $state->offset;\n }\n\n return $payload;\n }\n\n private function shouldSwitchToKeysetPagination(PaginationState $state, int $resultsPerPage): bool\n {\n // Check if we've hit the offset limit\n $shouldSwitch = $state->requestCount > 0 && ($state->offset + $resultsPerPage) > PaginationConfig::TOTAL_QUERY_LIMIT;\n\n if ($shouldSwitch && $state->lastRecordId === null) {\n $this->logger->warning('[Hubspot] Cannot switch to keyset pagination: lastRecordId is null', [\n 'request_count' => $state->requestCount,\n 'current_offset' => $state->offset,\n 'results_per_page' => $resultsPerPage,\n 'total_query_limit' => PaginationConfig::TOTAL_QUERY_LIMIT,\n ]);\n\n return false; // Continue with offset pagination\n }\n\n return $shouldSwitch;\n }\n\n private function validateTokenIfNeeded(Client $client, PaginationState $state): void\n {\n if ($state->shouldValidateToken()) {\n $client->ensureValidToken();\n $state->updateLastTokenCheck();\n }\n }\n\n private function executeSearchRequest(Client $client, string $objectType, array $payload, PaginationState $state): array\n {\n try {\n return $client->search($objectType, $payload);\n } catch (\\Exception $e) {\n if ($client->isUnauthorizedException($e)) {\n $this->logger->warning('[Hubspot] Got 401 during pagination, attempting token refresh', [\n 'team_id' => $client->getConfig()->getTeam()->getId(),\n 'error' => $e->getMessage(),\n ]);\n\n $client->ensureValidToken();\n $state->updateLastTokenCheck();\n\n try {\n $result = $client->search($objectType, $payload);\n\n $this->logger->info('[Hubspot] Token refresh and retry successful', [\n 'team_id' => $client->getConfig()->getTeam()->getId(),\n ]);\n\n return $result;\n } catch (\\Exception $retryException) {\n $this->logger->error('[Hubspot] Retry request failed after token refresh', [\n 'team_id' => $client->getConfig()->getTeam()->getId(),\n 'original_error' => $e->getMessage(),\n 'retry_error' => $retryException->getMessage(),\n ]);\n\n throw $retryException;\n }\n }\n\n // RateLimitException and other exceptions are re-thrown as-is\n throw $e;\n }\n }\n\n private function updateLastRecordId(array $page, PaginationState $state): void\n {\n $lastRecord = ! empty($page['results']) ? end($page['results']) : null;\n $lastRecordId = $lastRecord['id'] ?? null;\n $state->updateLastRecordId($lastRecordId);\n }\n\n private function getNextOffset(array $page): int\n {\n return isset($page['paging']['next']['after']) ? (int) $page['paging']['next']['after'] : 0;\n }\n\n private function logPaginationProgress(PaginationState $state, int $teamId, string $endpoint): void\n {\n if ($state->shouldLogProgress()) {\n $this->logger->info('[Hubspot] Pagination progress log', [\n 'team_id' => $teamId,\n 'endpoint' => $endpoint,\n 'requests_made' => $state->requestCount,\n 'records_fetched' => $state->totalRecords,\n 'elapsed_seconds' => $state->getElapsedSeconds(),\n ]);\n }\n }\n\n private function calculateDelayInMicroseconds(): int\n {\n return (int) (1 / PaginationConfig::SEARCH_RPS_LIMIT * 1000000);\n }\n}","depth":4,"on_screen":true,"value":"<?php\n\ndeclare(strict_types=1);\n\nnamespace Jiminny\\Services\\Crm\\Hubspot\\Pagination;\n\nuse Jiminny\\Exceptions\\RateLimitException;\nuse Jiminny\\Services\\Crm\\Hubspot\\Client;\nuse Jiminny\\Services\\Crm\\Hubspot\\PayloadBuilder;\nuse Psr\\Log\\LoggerInterface;\nuse SevenShores\\Hubspot\\Exceptions\\BadRequest;\nuse SevenShores\\Hubspot\\Exceptions\\HubspotException;\nuse Jiminny\\Exceptions\\SocialAccountTokenInvalidException;\n\nclass HubspotPaginationService\n{\n public function __construct(\n private LoggerInterface $logger\n ) {\n }\n\n /**\n * @throws HubspotException\n * @throws SocialAccountTokenInvalidException\n * @throws BadRequest\n */\n public function getPaginatedDataGenerator(\n Client $client,\n array $payload,\n string $type,\n int $offset = 0,\n int &$total = 0,\n ?string &$lastRecordId = null\n ): \\Generator {\n $state = new PaginationState(offset: $offset);\n $endpoint = Client::BASE_URL . \"/crm/v3/objects/{$type}/search\";\n $defaultFilter = $payload['filters'] ?? [];\n $resultsPerPage = PayloadBuilder::MAX_SEARCH_REQUEST_LIMIT;\n $teamId = $client->getConfig()->getTeam()->getId();\n $delay = $this->calculateDelayInMicroseconds();\n\n do {\n if ($this->shouldStopPagination($state, $teamId)) {\n break;\n }\n\n $payload = $this->handlePaginationStrategy($payload, $defaultFilter, $state, $resultsPerPage, $teamId);\n\n $this->validateTokenIfNeeded($client, $state);\n if ($state->requestCount > 0) {\n usleep($delay);\n }\n\n $page = $this->executeSearchRequest($client, $type, $payload, $state);\n\n $state->setTotal($page['total'] ?? 0);\n $this->updateLastRecordId($page, $state);\n\n // Safely iterate over results with null check\n $results = $page['results'] ?? [];\n foreach ($results as $row) {\n $state->incrementTotalRecords();\n yield $row;\n }\n\n $state->setOffset($this->getNextOffset($page));\n $state->incrementRequestCount();\n\n $this->logPaginationProgress($state, $teamId, $endpoint);\n } while ($state->offset && ! empty($page['results']));\n\n // Log final pagination completion stats\n $this->logger->info('[Hubspot] Pagination completed', [\n 'team_id' => $teamId,\n 'endpoint' => $endpoint,\n 'total_requests' => $state->requestCount,\n 'total_records_fetched' => $state->totalRecords,\n 'total_elapsed_seconds' => round($state->getElapsedSeconds(), 2),\n 'average_seconds_per_request' => $state->requestCount > 0 ? round($state->getElapsedSeconds() / $state->requestCount, 2) : 0,\n ]);\n\n // Update reference parameters\n $total = $state->total;\n $lastRecordId = $state->lastRecordId;\n }\n\n private function shouldStopPagination(PaginationState $state, int $teamId): bool\n {\n if ($state->hasReachedSafetyLimit()) {\n $this->logger->warning('[Hubspot] Reached maximum request limit during pagination', [\n 'team_id' => $teamId,\n 'safety_limit' => PaginationConfig::LOOP_SAFETY_LIMIT,\n 'total_fetched' => $state->totalRecords,\n ]);\n\n return true;\n }\n\n return false;\n }\n\n private function handlePaginationStrategy(\n array $payload,\n array $defaultFilter,\n PaginationState $state,\n int $resultsPerPage,\n int $teamId\n ): array {\n if ($this->shouldSwitchToKeysetPagination($state, $resultsPerPage)) {\n $payload['filters'] = $defaultFilter;\n $payload['filters'][] = [\n 'propertyName' => 'hs_object_id',\n 'operator' => 'LT',\n 'value' => $state->lastRecordId,\n ];\n\n $this->logger->info('[Hubspot] Search keyset pagination request', [\n 'team_id' => $teamId,\n 'sequence' => $state->requestCount,\n 'itemsPerPage' => $resultsPerPage,\n 'payload' => $payload,\n 'total' => $state->total,\n ]);\n\n unset($payload['after']);\n $state->setOffset(0);\n }\n\n if ($state->offset) {\n $payload['after'] = $state->offset;\n }\n\n return $payload;\n }\n\n private function shouldSwitchToKeysetPagination(PaginationState $state, int $resultsPerPage): bool\n {\n // Check if we've hit the offset limit\n $shouldSwitch = $state->requestCount > 0 && ($state->offset + $resultsPerPage) > PaginationConfig::TOTAL_QUERY_LIMIT;\n\n if ($shouldSwitch && $state->lastRecordId === null) {\n $this->logger->warning('[Hubspot] Cannot switch to keyset pagination: lastRecordId is null', [\n 'request_count' => $state->requestCount,\n 'current_offset' => $state->offset,\n 'results_per_page' => $resultsPerPage,\n 'total_query_limit' => PaginationConfig::TOTAL_QUERY_LIMIT,\n ]);\n\n return false; // Continue with offset pagination\n }\n\n return $shouldSwitch;\n }\n\n private function validateTokenIfNeeded(Client $client, PaginationState $state): void\n {\n if ($state->shouldValidateToken()) {\n $client->ensureValidToken();\n $state->updateLastTokenCheck();\n }\n }\n\n private function executeSearchRequest(Client $client, string $objectType, array $payload, PaginationState $state): array\n {\n try {\n return $client->search($objectType, $payload);\n } catch (\\Exception $e) {\n if ($client->isUnauthorizedException($e)) {\n $this->logger->warning('[Hubspot] Got 401 during pagination, attempting token refresh', [\n 'team_id' => $client->getConfig()->getTeam()->getId(),\n 'error' => $e->getMessage(),\n ]);\n\n $client->ensureValidToken();\n $state->updateLastTokenCheck();\n\n try {\n $result = $client->search($objectType, $payload);\n\n $this->logger->info('[Hubspot] Token refresh and retry successful', [\n 'team_id' => $client->getConfig()->getTeam()->getId(),\n ]);\n\n return $result;\n } catch (\\Exception $retryException) {\n $this->logger->error('[Hubspot] Retry request failed after token refresh', [\n 'team_id' => $client->getConfig()->getTeam()->getId(),\n 'original_error' => $e->getMessage(),\n 'retry_error' => $retryException->getMessage(),\n ]);\n\n throw $retryException;\n }\n }\n\n // RateLimitException and other exceptions are re-thrown as-is\n throw $e;\n }\n }\n\n private function updateLastRecordId(array $page, PaginationState $state): void\n {\n $lastRecord = ! empty($page['results']) ? end($page['results']) : null;\n $lastRecordId = $lastRecord['id'] ?? null;\n $state->updateLastRecordId($lastRecordId);\n }\n\n private function getNextOffset(array $page): int\n {\n return isset($page['paging']['next']['after']) ? (int) $page['paging']['next']['after'] : 0;\n }\n\n private function logPaginationProgress(PaginationState $state, int $teamId, string $endpoint): void\n {\n if ($state->shouldLogProgress()) {\n $this->logger->info('[Hubspot] Pagination progress log', [\n 'team_id' => $teamId,\n 'endpoint' => $endpoint,\n 'requests_made' => $state->requestCount,\n 'records_fetched' => $state->totalRecords,\n 'elapsed_seconds' => $state->getElapsedSeconds(),\n ]);\n }\n }\n\n private function calculateDelayInMicroseconds(): int\n {\n return (int) (1 / PaginationConfig::SEARCH_RPS_LIMIT * 1000000);\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":true,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Project","depth":3,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Project","depth":3,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"New File or Directory…","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Expand Selected","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Collapse All","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Options","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
-8710375623998314330
|
-5589570795668856337
|
click
|
accessibility
|
NULL
|
Project: faVsco.js, menu
JY-20725-handle-HS-search Project: faVsco.js, menu
JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
19
Previous Highlighted Error
Next Highlighted Error
[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {
"headers":{
"Date":["Thu,07 May 2026 14:21:15 GMT"],
"Content-Type":["application/json;charset=utf-8"],
"Transfer-Encoding":["chunked"],
"Connection":["keep-alive"],
"CF-Ray":["9f80deb8db60dc3a-SOF"],
"CF-Cache-Status":["DYNAMIC"],
"Strict-Transport-Security":["max-age=31536000; includeSubDomains; preload"],
"Vary":["origin,
accept-encoding"],
"access-control-allow-credentials":["false"],
"server-timing":["hcid;desc=\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\",
cfr;desc=\"9f80deb8e7c6dc3a-IAD\""],
"x-content-type-options":["nosniff"],
"x-hubspot-correlation-id":["019e02d0-6fd8-7812-bdba-885b7ccb3ee3"],
"Set-Cookie":["__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-[IP_ADDRESS]-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,
07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None"],
"Report-To":["{
\"endpoints\":[{
\"url\":\"https:\\/\\/a.nel.cloudflare.com\\/report\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\"}],
\"group\":\"cf-nel\",
\"max_age\":604800}"],
"NEL":["{
\"success_fraction\":0.01,
\"report_to\":\"cf-nel\",
\"max_age\":604800}"],
"Server":["cloudflare"]}} {
"correlation_id":"95236535-ec98-4541-b92a-adfa73b69eab",
"trace_id":"c7ab8365-903f-46d4-9403-0e5b551e3545"}
Sync Changes
Hide This Notification
Code changed:
Hide
13
Previous Highlighted Error
Next Highlighted Error
<?php
declare(strict_types=1);
namespace Jiminny\Services\Crm\Hubspot\Pagination;
use Jiminny\Exceptions\RateLimitException;
use Jiminny\Services\Crm\Hubspot\Client;
use Jiminny\Services\Crm\Hubspot\PayloadBuilder;
use Psr\Log\LoggerInterface;
use SevenShores\Hubspot\Exceptions\BadRequest;
use SevenShores\Hubspot\Exceptions\HubspotException;
use Jiminny\Exceptions\SocialAccountTokenInvalidException;
class HubspotPaginationService
{
public function __construct(
private LoggerInterface $logger
) {
}
/**
* @throws HubspotException
* @throws SocialAccountTokenInvalidException
* @throws BadRequest
*/
public function getPaginatedDataGenerator(
Client $client,
array $payload,
string $type,
int $offset = 0,
int &$total = 0,
?string &$lastRecordId = null
): \Generator {
$state = new PaginationState(offset: $offset);
$endpoint = Client::BASE_URL . "/crm/v3/objects/{$type}/search";
$defaultFilter = $payload['filters'] ?? [];
$resultsPerPage = PayloadBuilder::MAX_SEARCH_REQUEST_LIMIT;
$teamId = $client->getConfig()->getTeam()->getId();
$delay = $this->calculateDelayInMicroseconds();
do {
if ($this->shouldStopPagination($state, $teamId)) {
break;
}
$payload = $this->handlePaginationStrategy($payload, $defaultFilter, $state, $resultsPerPage, $teamId);
$this->validateTokenIfNeeded($client, $state);
if ($state->requestCount > 0) {
usleep($delay);
}
$page = $this->executeSearchRequest($client, $type, $payload, $state);
$state->setTotal($page['total'] ?? 0);
$this->updateLastRecordId($page, $state);
// Safely iterate over results with null check
$results = $page['results'] ?? [];
foreach ($results as $row) {
$state->incrementTotalRecords();
yield $row;
}
$state->setOffset($this->getNextOffset($page));
$state->incrementRequestCount();
$this->logPaginationProgress($state, $teamId, $endpoint);
} while ($state->offset && ! empty($page['results']));
// Log final pagination completion stats
$this->logger->info('[Hubspot] Pagination completed', [
'team_id' => $teamId,
'endpoint' => $endpoint,
'total_requests' => $state->requestCount,
'total_records_fetched' => $state->totalRecords,
'total_elapsed_seconds' => round($state->getElapsedSeconds(), 2),
'average_seconds_per_request' => $state->requestCount > 0 ? round($state->getElapsedSeconds() / $state->requestCount, 2) : 0,
]);
// Update reference parameters
$total = $state->total;
$lastRecordId = $state->lastRecordId;
}
private function shouldStopPagination(PaginationState $state, int $teamId): bool
{
if ($state->hasReachedSafetyLimit()) {
$this->logger->warning('[Hubspot] Reached maximum request limit during pagination', [
'team_id' => $teamId,
'safety_limit' => PaginationConfig::LOOP_SAFETY_LIMIT,
'total_fetched' => $state->totalRecords,
]);
return true;
}
return false;
}
private function handlePaginationStrategy(
array $payload,
array $defaultFilter,
PaginationState $state,
int $resultsPerPage,
int $teamId
): array {
if ($this->shouldSwitchToKeysetPagination($state, $resultsPerPage)) {
$payload['filters'] = $defaultFilter;
$payload['filters'][] = [
'propertyName' => 'hs_object_id',
'operator' => 'LT',
'value' => $state->lastRecordId,
];
$this->logger->info('[Hubspot] Search keyset pagination request', [
'team_id' => $teamId,
'sequence' => $state->requestCount,
'itemsPerPage' => $resultsPerPage,
'payload' => $payload,
'total' => $state->total,
]);
unset($payload['after']);
$state->setOffset(0);
}
if ($state->offset) {
$payload['after'] = $state->offset;
}
return $payload;
}
private function shouldSwitchToKeysetPagination(PaginationState $state, int $resultsPerPage): bool
{
// Check if we've hit the offset limit
$shouldSwitch = $state->requestCount > 0 && ($state->offset + $resultsPerPage) > PaginationConfig::TOTAL_QUERY_LIMIT;
if ($shouldSwitch && $state->lastRecordId === null) {
$this->logger->warning('[Hubspot] Cannot switch to keyset pagination: lastRecordId is null', [
'request_count' => $state->requestCount,
'current_offset' => $state->offset,
'results_per_page' => $resultsPerPage,
'total_query_limit' => PaginationConfig::TOTAL_QUERY_LIMIT,
]);
return false; // Continue with offset pagination
}
return $shouldSwitch;
}
private function validateTokenIfNeeded(Client $client, PaginationState $state): void
{
if ($state->shouldValidateToken()) {
$client->ensureValidToken();
$state->updateLastTokenCheck();
}
}
private function executeSearchRequest(Client $client, string $objectType, array $payload, PaginationState $state): array
{
try {
return $client->search($objectType, $payload);
} catch (\Exception $e) {
if ($client->isUnauthorizedException($e)) {
$this->logger->warning('[Hubspot] Got 401 during pagination, attempting token refresh', [
'team_id' => $client->getConfig()->getTeam()->getId(),
'error' => $e->getMessage(),
]);
$client->ensureValidToken();
$state->updateLastTokenCheck();
try {
$result = $client->search($objectType, $payload);
$this->logger->info('[Hubspot] Token refresh and retry successful', [
'team_id' => $client->getConfig()->getTeam()->getId(),
]);
return $result;
} catch (\Exception $retryException) {
$this->logger->error('[Hubspot] Retry request failed after token refresh', [
'team_id' => $client->getConfig()->getTeam()->getId(),
'original_error' => $e->getMessage(),
'retry_error' => $retryException->getMessage(),
]);
throw $retryException;
}
}
// RateLimitException and other exceptions are re-thrown as-is
throw $e;
}
}
private function updateLastRecordId(array $page, PaginationState $state): void
{
$lastRecord = ! empty($page['results']) ? end($page['results']) : null;
$lastRecordId = $lastRecord['id'] ?? null;
$state->updateLastRecordId($lastRecordId);
}
private function getNextOffset(array $page): int
{
return isset($page['paging']['next']['after']) ? (int) $page['paging']['next']['after'] : 0;
}
private function logPaginationProgress(PaginationState $state, int $teamId, string $endpoint): void
{
if ($state->shouldLogProgress()) {
$this->logger->info('[Hubspot] Pagination progress log', [
'team_id' => $teamId,
'endpoint' => $endpoint,
'requests_made' => $state->requestCount,
'records_fetched' => $state->totalRecords,
'elapsed_seconds' => $state->getElapsedSeconds(),
]);
}
}
private function calculateDelayInMicroseconds(): int
{
return (int) (1 / PaginationConfig::SEARCH_RPS_LIMIT * 1000000);
}
}
Project
Project
New File or Directory…
Expand Selected
Collapse All
Options
Hide...
|
9817
|
NULL
|
NULL
|
NULL
|
|
9818
|
444
|
15
|
2026-05-08T13:35:24.484685+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-08/1778 /Users/lukas/.screenpipe/data/data/2026-05-08/1778247324484_m2.jpg...
|
PhpStorm
|
faVsco.js – HubspotPaginationService.php
|
1
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
JY-20725-handle-HS-search Project: faVsco.js, menu
JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
19
Previous Highlighted Error
Next Highlighted Error
[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {
"headers":{
"Date":["Thu,07 May 2026 14:21:15 GMT"],
"Content-Type":["application/json;charset=utf-8"],
"Transfer-Encoding":["chunked"],
"Connection":["keep-alive"],
"CF-Ray":["9f80deb8db60dc3a-SOF"],
"CF-Cache-Status":["DYNAMIC"],
"Strict-Transport-Security":["max-age=31536000; includeSubDomains; preload"],
"Vary":["origin,
accept-encoding"],
"access-control-allow-credentials":["false"],
"server-timing":["hcid;desc=\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\",
cfr;desc=\"9f80deb8e7c6dc3a-IAD\""],
"x-content-type-options":["nosniff"],
"x-hubspot-correlation-id":["019e02d0-6fd8-7812-bdba-885b7ccb3ee3"],
"Set-Cookie":["__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-[IP_ADDRESS]-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,
07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None"],
"Report-To":["{
\"endpoints\":[{
\"url\":\"https:\\/\\/a.nel.cloudflare.com\\/report\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\"}],
\"group\":\"cf-nel\",
\"max_age\":604800}"],
"NEL":["{
\"success_fraction\":0.01,
\"report_to\":\"cf-nel\",
\"max_age\":604800}"],
"Server":["cloudflare"]}} {
"correlation_id":"95236535-ec98-4541-b92a-adfa73b69eab",
"trace_id":"c7ab8365-903f-46d4-9403-0e5b551e3545"}
Sync Changes
Hide This Notification
Code changed:
Hide
13
Previous Highlighted Error
Next Highlighted Error
<?php
declare(strict_types=1);
namespace Jiminny\Services\Crm\Hubspot\Pagination;
use Jiminny\Exceptions\RateLimitException;
use Jiminny\Services\Crm\Hubspot\Client;
use Jiminny\Services\Crm\Hubspot\PayloadBuilder;
use Psr\Log\LoggerInterface;
use SevenShores\Hubspot\Exceptions\BadRequest;
use SevenShores\Hubspot\Exceptions\HubspotException;
use Jiminny\Exceptions\SocialAccountTokenInvalidException;
class HubspotPaginationService
{
public function __construct(
private LoggerInterface $logger
) {
}
/**
* @throws HubspotException
* @throws SocialAccountTokenInvalidException
* @throws BadRequest
*/
public function getPaginatedDataGenerator(
Client $client,
array $payload,
string $type,
int $offset = 0,
int &$total = 0,
?string &$lastRecordId = null
): \Generator {
$state = new PaginationState(offset: $offset);
$endpoint = Client::BASE_URL . "/crm/v3/objects/{$type}/search";
$defaultFilter = $payload['filters'] ?? [];
$resultsPerPage = PayloadBuilder::MAX_SEARCH_REQUEST_LIMIT;
$teamId = $client->getConfig()->getTeam()->getId();
$delay = $this->calculateDelayInMicroseconds();
do {
if ($this->shouldStopPagination($state, $teamId)) {
break;
}
$payload = $this->handlePaginationStrategy($payload, $defaultFilter, $state, $resultsPerPage, $teamId);
$this->validateTokenIfNeeded($client, $state);
if ($state->requestCount > 0) {
usleep($delay);
}
$page = $this->executeSearchRequest($client, $type, $payload, $state);
$state->setTotal($page['total'] ?? 0);
$this->updateLastRecordId($page, $state);
// Safely iterate over results with null check
$results = $page['results'] ?? [];
foreach ($results as $row) {
$state->incrementTotalRecords();
yield $row;
}
$state->setOffset($this->getNextOffset($page));
$state->incrementRequestCount();
$this->logPaginationProgress($state, $teamId, $endpoint);
} while ($state->offset && ! empty($page['results']));
// Log final pagination completion stats
$this->logger->info('[Hubspot] Pagination completed', [
'team_id' => $teamId,
'endpoint' => $endpoint,
'total_requests' => $state->requestCount,
'total_records_fetched' => $state->totalRecords,
'total_elapsed_seconds' => round($state->getElapsedSeconds(), 2),
'average_seconds_per_request' => $state->requestCount > 0 ? round($state->getElapsedSeconds() / $state->requestCount, 2) : 0,
]);
// Update reference parameters
$total = $state->total;
$lastRecordId = $state->lastRecordId;
}
private function shouldStopPagination(PaginationState $state, int $teamId): bool
{
if ($state->hasReachedSafetyLimit()) {
$this->logger->warning('[Hubspot] Reached maximum request limit during pagination', [
'team_id' => $teamId,
'safety_limit' => PaginationConfig::LOOP_SAFETY_LIMIT,
'total_fetched' => $state->totalRecords,
]);
return true;
}
return false;
}
private function handlePaginationStrategy(
array $payload,
array $defaultFilter,
PaginationState $state,
int $resultsPerPage,
int $teamId
): array {
if ($this->shouldSwitchToKeysetPagination($state, $resultsPerPage)) {
$payload['filters'] = $defaultFilter;
$payload['filters'][] = [
'propertyName' => 'hs_object_id',
'operator' => 'LT',
'value' => $state->lastRecordId,
];
$this->logger->info('[Hubspot] Search keyset pagination request', [
'team_id' => $teamId,
'sequence' => $state->requestCount,
'itemsPerPage' => $resultsPerPage,
'payload' => $payload,
'total' => $state->total,
]);
unset($payload['after']);
$state->setOffset(0);
}
if ($state->offset) {
$payload['after'] = $state->offset;
}
return $payload;
}
private function shouldSwitchToKeysetPagination(PaginationState $state, int $resultsPerPage): bool
{
// Check if we've hit the offset limit
$shouldSwitch = $state->requestCount > 0 && ($state->offset + $resultsPerPage) > PaginationConfig::TOTAL_QUERY_LIMIT;
if ($shouldSwitch && $state->lastRecordId === null) {
$this->logger->warning('[Hubspot] Cannot switch to keyset pagination: lastRecordId is null', [
'request_count' => $state->requestCount,
'current_offset' => $state->offset,
'results_per_page' => $resultsPerPage,
'total_query_limit' => PaginationConfig::TOTAL_QUERY_LIMIT,
]);
return false; // Continue with offset pagination
}
return $shouldSwitch;
}
private function validateTokenIfNeeded(Client $client, PaginationState $state): void
{
if ($state->shouldValidateToken()) {
$client->ensureValidToken();
$state->updateLastTokenCheck();
}
}
private function executeSearchRequest(Client $client, string $objectType, array $payload, PaginationState $state): array
{
try {
return $client->search($objectType, $payload);
} catch (\Exception $e) {
if ($client->isUnauthorizedException($e)) {
$this->logger->warning('[Hubspot] Got 401 during pagination, attempting token refresh', [
'team_id' => $client->getConfig()->getTeam()->getId(),
'error' => $e->getMessage(),
]);
$client->ensureValidToken();
$state->updateLastTokenCheck();
try {
$result = $client->search($objectType, $payload);
$this->logger->info('[Hubspot] Token refresh and retry successful', [
'team_id' => $client->getConfig()->getTeam()->getId(),
]);
return $result;
} catch (\Exception $retryException) {
$this->logger->error('[Hubspot] Retry request failed after token refresh', [
'team_id' => $client->getConfig()->getTeam()->getId(),
'original_error' => $e->getMessage(),
'retry_error' => $retryException->getMessage(),
]);
throw $retryException;
}
}
// RateLimitException and other exceptions are re-thrown as-is
throw $e;
}
}
private function updateLastRecordId(array $page, PaginationState $state): void
{
$lastRecord = ! empty($page['results']) ? end($page['results']) : null;
$lastRecordId = $lastRecord['id'] ?? null;
$state->updateLastRecordId($lastRecordId);
}
private function getNextOffset(array $page): int
{
return isset($page['paging']['next']['after']) ? (int) $page['paging']['next']['after'] : 0;
}
private function logPaginationProgress(PaginationState $state, int $teamId, string $endpoint): void
{
if ($state->shouldLogProgress()) {
$this->logger->info('[Hubspot] Pagination progress log', [
'team_id' => $teamId,
'endpoint' => $endpoint,
'requests_made' => $state->requestCount,
'records_fetched' => $state->totalRecords,
'elapsed_seconds' => $state->getElapsedSeconds(),
]);
}
}
private function calculateDelayInMicroseconds(): int
{
return (int) (1 / PaginationConfig::SEARCH_RPS_LIMIT * 1000000);
}
}
Project
Project
New File or Directory…
Expand Selected
Collapse All
Options
Hide...
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"bounds":{"left":0.025930852,"top":0.019952115,"width":0.03856383,"height":0.025538707},"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JY-20725-handle-HS-search-rate-limit, menu","depth":5,"bounds":{"left":0.064494684,"top":0.019952115,"width":0.09541223,"height":0.025538707},"on_screen":true,"help_text":"Git Branch: JY-20725-handle-HS-search-rate-limit","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"bounds":{"left":0.8081782,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"AskJiminnyReportActivityServiceTest","depth":6,"bounds":{"left":0.8234708,"top":0.019952115,"width":0.09208777,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'AskJiminnyReportActivityServiceTest'","depth":6,"bounds":{"left":0.9155585,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'AskJiminnyReportActivityServiceTest'","depth":6,"bounds":{"left":0.9268617,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"bounds":{"left":0.9381649,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"bounds":{"left":0.96609044,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"bounds":{"left":0.9773936,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"IDE and Project Settings","depth":5,"bounds":{"left":0.9886968,"top":0.019952115,"width":0.011303186,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.042220745,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"19","depth":4,"bounds":{"left":0.6615692,"top":0.10055866,"width":0.009640957,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"bounds":{"left":0.67287236,"top":0.09896249,"width":0.00731383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"bounds":{"left":0.68018615,"top":0.09896249,"width":0.006981383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {\n\"headers\":{\n\"Date\":[\"Thu,07 May 2026 14:21:15 GMT\"],\n \"Content-Type\":[\"application/json;charset=utf-8\"],\n \"Transfer-Encoding\":[\"chunked\"],\n \"Connection\":[\"keep-alive\"],\n \"CF-Ray\":[\"9f80deb8db60dc3a-SOF\"],\n \"CF-Cache-Status\":[\"DYNAMIC\"],\n \"Strict-Transport-Security\":[\"max-age=31536000; includeSubDomains; preload\"],\n \"Vary\":[\"origin,\n accept-encoding\"],\n \"access-control-allow-credentials\":[\"false\"],\n \"server-timing\":[\"hcid;desc=\\\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\\\",\n cfr;desc=\\\"9f80deb8e7c6dc3a-IAD\\\"\"],\n \"x-content-type-options\":[\"nosniff\"],\n \"x-hubspot-correlation-id\":[\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\"],\n \"Set-Cookie\":[\"__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-1.0.1.1-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,\n 07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None\"],\n \"Report-To\":[\"{\n\\\"endpoints\\\":[{\n\\\"url\\\":\\\"https:\\\\/\\\\/a.nel.cloudflare.com\\\\/report\\\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\\\"}],\n\\\"group\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"NEL\":[\"{\n\\\"success_fraction\\\":0.01,\n\\\"report_to\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"Server\":[\"cloudflare\"]}} {\n\"correlation_id\":\"95236535-ec98-4541-b92a-adfa73b69eab\",\n\"trace_id\":\"c7ab8365-903f-46d4-9403-0e5b551e3545\"}","depth":4,"bounds":{"left":0.37632978,"top":0.09736632,"width":0.5728058,"height":0.8818835},"on_screen":true,"lines":[{"char_start":207,"char_count":30,"bounds":{"left":0.37632978,"top":0.0,"width":0.07513298,"height":0.014365523}},{"char_start":237,"char_count":36,"bounds":{"left":0.37632978,"top":0.0,"width":0.09075798,"height":0.014365523}},{"char_start":273,"char_count":32,"bounds":{"left":0.37632978,"top":0.0,"width":0.080119684,"height":0.014365523}},{"char_start":305,"char_count":79,"bounds":{"left":0.37632978,"top":0.0,"width":0.20212767,"height":0.014365523}},{"char_start":384,"char_count":18,"bounds":{"left":0.37632978,"top":0.0,"width":0.043882977,"height":0.014365523}},{"char_start":402,"char_count":21,"bounds":{"left":0.37632978,"top":0.0,"width":0.051861703,"height":0.014365523}},{"char_start":423,"char_count":48,"bounds":{"left":0.37632978,"top":0.008778931,"width":0.12167553,"height":0.014365523}},{"char_start":471,"char_count":72,"bounds":{"left":0.37632978,"top":0.026336791,"width":0.18384309,"height":0.014365523}},{"char_start":543,"char_count":40,"bounds":{"left":0.37632978,"top":0.043894652,"width":0.10106383,"height":0.014365523}},{"char_start":583,"char_count":41,"bounds":{"left":0.37632978,"top":0.061452515,"width":0.10372341,"height":0.014365523}},{"char_start":624,"char_count":72,"bounds":{"left":0.37632978,"top":0.079010375,"width":0.18384309,"height":0.014365523}},{"char_start":696,"char_count":219,"bounds":{"left":0.37632978,"top":0.096568234,"width":0.56515956,"height":0.014365523}},{"char_start":915,"char_count":83,"bounds":{"left":0.37632978,"top":0.11412609,"width":0.21243352,"height":0.014365523}},{"char_start":998,"char_count":20,"bounds":{"left":0.37632978,"top":0.13168396,"width":0.04920213,"height":0.014365523}},{"char_start":1018,"char_count":17,"bounds":{"left":0.37632978,"top":0.14924182,"width":0.041223403,"height":0.014365523}},{"char_start":1035,"char_count":203,"bounds":{"left":0.37632978,"top":0.16679968,"width":0.52360374,"height":0.014365523}},{"char_start":1238,"char_count":22,"bounds":{"left":0.37632978,"top":0.18435754,"width":0.05418883,"height":0.014365523}},{"char_start":1260,"char_count":23,"bounds":{"left":0.37632978,"top":0.2019154,"width":0.056848403,"height":0.014365523}},{"char_start":1283,"char_count":10,"bounds":{"left":0.37632978,"top":0.21947326,"width":0.023271276,"height":0.014365523}},{"char_start":1293,"char_count":27,"bounds":{"left":0.37632978,"top":0.23703113,"width":0.06715426,"height":0.014365523}},{"char_start":1320,"char_count":26,"bounds":{"left":0.37632978,"top":0.254589,"width":0.06482713,"height":0.014365523}},{"char_start":1346,"char_count":23,"bounds":{"left":0.37632978,"top":0.27214685,"width":0.056848403,"height":0.014365523}},{"char_start":1369,"char_count":28,"bounds":{"left":0.37632978,"top":0.2897047,"width":0.06981383,"height":0.014365523}},{"char_start":1397,"char_count":57,"bounds":{"left":0.37632978,"top":0.30726257,"width":0.14494681,"height":0.014365523}}],"value":"[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {\n\"headers\":{\n\"Date\":[\"Thu,07 May 2026 14:21:15 GMT\"],\n \"Content-Type\":[\"application/json;charset=utf-8\"],\n \"Transfer-Encoding\":[\"chunked\"],\n \"Connection\":[\"keep-alive\"],\n \"CF-Ray\":[\"9f80deb8db60dc3a-SOF\"],\n \"CF-Cache-Status\":[\"DYNAMIC\"],\n \"Strict-Transport-Security\":[\"max-age=31536000; includeSubDomains; preload\"],\n \"Vary\":[\"origin,\n accept-encoding\"],\n \"access-control-allow-credentials\":[\"false\"],\n \"server-timing\":[\"hcid;desc=\\\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\\\",\n cfr;desc=\\\"9f80deb8e7c6dc3a-IAD\\\"\"],\n \"x-content-type-options\":[\"nosniff\"],\n \"x-hubspot-correlation-id\":[\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\"],\n \"Set-Cookie\":[\"__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-1.0.1.1-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,\n 07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None\"],\n \"Report-To\":[\"{\n\\\"endpoints\\\":[{\n\\\"url\\\":\\\"https:\\\\/\\\\/a.nel.cloudflare.com\\\\/report\\\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\\\"}],\n\\\"group\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"NEL\":[\"{\n\\\"success_fraction\\\":0.01,\n\\\"report_to\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"Server\":[\"cloudflare\"]}} {\n\"correlation_id\":\"95236535-ec98-4541-b92a-adfa73b69eab\",\n\"trace_id\":\"c7ab8365-903f-46d4-9403-0e5b551e3545\"}","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.042220745,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"13","depth":4,"bounds":{"left":0.33144948,"top":0.19952115,"width":0.009640957,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"bounds":{"left":0.34275267,"top":0.19792499,"width":0.00731383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"bounds":{"left":0.35006648,"top":0.19792499,"width":0.006981383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\ndeclare(strict_types=1);\n\nnamespace Jiminny\\Services\\Crm\\Hubspot\\Pagination;\n\nuse Jiminny\\Exceptions\\RateLimitException;\nuse Jiminny\\Services\\Crm\\Hubspot\\Client;\nuse Jiminny\\Services\\Crm\\Hubspot\\PayloadBuilder;\nuse Psr\\Log\\LoggerInterface;\nuse SevenShores\\Hubspot\\Exceptions\\BadRequest;\nuse SevenShores\\Hubspot\\Exceptions\\HubspotException;\nuse Jiminny\\Exceptions\\SocialAccountTokenInvalidException;\n\nclass HubspotPaginationService\n{\n public function __construct(\n private LoggerInterface $logger\n ) {\n }\n\n /**\n * @throws HubspotException\n * @throws SocialAccountTokenInvalidException\n * @throws BadRequest\n */\n public function getPaginatedDataGenerator(\n Client $client,\n array $payload,\n string $type,\n int $offset = 0,\n int &$total = 0,\n ?string &$lastRecordId = null\n ): \\Generator {\n $state = new PaginationState(offset: $offset);\n $endpoint = Client::BASE_URL . \"/crm/v3/objects/{$type}/search\";\n $defaultFilter = $payload['filters'] ?? [];\n $resultsPerPage = PayloadBuilder::MAX_SEARCH_REQUEST_LIMIT;\n $teamId = $client->getConfig()->getTeam()->getId();\n $delay = $this->calculateDelayInMicroseconds();\n\n do {\n if ($this->shouldStopPagination($state, $teamId)) {\n break;\n }\n\n $payload = $this->handlePaginationStrategy($payload, $defaultFilter, $state, $resultsPerPage, $teamId);\n\n $this->validateTokenIfNeeded($client, $state);\n if ($state->requestCount > 0) {\n usleep($delay);\n }\n\n $page = $this->executeSearchRequest($client, $type, $payload, $state);\n\n $state->setTotal($page['total'] ?? 0);\n $this->updateLastRecordId($page, $state);\n\n // Safely iterate over results with null check\n $results = $page['results'] ?? [];\n foreach ($results as $row) {\n $state->incrementTotalRecords();\n yield $row;\n }\n\n $state->setOffset($this->getNextOffset($page));\n $state->incrementRequestCount();\n\n $this->logPaginationProgress($state, $teamId, $endpoint);\n } while ($state->offset && ! empty($page['results']));\n\n // Log final pagination completion stats\n $this->logger->info('[Hubspot] Pagination completed', [\n 'team_id' => $teamId,\n 'endpoint' => $endpoint,\n 'total_requests' => $state->requestCount,\n 'total_records_fetched' => $state->totalRecords,\n 'total_elapsed_seconds' => round($state->getElapsedSeconds(), 2),\n 'average_seconds_per_request' => $state->requestCount > 0 ? round($state->getElapsedSeconds() / $state->requestCount, 2) : 0,\n ]);\n\n // Update reference parameters\n $total = $state->total;\n $lastRecordId = $state->lastRecordId;\n }\n\n private function shouldStopPagination(PaginationState $state, int $teamId): bool\n {\n if ($state->hasReachedSafetyLimit()) {\n $this->logger->warning('[Hubspot] Reached maximum request limit during pagination', [\n 'team_id' => $teamId,\n 'safety_limit' => PaginationConfig::LOOP_SAFETY_LIMIT,\n 'total_fetched' => $state->totalRecords,\n ]);\n\n return true;\n }\n\n return false;\n }\n\n private function handlePaginationStrategy(\n array $payload,\n array $defaultFilter,\n PaginationState $state,\n int $resultsPerPage,\n int $teamId\n ): array {\n if ($this->shouldSwitchToKeysetPagination($state, $resultsPerPage)) {\n $payload['filters'] = $defaultFilter;\n $payload['filters'][] = [\n 'propertyName' => 'hs_object_id',\n 'operator' => 'LT',\n 'value' => $state->lastRecordId,\n ];\n\n $this->logger->info('[Hubspot] Search keyset pagination request', [\n 'team_id' => $teamId,\n 'sequence' => $state->requestCount,\n 'itemsPerPage' => $resultsPerPage,\n 'payload' => $payload,\n 'total' => $state->total,\n ]);\n\n unset($payload['after']);\n $state->setOffset(0);\n }\n\n if ($state->offset) {\n $payload['after'] = $state->offset;\n }\n\n return $payload;\n }\n\n private function shouldSwitchToKeysetPagination(PaginationState $state, int $resultsPerPage): bool\n {\n // Check if we've hit the offset limit\n $shouldSwitch = $state->requestCount > 0 && ($state->offset + $resultsPerPage) > PaginationConfig::TOTAL_QUERY_LIMIT;\n\n if ($shouldSwitch && $state->lastRecordId === null) {\n $this->logger->warning('[Hubspot] Cannot switch to keyset pagination: lastRecordId is null', [\n 'request_count' => $state->requestCount,\n 'current_offset' => $state->offset,\n 'results_per_page' => $resultsPerPage,\n 'total_query_limit' => PaginationConfig::TOTAL_QUERY_LIMIT,\n ]);\n\n return false; // Continue with offset pagination\n }\n\n return $shouldSwitch;\n }\n\n private function validateTokenIfNeeded(Client $client, PaginationState $state): void\n {\n if ($state->shouldValidateToken()) {\n $client->ensureValidToken();\n $state->updateLastTokenCheck();\n }\n }\n\n private function executeSearchRequest(Client $client, string $objectType, array $payload, PaginationState $state): array\n {\n try {\n return $client->search($objectType, $payload);\n } catch (\\Exception $e) {\n if ($client->isUnauthorizedException($e)) {\n $this->logger->warning('[Hubspot] Got 401 during pagination, attempting token refresh', [\n 'team_id' => $client->getConfig()->getTeam()->getId(),\n 'error' => $e->getMessage(),\n ]);\n\n $client->ensureValidToken();\n $state->updateLastTokenCheck();\n\n try {\n $result = $client->search($objectType, $payload);\n\n $this->logger->info('[Hubspot] Token refresh and retry successful', [\n 'team_id' => $client->getConfig()->getTeam()->getId(),\n ]);\n\n return $result;\n } catch (\\Exception $retryException) {\n $this->logger->error('[Hubspot] Retry request failed after token refresh', [\n 'team_id' => $client->getConfig()->getTeam()->getId(),\n 'original_error' => $e->getMessage(),\n 'retry_error' => $retryException->getMessage(),\n ]);\n\n throw $retryException;\n }\n }\n\n // RateLimitException and other exceptions are re-thrown as-is\n throw $e;\n }\n }\n\n private function updateLastRecordId(array $page, PaginationState $state): void\n {\n $lastRecord = ! empty($page['results']) ? end($page['results']) : null;\n $lastRecordId = $lastRecord['id'] ?? null;\n $state->updateLastRecordId($lastRecordId);\n }\n\n private function getNextOffset(array $page): int\n {\n return isset($page['paging']['next']['after']) ? (int) $page['paging']['next']['after'] : 0;\n }\n\n private function logPaginationProgress(PaginationState $state, int $teamId, string $endpoint): void\n {\n if ($state->shouldLogProgress()) {\n $this->logger->info('[Hubspot] Pagination progress log', [\n 'team_id' => $teamId,\n 'endpoint' => $endpoint,\n 'requests_made' => $state->requestCount,\n 'records_fetched' => $state->totalRecords,\n 'elapsed_seconds' => $state->getElapsedSeconds(),\n ]);\n }\n }\n\n private function calculateDelayInMicroseconds(): int\n {\n return (int) (1 / PaginationConfig::SEARCH_RPS_LIMIT * 1000000);\n }\n}","depth":4,"on_screen":true,"value":"<?php\n\ndeclare(strict_types=1);\n\nnamespace Jiminny\\Services\\Crm\\Hubspot\\Pagination;\n\nuse Jiminny\\Exceptions\\RateLimitException;\nuse Jiminny\\Services\\Crm\\Hubspot\\Client;\nuse Jiminny\\Services\\Crm\\Hubspot\\PayloadBuilder;\nuse Psr\\Log\\LoggerInterface;\nuse SevenShores\\Hubspot\\Exceptions\\BadRequest;\nuse SevenShores\\Hubspot\\Exceptions\\HubspotException;\nuse Jiminny\\Exceptions\\SocialAccountTokenInvalidException;\n\nclass HubspotPaginationService\n{\n public function __construct(\n private LoggerInterface $logger\n ) {\n }\n\n /**\n * @throws HubspotException\n * @throws SocialAccountTokenInvalidException\n * @throws BadRequest\n */\n public function getPaginatedDataGenerator(\n Client $client,\n array $payload,\n string $type,\n int $offset = 0,\n int &$total = 0,\n ?string &$lastRecordId = null\n ): \\Generator {\n $state = new PaginationState(offset: $offset);\n $endpoint = Client::BASE_URL . \"/crm/v3/objects/{$type}/search\";\n $defaultFilter = $payload['filters'] ?? [];\n $resultsPerPage = PayloadBuilder::MAX_SEARCH_REQUEST_LIMIT;\n $teamId = $client->getConfig()->getTeam()->getId();\n $delay = $this->calculateDelayInMicroseconds();\n\n do {\n if ($this->shouldStopPagination($state, $teamId)) {\n break;\n }\n\n $payload = $this->handlePaginationStrategy($payload, $defaultFilter, $state, $resultsPerPage, $teamId);\n\n $this->validateTokenIfNeeded($client, $state);\n if ($state->requestCount > 0) {\n usleep($delay);\n }\n\n $page = $this->executeSearchRequest($client, $type, $payload, $state);\n\n $state->setTotal($page['total'] ?? 0);\n $this->updateLastRecordId($page, $state);\n\n // Safely iterate over results with null check\n $results = $page['results'] ?? [];\n foreach ($results as $row) {\n $state->incrementTotalRecords();\n yield $row;\n }\n\n $state->setOffset($this->getNextOffset($page));\n $state->incrementRequestCount();\n\n $this->logPaginationProgress($state, $teamId, $endpoint);\n } while ($state->offset && ! empty($page['results']));\n\n // Log final pagination completion stats\n $this->logger->info('[Hubspot] Pagination completed', [\n 'team_id' => $teamId,\n 'endpoint' => $endpoint,\n 'total_requests' => $state->requestCount,\n 'total_records_fetched' => $state->totalRecords,\n 'total_elapsed_seconds' => round($state->getElapsedSeconds(), 2),\n 'average_seconds_per_request' => $state->requestCount > 0 ? round($state->getElapsedSeconds() / $state->requestCount, 2) : 0,\n ]);\n\n // Update reference parameters\n $total = $state->total;\n $lastRecordId = $state->lastRecordId;\n }\n\n private function shouldStopPagination(PaginationState $state, int $teamId): bool\n {\n if ($state->hasReachedSafetyLimit()) {\n $this->logger->warning('[Hubspot] Reached maximum request limit during pagination', [\n 'team_id' => $teamId,\n 'safety_limit' => PaginationConfig::LOOP_SAFETY_LIMIT,\n 'total_fetched' => $state->totalRecords,\n ]);\n\n return true;\n }\n\n return false;\n }\n\n private function handlePaginationStrategy(\n array $payload,\n array $defaultFilter,\n PaginationState $state,\n int $resultsPerPage,\n int $teamId\n ): array {\n if ($this->shouldSwitchToKeysetPagination($state, $resultsPerPage)) {\n $payload['filters'] = $defaultFilter;\n $payload['filters'][] = [\n 'propertyName' => 'hs_object_id',\n 'operator' => 'LT',\n 'value' => $state->lastRecordId,\n ];\n\n $this->logger->info('[Hubspot] Search keyset pagination request', [\n 'team_id' => $teamId,\n 'sequence' => $state->requestCount,\n 'itemsPerPage' => $resultsPerPage,\n 'payload' => $payload,\n 'total' => $state->total,\n ]);\n\n unset($payload['after']);\n $state->setOffset(0);\n }\n\n if ($state->offset) {\n $payload['after'] = $state->offset;\n }\n\n return $payload;\n }\n\n private function shouldSwitchToKeysetPagination(PaginationState $state, int $resultsPerPage): bool\n {\n // Check if we've hit the offset limit\n $shouldSwitch = $state->requestCount > 0 && ($state->offset + $resultsPerPage) > PaginationConfig::TOTAL_QUERY_LIMIT;\n\n if ($shouldSwitch && $state->lastRecordId === null) {\n $this->logger->warning('[Hubspot] Cannot switch to keyset pagination: lastRecordId is null', [\n 'request_count' => $state->requestCount,\n 'current_offset' => $state->offset,\n 'results_per_page' => $resultsPerPage,\n 'total_query_limit' => PaginationConfig::TOTAL_QUERY_LIMIT,\n ]);\n\n return false; // Continue with offset pagination\n }\n\n return $shouldSwitch;\n }\n\n private function validateTokenIfNeeded(Client $client, PaginationState $state): void\n {\n if ($state->shouldValidateToken()) {\n $client->ensureValidToken();\n $state->updateLastTokenCheck();\n }\n }\n\n private function executeSearchRequest(Client $client, string $objectType, array $payload, PaginationState $state): array\n {\n try {\n return $client->search($objectType, $payload);\n } catch (\\Exception $e) {\n if ($client->isUnauthorizedException($e)) {\n $this->logger->warning('[Hubspot] Got 401 during pagination, attempting token refresh', [\n 'team_id' => $client->getConfig()->getTeam()->getId(),\n 'error' => $e->getMessage(),\n ]);\n\n $client->ensureValidToken();\n $state->updateLastTokenCheck();\n\n try {\n $result = $client->search($objectType, $payload);\n\n $this->logger->info('[Hubspot] Token refresh and retry successful', [\n 'team_id' => $client->getConfig()->getTeam()->getId(),\n ]);\n\n return $result;\n } catch (\\Exception $retryException) {\n $this->logger->error('[Hubspot] Retry request failed after token refresh', [\n 'team_id' => $client->getConfig()->getTeam()->getId(),\n 'original_error' => $e->getMessage(),\n 'retry_error' => $retryException->getMessage(),\n ]);\n\n throw $retryException;\n }\n }\n\n // RateLimitException and other exceptions are re-thrown as-is\n throw $e;\n }\n }\n\n private function updateLastRecordId(array $page, PaginationState $state): void\n {\n $lastRecord = ! empty($page['results']) ? end($page['results']) : null;\n $lastRecordId = $lastRecord['id'] ?? null;\n $state->updateLastRecordId($lastRecordId);\n }\n\n private function getNextOffset(array $page): int\n {\n return isset($page['paging']['next']['after']) ? (int) $page['paging']['next']['after'] : 0;\n }\n\n private function logPaginationProgress(PaginationState $state, int $teamId, string $endpoint): void\n {\n if ($state->shouldLogProgress()) {\n $this->logger->info('[Hubspot] Pagination progress log', [\n 'team_id' => $teamId,\n 'endpoint' => $endpoint,\n 'requests_made' => $state->requestCount,\n 'records_fetched' => $state->totalRecords,\n 'elapsed_seconds' => $state->getElapsedSeconds(),\n ]);\n }\n }\n\n private function calculateDelayInMicroseconds(): int\n {\n return (int) (1 / PaginationConfig::SEARCH_RPS_LIMIT * 1000000);\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":true,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Project","depth":3,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Project","depth":3,"bounds":{"left":0.011968086,"top":0.047885075,"width":0.024268618,"height":0.024740623},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"New File or Directory…","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Expand Selected","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Collapse All","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Options","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
-8710375623998314330
|
-5589570795668856337
|
click
|
accessibility
|
NULL
|
Project: faVsco.js, menu
JY-20725-handle-HS-search Project: faVsco.js, menu
JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
19
Previous Highlighted Error
Next Highlighted Error
[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {
"headers":{
"Date":["Thu,07 May 2026 14:21:15 GMT"],
"Content-Type":["application/json;charset=utf-8"],
"Transfer-Encoding":["chunked"],
"Connection":["keep-alive"],
"CF-Ray":["9f80deb8db60dc3a-SOF"],
"CF-Cache-Status":["DYNAMIC"],
"Strict-Transport-Security":["max-age=31536000; includeSubDomains; preload"],
"Vary":["origin,
accept-encoding"],
"access-control-allow-credentials":["false"],
"server-timing":["hcid;desc=\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\",
cfr;desc=\"9f80deb8e7c6dc3a-IAD\""],
"x-content-type-options":["nosniff"],
"x-hubspot-correlation-id":["019e02d0-6fd8-7812-bdba-885b7ccb3ee3"],
"Set-Cookie":["__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-[IP_ADDRESS]-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,
07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None"],
"Report-To":["{
\"endpoints\":[{
\"url\":\"https:\\/\\/a.nel.cloudflare.com\\/report\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\"}],
\"group\":\"cf-nel\",
\"max_age\":604800}"],
"NEL":["{
\"success_fraction\":0.01,
\"report_to\":\"cf-nel\",
\"max_age\":604800}"],
"Server":["cloudflare"]}} {
"correlation_id":"95236535-ec98-4541-b92a-adfa73b69eab",
"trace_id":"c7ab8365-903f-46d4-9403-0e5b551e3545"}
Sync Changes
Hide This Notification
Code changed:
Hide
13
Previous Highlighted Error
Next Highlighted Error
<?php
declare(strict_types=1);
namespace Jiminny\Services\Crm\Hubspot\Pagination;
use Jiminny\Exceptions\RateLimitException;
use Jiminny\Services\Crm\Hubspot\Client;
use Jiminny\Services\Crm\Hubspot\PayloadBuilder;
use Psr\Log\LoggerInterface;
use SevenShores\Hubspot\Exceptions\BadRequest;
use SevenShores\Hubspot\Exceptions\HubspotException;
use Jiminny\Exceptions\SocialAccountTokenInvalidException;
class HubspotPaginationService
{
public function __construct(
private LoggerInterface $logger
) {
}
/**
* @throws HubspotException
* @throws SocialAccountTokenInvalidException
* @throws BadRequest
*/
public function getPaginatedDataGenerator(
Client $client,
array $payload,
string $type,
int $offset = 0,
int &$total = 0,
?string &$lastRecordId = null
): \Generator {
$state = new PaginationState(offset: $offset);
$endpoint = Client::BASE_URL . "/crm/v3/objects/{$type}/search";
$defaultFilter = $payload['filters'] ?? [];
$resultsPerPage = PayloadBuilder::MAX_SEARCH_REQUEST_LIMIT;
$teamId = $client->getConfig()->getTeam()->getId();
$delay = $this->calculateDelayInMicroseconds();
do {
if ($this->shouldStopPagination($state, $teamId)) {
break;
}
$payload = $this->handlePaginationStrategy($payload, $defaultFilter, $state, $resultsPerPage, $teamId);
$this->validateTokenIfNeeded($client, $state);
if ($state->requestCount > 0) {
usleep($delay);
}
$page = $this->executeSearchRequest($client, $type, $payload, $state);
$state->setTotal($page['total'] ?? 0);
$this->updateLastRecordId($page, $state);
// Safely iterate over results with null check
$results = $page['results'] ?? [];
foreach ($results as $row) {
$state->incrementTotalRecords();
yield $row;
}
$state->setOffset($this->getNextOffset($page));
$state->incrementRequestCount();
$this->logPaginationProgress($state, $teamId, $endpoint);
} while ($state->offset && ! empty($page['results']));
// Log final pagination completion stats
$this->logger->info('[Hubspot] Pagination completed', [
'team_id' => $teamId,
'endpoint' => $endpoint,
'total_requests' => $state->requestCount,
'total_records_fetched' => $state->totalRecords,
'total_elapsed_seconds' => round($state->getElapsedSeconds(), 2),
'average_seconds_per_request' => $state->requestCount > 0 ? round($state->getElapsedSeconds() / $state->requestCount, 2) : 0,
]);
// Update reference parameters
$total = $state->total;
$lastRecordId = $state->lastRecordId;
}
private function shouldStopPagination(PaginationState $state, int $teamId): bool
{
if ($state->hasReachedSafetyLimit()) {
$this->logger->warning('[Hubspot] Reached maximum request limit during pagination', [
'team_id' => $teamId,
'safety_limit' => PaginationConfig::LOOP_SAFETY_LIMIT,
'total_fetched' => $state->totalRecords,
]);
return true;
}
return false;
}
private function handlePaginationStrategy(
array $payload,
array $defaultFilter,
PaginationState $state,
int $resultsPerPage,
int $teamId
): array {
if ($this->shouldSwitchToKeysetPagination($state, $resultsPerPage)) {
$payload['filters'] = $defaultFilter;
$payload['filters'][] = [
'propertyName' => 'hs_object_id',
'operator' => 'LT',
'value' => $state->lastRecordId,
];
$this->logger->info('[Hubspot] Search keyset pagination request', [
'team_id' => $teamId,
'sequence' => $state->requestCount,
'itemsPerPage' => $resultsPerPage,
'payload' => $payload,
'total' => $state->total,
]);
unset($payload['after']);
$state->setOffset(0);
}
if ($state->offset) {
$payload['after'] = $state->offset;
}
return $payload;
}
private function shouldSwitchToKeysetPagination(PaginationState $state, int $resultsPerPage): bool
{
// Check if we've hit the offset limit
$shouldSwitch = $state->requestCount > 0 && ($state->offset + $resultsPerPage) > PaginationConfig::TOTAL_QUERY_LIMIT;
if ($shouldSwitch && $state->lastRecordId === null) {
$this->logger->warning('[Hubspot] Cannot switch to keyset pagination: lastRecordId is null', [
'request_count' => $state->requestCount,
'current_offset' => $state->offset,
'results_per_page' => $resultsPerPage,
'total_query_limit' => PaginationConfig::TOTAL_QUERY_LIMIT,
]);
return false; // Continue with offset pagination
}
return $shouldSwitch;
}
private function validateTokenIfNeeded(Client $client, PaginationState $state): void
{
if ($state->shouldValidateToken()) {
$client->ensureValidToken();
$state->updateLastTokenCheck();
}
}
private function executeSearchRequest(Client $client, string $objectType, array $payload, PaginationState $state): array
{
try {
return $client->search($objectType, $payload);
} catch (\Exception $e) {
if ($client->isUnauthorizedException($e)) {
$this->logger->warning('[Hubspot] Got 401 during pagination, attempting token refresh', [
'team_id' => $client->getConfig()->getTeam()->getId(),
'error' => $e->getMessage(),
]);
$client->ensureValidToken();
$state->updateLastTokenCheck();
try {
$result = $client->search($objectType, $payload);
$this->logger->info('[Hubspot] Token refresh and retry successful', [
'team_id' => $client->getConfig()->getTeam()->getId(),
]);
return $result;
} catch (\Exception $retryException) {
$this->logger->error('[Hubspot] Retry request failed after token refresh', [
'team_id' => $client->getConfig()->getTeam()->getId(),
'original_error' => $e->getMessage(),
'retry_error' => $retryException->getMessage(),
]);
throw $retryException;
}
}
// RateLimitException and other exceptions are re-thrown as-is
throw $e;
}
}
private function updateLastRecordId(array $page, PaginationState $state): void
{
$lastRecord = ! empty($page['results']) ? end($page['results']) : null;
$lastRecordId = $lastRecord['id'] ?? null;
$state->updateLastRecordId($lastRecordId);
}
private function getNextOffset(array $page): int
{
return isset($page['paging']['next']['after']) ? (int) $page['paging']['next']['after'] : 0;
}
private function logPaginationProgress(PaginationState $state, int $teamId, string $endpoint): void
{
if ($state->shouldLogProgress()) {
$this->logger->info('[Hubspot] Pagination progress log', [
'team_id' => $teamId,
'endpoint' => $endpoint,
'requests_made' => $state->requestCount,
'records_fetched' => $state->totalRecords,
'elapsed_seconds' => $state->getElapsedSeconds(),
]);
}
}
private function calculateDelayInMicroseconds(): int
{
return (int) (1 / PaginationConfig::SEARCH_RPS_LIMIT * 1000000);
}
}
Project
Project
New File or Directory…
Expand Selected
Collapse All
Options
Hide...
|
9816
|
NULL
|
NULL
|
NULL
|
|
9817
|
443
|
9
|
2026-05-08T13:35:18.490689+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-08/1778 /Users/lukas/.screenpipe/data/data/2026-05-08/1778247318490_m1.jpg...
|
PhpStorm
|
faVsco.js – Client.php
|
1
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
JY-20725-handle-HS-search Project: faVsco.js, menu
JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
19
Previous Highlighted Error
Next Highlighted Error
[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {
"headers":{
"Date":["Thu,07 May 2026 14:21:15 GMT"],
"Content-Type":["application/json;charset=utf-8"],
"Transfer-Encoding":["chunked"],
"Connection":["keep-alive"],
"CF-Ray":["9f80deb8db60dc3a-SOF"],
"CF-Cache-Status":["DYNAMIC"],
"Strict-Transport-Security":["max-age=31536000; includeSubDomains; preload"],
"Vary":["origin,
accept-encoding"],
"access-control-allow-credentials":["false"],
"server-timing":["hcid;desc=\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\",
cfr;desc=\"9f80deb8e7c6dc3a-IAD\""],
"x-content-type-options":["nosniff"],
"x-hubspot-correlation-id":["019e02d0-6fd8-7812-bdba-885b7ccb3ee3"],
"Set-Cookie":["__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-[IP_ADDRESS]-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,
07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None"],
"Report-To":["{
\"endpoints\":[{
\"url\":\"https:\\/\\/a.nel.cloudflare.com\\/report\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\"}],
\"group\":\"cf-nel\",
\"max_age\":604800}"],
"NEL":["{
\"success_fraction\":0.01,
\"report_to\":\"cf-nel\",
\"max_age\":604800}"],
"Server":["cloudflare"]}} {
"correlation_id":"95236535-ec98-4541-b92a-adfa73b69eab",
"trace_id":"c7ab8365-903f-46d4-9403-0e5b551e3545"}
Show Replace Field
Search History
rateLimiter
New Line
Match Case
Words
Regex
Replace History
Replace...
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JY-20725-handle-HS-search-rate-limit, menu","depth":5,"on_screen":true,"help_text":"Git Branch: JY-20725-handle-HS-search-rate-limit","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"AskJiminnyReportActivityServiceTest","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'AskJiminnyReportActivityServiceTest'","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'AskJiminnyReportActivityServiceTest'","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"IDE and Project Settings","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.088194445,"height":0.027777778},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"19","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {\n\"headers\":{\n\"Date\":[\"Thu,07 May 2026 14:21:15 GMT\"],\n \"Content-Type\":[\"application/json;charset=utf-8\"],\n \"Transfer-Encoding\":[\"chunked\"],\n \"Connection\":[\"keep-alive\"],\n \"CF-Ray\":[\"9f80deb8db60dc3a-SOF\"],\n \"CF-Cache-Status\":[\"DYNAMIC\"],\n \"Strict-Transport-Security\":[\"max-age=31536000; includeSubDomains; preload\"],\n \"Vary\":[\"origin,\n accept-encoding\"],\n \"access-control-allow-credentials\":[\"false\"],\n \"server-timing\":[\"hcid;desc=\\\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\\\",\n cfr;desc=\\\"9f80deb8e7c6dc3a-IAD\\\"\"],\n \"x-content-type-options\":[\"nosniff\"],\n \"x-hubspot-correlation-id\":[\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\"],\n \"Set-Cookie\":[\"__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-1.0.1.1-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,\n 07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None\"],\n \"Report-To\":[\"{\n\\\"endpoints\\\":[{\n\\\"url\\\":\\\"https:\\\\/\\\\/a.nel.cloudflare.com\\\\/report\\\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\\\"}],\n\\\"group\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"NEL\":[\"{\n\\\"success_fraction\\\":0.01,\n\\\"report_to\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"Server\":[\"cloudflare\"]}} {\n\"correlation_id\":\"95236535-ec98-4541-b92a-adfa73b69eab\",\n\"trace_id\":\"c7ab8365-903f-46d4-9403-0e5b551e3545\"}","depth":4,"on_screen":true,"value":"[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {\n\"headers\":{\n\"Date\":[\"Thu,07 May 2026 14:21:15 GMT\"],\n \"Content-Type\":[\"application/json;charset=utf-8\"],\n \"Transfer-Encoding\":[\"chunked\"],\n \"Connection\":[\"keep-alive\"],\n \"CF-Ray\":[\"9f80deb8db60dc3a-SOF\"],\n \"CF-Cache-Status\":[\"DYNAMIC\"],\n \"Strict-Transport-Security\":[\"max-age=31536000; includeSubDomains; preload\"],\n \"Vary\":[\"origin,\n accept-encoding\"],\n \"access-control-allow-credentials\":[\"false\"],\n \"server-timing\":[\"hcid;desc=\\\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\\\",\n cfr;desc=\\\"9f80deb8e7c6dc3a-IAD\\\"\"],\n \"x-content-type-options\":[\"nosniff\"],\n \"x-hubspot-correlation-id\":[\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\"],\n \"Set-Cookie\":[\"__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-1.0.1.1-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,\n 07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None\"],\n \"Report-To\":[\"{\n\\\"endpoints\\\":[{\n\\\"url\\\":\\\"https:\\\\/\\\\/a.nel.cloudflare.com\\\\/report\\\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\\\"}],\n\\\"group\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"NEL\":[\"{\n\\\"success_fraction\\\":0.01,\n\\\"report_to\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"Server\":[\"cloudflare\"]}} {\n\"correlation_id\":\"95236535-ec98-4541-b92a-adfa73b69eab\",\n\"trace_id\":\"c7ab8365-903f-46d4-9403-0e5b551e3545\"}","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Show Replace Field","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Search History","depth":3,"on_screen":true,"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"rateLimiter","depth":4,"on_screen":true,"value":"rateLimiter","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"New Line","depth":3,"on_screen":true,"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Match Case","depth":3,"on_screen":true,"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Words","depth":3,"on_screen":true,"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Regex","depth":3,"on_screen":true,"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Replace History","depth":3,"bounds":{"left":0.0,"top":0.0,"width":0.015277778,"height":0.024444444},"on_screen":false,"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextField","text":"Replace","depth":4,"on_screen":false,"role_description":"text field","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
605454064966368576
|
-3048923519438845494
|
click
|
accessibility
|
NULL
|
Project: faVsco.js, menu
JY-20725-handle-HS-search Project: faVsco.js, menu
JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
19
Previous Highlighted Error
Next Highlighted Error
[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {
"headers":{
"Date":["Thu,07 May 2026 14:21:15 GMT"],
"Content-Type":["application/json;charset=utf-8"],
"Transfer-Encoding":["chunked"],
"Connection":["keep-alive"],
"CF-Ray":["9f80deb8db60dc3a-SOF"],
"CF-Cache-Status":["DYNAMIC"],
"Strict-Transport-Security":["max-age=31536000; includeSubDomains; preload"],
"Vary":["origin,
accept-encoding"],
"access-control-allow-credentials":["false"],
"server-timing":["hcid;desc=\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\",
cfr;desc=\"9f80deb8e7c6dc3a-IAD\""],
"x-content-type-options":["nosniff"],
"x-hubspot-correlation-id":["019e02d0-6fd8-7812-bdba-885b7ccb3ee3"],
"Set-Cookie":["__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-[IP_ADDRESS]-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,
07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None"],
"Report-To":["{
\"endpoints\":[{
\"url\":\"https:\\/\\/a.nel.cloudflare.com\\/report\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\"}],
\"group\":\"cf-nel\",
\"max_age\":604800}"],
"NEL":["{
\"success_fraction\":0.01,
\"report_to\":\"cf-nel\",
\"max_age\":604800}"],
"Server":["cloudflare"]}} {
"correlation_id":"95236535-ec98-4541-b92a-adfa73b69eab",
"trace_id":"c7ab8365-903f-46d4-9403-0e5b551e3545"}
Show Replace Field
Search History
rateLimiter
New Line
Match Case
Words
Regex
Replace History
Replace...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
9816
|
444
|
14
|
2026-05-08T13:35:18.404070+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-08/1778 /Users/lukas/.screenpipe/data/data/2026-05-08/1778247318404_m2.jpg...
|
PhpStorm
|
faVsco.js – Client.php
|
1
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
JY-20725-handle-HS-search Project: faVsco.js, menu
JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
19
Previous Highlighted Error
Next Highlighted Error
[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {
"headers":{
"Date":["Thu,07 May 2026 14:21:15 GMT"],
"Content-Type":["application/json;charset=utf-8"],
"Transfer-Encoding":["chunked"],
"Connection":["keep-alive"],
"CF-Ray":["9f80deb8db60dc3a-SOF"],
"CF-Cache-Status":["DYNAMIC"],
"Strict-Transport-Security":["max-age=31536000; includeSubDomains; preload"],
"Vary":["origin,
accept-encoding"],
"access-control-allow-credentials":["false"],
"server-timing":["hcid;desc=\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\",
cfr;desc=\"9f80deb8e7c6dc3a-IAD\""],
"x-content-type-options":["nosniff"],
"x-hubspot-correlation-id":["019e02d0-6fd8-7812-bdba-885b7ccb3ee3"],
"Set-Cookie":["__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-[IP_ADDRESS]-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,
07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None"],
"Report-To":["{
\"endpoints\":[{
\"url\":\"https:\\/\\/a.nel.cloudflare.com\\/report\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\"}],
\"group\":\"cf-nel\",
\"max_age\":604800}"],
"NEL":["{
\"success_fraction\":0.01,
\"report_to\":\"cf-nel\",
\"max_age\":604800}"],
"Server":["cloudflare"]}} {
"correlation_id":"95236535-ec98-4541-b92a-adfa73b69eab",
"trace_id":"c7ab8365-903f-46d4-9403-0e5b551e3545"}
Show Replace Field
Search History
rateLimiter
New Line
Match Case
Words
Regex
Replace History...
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"bounds":{"left":0.025930852,"top":0.019952115,"width":0.03856383,"height":0.025538707},"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JY-20725-handle-HS-search-rate-limit, menu","depth":5,"bounds":{"left":0.064494684,"top":0.019952115,"width":0.09541223,"height":0.025538707},"on_screen":true,"help_text":"Git Branch: JY-20725-handle-HS-search-rate-limit","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"bounds":{"left":0.8081782,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"AskJiminnyReportActivityServiceTest","depth":6,"bounds":{"left":0.8234708,"top":0.019952115,"width":0.09208777,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'AskJiminnyReportActivityServiceTest'","depth":6,"bounds":{"left":0.9155585,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'AskJiminnyReportActivityServiceTest'","depth":6,"bounds":{"left":0.9268617,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"bounds":{"left":0.9381649,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"bounds":{"left":0.96609044,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"bounds":{"left":0.9773936,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"IDE and Project Settings","depth":5,"bounds":{"left":0.9886968,"top":0.019952115,"width":0.011303186,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.042220745,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"19","depth":4,"bounds":{"left":0.6615692,"top":0.10055866,"width":0.009640957,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"bounds":{"left":0.67287236,"top":0.09896249,"width":0.00731383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"bounds":{"left":0.68018615,"top":0.09896249,"width":0.006981383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {\n\"headers\":{\n\"Date\":[\"Thu,07 May 2026 14:21:15 GMT\"],\n \"Content-Type\":[\"application/json;charset=utf-8\"],\n \"Transfer-Encoding\":[\"chunked\"],\n \"Connection\":[\"keep-alive\"],\n \"CF-Ray\":[\"9f80deb8db60dc3a-SOF\"],\n \"CF-Cache-Status\":[\"DYNAMIC\"],\n \"Strict-Transport-Security\":[\"max-age=31536000; includeSubDomains; preload\"],\n \"Vary\":[\"origin,\n accept-encoding\"],\n \"access-control-allow-credentials\":[\"false\"],\n \"server-timing\":[\"hcid;desc=\\\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\\\",\n cfr;desc=\\\"9f80deb8e7c6dc3a-IAD\\\"\"],\n \"x-content-type-options\":[\"nosniff\"],\n \"x-hubspot-correlation-id\":[\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\"],\n \"Set-Cookie\":[\"__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-1.0.1.1-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,\n 07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None\"],\n \"Report-To\":[\"{\n\\\"endpoints\\\":[{\n\\\"url\\\":\\\"https:\\\\/\\\\/a.nel.cloudflare.com\\\\/report\\\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\\\"}],\n\\\"group\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"NEL\":[\"{\n\\\"success_fraction\\\":0.01,\n\\\"report_to\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"Server\":[\"cloudflare\"]}} {\n\"correlation_id\":\"95236535-ec98-4541-b92a-adfa73b69eab\",\n\"trace_id\":\"c7ab8365-903f-46d4-9403-0e5b551e3545\"}","depth":4,"bounds":{"left":0.37632978,"top":0.09736632,"width":0.5728058,"height":0.8818835},"on_screen":true,"lines":[{"char_start":207,"char_count":30,"bounds":{"left":0.37632978,"top":0.0,"width":0.07513298,"height":0.014365523}},{"char_start":237,"char_count":36,"bounds":{"left":0.37632978,"top":0.0,"width":0.09075798,"height":0.014365523}},{"char_start":273,"char_count":32,"bounds":{"left":0.37632978,"top":0.0,"width":0.080119684,"height":0.014365523}},{"char_start":305,"char_count":79,"bounds":{"left":0.37632978,"top":0.0,"width":0.20212767,"height":0.014365523}},{"char_start":384,"char_count":18,"bounds":{"left":0.37632978,"top":0.0,"width":0.043882977,"height":0.014365523}},{"char_start":402,"char_count":21,"bounds":{"left":0.37632978,"top":0.0,"width":0.051861703,"height":0.014365523}},{"char_start":423,"char_count":48,"bounds":{"left":0.37632978,"top":0.008778931,"width":0.12167553,"height":0.014365523}},{"char_start":471,"char_count":72,"bounds":{"left":0.37632978,"top":0.026336791,"width":0.18384309,"height":0.014365523}},{"char_start":543,"char_count":40,"bounds":{"left":0.37632978,"top":0.043894652,"width":0.10106383,"height":0.014365523}},{"char_start":583,"char_count":41,"bounds":{"left":0.37632978,"top":0.061452515,"width":0.10372341,"height":0.014365523}},{"char_start":624,"char_count":72,"bounds":{"left":0.37632978,"top":0.079010375,"width":0.18384309,"height":0.014365523}},{"char_start":696,"char_count":219,"bounds":{"left":0.37632978,"top":0.096568234,"width":0.56515956,"height":0.014365523}},{"char_start":915,"char_count":83,"bounds":{"left":0.37632978,"top":0.11412609,"width":0.21243352,"height":0.014365523}},{"char_start":998,"char_count":20,"bounds":{"left":0.37632978,"top":0.13168396,"width":0.04920213,"height":0.014365523}},{"char_start":1018,"char_count":17,"bounds":{"left":0.37632978,"top":0.14924182,"width":0.041223403,"height":0.014365523}},{"char_start":1035,"char_count":203,"bounds":{"left":0.37632978,"top":0.16679968,"width":0.52360374,"height":0.014365523}},{"char_start":1238,"char_count":22,"bounds":{"left":0.37632978,"top":0.18435754,"width":0.05418883,"height":0.014365523}},{"char_start":1260,"char_count":23,"bounds":{"left":0.37632978,"top":0.2019154,"width":0.056848403,"height":0.014365523}},{"char_start":1283,"char_count":10,"bounds":{"left":0.37632978,"top":0.21947326,"width":0.023271276,"height":0.014365523}},{"char_start":1293,"char_count":27,"bounds":{"left":0.37632978,"top":0.23703113,"width":0.06715426,"height":0.014365523}},{"char_start":1320,"char_count":26,"bounds":{"left":0.37632978,"top":0.254589,"width":0.06482713,"height":0.014365523}},{"char_start":1346,"char_count":23,"bounds":{"left":0.37632978,"top":0.27214685,"width":0.056848403,"height":0.014365523}},{"char_start":1369,"char_count":28,"bounds":{"left":0.37632978,"top":0.2897047,"width":0.06981383,"height":0.014365523}},{"char_start":1397,"char_count":57,"bounds":{"left":0.37632978,"top":0.30726257,"width":0.14494681,"height":0.014365523}}],"value":"[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {\n\"headers\":{\n\"Date\":[\"Thu,07 May 2026 14:21:15 GMT\"],\n \"Content-Type\":[\"application/json;charset=utf-8\"],\n \"Transfer-Encoding\":[\"chunked\"],\n \"Connection\":[\"keep-alive\"],\n \"CF-Ray\":[\"9f80deb8db60dc3a-SOF\"],\n \"CF-Cache-Status\":[\"DYNAMIC\"],\n \"Strict-Transport-Security\":[\"max-age=31536000; includeSubDomains; preload\"],\n \"Vary\":[\"origin,\n accept-encoding\"],\n \"access-control-allow-credentials\":[\"false\"],\n \"server-timing\":[\"hcid;desc=\\\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\\\",\n cfr;desc=\\\"9f80deb8e7c6dc3a-IAD\\\"\"],\n \"x-content-type-options\":[\"nosniff\"],\n \"x-hubspot-correlation-id\":[\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\"],\n \"Set-Cookie\":[\"__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-1.0.1.1-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,\n 07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None\"],\n \"Report-To\":[\"{\n\\\"endpoints\\\":[{\n\\\"url\\\":\\\"https:\\\\/\\\\/a.nel.cloudflare.com\\\\/report\\\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\\\"}],\n\\\"group\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"NEL\":[\"{\n\\\"success_fraction\\\":0.01,\n\\\"report_to\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"Server\":[\"cloudflare\"]}} {\n\"correlation_id\":\"95236535-ec98-4541-b92a-adfa73b69eab\",\n\"trace_id\":\"c7ab8365-903f-46d4-9403-0e5b551e3545\"}","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Show Replace Field","depth":4,"bounds":{"left":0.10472074,"top":0.20430966,"width":0.008643617,"height":0.01915403},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Search History","depth":3,"bounds":{"left":0.11735372,"top":0.20351157,"width":0.00731383,"height":0.017557861},"on_screen":true,"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"rateLimiter","depth":4,"bounds":{"left":0.12832446,"top":0.20351157,"width":0.043882977,"height":0.015961692},"on_screen":true,"value":"rateLimiter","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"New Line","depth":3,"bounds":{"left":0.18118352,"top":0.20351157,"width":0.00731383,"height":0.017557861},"on_screen":true,"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Match Case","depth":3,"bounds":{"left":0.19115691,"top":0.20351157,"width":0.00731383,"height":0.017557861},"on_screen":true,"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Words","depth":3,"bounds":{"left":0.19980054,"top":0.20351157,"width":0.00731383,"height":0.017557861},"on_screen":true,"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Regex","depth":3,"bounds":{"left":0.20844415,"top":0.20351157,"width":0.00731383,"height":0.017557861},"on_screen":true,"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Replace History","depth":3,"bounds":{"left":0.27027926,"top":1.0,"width":0.00731383,"height":0.0},"on_screen":false,"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
4663238826599414392
|
-3048905926984103478
|
click
|
accessibility
|
NULL
|
Project: faVsco.js, menu
JY-20725-handle-HS-search Project: faVsco.js, menu
JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
19
Previous Highlighted Error
Next Highlighted Error
[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {
"headers":{
"Date":["Thu,07 May 2026 14:21:15 GMT"],
"Content-Type":["application/json;charset=utf-8"],
"Transfer-Encoding":["chunked"],
"Connection":["keep-alive"],
"CF-Ray":["9f80deb8db60dc3a-SOF"],
"CF-Cache-Status":["DYNAMIC"],
"Strict-Transport-Security":["max-age=31536000; includeSubDomains; preload"],
"Vary":["origin,
accept-encoding"],
"access-control-allow-credentials":["false"],
"server-timing":["hcid;desc=\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\",
cfr;desc=\"9f80deb8e7c6dc3a-IAD\""],
"x-content-type-options":["nosniff"],
"x-hubspot-correlation-id":["019e02d0-6fd8-7812-bdba-885b7ccb3ee3"],
"Set-Cookie":["__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-[IP_ADDRESS]-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,
07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None"],
"Report-To":["{
\"endpoints\":[{
\"url\":\"https:\\/\\/a.nel.cloudflare.com\\/report\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\"}],
\"group\":\"cf-nel\",
\"max_age\":604800}"],
"NEL":["{
\"success_fraction\":0.01,
\"report_to\":\"cf-nel\",
\"max_age\":604800}"],
"Server":["cloudflare"]}} {
"correlation_id":"95236535-ec98-4541-b92a-adfa73b69eab",
"trace_id":"c7ab8365-903f-46d4-9403-0e5b551e3545"}
Show Replace Field
Search History
rateLimiter
New Line
Match Case
Words
Regex
Replace History...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
9815
|
443
|
8
|
2026-05-08T13:35:13.988308+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-08/1778 /Users/lukas/.screenpipe/data/data/2026-05-08/1778247313988_m1.jpg...
|
PhpStorm
|
faVsco.js – SyncRelatedActivityManager.php
|
1
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
JY-20725-handle-HS-search Project: faVsco.js, menu
JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
19
Previous Highlighted Error
Next Highlighted Error
[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {
"headers":{
"Date":["Thu,07 May 2026 14:21:15 GMT"],
"Content-Type":["application/json;charset=utf-8"],
"Transfer-Encoding":["chunked"],
"Connection":["keep-alive"],
"CF-Ray":["9f80deb8db60dc3a-SOF"],
"CF-Cache-Status":["DYNAMIC"],
"Strict-Transport-Security":["max-age=31536000; includeSubDomains; preload"],
"Vary":["origin,
accept-encoding"],
"access-control-allow-credentials":["false"],
"server-timing":["hcid;desc=\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\",
cfr;desc=\"9f80deb8e7c6dc3a-IAD\""],
"x-content-type-options":["nosniff"],
"x-hubspot-correlation-id":["019e02d0-6fd8-7812-bdba-885b7ccb3ee3"],
"Set-Cookie":["__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-[IP_ADDRESS]-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,
07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None"],
"Report-To":["{
\"endpoints\":[{
\"url\":\"https:\\/\\/a.nel.cloudflare.com\\/report\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\"}],
\"group\":\"cf-nel\",
\"max_age\":604800}"],
"NEL":["{
\"success_fraction\":0.01,
\"report_to\":\"cf-nel\",
\"max_age\":604800}"],
"Server":["cloudflare"]}} {
"correlation_id":"95236535-ec98-4541-b92a-adfa73b69eab",
"trace_id":"c7ab8365-903f-46d4-9403-0e5b551e3545"}
Sync Changes
Hide This Notification
Code changed:
Hide
3
11
Previous Highlighted Error
Next Highlighted Error
<?php
declare(strict_types=1);
namespace Jiminny\Services\Crm\Hubspot;
use Carbon\Carbon;
use Exception;
use HubSpot\Client\Crm\Objects\ApiException;
use Jiminny\Integrations\PlaybookResolver;
use Jiminny\Models\Activity;
use Jiminny\Models\Calendar\CalendarEvent;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\Profile;
use Jiminny\Models\Playbook;
use Jiminny\Models\PlaybookCategory;
use Jiminny\Repositories\ActivityRepository;
use Jiminny\Repositories\PlaybookCategoryRepository;
use Psr\Log\LoggerInterface;
class SyncRelatedActivityManager
{
public const string OBJECT_MEETING = 'meetings';
public const string DEFAULT_ACTIVITY_TYPE = 'hs_activity_type';
private RemoteCrmObjectManipulator $crmManipulator;
public function __construct(
private readonly Client $client,
private readonly PayloadBuilder $payloadBuilder,
private readonly LoggerInterface $logger
) {
$this->crmManipulator = app(RemoteCrmObjectManipulator::class, [
'client' => $this->client,
'logger' => $this->logger,
]);
}
public function fetchAndAssociateRelatedActivity(Activity $activity): ?Activity
{
// Validate if the activity is applicable.
if ($this->isActivityApplicable($activity) === false) {
$this->logger->info('[Hubspot] Fetch related activity - not applicable', [
'activityId' => $activity->getUuid(),
]);
return null;
}
// Find and fetch related activity.
try {
$crmActivity = $this->fetchRelatedActivity($activity);
if (empty($crmActivity)) {
return null;
}
} catch (Exception $exception) {
$this->logger->error('[Hubspot] Fetch related activity - failed', [
'activityId' => $activity->getUuid(),
'reason' => $exception->getMessage(),
]);
throw $exception;
}
// get playbook category id
$playbookCategoryId = $this->getPlaybookCategoryId($activity, $crmActivity);
$data = [
'crm_provider_id' => $crmActivity['id'],
'playbook_category_id' => $playbookCategoryId,
];
if (! $this->updateActivity($activity, $data)) {
return null;
}
$this->crmManipulator->updateRelatedCrmActivity($activity, $crmActivity);
$activity->refresh();
return $activity;
}
private function isActivityApplicable(Activity $activity): bool
{
if (! $activity->isTypeConference()) {
return false;
}
if (! $this->hasMinimumFilterRequirements($activity)) {
$this->logger->info('[Hubspot] Fetch related activity - not enough filter data', [
'activityId' => $activity->getUuid(),
]);
return false;
}
// The related activity is already linked.
if ($activity->hasCrmProviderId()) {
return false;
}
if (! $activity->getUser()->getProfile() instanceof Profile) {
$this->logger->info('[Hubspot] Fetch related activity - activity user has no crm profile', [
'activityId' => $activity->getUuid(),
]);
return false;
}
return true;
}
private function hasMinimumFilterRequirements(Activity $activity): bool
{
// We need to know when it started to determine when to look for related activity.
if (! $activity->hasActualStartTime()) {
return false;
}
if (! $activity->hasActualEndTime()) {
return false;
}
// At least one prospect should be associated to the activity
if ($activity->hasContact() || $activity->hasAccount()) {
return true;
}
return false;
}
public function fetchRelatedActivity(Activity $activity): array
{
$this->logger->info('[Hubspot] Fetch related activity - searching related CRM activity', [
'activityId' => $activity->getUuid(),
]);
$payloadData = $this->getPayloadFilterData($activity);
try {
$payload = $this->payloadBuilder->getFindRelatedActivityPayload($payloadData, self::OBJECT_MEETING);
$response = $this->client->getPaginatedData($payload, self::OBJECT_MEETING);
$crmActivities = $response['results'];
} catch (Exception $e) {
$this->logger->error('[Hubspot] Fetch related activity - failed', [
'activityId' => $activity->getUuid(),
'reason' => $e->getMessage(),
]);
throw $e;
}
if (empty($crmActivities)) {
$this->logger->info('[Hubspot] Fetch related activity - no CRM related activity found', [
'activityId' => $activity->getUuid(),
'payload' => $payload,
]);
return [];
}
$relatedActivity = $this->getBestCrmActivityCandidate($crmActivities, $payloadData);
if (empty($relatedActivity)) {
$this->logger->info('[Hubspot] Fetch related activity - no related activity candidate', [
'activityId' => $activity->getUuid(),
]);
return [];
}
$this->logger->info('[Hubspot] Fetch related activity - found related CRM activity', [
'activityId' => $activity->getUuid(),
'crmActivity' => $relatedActivity,
]);
return $relatedActivity;
}
/**
* @param Activity $activity
*
* @return array
*/
protected function getPayloadFilterData(Activity $activity): array
{
[$from, $to] = $this->getActivityTimeFilterRange($activity);
$data = [
'from' => $from->getPreciseTimestamp(3),
'to' => $to->getPreciseTimestamp(3),
'contact' => $activity->hasContact() ? $activity->getContact()->getCrmProviderId() : null,
'company' => $activity->hasAccount() ? $activity->getAccount()->getCrmProviderId() : null,
'deal' => $activity->hasOpportunity() ? $activity->getOpportunity()->getCrmProviderId() : null,
'owner' => $activity->getUser()->getProfile()->getCrmProviderId(),
'activity' => $activity->getUuid(),
];
return array_filter($data);
}
private function getActivityTimeFilterRange(Activity $activity): array
{
if ($activity->hasScheduledStartTime() && $activity->hasScheduledEndTime()) {
$calendarEvent = $activity->getCalendarEvent();
if ($calendarEvent instanceof CalendarEvent) {
$from = $calendarEvent->getStartTime();
$to = $calendarEvent->getEndTime()->addMinutes(15);
} else {
$from = Carbon::instance($activity->getScheduledStartTime());
$to = Carbon::instance($activity->getScheduledEndTime())->addMinutes(15);
}
return [$from, $to];
}
$from = Carbon::instance($activity->getActualStartTime());
$to = Carbon::instance($activity->getActualEndTime());
return [$from, $to];
}
/**
* Determines the best CRM activity candidate based on the number of matching prospects and the owner.
*
* @param array $crmActivities List of CRM activities to evaluate.
* @param array $data Data containing the prospects and owner information.
*
* @return array The best CRM activity candidate.
*/
private function getBestCrmActivityCandidate(array $crmActivities, array $data): array
{
$prospectCount = count(array_intersect_key($data, array_flip(['contact', 'company', 'deal'])));
$bestCandidate = null;
$maxMatchingProspects = 0;
$validActivitiesCount = count($crmActivities);
if ($validActivitiesCount > 1) {
$this->logger->info('[Hubspot] Fetch related activity - determining the best candidate', [
'activityId' => $data['activity'],
'activitiesCount' => $validActivitiesCount,
'crmActivities' => $crmActivities,
]);
}
foreach ($crmActivities as $crmActivity) {
if ($this->isOwnerMismatch($crmActivity, $data)) {
$validActivitiesCount--;
continue;
}
if ($validActivitiesCount === 1) {
return $crmActivity;
}
if ($prospectCount > 1) {
$matchingProspects = $this->countMatchingProspects($crmActivity, $data);
if ($matchingProspects > $maxMatchingProspects) {
$maxMatchingProspects = $matchingProspects;
$bestCandidate = $crmActivity;
}
} else {
return $crmActivity;
}
}
return $bestCandidate ?? [];
}
private function isOwnerMismatch(array $crmActivity, array $data): bool
{
if ($crmActivity['properties']['hubspot_owner_id'] !== $data['owner']) {
$this->logger->info('[Hubspot] Fetch related activity - owner mismatch', [
'activityId' => $data['activity'],
'crmActivity' => $crmActivity['id'],
'jiminnyOwner' => $data['owner'],
'hubspotOwner' => $crmActivity['properties']['hubspot_owner_id'],
]);
return true;
}
return false;
}
private function countMatchingProspects(array $crmActivity, array $data): int
{
try {
$crmActivityWithAssociations = $this->client->getMeeting($crmActivity['id']);
} catch (ApiException $e) {
$this->logger->error('[Hubspot] Fetch related activity - failed to get meeting associations', [
'activityId' => $data['activity'],
'crmActivity' => $crmActivity['id'],
'reason' => $e->getMessage(),
]);
return 0;
}
$associations = $crmActivityWithAssociations->getAssociations();
$matchingProspects = 0;
$matchingProspects += $this->countMatchingAssociation($data, $associations, 'company', 'companies');
$matchingProspects += $this->countMatchingAssociation($data, $associations, 'contact', 'contacts');
$matchingProspects += $this->countMatchingAssociation($data, $associations, 'deal', 'deals');
return $matchingProspects;
}
private function countMatchingAssociation(array $data, array $associations, string $dataKey, string $associationKey): int
{
if (isset($data[$dataKey]) && isset($associations[$associationKey])) {
$associatedItems = $associations[$associationKey]->getResults();
foreach ($associatedItems as $associatedItem) {
if ($data[$dataKey] == $associatedItem->getId()) {
return 1;
}
}
}
return 0;
}
private function getPlaybookCategoryId(Activity $activity, array $crmActivity): ?int
{
if (! $activity->hasUser()) {
$this->logger->info('[Hubspot] Fetch related activity - activity has no user', [
'activityId' => $activity->getUuid(),
]);
return null;
}
$playbookResolver = app(PlaybookResolver::class);
$playbook = $playbookResolver->resolvePlaybookByUser($activity->getUser());
if (! $playbook instanceof Playbook) {
$this->logger->info('[Hubspot] Fetch related activity - playbook not set for user', [
'activityId' => $activity->getUuid(),
]);
return null;
}
if (! $playbook->getActivityField() instanceof Field) {
$this->logger->info('[Hubspot] Fetch related activity - playbook activity field not set', [
'activityId' => $activity->getUuid(),
]);
return null;
}
$playbookCategory = null;
$activityTypeSource = null;
$playbookActivityFieldName = $playbook->getActivityField()->getCrmProviderId();
if ($playbookActivityFieldName === 'activityType') {
$playbookActivityFieldName = self::DEFAULT_ACTIVITY_TYPE;
}
// First attempt to match playbook category from crm activity
if (! empty($crmActivity['properties'][$playbookActivityFieldName])) {
$playbookCategoryRepository = app(PlaybookCategoryRepository::class);
$playbookCategory = $playbookCategoryRepository->getPlaybookCategoryByName(
$playbook,
strval($crmActivity['properties'][$playbookActivityFieldName])
);
$activityTypeSource = 'crm';
}
// If not matched get playbook category from Jiminny activity
if (! $playbookCategory instanceof PlaybookCategory && $activity->hasActivityType()) {
$playbookCategory = $activity->getActivityType();
$activityTypeSource = 'jiminny';
}
if ($playbookCategory instanceof PlaybookCategory) {
$this->logger->info('[Hubspot] Fetch related activity - Playbook category matched', [
'activityId' => $activity->getUuid(),
'playbookCategoryId' => $playbookCategory->getId(),
'activityFieldName' => $playbookActivityFieldName,
'crmActivity' => $crmActivity['id'],
'source' => $activityTypeSource,
]);
return $playbookCategory->getId();
}
$this->logger->info('[Hubspot] Fetch related activity - Playbook category not found', [
'activityId' => $activity->getUuid(),
'activityFieldName' => $playbookActivityFieldName,
'crmActivity' => $crmActivity['id'],
]);
return null;
}
private function updateActivity(Activity $activity, array $data): bool
{
$activityRepository = app(ActivityRepository::class);
$alreadyLoggedActivity = $activityRepository->findUserActivityByCrmProviderId(
$activity->getUser(),
$data['crm_provider_id']
);
if ($alreadyLoggedActivity instanceof Activity) {
$this->logger->info('[Hubspot] Fetch related activity - activity already linked', [
'currentActivityId' => $activity->getUuid(),
'linkedActivityId' => $alreadyLoggedActivity->getUuid(),
'crmActivityId' => $data['crm_provider_id'],
]);
// Return false to prevent updating the remote CRM engagement. This can happen when two Jiminny
// activities share the same calendar event (e.g., rescheduled meetings). Without this, the first
// activity's summary would be overwritten by the second activity.
return false;
}
try {
$activityRepository->update($activity, $data);
} catch (Exception $e) {
$this->logger->error('[Hubspot] Fetch related activity - activity update failed', [
'activityId' => $activity->getUuid(),
'reason' => $e->getMessage(),
]);
return false;
}
$this->logger->info('[Hubspot] Fetch related activity - activity updated', [
'activityId' => $activity->getUuid(),
'data' => $data,
]);
return true;
}
}
Project
Project
New File or Directory…
Expand Selected
Collapse All
Options
Hide...
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JY-20725-handle-HS-search-rate-limit, menu","depth":5,"on_screen":true,"help_text":"Git Branch: JY-20725-handle-HS-search-rate-limit","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"AskJiminnyReportActivityServiceTest","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'AskJiminnyReportActivityServiceTest'","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'AskJiminnyReportActivityServiceTest'","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"IDE and Project Settings","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.088194445,"height":0.027777778},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"19","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {\n\"headers\":{\n\"Date\":[\"Thu,07 May 2026 14:21:15 GMT\"],\n \"Content-Type\":[\"application/json;charset=utf-8\"],\n \"Transfer-Encoding\":[\"chunked\"],\n \"Connection\":[\"keep-alive\"],\n \"CF-Ray\":[\"9f80deb8db60dc3a-SOF\"],\n \"CF-Cache-Status\":[\"DYNAMIC\"],\n \"Strict-Transport-Security\":[\"max-age=31536000; includeSubDomains; preload\"],\n \"Vary\":[\"origin,\n accept-encoding\"],\n \"access-control-allow-credentials\":[\"false\"],\n \"server-timing\":[\"hcid;desc=\\\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\\\",\n cfr;desc=\\\"9f80deb8e7c6dc3a-IAD\\\"\"],\n \"x-content-type-options\":[\"nosniff\"],\n \"x-hubspot-correlation-id\":[\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\"],\n \"Set-Cookie\":[\"__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-1.0.1.1-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,\n 07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None\"],\n \"Report-To\":[\"{\n\\\"endpoints\\\":[{\n\\\"url\\\":\\\"https:\\\\/\\\\/a.nel.cloudflare.com\\\\/report\\\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\\\"}],\n\\\"group\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"NEL\":[\"{\n\\\"success_fraction\\\":0.01,\n\\\"report_to\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"Server\":[\"cloudflare\"]}} {\n\"correlation_id\":\"95236535-ec98-4541-b92a-adfa73b69eab\",\n\"trace_id\":\"c7ab8365-903f-46d4-9403-0e5b551e3545\"}","depth":4,"on_screen":true,"value":"[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {\n\"headers\":{\n\"Date\":[\"Thu,07 May 2026 14:21:15 GMT\"],\n \"Content-Type\":[\"application/json;charset=utf-8\"],\n \"Transfer-Encoding\":[\"chunked\"],\n \"Connection\":[\"keep-alive\"],\n \"CF-Ray\":[\"9f80deb8db60dc3a-SOF\"],\n \"CF-Cache-Status\":[\"DYNAMIC\"],\n \"Strict-Transport-Security\":[\"max-age=31536000; includeSubDomains; preload\"],\n \"Vary\":[\"origin,\n accept-encoding\"],\n \"access-control-allow-credentials\":[\"false\"],\n \"server-timing\":[\"hcid;desc=\\\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\\\",\n cfr;desc=\\\"9f80deb8e7c6dc3a-IAD\\\"\"],\n \"x-content-type-options\":[\"nosniff\"],\n \"x-hubspot-correlation-id\":[\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\"],\n \"Set-Cookie\":[\"__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-1.0.1.1-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,\n 07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None\"],\n \"Report-To\":[\"{\n\\\"endpoints\\\":[{\n\\\"url\\\":\\\"https:\\\\/\\\\/a.nel.cloudflare.com\\\\/report\\\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\\\"}],\n\\\"group\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"NEL\":[\"{\n\\\"success_fraction\\\":0.01,\n\\\"report_to\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"Server\":[\"cloudflare\"]}} {\n\"correlation_id\":\"95236535-ec98-4541-b92a-adfa73b69eab\",\n\"trace_id\":\"c7ab8365-903f-46d4-9403-0e5b551e3545\"}","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.088194445,"height":0.027777778},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"3","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"11","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\ndeclare(strict_types=1);\n\nnamespace Jiminny\\Services\\Crm\\Hubspot;\n\nuse Carbon\\Carbon;\nuse Exception;\nuse HubSpot\\Client\\Crm\\Objects\\ApiException;\nuse Jiminny\\Integrations\\PlaybookResolver;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Calendar\\CalendarEvent;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Models\\Crm\\Profile;\nuse Jiminny\\Models\\Playbook;\nuse Jiminny\\Models\\PlaybookCategory;\nuse Jiminny\\Repositories\\ActivityRepository;\nuse Jiminny\\Repositories\\PlaybookCategoryRepository;\nuse Psr\\Log\\LoggerInterface;\n\nclass SyncRelatedActivityManager\n{\n public const string OBJECT_MEETING = 'meetings';\n public const string DEFAULT_ACTIVITY_TYPE = 'hs_activity_type';\n private RemoteCrmObjectManipulator $crmManipulator;\n\n public function __construct(\n private readonly Client $client,\n private readonly PayloadBuilder $payloadBuilder,\n private readonly LoggerInterface $logger\n ) {\n $this->crmManipulator = app(RemoteCrmObjectManipulator::class, [\n 'client' => $this->client,\n 'logger' => $this->logger,\n ]);\n }\n\n public function fetchAndAssociateRelatedActivity(Activity $activity): ?Activity\n {\n // Validate if the activity is applicable.\n if ($this->isActivityApplicable($activity) === false) {\n $this->logger->info('[Hubspot] Fetch related activity - not applicable', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n return null;\n }\n\n // Find and fetch related activity.\n try {\n $crmActivity = $this->fetchRelatedActivity($activity);\n if (empty($crmActivity)) {\n return null;\n }\n } catch (Exception $exception) {\n $this->logger->error('[Hubspot] Fetch related activity - failed', [\n 'activityId' => $activity->getUuid(),\n 'reason' => $exception->getMessage(),\n ]);\n\n throw $exception;\n }\n\n // get playbook category id\n $playbookCategoryId = $this->getPlaybookCategoryId($activity, $crmActivity);\n\n $data = [\n 'crm_provider_id' => $crmActivity['id'],\n 'playbook_category_id' => $playbookCategoryId,\n ];\n\n if (! $this->updateActivity($activity, $data)) {\n return null;\n }\n\n $this->crmManipulator->updateRelatedCrmActivity($activity, $crmActivity);\n\n $activity->refresh();\n\n return $activity;\n }\n\n private function isActivityApplicable(Activity $activity): bool\n {\n if (! $activity->isTypeConference()) {\n return false;\n }\n\n if (! $this->hasMinimumFilterRequirements($activity)) {\n $this->logger->info('[Hubspot] Fetch related activity - not enough filter data', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n return false;\n }\n\n // The related activity is already linked.\n if ($activity->hasCrmProviderId()) {\n return false;\n }\n\n if (! $activity->getUser()->getProfile() instanceof Profile) {\n $this->logger->info('[Hubspot] Fetch related activity - activity user has no crm profile', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n return false;\n }\n\n return true;\n }\n\n private function hasMinimumFilterRequirements(Activity $activity): bool\n {\n // We need to know when it started to determine when to look for related activity.\n if (! $activity->hasActualStartTime()) {\n return false;\n }\n\n if (! $activity->hasActualEndTime()) {\n return false;\n }\n\n // At least one prospect should be associated to the activity\n if ($activity->hasContact() || $activity->hasAccount()) {\n return true;\n }\n\n return false;\n }\n\n public function fetchRelatedActivity(Activity $activity): array\n {\n $this->logger->info('[Hubspot] Fetch related activity - searching related CRM activity', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n $payloadData = $this->getPayloadFilterData($activity);\n\n try {\n $payload = $this->payloadBuilder->getFindRelatedActivityPayload($payloadData, self::OBJECT_MEETING);\n $response = $this->client->getPaginatedData($payload, self::OBJECT_MEETING);\n $crmActivities = $response['results'];\n } catch (Exception $e) {\n $this->logger->error('[Hubspot] Fetch related activity - failed', [\n 'activityId' => $activity->getUuid(),\n 'reason' => $e->getMessage(),\n ]);\n\n throw $e;\n }\n\n if (empty($crmActivities)) {\n $this->logger->info('[Hubspot] Fetch related activity - no CRM related activity found', [\n 'activityId' => $activity->getUuid(),\n 'payload' => $payload,\n ]);\n\n return [];\n }\n\n $relatedActivity = $this->getBestCrmActivityCandidate($crmActivities, $payloadData);\n if (empty($relatedActivity)) {\n $this->logger->info('[Hubspot] Fetch related activity - no related activity candidate', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n return [];\n }\n\n $this->logger->info('[Hubspot] Fetch related activity - found related CRM activity', [\n 'activityId' => $activity->getUuid(),\n 'crmActivity' => $relatedActivity,\n ]);\n\n return $relatedActivity;\n }\n\n /**\n * @param Activity $activity\n *\n * @return array\n */\n protected function getPayloadFilterData(Activity $activity): array\n {\n [$from, $to] = $this->getActivityTimeFilterRange($activity);\n\n $data = [\n 'from' => $from->getPreciseTimestamp(3),\n 'to' => $to->getPreciseTimestamp(3),\n 'contact' => $activity->hasContact() ? $activity->getContact()->getCrmProviderId() : null,\n 'company' => $activity->hasAccount() ? $activity->getAccount()->getCrmProviderId() : null,\n 'deal' => $activity->hasOpportunity() ? $activity->getOpportunity()->getCrmProviderId() : null,\n 'owner' => $activity->getUser()->getProfile()->getCrmProviderId(),\n 'activity' => $activity->getUuid(),\n ];\n\n return array_filter($data);\n }\n\n private function getActivityTimeFilterRange(Activity $activity): array\n {\n if ($activity->hasScheduledStartTime() && $activity->hasScheduledEndTime()) {\n $calendarEvent = $activity->getCalendarEvent();\n\n if ($calendarEvent instanceof CalendarEvent) {\n $from = $calendarEvent->getStartTime();\n $to = $calendarEvent->getEndTime()->addMinutes(15);\n } else {\n $from = Carbon::instance($activity->getScheduledStartTime());\n $to = Carbon::instance($activity->getScheduledEndTime())->addMinutes(15);\n }\n\n return [$from, $to];\n }\n\n $from = Carbon::instance($activity->getActualStartTime());\n $to = Carbon::instance($activity->getActualEndTime());\n\n return [$from, $to];\n }\n\n /**\n * Determines the best CRM activity candidate based on the number of matching prospects and the owner.\n *\n * @param array $crmActivities List of CRM activities to evaluate.\n * @param array $data Data containing the prospects and owner information.\n *\n * @return array The best CRM activity candidate.\n */\n private function getBestCrmActivityCandidate(array $crmActivities, array $data): array\n {\n $prospectCount = count(array_intersect_key($data, array_flip(['contact', 'company', 'deal'])));\n $bestCandidate = null;\n $maxMatchingProspects = 0;\n $validActivitiesCount = count($crmActivities);\n\n if ($validActivitiesCount > 1) {\n $this->logger->info('[Hubspot] Fetch related activity - determining the best candidate', [\n 'activityId' => $data['activity'],\n 'activitiesCount' => $validActivitiesCount,\n 'crmActivities' => $crmActivities,\n ]);\n }\n\n foreach ($crmActivities as $crmActivity) {\n if ($this->isOwnerMismatch($crmActivity, $data)) {\n $validActivitiesCount--;\n\n continue;\n }\n\n if ($validActivitiesCount === 1) {\n return $crmActivity;\n }\n\n if ($prospectCount > 1) {\n $matchingProspects = $this->countMatchingProspects($crmActivity, $data);\n if ($matchingProspects > $maxMatchingProspects) {\n $maxMatchingProspects = $matchingProspects;\n $bestCandidate = $crmActivity;\n }\n } else {\n return $crmActivity;\n }\n }\n\n return $bestCandidate ?? [];\n }\n\n private function isOwnerMismatch(array $crmActivity, array $data): bool\n {\n if ($crmActivity['properties']['hubspot_owner_id'] !== $data['owner']) {\n $this->logger->info('[Hubspot] Fetch related activity - owner mismatch', [\n 'activityId' => $data['activity'],\n 'crmActivity' => $crmActivity['id'],\n 'jiminnyOwner' => $data['owner'],\n 'hubspotOwner' => $crmActivity['properties']['hubspot_owner_id'],\n ]);\n\n return true;\n }\n\n return false;\n }\n\n private function countMatchingProspects(array $crmActivity, array $data): int\n {\n try {\n $crmActivityWithAssociations = $this->client->getMeeting($crmActivity['id']);\n } catch (ApiException $e) {\n $this->logger->error('[Hubspot] Fetch related activity - failed to get meeting associations', [\n 'activityId' => $data['activity'],\n 'crmActivity' => $crmActivity['id'],\n 'reason' => $e->getMessage(),\n ]);\n\n return 0;\n }\n\n $associations = $crmActivityWithAssociations->getAssociations();\n $matchingProspects = 0;\n\n $matchingProspects += $this->countMatchingAssociation($data, $associations, 'company', 'companies');\n $matchingProspects += $this->countMatchingAssociation($data, $associations, 'contact', 'contacts');\n $matchingProspects += $this->countMatchingAssociation($data, $associations, 'deal', 'deals');\n\n return $matchingProspects;\n }\n\n private function countMatchingAssociation(array $data, array $associations, string $dataKey, string $associationKey): int\n {\n if (isset($data[$dataKey]) && isset($associations[$associationKey])) {\n $associatedItems = $associations[$associationKey]->getResults();\n foreach ($associatedItems as $associatedItem) {\n if ($data[$dataKey] == $associatedItem->getId()) {\n\n return 1;\n }\n }\n }\n\n return 0;\n }\n\n private function getPlaybookCategoryId(Activity $activity, array $crmActivity): ?int\n {\n if (! $activity->hasUser()) {\n $this->logger->info('[Hubspot] Fetch related activity - activity has no user', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n return null;\n }\n\n $playbookResolver = app(PlaybookResolver::class);\n $playbook = $playbookResolver->resolvePlaybookByUser($activity->getUser());\n\n if (! $playbook instanceof Playbook) {\n $this->logger->info('[Hubspot] Fetch related activity - playbook not set for user', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n return null;\n }\n\n if (! $playbook->getActivityField() instanceof Field) {\n $this->logger->info('[Hubspot] Fetch related activity - playbook activity field not set', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n return null;\n }\n\n $playbookCategory = null;\n $activityTypeSource = null;\n\n $playbookActivityFieldName = $playbook->getActivityField()->getCrmProviderId();\n if ($playbookActivityFieldName === 'activityType') {\n $playbookActivityFieldName = self::DEFAULT_ACTIVITY_TYPE;\n }\n\n // First attempt to match playbook category from crm activity\n if (! empty($crmActivity['properties'][$playbookActivityFieldName])) {\n $playbookCategoryRepository = app(PlaybookCategoryRepository::class);\n $playbookCategory = $playbookCategoryRepository->getPlaybookCategoryByName(\n $playbook,\n strval($crmActivity['properties'][$playbookActivityFieldName])\n );\n $activityTypeSource = 'crm';\n }\n\n // If not matched get playbook category from Jiminny activity\n if (! $playbookCategory instanceof PlaybookCategory && $activity->hasActivityType()) {\n $playbookCategory = $activity->getActivityType();\n $activityTypeSource = 'jiminny';\n }\n\n if ($playbookCategory instanceof PlaybookCategory) {\n $this->logger->info('[Hubspot] Fetch related activity - Playbook category matched', [\n 'activityId' => $activity->getUuid(),\n 'playbookCategoryId' => $playbookCategory->getId(),\n 'activityFieldName' => $playbookActivityFieldName,\n 'crmActivity' => $crmActivity['id'],\n 'source' => $activityTypeSource,\n ]);\n\n return $playbookCategory->getId();\n }\n\n $this->logger->info('[Hubspot] Fetch related activity - Playbook category not found', [\n 'activityId' => $activity->getUuid(),\n 'activityFieldName' => $playbookActivityFieldName,\n 'crmActivity' => $crmActivity['id'],\n ]);\n\n return null;\n }\n\n private function updateActivity(Activity $activity, array $data): bool\n {\n $activityRepository = app(ActivityRepository::class);\n\n $alreadyLoggedActivity = $activityRepository->findUserActivityByCrmProviderId(\n $activity->getUser(),\n $data['crm_provider_id']\n );\n\n if ($alreadyLoggedActivity instanceof Activity) {\n $this->logger->info('[Hubspot] Fetch related activity - activity already linked', [\n 'currentActivityId' => $activity->getUuid(),\n 'linkedActivityId' => $alreadyLoggedActivity->getUuid(),\n 'crmActivityId' => $data['crm_provider_id'],\n ]);\n\n // Return false to prevent updating the remote CRM engagement. This can happen when two Jiminny\n // activities share the same calendar event (e.g., rescheduled meetings). Without this, the first\n // activity's summary would be overwritten by the second activity.\n return false;\n }\n\n try {\n $activityRepository->update($activity, $data);\n } catch (Exception $e) {\n $this->logger->error('[Hubspot] Fetch related activity - activity update failed', [\n 'activityId' => $activity->getUuid(),\n 'reason' => $e->getMessage(),\n ]);\n\n return false;\n }\n\n $this->logger->info('[Hubspot] Fetch related activity - activity updated', [\n 'activityId' => $activity->getUuid(),\n 'data' => $data,\n ]);\n\n return true;\n }\n}","depth":4,"on_screen":true,"value":"<?php\n\ndeclare(strict_types=1);\n\nnamespace Jiminny\\Services\\Crm\\Hubspot;\n\nuse Carbon\\Carbon;\nuse Exception;\nuse HubSpot\\Client\\Crm\\Objects\\ApiException;\nuse Jiminny\\Integrations\\PlaybookResolver;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Calendar\\CalendarEvent;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Models\\Crm\\Profile;\nuse Jiminny\\Models\\Playbook;\nuse Jiminny\\Models\\PlaybookCategory;\nuse Jiminny\\Repositories\\ActivityRepository;\nuse Jiminny\\Repositories\\PlaybookCategoryRepository;\nuse Psr\\Log\\LoggerInterface;\n\nclass SyncRelatedActivityManager\n{\n public const string OBJECT_MEETING = 'meetings';\n public const string DEFAULT_ACTIVITY_TYPE = 'hs_activity_type';\n private RemoteCrmObjectManipulator $crmManipulator;\n\n public function __construct(\n private readonly Client $client,\n private readonly PayloadBuilder $payloadBuilder,\n private readonly LoggerInterface $logger\n ) {\n $this->crmManipulator = app(RemoteCrmObjectManipulator::class, [\n 'client' => $this->client,\n 'logger' => $this->logger,\n ]);\n }\n\n public function fetchAndAssociateRelatedActivity(Activity $activity): ?Activity\n {\n // Validate if the activity is applicable.\n if ($this->isActivityApplicable($activity) === false) {\n $this->logger->info('[Hubspot] Fetch related activity - not applicable', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n return null;\n }\n\n // Find and fetch related activity.\n try {\n $crmActivity = $this->fetchRelatedActivity($activity);\n if (empty($crmActivity)) {\n return null;\n }\n } catch (Exception $exception) {\n $this->logger->error('[Hubspot] Fetch related activity - failed', [\n 'activityId' => $activity->getUuid(),\n 'reason' => $exception->getMessage(),\n ]);\n\n throw $exception;\n }\n\n // get playbook category id\n $playbookCategoryId = $this->getPlaybookCategoryId($activity, $crmActivity);\n\n $data = [\n 'crm_provider_id' => $crmActivity['id'],\n 'playbook_category_id' => $playbookCategoryId,\n ];\n\n if (! $this->updateActivity($activity, $data)) {\n return null;\n }\n\n $this->crmManipulator->updateRelatedCrmActivity($activity, $crmActivity);\n\n $activity->refresh();\n\n return $activity;\n }\n\n private function isActivityApplicable(Activity $activity): bool\n {\n if (! $activity->isTypeConference()) {\n return false;\n }\n\n if (! $this->hasMinimumFilterRequirements($activity)) {\n $this->logger->info('[Hubspot] Fetch related activity - not enough filter data', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n return false;\n }\n\n // The related activity is already linked.\n if ($activity->hasCrmProviderId()) {\n return false;\n }\n\n if (! $activity->getUser()->getProfile() instanceof Profile) {\n $this->logger->info('[Hubspot] Fetch related activity - activity user has no crm profile', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n return false;\n }\n\n return true;\n }\n\n private function hasMinimumFilterRequirements(Activity $activity): bool\n {\n // We need to know when it started to determine when to look for related activity.\n if (! $activity->hasActualStartTime()) {\n return false;\n }\n\n if (! $activity->hasActualEndTime()) {\n return false;\n }\n\n // At least one prospect should be associated to the activity\n if ($activity->hasContact() || $activity->hasAccount()) {\n return true;\n }\n\n return false;\n }\n\n public function fetchRelatedActivity(Activity $activity): array\n {\n $this->logger->info('[Hubspot] Fetch related activity - searching related CRM activity', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n $payloadData = $this->getPayloadFilterData($activity);\n\n try {\n $payload = $this->payloadBuilder->getFindRelatedActivityPayload($payloadData, self::OBJECT_MEETING);\n $response = $this->client->getPaginatedData($payload, self::OBJECT_MEETING);\n $crmActivities = $response['results'];\n } catch (Exception $e) {\n $this->logger->error('[Hubspot] Fetch related activity - failed', [\n 'activityId' => $activity->getUuid(),\n 'reason' => $e->getMessage(),\n ]);\n\n throw $e;\n }\n\n if (empty($crmActivities)) {\n $this->logger->info('[Hubspot] Fetch related activity - no CRM related activity found', [\n 'activityId' => $activity->getUuid(),\n 'payload' => $payload,\n ]);\n\n return [];\n }\n\n $relatedActivity = $this->getBestCrmActivityCandidate($crmActivities, $payloadData);\n if (empty($relatedActivity)) {\n $this->logger->info('[Hubspot] Fetch related activity - no related activity candidate', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n return [];\n }\n\n $this->logger->info('[Hubspot] Fetch related activity - found related CRM activity', [\n 'activityId' => $activity->getUuid(),\n 'crmActivity' => $relatedActivity,\n ]);\n\n return $relatedActivity;\n }\n\n /**\n * @param Activity $activity\n *\n * @return array\n */\n protected function getPayloadFilterData(Activity $activity): array\n {\n [$from, $to] = $this->getActivityTimeFilterRange($activity);\n\n $data = [\n 'from' => $from->getPreciseTimestamp(3),\n 'to' => $to->getPreciseTimestamp(3),\n 'contact' => $activity->hasContact() ? $activity->getContact()->getCrmProviderId() : null,\n 'company' => $activity->hasAccount() ? $activity->getAccount()->getCrmProviderId() : null,\n 'deal' => $activity->hasOpportunity() ? $activity->getOpportunity()->getCrmProviderId() : null,\n 'owner' => $activity->getUser()->getProfile()->getCrmProviderId(),\n 'activity' => $activity->getUuid(),\n ];\n\n return array_filter($data);\n }\n\n private function getActivityTimeFilterRange(Activity $activity): array\n {\n if ($activity->hasScheduledStartTime() && $activity->hasScheduledEndTime()) {\n $calendarEvent = $activity->getCalendarEvent();\n\n if ($calendarEvent instanceof CalendarEvent) {\n $from = $calendarEvent->getStartTime();\n $to = $calendarEvent->getEndTime()->addMinutes(15);\n } else {\n $from = Carbon::instance($activity->getScheduledStartTime());\n $to = Carbon::instance($activity->getScheduledEndTime())->addMinutes(15);\n }\n\n return [$from, $to];\n }\n\n $from = Carbon::instance($activity->getActualStartTime());\n $to = Carbon::instance($activity->getActualEndTime());\n\n return [$from, $to];\n }\n\n /**\n * Determines the best CRM activity candidate based on the number of matching prospects and the owner.\n *\n * @param array $crmActivities List of CRM activities to evaluate.\n * @param array $data Data containing the prospects and owner information.\n *\n * @return array The best CRM activity candidate.\n */\n private function getBestCrmActivityCandidate(array $crmActivities, array $data): array\n {\n $prospectCount = count(array_intersect_key($data, array_flip(['contact', 'company', 'deal'])));\n $bestCandidate = null;\n $maxMatchingProspects = 0;\n $validActivitiesCount = count($crmActivities);\n\n if ($validActivitiesCount > 1) {\n $this->logger->info('[Hubspot] Fetch related activity - determining the best candidate', [\n 'activityId' => $data['activity'],\n 'activitiesCount' => $validActivitiesCount,\n 'crmActivities' => $crmActivities,\n ]);\n }\n\n foreach ($crmActivities as $crmActivity) {\n if ($this->isOwnerMismatch($crmActivity, $data)) {\n $validActivitiesCount--;\n\n continue;\n }\n\n if ($validActivitiesCount === 1) {\n return $crmActivity;\n }\n\n if ($prospectCount > 1) {\n $matchingProspects = $this->countMatchingProspects($crmActivity, $data);\n if ($matchingProspects > $maxMatchingProspects) {\n $maxMatchingProspects = $matchingProspects;\n $bestCandidate = $crmActivity;\n }\n } else {\n return $crmActivity;\n }\n }\n\n return $bestCandidate ?? [];\n }\n\n private function isOwnerMismatch(array $crmActivity, array $data): bool\n {\n if ($crmActivity['properties']['hubspot_owner_id'] !== $data['owner']) {\n $this->logger->info('[Hubspot] Fetch related activity - owner mismatch', [\n 'activityId' => $data['activity'],\n 'crmActivity' => $crmActivity['id'],\n 'jiminnyOwner' => $data['owner'],\n 'hubspotOwner' => $crmActivity['properties']['hubspot_owner_id'],\n ]);\n\n return true;\n }\n\n return false;\n }\n\n private function countMatchingProspects(array $crmActivity, array $data): int\n {\n try {\n $crmActivityWithAssociations = $this->client->getMeeting($crmActivity['id']);\n } catch (ApiException $e) {\n $this->logger->error('[Hubspot] Fetch related activity - failed to get meeting associations', [\n 'activityId' => $data['activity'],\n 'crmActivity' => $crmActivity['id'],\n 'reason' => $e->getMessage(),\n ]);\n\n return 0;\n }\n\n $associations = $crmActivityWithAssociations->getAssociations();\n $matchingProspects = 0;\n\n $matchingProspects += $this->countMatchingAssociation($data, $associations, 'company', 'companies');\n $matchingProspects += $this->countMatchingAssociation($data, $associations, 'contact', 'contacts');\n $matchingProspects += $this->countMatchingAssociation($data, $associations, 'deal', 'deals');\n\n return $matchingProspects;\n }\n\n private function countMatchingAssociation(array $data, array $associations, string $dataKey, string $associationKey): int\n {\n if (isset($data[$dataKey]) && isset($associations[$associationKey])) {\n $associatedItems = $associations[$associationKey]->getResults();\n foreach ($associatedItems as $associatedItem) {\n if ($data[$dataKey] == $associatedItem->getId()) {\n\n return 1;\n }\n }\n }\n\n return 0;\n }\n\n private function getPlaybookCategoryId(Activity $activity, array $crmActivity): ?int\n {\n if (! $activity->hasUser()) {\n $this->logger->info('[Hubspot] Fetch related activity - activity has no user', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n return null;\n }\n\n $playbookResolver = app(PlaybookResolver::class);\n $playbook = $playbookResolver->resolvePlaybookByUser($activity->getUser());\n\n if (! $playbook instanceof Playbook) {\n $this->logger->info('[Hubspot] Fetch related activity - playbook not set for user', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n return null;\n }\n\n if (! $playbook->getActivityField() instanceof Field) {\n $this->logger->info('[Hubspot] Fetch related activity - playbook activity field not set', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n return null;\n }\n\n $playbookCategory = null;\n $activityTypeSource = null;\n\n $playbookActivityFieldName = $playbook->getActivityField()->getCrmProviderId();\n if ($playbookActivityFieldName === 'activityType') {\n $playbookActivityFieldName = self::DEFAULT_ACTIVITY_TYPE;\n }\n\n // First attempt to match playbook category from crm activity\n if (! empty($crmActivity['properties'][$playbookActivityFieldName])) {\n $playbookCategoryRepository = app(PlaybookCategoryRepository::class);\n $playbookCategory = $playbookCategoryRepository->getPlaybookCategoryByName(\n $playbook,\n strval($crmActivity['properties'][$playbookActivityFieldName])\n );\n $activityTypeSource = 'crm';\n }\n\n // If not matched get playbook category from Jiminny activity\n if (! $playbookCategory instanceof PlaybookCategory && $activity->hasActivityType()) {\n $playbookCategory = $activity->getActivityType();\n $activityTypeSource = 'jiminny';\n }\n\n if ($playbookCategory instanceof PlaybookCategory) {\n $this->logger->info('[Hubspot] Fetch related activity - Playbook category matched', [\n 'activityId' => $activity->getUuid(),\n 'playbookCategoryId' => $playbookCategory->getId(),\n 'activityFieldName' => $playbookActivityFieldName,\n 'crmActivity' => $crmActivity['id'],\n 'source' => $activityTypeSource,\n ]);\n\n return $playbookCategory->getId();\n }\n\n $this->logger->info('[Hubspot] Fetch related activity - Playbook category not found', [\n 'activityId' => $activity->getUuid(),\n 'activityFieldName' => $playbookActivityFieldName,\n 'crmActivity' => $crmActivity['id'],\n ]);\n\n return null;\n }\n\n private function updateActivity(Activity $activity, array $data): bool\n {\n $activityRepository = app(ActivityRepository::class);\n\n $alreadyLoggedActivity = $activityRepository->findUserActivityByCrmProviderId(\n $activity->getUser(),\n $data['crm_provider_id']\n );\n\n if ($alreadyLoggedActivity instanceof Activity) {\n $this->logger->info('[Hubspot] Fetch related activity - activity already linked', [\n 'currentActivityId' => $activity->getUuid(),\n 'linkedActivityId' => $alreadyLoggedActivity->getUuid(),\n 'crmActivityId' => $data['crm_provider_id'],\n ]);\n\n // Return false to prevent updating the remote CRM engagement. This can happen when two Jiminny\n // activities share the same calendar event (e.g., rescheduled meetings). Without this, the first\n // activity's summary would be overwritten by the second activity.\n return false;\n }\n\n try {\n $activityRepository->update($activity, $data);\n } catch (Exception $e) {\n $this->logger->error('[Hubspot] Fetch related activity - activity update failed', [\n 'activityId' => $activity->getUuid(),\n 'reason' => $e->getMessage(),\n ]);\n\n return false;\n }\n\n $this->logger->info('[Hubspot] Fetch related activity - activity updated', [\n 'activityId' => $activity->getUuid(),\n 'data' => $data,\n ]);\n\n return true;\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":true,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Project","depth":3,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Project","depth":3,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"New File or Directory…","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Expand Selected","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Collapse All","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Options","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
-2435399166852945830
|
-3377671218453133244
|
click
|
accessibility
|
NULL
|
Project: faVsco.js, menu
JY-20725-handle-HS-search Project: faVsco.js, menu
JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
19
Previous Highlighted Error
Next Highlighted Error
[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {
"headers":{
"Date":["Thu,07 May 2026 14:21:15 GMT"],
"Content-Type":["application/json;charset=utf-8"],
"Transfer-Encoding":["chunked"],
"Connection":["keep-alive"],
"CF-Ray":["9f80deb8db60dc3a-SOF"],
"CF-Cache-Status":["DYNAMIC"],
"Strict-Transport-Security":["max-age=31536000; includeSubDomains; preload"],
"Vary":["origin,
accept-encoding"],
"access-control-allow-credentials":["false"],
"server-timing":["hcid;desc=\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\",
cfr;desc=\"9f80deb8e7c6dc3a-IAD\""],
"x-content-type-options":["nosniff"],
"x-hubspot-correlation-id":["019e02d0-6fd8-7812-bdba-885b7ccb3ee3"],
"Set-Cookie":["__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-[IP_ADDRESS]-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,
07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None"],
"Report-To":["{
\"endpoints\":[{
\"url\":\"https:\\/\\/a.nel.cloudflare.com\\/report\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\"}],
\"group\":\"cf-nel\",
\"max_age\":604800}"],
"NEL":["{
\"success_fraction\":0.01,
\"report_to\":\"cf-nel\",
\"max_age\":604800}"],
"Server":["cloudflare"]}} {
"correlation_id":"95236535-ec98-4541-b92a-adfa73b69eab",
"trace_id":"c7ab8365-903f-46d4-9403-0e5b551e3545"}
Sync Changes
Hide This Notification
Code changed:
Hide
3
11
Previous Highlighted Error
Next Highlighted Error
<?php
declare(strict_types=1);
namespace Jiminny\Services\Crm\Hubspot;
use Carbon\Carbon;
use Exception;
use HubSpot\Client\Crm\Objects\ApiException;
use Jiminny\Integrations\PlaybookResolver;
use Jiminny\Models\Activity;
use Jiminny\Models\Calendar\CalendarEvent;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\Profile;
use Jiminny\Models\Playbook;
use Jiminny\Models\PlaybookCategory;
use Jiminny\Repositories\ActivityRepository;
use Jiminny\Repositories\PlaybookCategoryRepository;
use Psr\Log\LoggerInterface;
class SyncRelatedActivityManager
{
public const string OBJECT_MEETING = 'meetings';
public const string DEFAULT_ACTIVITY_TYPE = 'hs_activity_type';
private RemoteCrmObjectManipulator $crmManipulator;
public function __construct(
private readonly Client $client,
private readonly PayloadBuilder $payloadBuilder,
private readonly LoggerInterface $logger
) {
$this->crmManipulator = app(RemoteCrmObjectManipulator::class, [
'client' => $this->client,
'logger' => $this->logger,
]);
}
public function fetchAndAssociateRelatedActivity(Activity $activity): ?Activity
{
// Validate if the activity is applicable.
if ($this->isActivityApplicable($activity) === false) {
$this->logger->info('[Hubspot] Fetch related activity - not applicable', [
'activityId' => $activity->getUuid(),
]);
return null;
}
// Find and fetch related activity.
try {
$crmActivity = $this->fetchRelatedActivity($activity);
if (empty($crmActivity)) {
return null;
}
} catch (Exception $exception) {
$this->logger->error('[Hubspot] Fetch related activity - failed', [
'activityId' => $activity->getUuid(),
'reason' => $exception->getMessage(),
]);
throw $exception;
}
// get playbook category id
$playbookCategoryId = $this->getPlaybookCategoryId($activity, $crmActivity);
$data = [
'crm_provider_id' => $crmActivity['id'],
'playbook_category_id' => $playbookCategoryId,
];
if (! $this->updateActivity($activity, $data)) {
return null;
}
$this->crmManipulator->updateRelatedCrmActivity($activity, $crmActivity);
$activity->refresh();
return $activity;
}
private function isActivityApplicable(Activity $activity): bool
{
if (! $activity->isTypeConference()) {
return false;
}
if (! $this->hasMinimumFilterRequirements($activity)) {
$this->logger->info('[Hubspot] Fetch related activity - not enough filter data', [
'activityId' => $activity->getUuid(),
]);
return false;
}
// The related activity is already linked.
if ($activity->hasCrmProviderId()) {
return false;
}
if (! $activity->getUser()->getProfile() instanceof Profile) {
$this->logger->info('[Hubspot] Fetch related activity - activity user has no crm profile', [
'activityId' => $activity->getUuid(),
]);
return false;
}
return true;
}
private function hasMinimumFilterRequirements(Activity $activity): bool
{
// We need to know when it started to determine when to look for related activity.
if (! $activity->hasActualStartTime()) {
return false;
}
if (! $activity->hasActualEndTime()) {
return false;
}
// At least one prospect should be associated to the activity
if ($activity->hasContact() || $activity->hasAccount()) {
return true;
}
return false;
}
public function fetchRelatedActivity(Activity $activity): array
{
$this->logger->info('[Hubspot] Fetch related activity - searching related CRM activity', [
'activityId' => $activity->getUuid(),
]);
$payloadData = $this->getPayloadFilterData($activity);
try {
$payload = $this->payloadBuilder->getFindRelatedActivityPayload($payloadData, self::OBJECT_MEETING);
$response = $this->client->getPaginatedData($payload, self::OBJECT_MEETING);
$crmActivities = $response['results'];
} catch (Exception $e) {
$this->logger->error('[Hubspot] Fetch related activity - failed', [
'activityId' => $activity->getUuid(),
'reason' => $e->getMessage(),
]);
throw $e;
}
if (empty($crmActivities)) {
$this->logger->info('[Hubspot] Fetch related activity - no CRM related activity found', [
'activityId' => $activity->getUuid(),
'payload' => $payload,
]);
return [];
}
$relatedActivity = $this->getBestCrmActivityCandidate($crmActivities, $payloadData);
if (empty($relatedActivity)) {
$this->logger->info('[Hubspot] Fetch related activity - no related activity candidate', [
'activityId' => $activity->getUuid(),
]);
return [];
}
$this->logger->info('[Hubspot] Fetch related activity - found related CRM activity', [
'activityId' => $activity->getUuid(),
'crmActivity' => $relatedActivity,
]);
return $relatedActivity;
}
/**
* @param Activity $activity
*
* @return array
*/
protected function getPayloadFilterData(Activity $activity): array
{
[$from, $to] = $this->getActivityTimeFilterRange($activity);
$data = [
'from' => $from->getPreciseTimestamp(3),
'to' => $to->getPreciseTimestamp(3),
'contact' => $activity->hasContact() ? $activity->getContact()->getCrmProviderId() : null,
'company' => $activity->hasAccount() ? $activity->getAccount()->getCrmProviderId() : null,
'deal' => $activity->hasOpportunity() ? $activity->getOpportunity()->getCrmProviderId() : null,
'owner' => $activity->getUser()->getProfile()->getCrmProviderId(),
'activity' => $activity->getUuid(),
];
return array_filter($data);
}
private function getActivityTimeFilterRange(Activity $activity): array
{
if ($activity->hasScheduledStartTime() && $activity->hasScheduledEndTime()) {
$calendarEvent = $activity->getCalendarEvent();
if ($calendarEvent instanceof CalendarEvent) {
$from = $calendarEvent->getStartTime();
$to = $calendarEvent->getEndTime()->addMinutes(15);
} else {
$from = Carbon::instance($activity->getScheduledStartTime());
$to = Carbon::instance($activity->getScheduledEndTime())->addMinutes(15);
}
return [$from, $to];
}
$from = Carbon::instance($activity->getActualStartTime());
$to = Carbon::instance($activity->getActualEndTime());
return [$from, $to];
}
/**
* Determines the best CRM activity candidate based on the number of matching prospects and the owner.
*
* @param array $crmActivities List of CRM activities to evaluate.
* @param array $data Data containing the prospects and owner information.
*
* @return array The best CRM activity candidate.
*/
private function getBestCrmActivityCandidate(array $crmActivities, array $data): array
{
$prospectCount = count(array_intersect_key($data, array_flip(['contact', 'company', 'deal'])));
$bestCandidate = null;
$maxMatchingProspects = 0;
$validActivitiesCount = count($crmActivities);
if ($validActivitiesCount > 1) {
$this->logger->info('[Hubspot] Fetch related activity - determining the best candidate', [
'activityId' => $data['activity'],
'activitiesCount' => $validActivitiesCount,
'crmActivities' => $crmActivities,
]);
}
foreach ($crmActivities as $crmActivity) {
if ($this->isOwnerMismatch($crmActivity, $data)) {
$validActivitiesCount--;
continue;
}
if ($validActivitiesCount === 1) {
return $crmActivity;
}
if ($prospectCount > 1) {
$matchingProspects = $this->countMatchingProspects($crmActivity, $data);
if ($matchingProspects > $maxMatchingProspects) {
$maxMatchingProspects = $matchingProspects;
$bestCandidate = $crmActivity;
}
} else {
return $crmActivity;
}
}
return $bestCandidate ?? [];
}
private function isOwnerMismatch(array $crmActivity, array $data): bool
{
if ($crmActivity['properties']['hubspot_owner_id'] !== $data['owner']) {
$this->logger->info('[Hubspot] Fetch related activity - owner mismatch', [
'activityId' => $data['activity'],
'crmActivity' => $crmActivity['id'],
'jiminnyOwner' => $data['owner'],
'hubspotOwner' => $crmActivity['properties']['hubspot_owner_id'],
]);
return true;
}
return false;
}
private function countMatchingProspects(array $crmActivity, array $data): int
{
try {
$crmActivityWithAssociations = $this->client->getMeeting($crmActivity['id']);
} catch (ApiException $e) {
$this->logger->error('[Hubspot] Fetch related activity - failed to get meeting associations', [
'activityId' => $data['activity'],
'crmActivity' => $crmActivity['id'],
'reason' => $e->getMessage(),
]);
return 0;
}
$associations = $crmActivityWithAssociations->getAssociations();
$matchingProspects = 0;
$matchingProspects += $this->countMatchingAssociation($data, $associations, 'company', 'companies');
$matchingProspects += $this->countMatchingAssociation($data, $associations, 'contact', 'contacts');
$matchingProspects += $this->countMatchingAssociation($data, $associations, 'deal', 'deals');
return $matchingProspects;
}
private function countMatchingAssociation(array $data, array $associations, string $dataKey, string $associationKey): int
{
if (isset($data[$dataKey]) && isset($associations[$associationKey])) {
$associatedItems = $associations[$associationKey]->getResults();
foreach ($associatedItems as $associatedItem) {
if ($data[$dataKey] == $associatedItem->getId()) {
return 1;
}
}
}
return 0;
}
private function getPlaybookCategoryId(Activity $activity, array $crmActivity): ?int
{
if (! $activity->hasUser()) {
$this->logger->info('[Hubspot] Fetch related activity - activity has no user', [
'activityId' => $activity->getUuid(),
]);
return null;
}
$playbookResolver = app(PlaybookResolver::class);
$playbook = $playbookResolver->resolvePlaybookByUser($activity->getUser());
if (! $playbook instanceof Playbook) {
$this->logger->info('[Hubspot] Fetch related activity - playbook not set for user', [
'activityId' => $activity->getUuid(),
]);
return null;
}
if (! $playbook->getActivityField() instanceof Field) {
$this->logger->info('[Hubspot] Fetch related activity - playbook activity field not set', [
'activityId' => $activity->getUuid(),
]);
return null;
}
$playbookCategory = null;
$activityTypeSource = null;
$playbookActivityFieldName = $playbook->getActivityField()->getCrmProviderId();
if ($playbookActivityFieldName === 'activityType') {
$playbookActivityFieldName = self::DEFAULT_ACTIVITY_TYPE;
}
// First attempt to match playbook category from crm activity
if (! empty($crmActivity['properties'][$playbookActivityFieldName])) {
$playbookCategoryRepository = app(PlaybookCategoryRepository::class);
$playbookCategory = $playbookCategoryRepository->getPlaybookCategoryByName(
$playbook,
strval($crmActivity['properties'][$playbookActivityFieldName])
);
$activityTypeSource = 'crm';
}
// If not matched get playbook category from Jiminny activity
if (! $playbookCategory instanceof PlaybookCategory && $activity->hasActivityType()) {
$playbookCategory = $activity->getActivityType();
$activityTypeSource = 'jiminny';
}
if ($playbookCategory instanceof PlaybookCategory) {
$this->logger->info('[Hubspot] Fetch related activity - Playbook category matched', [
'activityId' => $activity->getUuid(),
'playbookCategoryId' => $playbookCategory->getId(),
'activityFieldName' => $playbookActivityFieldName,
'crmActivity' => $crmActivity['id'],
'source' => $activityTypeSource,
]);
return $playbookCategory->getId();
}
$this->logger->info('[Hubspot] Fetch related activity - Playbook category not found', [
'activityId' => $activity->getUuid(),
'activityFieldName' => $playbookActivityFieldName,
'crmActivity' => $crmActivity['id'],
]);
return null;
}
private function updateActivity(Activity $activity, array $data): bool
{
$activityRepository = app(ActivityRepository::class);
$alreadyLoggedActivity = $activityRepository->findUserActivityByCrmProviderId(
$activity->getUser(),
$data['crm_provider_id']
);
if ($alreadyLoggedActivity instanceof Activity) {
$this->logger->info('[Hubspot] Fetch related activity - activity already linked', [
'currentActivityId' => $activity->getUuid(),
'linkedActivityId' => $alreadyLoggedActivity->getUuid(),
'crmActivityId' => $data['crm_provider_id'],
]);
// Return false to prevent updating the remote CRM engagement. This can happen when two Jiminny
// activities share the same calendar event (e.g., rescheduled meetings). Without this, the first
// activity's summary would be overwritten by the second activity.
return false;
}
try {
$activityRepository->update($activity, $data);
} catch (Exception $e) {
$this->logger->error('[Hubspot] Fetch related activity - activity update failed', [
'activityId' => $activity->getUuid(),
'reason' => $e->getMessage(),
]);
return false;
}
$this->logger->info('[Hubspot] Fetch related activity - activity updated', [
'activityId' => $activity->getUuid(),
'data' => $data,
]);
return true;
}
}
Project
Project
New File or Directory…
Expand Selected
Collapse All
Options
Hide...
|
9814
|
NULL
|
NULL
|
NULL
|
|
9814
|
443
|
7
|
2026-05-08T13:35:11.204084+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-08/1778 /Users/lukas/.screenpipe/data/data/2026-05-08/1778247311204_m1.jpg...
|
PhpStorm
|
faVsco.js – SyncRelatedActivityManager.php
|
1
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
JY-20725-handle-HS-search Project: faVsco.js, menu
JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
19
Previous Highlighted Error
Next Highlighted Error
[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {
"headers":{
"Date":["Thu,07 May 2026 14:21:15 GMT"],
"Content-Type":["application/json;charset=utf-8"],
"Transfer-Encoding":["chunked"],
"Connection":["keep-alive"],
"CF-Ray":["9f80deb8db60dc3a-SOF"],
"CF-Cache-Status":["DYNAMIC"],
"Strict-Transport-Security":["max-age=31536000; includeSubDomains; preload"],
"Vary":["origin,
accept-encoding"],
"access-control-allow-credentials":["false"],
"server-timing":["hcid;desc=\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\",
cfr;desc=\"9f80deb8e7c6dc3a-IAD\""],
"x-content-type-options":["nosniff"],
"x-hubspot-correlation-id":["019e02d0-6fd8-7812-bdba-885b7ccb3ee3"],
"Set-Cookie":["__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-[IP_ADDRESS]-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,
07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None"],
"Report-To":["{
\"endpoints\":[{
\"url\":\"https:\\/\\/a.nel.cloudflare.com\\/report\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\"}],
\"group\":\"cf-nel\",
\"max_age\":604800}"],
"NEL":["{
\"success_fraction\":0.01,
\"report_to\":\"cf-nel\",
\"max_age\":604800}"],
"Server":["cloudflare"]}} {
"correlation_id":"95236535-ec98-4541-b92a-adfa73b69eab",
"trace_id":"c7ab8365-903f-46d4-9403-0e5b551e3545"}
Sync Changes
Hide This Notification
Code changed:
Hide
3
11
Previous Highlighted Error
Next Highlighted Error
<?php
declare(strict_types=1);
namespace Jiminny\Services\Crm\Hubspot;
use Carbon\Carbon;
use Exception;
use HubSpot\Client\Crm\Objects\ApiException;
use Jiminny\Integrations\PlaybookResolver;
use Jiminny\Models\Activity;
use Jiminny\Models\Calendar\CalendarEvent;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\Profile;
use Jiminny\Models\Playbook;
use Jiminny\Models\PlaybookCategory;
use Jiminny\Repositories\ActivityRepository;
use Jiminny\Repositories\PlaybookCategoryRepository;
use Psr\Log\LoggerInterface;
class SyncRelatedActivityManager
{
public const string OBJECT_MEETING = 'meetings';
public const string DEFAULT_ACTIVITY_TYPE = 'hs_activity_type';
private RemoteCrmObjectManipulator $crmManipulator;
public function __construct(
private readonly Client $client,
private readonly PayloadBuilder $payloadBuilder,
private readonly LoggerInterface $logger
) {
$this->crmManipulator = app(RemoteCrmObjectManipulator::class, [
'client' => $this->client,
'logger' => $this->logger,
]);
}
public function fetchAndAssociateRelatedActivity(Activity $activity): ?Activity
{
// Validate if the activity is applicable.
if ($this->isActivityApplicable($activity) === false) {
$this->logger->info('[Hubspot] Fetch related activity - not applicable', [
'activityId' => $activity->getUuid(),
]);
return null;
}
// Find and fetch related activity.
try {
$crmActivity = $this->fetchRelatedActivity($activity);
if (empty($crmActivity)) {
return null;
}
} catch (Exception $exception) {
$this->logger->error('[Hubspot] Fetch related activity - failed', [
'activityId' => $activity->getUuid(),
'reason' => $exception->getMessage(),
]);
throw $exception;
}
// get playbook category id
$playbookCategoryId = $this->getPlaybookCategoryId($activity, $crmActivity);
$data = [
'crm_provider_id' => $crmActivity['id'],
'playbook_category_id' => $playbookCategoryId,
];
if (! $this->updateActivity($activity, $data)) {
return null;
}
$this->crmManipulator->updateRelatedCrmActivity($activity, $crmActivity);
$activity->refresh();
return $activity;
}
private function isActivityApplicable(Activity $activity): bool
{
if (! $activity->isTypeConference()) {
return false;
}
if (! $this->hasMinimumFilterRequirements($activity)) {
$this->logger->info('[Hubspot] Fetch related activity - not enough filter data', [
'activityId' => $activity->getUuid(),
]);
return false;
}
// The related activity is already linked.
if ($activity->hasCrmProviderId()) {
return false;
}
if (! $activity->getUser()->getProfile() instanceof Profile) {
$this->logger->info('[Hubspot] Fetch related activity - activity user has no crm profile', [
'activityId' => $activity->getUuid(),
]);
return false;
}
return true;
}
private function hasMinimumFilterRequirements(Activity $activity): bool
{
// We need to know when it started to determine when to look for related activity.
if (! $activity->hasActualStartTime()) {
return false;
}
if (! $activity->hasActualEndTime()) {
return false;
}
// At least one prospect should be associated to the activity
if ($activity->hasContact() || $activity->hasAccount()) {
return true;
}
return false;
}
public function fetchRelatedActivity(Activity $activity): array
{
$this->logger->info('[Hubspot] Fetch related activity - searching related CRM activity', [
'activityId' => $activity->getUuid(),
]);
$payloadData = $this->getPayloadFilterData($activity);
try {
$payload = $this->payloadBuilder->getFindRelatedActivityPayload($payloadData, self::OBJECT_MEETING);
$response = $this->client->getPaginatedData($payload, self::OBJECT_MEETING);
$crmActivities = $response['results'];
} catch (Exception $e) {
$this->logger->error('[Hubspot] Fetch related activity - failed', [
'activityId' => $activity->getUuid(),
'reason' => $e->getMessage(),
]);
throw $e;
}
if (empty($crmActivities)) {
$this->logger->info('[Hubspot] Fetch related activity - no CRM related activity found', [
'activityId' => $activity->getUuid(),
'payload' => $payload,
]);
return [];
}
$relatedActivity = $this->getBestCrmActivityCandidate($crmActivities, $payloadData);
if (empty($relatedActivity)) {
$this->logger->info('[Hubspot] Fetch related activity - no related activity candidate', [
'activityId' => $activity->getUuid(),
]);
return [];
}
$this->logger->info('[Hubspot] Fetch related activity - found related CRM activity', [
'activityId' => $activity->getUuid(),
'crmActivity' => $relatedActivity,
]);
return $relatedActivity;
}
/**
* @param Activity $activity
*
* @return array
*/
protected function getPayloadFilterData(Activity $activity): array
{
[$from, $to] = $this->getActivityTimeFilterRange($activity);
$data = [
'from' => $from->getPreciseTimestamp(3),
'to' => $to->getPreciseTimestamp(3),
'contact' => $activity->hasContact() ? $activity->getContact()->getCrmProviderId() : null,
'company' => $activity->hasAccount() ? $activity->getAccount()->getCrmProviderId() : null,
'deal' => $activity->hasOpportunity() ? $activity->getOpportunity()->getCrmProviderId() : null,
'owner' => $activity->getUser()->getProfile()->getCrmProviderId(),
'activity' => $activity->getUuid(),
];
return array_filter($data);
}
private function getActivityTimeFilterRange(Activity $activity): array
{
if ($activity->hasScheduledStartTime() && $activity->hasScheduledEndTime()) {
$calendarEvent = $activity->getCalendarEvent();
if ($calendarEvent instanceof CalendarEvent) {
$from = $calendarEvent->getStartTime();
$to = $calendarEvent->getEndTime()->addMinutes(15);
} else {
$from = Carbon::instance($activity->getScheduledStartTime());
$to = Carbon::instance($activity->getScheduledEndTime())->addMinutes(15);
}
return [$from, $to];
}
$from = Carbon::instance($activity->getActualStartTime());
$to = Carbon::instance($activity->getActualEndTime());
return [$from, $to];
}
/**
* Determines the best CRM activity candidate based on the number of matching prospects and the owner.
*
* @param array $crmActivities List of CRM activities to evaluate.
* @param array $data Data containing the prospects and owner information.
*
* @return array The best CRM activity candidate.
*/
private function getBestCrmActivityCandidate(array $crmActivities, array $data): array
{
$prospectCount = count(array_intersect_key($data, array_flip(['contact', 'company', 'deal'])));
$bestCandidate = null;
$maxMatchingProspects = 0;
$validActivitiesCount = count($crmActivities);
if ($validActivitiesCount > 1) {
$this->logger->info('[Hubspot] Fetch related activity - determining the best candidate', [
'activityId' => $data['activity'],
'activitiesCount' => $validActivitiesCount,
'crmActivities' => $crmActivities,
]);
}
foreach ($crmActivities as $crmActivity) {
if ($this->isOwnerMismatch($crmActivity, $data)) {
$validActivitiesCount--;
continue;
}
if ($validActivitiesCount === 1) {
return $crmActivity;
}
if ($prospectCount > 1) {
$matchingProspects = $this->countMatchingProspects($crmActivity, $data);
if ($matchingProspects > $maxMatchingProspects) {
$maxMatchingProspects = $matchingProspects;
$bestCandidate = $crmActivity;
}
} else {
return $crmActivity;
}
}
return $bestCandidate ?? [];
}
private function isOwnerMismatch(array $crmActivity, array $data): bool
{
if ($crmActivity['properties']['hubspot_owner_id'] !== $data['owner']) {
$this->logger->info('[Hubspot] Fetch related activity - owner mismatch', [
'activityId' => $data['activity'],
'crmActivity' => $crmActivity['id'],
'jiminnyOwner' => $data['owner'],
'hubspotOwner' => $crmActivity['properties']['hubspot_owner_id'],
]);
return true;
}
return false;
}
private function countMatchingProspects(array $crmActivity, array $data): int
{
try {
$crmActivityWithAssociations = $this->client->getMeeting($crmActivity['id']);
} catch (ApiException $e) {
$this->logger->error('[Hubspot] Fetch related activity - failed to get meeting associations', [
'activityId' => $data['activity'],
'crmActivity' => $crmActivity['id'],
'reason' => $e->getMessage(),
]);
return 0;
}
$associations = $crmActivityWithAssociations->getAssociations();
$matchingProspects = 0;
$matchingProspects += $this->countMatchingAssociation($data, $associations, 'company', 'companies');
$matchingProspects += $this->countMatchingAssociation($data, $associations, 'contact', 'contacts');
$matchingProspects += $this->countMatchingAssociation($data, $associations, 'deal', 'deals');
return $matchingProspects;
}
private function countMatchingAssociation(array $data, array $associations, string $dataKey, string $associationKey): int
{
if (isset($data[$dataKey]) && isset($associations[$associationKey])) {
$associatedItems = $associations[$associationKey]->getResults();
foreach ($associatedItems as $associatedItem) {
if ($data[$dataKey] == $associatedItem->getId()) {
return 1;
}
}
}
return 0;
}
private function getPlaybookCategoryId(Activity $activity, array $crmActivity): ?int
{
if (! $activity->hasUser()) {
$this->logger->info('[Hubspot] Fetch related activity - activity has no user', [
'activityId' => $activity->getUuid(),
]);
return null;
}
$playbookResolver = app(PlaybookResolver::class);
$playbook = $playbookResolver->resolvePlaybookByUser($activity->getUser());
if (! $playbook instanceof Playbook) {
$this->logger->info('[Hubspot] Fetch related activity - playbook not set for user', [
'activityId' => $activity->getUuid(),
]);
return null;
}
if (! $playbook->getActivityField() instanceof Field) {
$this->logger->info('[Hubspot] Fetch related activity - playbook activity field not set', [
'activityId' => $activity->getUuid(),
]);
return null;
}
$playbookCategory = null;
$activityTypeSource = null;
$playbookActivityFieldName = $playbook->getActivityField()->getCrmProviderId();
if ($playbookActivityFieldName === 'activityType') {
$playbookActivityFieldName = self::DEFAULT_ACTIVITY_TYPE;
}
// First attempt to match playbook category from crm activity
if (! empty($crmActivity['properties'][$playbookActivityFieldName])) {
$playbookCategoryRepository = app(PlaybookCategoryRepository::class);
$playbookCategory = $playbookCategoryRepository->getPlaybookCategoryByName(
$playbook,
strval($crmActivity['properties'][$playbookActivityFieldName])
);
$activityTypeSource = 'crm';
}
// If not matched get playbook category from Jiminny activity
if (! $playbookCategory instanceof PlaybookCategory && $activity->hasActivityType()) {
$playbookCategory = $activity->getActivityType();
$activityTypeSource = 'jiminny';
}
if ($playbookCategory instanceof PlaybookCategory) {
$this->logger->info('[Hubspot] Fetch related activity - Playbook category matched', [
'activityId' => $activity->getUuid(),
'playbookCategoryId' => $playbookCategory->getId(),
'activityFieldName' => $playbookActivityFieldName,
'crmActivity' => $crmActivity['id'],
'source' => $activityTypeSource,
]);
return $playbookCategory->getId();
}
$this->logger->info('[Hubspot] Fetch related activity - Playbook category not found', [
'activityId' => $activity->getUuid(),
'activityFieldName' => $playbookActivityFieldName,
'crmActivity' => $crmActivity['id'],
]);
return null;
}
private function updateActivity(Activity $activity, array $data): bool
{
$activityRepository = app(ActivityRepository::class);
$alreadyLoggedActivity = $activityRepository->findUserActivityByCrmProviderId(
$activity->getUser(),
$data['crm_provider_id']
);
if ($alreadyLoggedActivity instanceof Activity) {
$this->logger->info('[Hubspot] Fetch related activity - activity already linked', [
'currentActivityId' => $activity->getUuid(),
'linkedActivityId' => $alreadyLoggedActivity->getUuid(),
'crmActivityId' => $data['crm_provider_id'],
]);
// Return false to prevent updating the remote CRM engagement. This can happen when two Jiminny
// activities share the same calendar event (e.g., rescheduled meetings). Without this, the first
// activity's summary would be overwritten by the second activity.
return false;
}
try {
$activityRepository->update($activity, $data);
} catch (Exception $e) {
$this->logger->error('[Hubspot] Fetch related activity - activity update failed', [
'activityId' => $activity->getUuid(),
'reason' => $e->getMessage(),
]);
return false;
}
$this->logger->info('[Hubspot] Fetch related activity - activity updated', [
'activityId' => $activity->getUuid(),
'data' => $data,
]);
return true;
}
}
Project
Project
New File or Directory…...
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JY-20725-handle-HS-search-rate-limit, menu","depth":5,"on_screen":true,"help_text":"Git Branch: JY-20725-handle-HS-search-rate-limit","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"AskJiminnyReportActivityServiceTest","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'AskJiminnyReportActivityServiceTest'","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'AskJiminnyReportActivityServiceTest'","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"IDE and Project Settings","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.088194445,"height":0.027777778},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"19","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {\n\"headers\":{\n\"Date\":[\"Thu,07 May 2026 14:21:15 GMT\"],\n \"Content-Type\":[\"application/json;charset=utf-8\"],\n \"Transfer-Encoding\":[\"chunked\"],\n \"Connection\":[\"keep-alive\"],\n \"CF-Ray\":[\"9f80deb8db60dc3a-SOF\"],\n \"CF-Cache-Status\":[\"DYNAMIC\"],\n \"Strict-Transport-Security\":[\"max-age=31536000; includeSubDomains; preload\"],\n \"Vary\":[\"origin,\n accept-encoding\"],\n \"access-control-allow-credentials\":[\"false\"],\n \"server-timing\":[\"hcid;desc=\\\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\\\",\n cfr;desc=\\\"9f80deb8e7c6dc3a-IAD\\\"\"],\n \"x-content-type-options\":[\"nosniff\"],\n \"x-hubspot-correlation-id\":[\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\"],\n \"Set-Cookie\":[\"__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-1.0.1.1-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,\n 07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None\"],\n \"Report-To\":[\"{\n\\\"endpoints\\\":[{\n\\\"url\\\":\\\"https:\\\\/\\\\/a.nel.cloudflare.com\\\\/report\\\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\\\"}],\n\\\"group\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"NEL\":[\"{\n\\\"success_fraction\\\":0.01,\n\\\"report_to\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"Server\":[\"cloudflare\"]}} {\n\"correlation_id\":\"95236535-ec98-4541-b92a-adfa73b69eab\",\n\"trace_id\":\"c7ab8365-903f-46d4-9403-0e5b551e3545\"}","depth":4,"on_screen":true,"value":"[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {\n\"headers\":{\n\"Date\":[\"Thu,07 May 2026 14:21:15 GMT\"],\n \"Content-Type\":[\"application/json;charset=utf-8\"],\n \"Transfer-Encoding\":[\"chunked\"],\n \"Connection\":[\"keep-alive\"],\n \"CF-Ray\":[\"9f80deb8db60dc3a-SOF\"],\n \"CF-Cache-Status\":[\"DYNAMIC\"],\n \"Strict-Transport-Security\":[\"max-age=31536000; includeSubDomains; preload\"],\n \"Vary\":[\"origin,\n accept-encoding\"],\n \"access-control-allow-credentials\":[\"false\"],\n \"server-timing\":[\"hcid;desc=\\\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\\\",\n cfr;desc=\\\"9f80deb8e7c6dc3a-IAD\\\"\"],\n \"x-content-type-options\":[\"nosniff\"],\n \"x-hubspot-correlation-id\":[\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\"],\n \"Set-Cookie\":[\"__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-1.0.1.1-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,\n 07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None\"],\n \"Report-To\":[\"{\n\\\"endpoints\\\":[{\n\\\"url\\\":\\\"https:\\\\/\\\\/a.nel.cloudflare.com\\\\/report\\\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\\\"}],\n\\\"group\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"NEL\":[\"{\n\\\"success_fraction\\\":0.01,\n\\\"report_to\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"Server\":[\"cloudflare\"]}} {\n\"correlation_id\":\"95236535-ec98-4541-b92a-adfa73b69eab\",\n\"trace_id\":\"c7ab8365-903f-46d4-9403-0e5b551e3545\"}","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.088194445,"height":0.027777778},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"3","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"11","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\ndeclare(strict_types=1);\n\nnamespace Jiminny\\Services\\Crm\\Hubspot;\n\nuse Carbon\\Carbon;\nuse Exception;\nuse HubSpot\\Client\\Crm\\Objects\\ApiException;\nuse Jiminny\\Integrations\\PlaybookResolver;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Calendar\\CalendarEvent;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Models\\Crm\\Profile;\nuse Jiminny\\Models\\Playbook;\nuse Jiminny\\Models\\PlaybookCategory;\nuse Jiminny\\Repositories\\ActivityRepository;\nuse Jiminny\\Repositories\\PlaybookCategoryRepository;\nuse Psr\\Log\\LoggerInterface;\n\nclass SyncRelatedActivityManager\n{\n public const string OBJECT_MEETING = 'meetings';\n public const string DEFAULT_ACTIVITY_TYPE = 'hs_activity_type';\n private RemoteCrmObjectManipulator $crmManipulator;\n\n public function __construct(\n private readonly Client $client,\n private readonly PayloadBuilder $payloadBuilder,\n private readonly LoggerInterface $logger\n ) {\n $this->crmManipulator = app(RemoteCrmObjectManipulator::class, [\n 'client' => $this->client,\n 'logger' => $this->logger,\n ]);\n }\n\n public function fetchAndAssociateRelatedActivity(Activity $activity): ?Activity\n {\n // Validate if the activity is applicable.\n if ($this->isActivityApplicable($activity) === false) {\n $this->logger->info('[Hubspot] Fetch related activity - not applicable', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n return null;\n }\n\n // Find and fetch related activity.\n try {\n $crmActivity = $this->fetchRelatedActivity($activity);\n if (empty($crmActivity)) {\n return null;\n }\n } catch (Exception $exception) {\n $this->logger->error('[Hubspot] Fetch related activity - failed', [\n 'activityId' => $activity->getUuid(),\n 'reason' => $exception->getMessage(),\n ]);\n\n throw $exception;\n }\n\n // get playbook category id\n $playbookCategoryId = $this->getPlaybookCategoryId($activity, $crmActivity);\n\n $data = [\n 'crm_provider_id' => $crmActivity['id'],\n 'playbook_category_id' => $playbookCategoryId,\n ];\n\n if (! $this->updateActivity($activity, $data)) {\n return null;\n }\n\n $this->crmManipulator->updateRelatedCrmActivity($activity, $crmActivity);\n\n $activity->refresh();\n\n return $activity;\n }\n\n private function isActivityApplicable(Activity $activity): bool\n {\n if (! $activity->isTypeConference()) {\n return false;\n }\n\n if (! $this->hasMinimumFilterRequirements($activity)) {\n $this->logger->info('[Hubspot] Fetch related activity - not enough filter data', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n return false;\n }\n\n // The related activity is already linked.\n if ($activity->hasCrmProviderId()) {\n return false;\n }\n\n if (! $activity->getUser()->getProfile() instanceof Profile) {\n $this->logger->info('[Hubspot] Fetch related activity - activity user has no crm profile', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n return false;\n }\n\n return true;\n }\n\n private function hasMinimumFilterRequirements(Activity $activity): bool\n {\n // We need to know when it started to determine when to look for related activity.\n if (! $activity->hasActualStartTime()) {\n return false;\n }\n\n if (! $activity->hasActualEndTime()) {\n return false;\n }\n\n // At least one prospect should be associated to the activity\n if ($activity->hasContact() || $activity->hasAccount()) {\n return true;\n }\n\n return false;\n }\n\n public function fetchRelatedActivity(Activity $activity): array\n {\n $this->logger->info('[Hubspot] Fetch related activity - searching related CRM activity', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n $payloadData = $this->getPayloadFilterData($activity);\n\n try {\n $payload = $this->payloadBuilder->getFindRelatedActivityPayload($payloadData, self::OBJECT_MEETING);\n $response = $this->client->getPaginatedData($payload, self::OBJECT_MEETING);\n $crmActivities = $response['results'];\n } catch (Exception $e) {\n $this->logger->error('[Hubspot] Fetch related activity - failed', [\n 'activityId' => $activity->getUuid(),\n 'reason' => $e->getMessage(),\n ]);\n\n throw $e;\n }\n\n if (empty($crmActivities)) {\n $this->logger->info('[Hubspot] Fetch related activity - no CRM related activity found', [\n 'activityId' => $activity->getUuid(),\n 'payload' => $payload,\n ]);\n\n return [];\n }\n\n $relatedActivity = $this->getBestCrmActivityCandidate($crmActivities, $payloadData);\n if (empty($relatedActivity)) {\n $this->logger->info('[Hubspot] Fetch related activity - no related activity candidate', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n return [];\n }\n\n $this->logger->info('[Hubspot] Fetch related activity - found related CRM activity', [\n 'activityId' => $activity->getUuid(),\n 'crmActivity' => $relatedActivity,\n ]);\n\n return $relatedActivity;\n }\n\n /**\n * @param Activity $activity\n *\n * @return array\n */\n protected function getPayloadFilterData(Activity $activity): array\n {\n [$from, $to] = $this->getActivityTimeFilterRange($activity);\n\n $data = [\n 'from' => $from->getPreciseTimestamp(3),\n 'to' => $to->getPreciseTimestamp(3),\n 'contact' => $activity->hasContact() ? $activity->getContact()->getCrmProviderId() : null,\n 'company' => $activity->hasAccount() ? $activity->getAccount()->getCrmProviderId() : null,\n 'deal' => $activity->hasOpportunity() ? $activity->getOpportunity()->getCrmProviderId() : null,\n 'owner' => $activity->getUser()->getProfile()->getCrmProviderId(),\n 'activity' => $activity->getUuid(),\n ];\n\n return array_filter($data);\n }\n\n private function getActivityTimeFilterRange(Activity $activity): array\n {\n if ($activity->hasScheduledStartTime() && $activity->hasScheduledEndTime()) {\n $calendarEvent = $activity->getCalendarEvent();\n\n if ($calendarEvent instanceof CalendarEvent) {\n $from = $calendarEvent->getStartTime();\n $to = $calendarEvent->getEndTime()->addMinutes(15);\n } else {\n $from = Carbon::instance($activity->getScheduledStartTime());\n $to = Carbon::instance($activity->getScheduledEndTime())->addMinutes(15);\n }\n\n return [$from, $to];\n }\n\n $from = Carbon::instance($activity->getActualStartTime());\n $to = Carbon::instance($activity->getActualEndTime());\n\n return [$from, $to];\n }\n\n /**\n * Determines the best CRM activity candidate based on the number of matching prospects and the owner.\n *\n * @param array $crmActivities List of CRM activities to evaluate.\n * @param array $data Data containing the prospects and owner information.\n *\n * @return array The best CRM activity candidate.\n */\n private function getBestCrmActivityCandidate(array $crmActivities, array $data): array\n {\n $prospectCount = count(array_intersect_key($data, array_flip(['contact', 'company', 'deal'])));\n $bestCandidate = null;\n $maxMatchingProspects = 0;\n $validActivitiesCount = count($crmActivities);\n\n if ($validActivitiesCount > 1) {\n $this->logger->info('[Hubspot] Fetch related activity - determining the best candidate', [\n 'activityId' => $data['activity'],\n 'activitiesCount' => $validActivitiesCount,\n 'crmActivities' => $crmActivities,\n ]);\n }\n\n foreach ($crmActivities as $crmActivity) {\n if ($this->isOwnerMismatch($crmActivity, $data)) {\n $validActivitiesCount--;\n\n continue;\n }\n\n if ($validActivitiesCount === 1) {\n return $crmActivity;\n }\n\n if ($prospectCount > 1) {\n $matchingProspects = $this->countMatchingProspects($crmActivity, $data);\n if ($matchingProspects > $maxMatchingProspects) {\n $maxMatchingProspects = $matchingProspects;\n $bestCandidate = $crmActivity;\n }\n } else {\n return $crmActivity;\n }\n }\n\n return $bestCandidate ?? [];\n }\n\n private function isOwnerMismatch(array $crmActivity, array $data): bool\n {\n if ($crmActivity['properties']['hubspot_owner_id'] !== $data['owner']) {\n $this->logger->info('[Hubspot] Fetch related activity - owner mismatch', [\n 'activityId' => $data['activity'],\n 'crmActivity' => $crmActivity['id'],\n 'jiminnyOwner' => $data['owner'],\n 'hubspotOwner' => $crmActivity['properties']['hubspot_owner_id'],\n ]);\n\n return true;\n }\n\n return false;\n }\n\n private function countMatchingProspects(array $crmActivity, array $data): int\n {\n try {\n $crmActivityWithAssociations = $this->client->getMeeting($crmActivity['id']);\n } catch (ApiException $e) {\n $this->logger->error('[Hubspot] Fetch related activity - failed to get meeting associations', [\n 'activityId' => $data['activity'],\n 'crmActivity' => $crmActivity['id'],\n 'reason' => $e->getMessage(),\n ]);\n\n return 0;\n }\n\n $associations = $crmActivityWithAssociations->getAssociations();\n $matchingProspects = 0;\n\n $matchingProspects += $this->countMatchingAssociation($data, $associations, 'company', 'companies');\n $matchingProspects += $this->countMatchingAssociation($data, $associations, 'contact', 'contacts');\n $matchingProspects += $this->countMatchingAssociation($data, $associations, 'deal', 'deals');\n\n return $matchingProspects;\n }\n\n private function countMatchingAssociation(array $data, array $associations, string $dataKey, string $associationKey): int\n {\n if (isset($data[$dataKey]) && isset($associations[$associationKey])) {\n $associatedItems = $associations[$associationKey]->getResults();\n foreach ($associatedItems as $associatedItem) {\n if ($data[$dataKey] == $associatedItem->getId()) {\n\n return 1;\n }\n }\n }\n\n return 0;\n }\n\n private function getPlaybookCategoryId(Activity $activity, array $crmActivity): ?int\n {\n if (! $activity->hasUser()) {\n $this->logger->info('[Hubspot] Fetch related activity - activity has no user', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n return null;\n }\n\n $playbookResolver = app(PlaybookResolver::class);\n $playbook = $playbookResolver->resolvePlaybookByUser($activity->getUser());\n\n if (! $playbook instanceof Playbook) {\n $this->logger->info('[Hubspot] Fetch related activity - playbook not set for user', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n return null;\n }\n\n if (! $playbook->getActivityField() instanceof Field) {\n $this->logger->info('[Hubspot] Fetch related activity - playbook activity field not set', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n return null;\n }\n\n $playbookCategory = null;\n $activityTypeSource = null;\n\n $playbookActivityFieldName = $playbook->getActivityField()->getCrmProviderId();\n if ($playbookActivityFieldName === 'activityType') {\n $playbookActivityFieldName = self::DEFAULT_ACTIVITY_TYPE;\n }\n\n // First attempt to match playbook category from crm activity\n if (! empty($crmActivity['properties'][$playbookActivityFieldName])) {\n $playbookCategoryRepository = app(PlaybookCategoryRepository::class);\n $playbookCategory = $playbookCategoryRepository->getPlaybookCategoryByName(\n $playbook,\n strval($crmActivity['properties'][$playbookActivityFieldName])\n );\n $activityTypeSource = 'crm';\n }\n\n // If not matched get playbook category from Jiminny activity\n if (! $playbookCategory instanceof PlaybookCategory && $activity->hasActivityType()) {\n $playbookCategory = $activity->getActivityType();\n $activityTypeSource = 'jiminny';\n }\n\n if ($playbookCategory instanceof PlaybookCategory) {\n $this->logger->info('[Hubspot] Fetch related activity - Playbook category matched', [\n 'activityId' => $activity->getUuid(),\n 'playbookCategoryId' => $playbookCategory->getId(),\n 'activityFieldName' => $playbookActivityFieldName,\n 'crmActivity' => $crmActivity['id'],\n 'source' => $activityTypeSource,\n ]);\n\n return $playbookCategory->getId();\n }\n\n $this->logger->info('[Hubspot] Fetch related activity - Playbook category not found', [\n 'activityId' => $activity->getUuid(),\n 'activityFieldName' => $playbookActivityFieldName,\n 'crmActivity' => $crmActivity['id'],\n ]);\n\n return null;\n }\n\n private function updateActivity(Activity $activity, array $data): bool\n {\n $activityRepository = app(ActivityRepository::class);\n\n $alreadyLoggedActivity = $activityRepository->findUserActivityByCrmProviderId(\n $activity->getUser(),\n $data['crm_provider_id']\n );\n\n if ($alreadyLoggedActivity instanceof Activity) {\n $this->logger->info('[Hubspot] Fetch related activity - activity already linked', [\n 'currentActivityId' => $activity->getUuid(),\n 'linkedActivityId' => $alreadyLoggedActivity->getUuid(),\n 'crmActivityId' => $data['crm_provider_id'],\n ]);\n\n // Return false to prevent updating the remote CRM engagement. This can happen when two Jiminny\n // activities share the same calendar event (e.g., rescheduled meetings). Without this, the first\n // activity's summary would be overwritten by the second activity.\n return false;\n }\n\n try {\n $activityRepository->update($activity, $data);\n } catch (Exception $e) {\n $this->logger->error('[Hubspot] Fetch related activity - activity update failed', [\n 'activityId' => $activity->getUuid(),\n 'reason' => $e->getMessage(),\n ]);\n\n return false;\n }\n\n $this->logger->info('[Hubspot] Fetch related activity - activity updated', [\n 'activityId' => $activity->getUuid(),\n 'data' => $data,\n ]);\n\n return true;\n }\n}","depth":4,"on_screen":true,"value":"<?php\n\ndeclare(strict_types=1);\n\nnamespace Jiminny\\Services\\Crm\\Hubspot;\n\nuse Carbon\\Carbon;\nuse Exception;\nuse HubSpot\\Client\\Crm\\Objects\\ApiException;\nuse Jiminny\\Integrations\\PlaybookResolver;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Calendar\\CalendarEvent;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Models\\Crm\\Profile;\nuse Jiminny\\Models\\Playbook;\nuse Jiminny\\Models\\PlaybookCategory;\nuse Jiminny\\Repositories\\ActivityRepository;\nuse Jiminny\\Repositories\\PlaybookCategoryRepository;\nuse Psr\\Log\\LoggerInterface;\n\nclass SyncRelatedActivityManager\n{\n public const string OBJECT_MEETING = 'meetings';\n public const string DEFAULT_ACTIVITY_TYPE = 'hs_activity_type';\n private RemoteCrmObjectManipulator $crmManipulator;\n\n public function __construct(\n private readonly Client $client,\n private readonly PayloadBuilder $payloadBuilder,\n private readonly LoggerInterface $logger\n ) {\n $this->crmManipulator = app(RemoteCrmObjectManipulator::class, [\n 'client' => $this->client,\n 'logger' => $this->logger,\n ]);\n }\n\n public function fetchAndAssociateRelatedActivity(Activity $activity): ?Activity\n {\n // Validate if the activity is applicable.\n if ($this->isActivityApplicable($activity) === false) {\n $this->logger->info('[Hubspot] Fetch related activity - not applicable', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n return null;\n }\n\n // Find and fetch related activity.\n try {\n $crmActivity = $this->fetchRelatedActivity($activity);\n if (empty($crmActivity)) {\n return null;\n }\n } catch (Exception $exception) {\n $this->logger->error('[Hubspot] Fetch related activity - failed', [\n 'activityId' => $activity->getUuid(),\n 'reason' => $exception->getMessage(),\n ]);\n\n throw $exception;\n }\n\n // get playbook category id\n $playbookCategoryId = $this->getPlaybookCategoryId($activity, $crmActivity);\n\n $data = [\n 'crm_provider_id' => $crmActivity['id'],\n 'playbook_category_id' => $playbookCategoryId,\n ];\n\n if (! $this->updateActivity($activity, $data)) {\n return null;\n }\n\n $this->crmManipulator->updateRelatedCrmActivity($activity, $crmActivity);\n\n $activity->refresh();\n\n return $activity;\n }\n\n private function isActivityApplicable(Activity $activity): bool\n {\n if (! $activity->isTypeConference()) {\n return false;\n }\n\n if (! $this->hasMinimumFilterRequirements($activity)) {\n $this->logger->info('[Hubspot] Fetch related activity - not enough filter data', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n return false;\n }\n\n // The related activity is already linked.\n if ($activity->hasCrmProviderId()) {\n return false;\n }\n\n if (! $activity->getUser()->getProfile() instanceof Profile) {\n $this->logger->info('[Hubspot] Fetch related activity - activity user has no crm profile', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n return false;\n }\n\n return true;\n }\n\n private function hasMinimumFilterRequirements(Activity $activity): bool\n {\n // We need to know when it started to determine when to look for related activity.\n if (! $activity->hasActualStartTime()) {\n return false;\n }\n\n if (! $activity->hasActualEndTime()) {\n return false;\n }\n\n // At least one prospect should be associated to the activity\n if ($activity->hasContact() || $activity->hasAccount()) {\n return true;\n }\n\n return false;\n }\n\n public function fetchRelatedActivity(Activity $activity): array\n {\n $this->logger->info('[Hubspot] Fetch related activity - searching related CRM activity', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n $payloadData = $this->getPayloadFilterData($activity);\n\n try {\n $payload = $this->payloadBuilder->getFindRelatedActivityPayload($payloadData, self::OBJECT_MEETING);\n $response = $this->client->getPaginatedData($payload, self::OBJECT_MEETING);\n $crmActivities = $response['results'];\n } catch (Exception $e) {\n $this->logger->error('[Hubspot] Fetch related activity - failed', [\n 'activityId' => $activity->getUuid(),\n 'reason' => $e->getMessage(),\n ]);\n\n throw $e;\n }\n\n if (empty($crmActivities)) {\n $this->logger->info('[Hubspot] Fetch related activity - no CRM related activity found', [\n 'activityId' => $activity->getUuid(),\n 'payload' => $payload,\n ]);\n\n return [];\n }\n\n $relatedActivity = $this->getBestCrmActivityCandidate($crmActivities, $payloadData);\n if (empty($relatedActivity)) {\n $this->logger->info('[Hubspot] Fetch related activity - no related activity candidate', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n return [];\n }\n\n $this->logger->info('[Hubspot] Fetch related activity - found related CRM activity', [\n 'activityId' => $activity->getUuid(),\n 'crmActivity' => $relatedActivity,\n ]);\n\n return $relatedActivity;\n }\n\n /**\n * @param Activity $activity\n *\n * @return array\n */\n protected function getPayloadFilterData(Activity $activity): array\n {\n [$from, $to] = $this->getActivityTimeFilterRange($activity);\n\n $data = [\n 'from' => $from->getPreciseTimestamp(3),\n 'to' => $to->getPreciseTimestamp(3),\n 'contact' => $activity->hasContact() ? $activity->getContact()->getCrmProviderId() : null,\n 'company' => $activity->hasAccount() ? $activity->getAccount()->getCrmProviderId() : null,\n 'deal' => $activity->hasOpportunity() ? $activity->getOpportunity()->getCrmProviderId() : null,\n 'owner' => $activity->getUser()->getProfile()->getCrmProviderId(),\n 'activity' => $activity->getUuid(),\n ];\n\n return array_filter($data);\n }\n\n private function getActivityTimeFilterRange(Activity $activity): array\n {\n if ($activity->hasScheduledStartTime() && $activity->hasScheduledEndTime()) {\n $calendarEvent = $activity->getCalendarEvent();\n\n if ($calendarEvent instanceof CalendarEvent) {\n $from = $calendarEvent->getStartTime();\n $to = $calendarEvent->getEndTime()->addMinutes(15);\n } else {\n $from = Carbon::instance($activity->getScheduledStartTime());\n $to = Carbon::instance($activity->getScheduledEndTime())->addMinutes(15);\n }\n\n return [$from, $to];\n }\n\n $from = Carbon::instance($activity->getActualStartTime());\n $to = Carbon::instance($activity->getActualEndTime());\n\n return [$from, $to];\n }\n\n /**\n * Determines the best CRM activity candidate based on the number of matching prospects and the owner.\n *\n * @param array $crmActivities List of CRM activities to evaluate.\n * @param array $data Data containing the prospects and owner information.\n *\n * @return array The best CRM activity candidate.\n */\n private function getBestCrmActivityCandidate(array $crmActivities, array $data): array\n {\n $prospectCount = count(array_intersect_key($data, array_flip(['contact', 'company', 'deal'])));\n $bestCandidate = null;\n $maxMatchingProspects = 0;\n $validActivitiesCount = count($crmActivities);\n\n if ($validActivitiesCount > 1) {\n $this->logger->info('[Hubspot] Fetch related activity - determining the best candidate', [\n 'activityId' => $data['activity'],\n 'activitiesCount' => $validActivitiesCount,\n 'crmActivities' => $crmActivities,\n ]);\n }\n\n foreach ($crmActivities as $crmActivity) {\n if ($this->isOwnerMismatch($crmActivity, $data)) {\n $validActivitiesCount--;\n\n continue;\n }\n\n if ($validActivitiesCount === 1) {\n return $crmActivity;\n }\n\n if ($prospectCount > 1) {\n $matchingProspects = $this->countMatchingProspects($crmActivity, $data);\n if ($matchingProspects > $maxMatchingProspects) {\n $maxMatchingProspects = $matchingProspects;\n $bestCandidate = $crmActivity;\n }\n } else {\n return $crmActivity;\n }\n }\n\n return $bestCandidate ?? [];\n }\n\n private function isOwnerMismatch(array $crmActivity, array $data): bool\n {\n if ($crmActivity['properties']['hubspot_owner_id'] !== $data['owner']) {\n $this->logger->info('[Hubspot] Fetch related activity - owner mismatch', [\n 'activityId' => $data['activity'],\n 'crmActivity' => $crmActivity['id'],\n 'jiminnyOwner' => $data['owner'],\n 'hubspotOwner' => $crmActivity['properties']['hubspot_owner_id'],\n ]);\n\n return true;\n }\n\n return false;\n }\n\n private function countMatchingProspects(array $crmActivity, array $data): int\n {\n try {\n $crmActivityWithAssociations = $this->client->getMeeting($crmActivity['id']);\n } catch (ApiException $e) {\n $this->logger->error('[Hubspot] Fetch related activity - failed to get meeting associations', [\n 'activityId' => $data['activity'],\n 'crmActivity' => $crmActivity['id'],\n 'reason' => $e->getMessage(),\n ]);\n\n return 0;\n }\n\n $associations = $crmActivityWithAssociations->getAssociations();\n $matchingProspects = 0;\n\n $matchingProspects += $this->countMatchingAssociation($data, $associations, 'company', 'companies');\n $matchingProspects += $this->countMatchingAssociation($data, $associations, 'contact', 'contacts');\n $matchingProspects += $this->countMatchingAssociation($data, $associations, 'deal', 'deals');\n\n return $matchingProspects;\n }\n\n private function countMatchingAssociation(array $data, array $associations, string $dataKey, string $associationKey): int\n {\n if (isset($data[$dataKey]) && isset($associations[$associationKey])) {\n $associatedItems = $associations[$associationKey]->getResults();\n foreach ($associatedItems as $associatedItem) {\n if ($data[$dataKey] == $associatedItem->getId()) {\n\n return 1;\n }\n }\n }\n\n return 0;\n }\n\n private function getPlaybookCategoryId(Activity $activity, array $crmActivity): ?int\n {\n if (! $activity->hasUser()) {\n $this->logger->info('[Hubspot] Fetch related activity - activity has no user', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n return null;\n }\n\n $playbookResolver = app(PlaybookResolver::class);\n $playbook = $playbookResolver->resolvePlaybookByUser($activity->getUser());\n\n if (! $playbook instanceof Playbook) {\n $this->logger->info('[Hubspot] Fetch related activity - playbook not set for user', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n return null;\n }\n\n if (! $playbook->getActivityField() instanceof Field) {\n $this->logger->info('[Hubspot] Fetch related activity - playbook activity field not set', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n return null;\n }\n\n $playbookCategory = null;\n $activityTypeSource = null;\n\n $playbookActivityFieldName = $playbook->getActivityField()->getCrmProviderId();\n if ($playbookActivityFieldName === 'activityType') {\n $playbookActivityFieldName = self::DEFAULT_ACTIVITY_TYPE;\n }\n\n // First attempt to match playbook category from crm activity\n if (! empty($crmActivity['properties'][$playbookActivityFieldName])) {\n $playbookCategoryRepository = app(PlaybookCategoryRepository::class);\n $playbookCategory = $playbookCategoryRepository->getPlaybookCategoryByName(\n $playbook,\n strval($crmActivity['properties'][$playbookActivityFieldName])\n );\n $activityTypeSource = 'crm';\n }\n\n // If not matched get playbook category from Jiminny activity\n if (! $playbookCategory instanceof PlaybookCategory && $activity->hasActivityType()) {\n $playbookCategory = $activity->getActivityType();\n $activityTypeSource = 'jiminny';\n }\n\n if ($playbookCategory instanceof PlaybookCategory) {\n $this->logger->info('[Hubspot] Fetch related activity - Playbook category matched', [\n 'activityId' => $activity->getUuid(),\n 'playbookCategoryId' => $playbookCategory->getId(),\n 'activityFieldName' => $playbookActivityFieldName,\n 'crmActivity' => $crmActivity['id'],\n 'source' => $activityTypeSource,\n ]);\n\n return $playbookCategory->getId();\n }\n\n $this->logger->info('[Hubspot] Fetch related activity - Playbook category not found', [\n 'activityId' => $activity->getUuid(),\n 'activityFieldName' => $playbookActivityFieldName,\n 'crmActivity' => $crmActivity['id'],\n ]);\n\n return null;\n }\n\n private function updateActivity(Activity $activity, array $data): bool\n {\n $activityRepository = app(ActivityRepository::class);\n\n $alreadyLoggedActivity = $activityRepository->findUserActivityByCrmProviderId(\n $activity->getUser(),\n $data['crm_provider_id']\n );\n\n if ($alreadyLoggedActivity instanceof Activity) {\n $this->logger->info('[Hubspot] Fetch related activity - activity already linked', [\n 'currentActivityId' => $activity->getUuid(),\n 'linkedActivityId' => $alreadyLoggedActivity->getUuid(),\n 'crmActivityId' => $data['crm_provider_id'],\n ]);\n\n // Return false to prevent updating the remote CRM engagement. This can happen when two Jiminny\n // activities share the same calendar event (e.g., rescheduled meetings). Without this, the first\n // activity's summary would be overwritten by the second activity.\n return false;\n }\n\n try {\n $activityRepository->update($activity, $data);\n } catch (Exception $e) {\n $this->logger->error('[Hubspot] Fetch related activity - activity update failed', [\n 'activityId' => $activity->getUuid(),\n 'reason' => $e->getMessage(),\n ]);\n\n return false;\n }\n\n $this->logger->info('[Hubspot] Fetch related activity - activity updated', [\n 'activityId' => $activity->getUuid(),\n 'data' => $data,\n ]);\n\n return true;\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":true,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Project","depth":3,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Project","depth":3,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"New File or Directory…","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
-4158632746020519110
|
-3377671218453133244
|
click
|
accessibility
|
NULL
|
Project: faVsco.js, menu
JY-20725-handle-HS-search Project: faVsco.js, menu
JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
19
Previous Highlighted Error
Next Highlighted Error
[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {
"headers":{
"Date":["Thu,07 May 2026 14:21:15 GMT"],
"Content-Type":["application/json;charset=utf-8"],
"Transfer-Encoding":["chunked"],
"Connection":["keep-alive"],
"CF-Ray":["9f80deb8db60dc3a-SOF"],
"CF-Cache-Status":["DYNAMIC"],
"Strict-Transport-Security":["max-age=31536000; includeSubDomains; preload"],
"Vary":["origin,
accept-encoding"],
"access-control-allow-credentials":["false"],
"server-timing":["hcid;desc=\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\",
cfr;desc=\"9f80deb8e7c6dc3a-IAD\""],
"x-content-type-options":["nosniff"],
"x-hubspot-correlation-id":["019e02d0-6fd8-7812-bdba-885b7ccb3ee3"],
"Set-Cookie":["__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-[IP_ADDRESS]-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,
07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None"],
"Report-To":["{
\"endpoints\":[{
\"url\":\"https:\\/\\/a.nel.cloudflare.com\\/report\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\"}],
\"group\":\"cf-nel\",
\"max_age\":604800}"],
"NEL":["{
\"success_fraction\":0.01,
\"report_to\":\"cf-nel\",
\"max_age\":604800}"],
"Server":["cloudflare"]}} {
"correlation_id":"95236535-ec98-4541-b92a-adfa73b69eab",
"trace_id":"c7ab8365-903f-46d4-9403-0e5b551e3545"}
Sync Changes
Hide This Notification
Code changed:
Hide
3
11
Previous Highlighted Error
Next Highlighted Error
<?php
declare(strict_types=1);
namespace Jiminny\Services\Crm\Hubspot;
use Carbon\Carbon;
use Exception;
use HubSpot\Client\Crm\Objects\ApiException;
use Jiminny\Integrations\PlaybookResolver;
use Jiminny\Models\Activity;
use Jiminny\Models\Calendar\CalendarEvent;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\Profile;
use Jiminny\Models\Playbook;
use Jiminny\Models\PlaybookCategory;
use Jiminny\Repositories\ActivityRepository;
use Jiminny\Repositories\PlaybookCategoryRepository;
use Psr\Log\LoggerInterface;
class SyncRelatedActivityManager
{
public const string OBJECT_MEETING = 'meetings';
public const string DEFAULT_ACTIVITY_TYPE = 'hs_activity_type';
private RemoteCrmObjectManipulator $crmManipulator;
public function __construct(
private readonly Client $client,
private readonly PayloadBuilder $payloadBuilder,
private readonly LoggerInterface $logger
) {
$this->crmManipulator = app(RemoteCrmObjectManipulator::class, [
'client' => $this->client,
'logger' => $this->logger,
]);
}
public function fetchAndAssociateRelatedActivity(Activity $activity): ?Activity
{
// Validate if the activity is applicable.
if ($this->isActivityApplicable($activity) === false) {
$this->logger->info('[Hubspot] Fetch related activity - not applicable', [
'activityId' => $activity->getUuid(),
]);
return null;
}
// Find and fetch related activity.
try {
$crmActivity = $this->fetchRelatedActivity($activity);
if (empty($crmActivity)) {
return null;
}
} catch (Exception $exception) {
$this->logger->error('[Hubspot] Fetch related activity - failed', [
'activityId' => $activity->getUuid(),
'reason' => $exception->getMessage(),
]);
throw $exception;
}
// get playbook category id
$playbookCategoryId = $this->getPlaybookCategoryId($activity, $crmActivity);
$data = [
'crm_provider_id' => $crmActivity['id'],
'playbook_category_id' => $playbookCategoryId,
];
if (! $this->updateActivity($activity, $data)) {
return null;
}
$this->crmManipulator->updateRelatedCrmActivity($activity, $crmActivity);
$activity->refresh();
return $activity;
}
private function isActivityApplicable(Activity $activity): bool
{
if (! $activity->isTypeConference()) {
return false;
}
if (! $this->hasMinimumFilterRequirements($activity)) {
$this->logger->info('[Hubspot] Fetch related activity - not enough filter data', [
'activityId' => $activity->getUuid(),
]);
return false;
}
// The related activity is already linked.
if ($activity->hasCrmProviderId()) {
return false;
}
if (! $activity->getUser()->getProfile() instanceof Profile) {
$this->logger->info('[Hubspot] Fetch related activity - activity user has no crm profile', [
'activityId' => $activity->getUuid(),
]);
return false;
}
return true;
}
private function hasMinimumFilterRequirements(Activity $activity): bool
{
// We need to know when it started to determine when to look for related activity.
if (! $activity->hasActualStartTime()) {
return false;
}
if (! $activity->hasActualEndTime()) {
return false;
}
// At least one prospect should be associated to the activity
if ($activity->hasContact() || $activity->hasAccount()) {
return true;
}
return false;
}
public function fetchRelatedActivity(Activity $activity): array
{
$this->logger->info('[Hubspot] Fetch related activity - searching related CRM activity', [
'activityId' => $activity->getUuid(),
]);
$payloadData = $this->getPayloadFilterData($activity);
try {
$payload = $this->payloadBuilder->getFindRelatedActivityPayload($payloadData, self::OBJECT_MEETING);
$response = $this->client->getPaginatedData($payload, self::OBJECT_MEETING);
$crmActivities = $response['results'];
} catch (Exception $e) {
$this->logger->error('[Hubspot] Fetch related activity - failed', [
'activityId' => $activity->getUuid(),
'reason' => $e->getMessage(),
]);
throw $e;
}
if (empty($crmActivities)) {
$this->logger->info('[Hubspot] Fetch related activity - no CRM related activity found', [
'activityId' => $activity->getUuid(),
'payload' => $payload,
]);
return [];
}
$relatedActivity = $this->getBestCrmActivityCandidate($crmActivities, $payloadData);
if (empty($relatedActivity)) {
$this->logger->info('[Hubspot] Fetch related activity - no related activity candidate', [
'activityId' => $activity->getUuid(),
]);
return [];
}
$this->logger->info('[Hubspot] Fetch related activity - found related CRM activity', [
'activityId' => $activity->getUuid(),
'crmActivity' => $relatedActivity,
]);
return $relatedActivity;
}
/**
* @param Activity $activity
*
* @return array
*/
protected function getPayloadFilterData(Activity $activity): array
{
[$from, $to] = $this->getActivityTimeFilterRange($activity);
$data = [
'from' => $from->getPreciseTimestamp(3),
'to' => $to->getPreciseTimestamp(3),
'contact' => $activity->hasContact() ? $activity->getContact()->getCrmProviderId() : null,
'company' => $activity->hasAccount() ? $activity->getAccount()->getCrmProviderId() : null,
'deal' => $activity->hasOpportunity() ? $activity->getOpportunity()->getCrmProviderId() : null,
'owner' => $activity->getUser()->getProfile()->getCrmProviderId(),
'activity' => $activity->getUuid(),
];
return array_filter($data);
}
private function getActivityTimeFilterRange(Activity $activity): array
{
if ($activity->hasScheduledStartTime() && $activity->hasScheduledEndTime()) {
$calendarEvent = $activity->getCalendarEvent();
if ($calendarEvent instanceof CalendarEvent) {
$from = $calendarEvent->getStartTime();
$to = $calendarEvent->getEndTime()->addMinutes(15);
} else {
$from = Carbon::instance($activity->getScheduledStartTime());
$to = Carbon::instance($activity->getScheduledEndTime())->addMinutes(15);
}
return [$from, $to];
}
$from = Carbon::instance($activity->getActualStartTime());
$to = Carbon::instance($activity->getActualEndTime());
return [$from, $to];
}
/**
* Determines the best CRM activity candidate based on the number of matching prospects and the owner.
*
* @param array $crmActivities List of CRM activities to evaluate.
* @param array $data Data containing the prospects and owner information.
*
* @return array The best CRM activity candidate.
*/
private function getBestCrmActivityCandidate(array $crmActivities, array $data): array
{
$prospectCount = count(array_intersect_key($data, array_flip(['contact', 'company', 'deal'])));
$bestCandidate = null;
$maxMatchingProspects = 0;
$validActivitiesCount = count($crmActivities);
if ($validActivitiesCount > 1) {
$this->logger->info('[Hubspot] Fetch related activity - determining the best candidate', [
'activityId' => $data['activity'],
'activitiesCount' => $validActivitiesCount,
'crmActivities' => $crmActivities,
]);
}
foreach ($crmActivities as $crmActivity) {
if ($this->isOwnerMismatch($crmActivity, $data)) {
$validActivitiesCount--;
continue;
}
if ($validActivitiesCount === 1) {
return $crmActivity;
}
if ($prospectCount > 1) {
$matchingProspects = $this->countMatchingProspects($crmActivity, $data);
if ($matchingProspects > $maxMatchingProspects) {
$maxMatchingProspects = $matchingProspects;
$bestCandidate = $crmActivity;
}
} else {
return $crmActivity;
}
}
return $bestCandidate ?? [];
}
private function isOwnerMismatch(array $crmActivity, array $data): bool
{
if ($crmActivity['properties']['hubspot_owner_id'] !== $data['owner']) {
$this->logger->info('[Hubspot] Fetch related activity - owner mismatch', [
'activityId' => $data['activity'],
'crmActivity' => $crmActivity['id'],
'jiminnyOwner' => $data['owner'],
'hubspotOwner' => $crmActivity['properties']['hubspot_owner_id'],
]);
return true;
}
return false;
}
private function countMatchingProspects(array $crmActivity, array $data): int
{
try {
$crmActivityWithAssociations = $this->client->getMeeting($crmActivity['id']);
} catch (ApiException $e) {
$this->logger->error('[Hubspot] Fetch related activity - failed to get meeting associations', [
'activityId' => $data['activity'],
'crmActivity' => $crmActivity['id'],
'reason' => $e->getMessage(),
]);
return 0;
}
$associations = $crmActivityWithAssociations->getAssociations();
$matchingProspects = 0;
$matchingProspects += $this->countMatchingAssociation($data, $associations, 'company', 'companies');
$matchingProspects += $this->countMatchingAssociation($data, $associations, 'contact', 'contacts');
$matchingProspects += $this->countMatchingAssociation($data, $associations, 'deal', 'deals');
return $matchingProspects;
}
private function countMatchingAssociation(array $data, array $associations, string $dataKey, string $associationKey): int
{
if (isset($data[$dataKey]) && isset($associations[$associationKey])) {
$associatedItems = $associations[$associationKey]->getResults();
foreach ($associatedItems as $associatedItem) {
if ($data[$dataKey] == $associatedItem->getId()) {
return 1;
}
}
}
return 0;
}
private function getPlaybookCategoryId(Activity $activity, array $crmActivity): ?int
{
if (! $activity->hasUser()) {
$this->logger->info('[Hubspot] Fetch related activity - activity has no user', [
'activityId' => $activity->getUuid(),
]);
return null;
}
$playbookResolver = app(PlaybookResolver::class);
$playbook = $playbookResolver->resolvePlaybookByUser($activity->getUser());
if (! $playbook instanceof Playbook) {
$this->logger->info('[Hubspot] Fetch related activity - playbook not set for user', [
'activityId' => $activity->getUuid(),
]);
return null;
}
if (! $playbook->getActivityField() instanceof Field) {
$this->logger->info('[Hubspot] Fetch related activity - playbook activity field not set', [
'activityId' => $activity->getUuid(),
]);
return null;
}
$playbookCategory = null;
$activityTypeSource = null;
$playbookActivityFieldName = $playbook->getActivityField()->getCrmProviderId();
if ($playbookActivityFieldName === 'activityType') {
$playbookActivityFieldName = self::DEFAULT_ACTIVITY_TYPE;
}
// First attempt to match playbook category from crm activity
if (! empty($crmActivity['properties'][$playbookActivityFieldName])) {
$playbookCategoryRepository = app(PlaybookCategoryRepository::class);
$playbookCategory = $playbookCategoryRepository->getPlaybookCategoryByName(
$playbook,
strval($crmActivity['properties'][$playbookActivityFieldName])
);
$activityTypeSource = 'crm';
}
// If not matched get playbook category from Jiminny activity
if (! $playbookCategory instanceof PlaybookCategory && $activity->hasActivityType()) {
$playbookCategory = $activity->getActivityType();
$activityTypeSource = 'jiminny';
}
if ($playbookCategory instanceof PlaybookCategory) {
$this->logger->info('[Hubspot] Fetch related activity - Playbook category matched', [
'activityId' => $activity->getUuid(),
'playbookCategoryId' => $playbookCategory->getId(),
'activityFieldName' => $playbookActivityFieldName,
'crmActivity' => $crmActivity['id'],
'source' => $activityTypeSource,
]);
return $playbookCategory->getId();
}
$this->logger->info('[Hubspot] Fetch related activity - Playbook category not found', [
'activityId' => $activity->getUuid(),
'activityFieldName' => $playbookActivityFieldName,
'crmActivity' => $crmActivity['id'],
]);
return null;
}
private function updateActivity(Activity $activity, array $data): bool
{
$activityRepository = app(ActivityRepository::class);
$alreadyLoggedActivity = $activityRepository->findUserActivityByCrmProviderId(
$activity->getUser(),
$data['crm_provider_id']
);
if ($alreadyLoggedActivity instanceof Activity) {
$this->logger->info('[Hubspot] Fetch related activity - activity already linked', [
'currentActivityId' => $activity->getUuid(),
'linkedActivityId' => $alreadyLoggedActivity->getUuid(),
'crmActivityId' => $data['crm_provider_id'],
]);
// Return false to prevent updating the remote CRM engagement. This can happen when two Jiminny
// activities share the same calendar event (e.g., rescheduled meetings). Without this, the first
// activity's summary would be overwritten by the second activity.
return false;
}
try {
$activityRepository->update($activity, $data);
} catch (Exception $e) {
$this->logger->error('[Hubspot] Fetch related activity - activity update failed', [
'activityId' => $activity->getUuid(),
'reason' => $e->getMessage(),
]);
return false;
}
$this->logger->info('[Hubspot] Fetch related activity - activity updated', [
'activityId' => $activity->getUuid(),
'data' => $data,
]);
return true;
}
}
Project
Project
New File or Directory…...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
9813
|
444
|
13
|
2026-05-08T13:35:10.786040+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-08/1778 /Users/lukas/.screenpipe/data/data/2026-05-08/1778247310786_m2.jpg...
|
PhpStorm
|
faVsco.js – SyncRelatedActivityManager.php
|
1
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
JY-20725-handle-HS-search Project: faVsco.js, menu
JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
19
Previous Highlighted Error
Next Highlighted Error
[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {
"headers":{
"Date":["Thu,07 May 2026 14:21:15 GMT"],
"Content-Type":["application/json;charset=utf-8"],
"Transfer-Encoding":["chunked"],
"Connection":["keep-alive"],
"CF-Ray":["9f80deb8db60dc3a-SOF"],
"CF-Cache-Status":["DYNAMIC"],
"Strict-Transport-Security":["max-age=31536000; includeSubDomains; preload"],
"Vary":["origin,
accept-encoding"],
"access-control-allow-credentials":["false"],
"server-timing":["hcid;desc=\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\",
cfr;desc=\"9f80deb8e7c6dc3a-IAD\""],
"x-content-type-options":["nosniff"],
"x-hubspot-correlation-id":["019e02d0-6fd8-7812-bdba-885b7ccb3ee3"],
"Set-Cookie":["__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-[IP_ADDRESS]-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,
07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None"],
"Report-To":["{
\"endpoints\":[{
\"url\":\"https:\\/\\/a.nel.cloudflare.com\\/report\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\"}],
\"group\":\"cf-nel\",
\"max_age\":604800}"],
"NEL":["{
\"success_fraction\":0.01,
\"report_to\":\"cf-nel\",
\"max_age\":604800}"],
"Server":["cloudflare"]}} {
"correlation_id":"95236535-ec98-4541-b92a-adfa73b69eab",
"trace_id":"c7ab8365-903f-46d4-9403-0e5b551e3545"}
Sync Changes
Hide This Notification
Code changed:
Hide
3
11
Previous Highlighted Error
Next Highlighted Error
<?php
declare(strict_types=1);
namespace Jiminny\Services\Crm\Hubspot;
use Carbon\Carbon;
use Exception;
use HubSpot\Client\Crm\Objects\ApiException;
use Jiminny\Integrations\PlaybookResolver;
use Jiminny\Models\Activity;
use Jiminny\Models\Calendar\CalendarEvent;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\Profile;
use Jiminny\Models\Playbook;
use Jiminny\Models\PlaybookCategory;
use Jiminny\Repositories\ActivityRepository;
use Jiminny\Repositories\PlaybookCategoryRepository;
use Psr\Log\LoggerInterface;
class SyncRelatedActivityManager
{
public const string OBJECT_MEETING = 'meetings';
public const string DEFAULT_ACTIVITY_TYPE = 'hs_activity_type';
private RemoteCrmObjectManipulator $crmManipulator;
public function __construct(
private readonly Client $client,
private readonly PayloadBuilder $payloadBuilder,
private readonly LoggerInterface $logger
) {
$this->crmManipulator = app(RemoteCrmObjectManipulator::class, [
'client' => $this->client,
'logger' => $this->logger,
]);
}
public function fetchAndAssociateRelatedActivity(Activity $activity): ?Activity
{
// Validate if the activity is applicable.
if ($this->isActivityApplicable($activity) === false) {
$this->logger->info('[Hubspot] Fetch related activity - not applicable', [
'activityId' => $activity->getUuid(),
]);
return null;
}
// Find and fetch related activity.
try {
$crmActivity = $this->fetchRelatedActivity($activity);
if (empty($crmActivity)) {
return null;
}
} catch (Exception $exception) {
$this->logger->error('[Hubspot] Fetch related activity - failed', [
'activityId' => $activity->getUuid(),
'reason' => $exception->getMessage(),
]);
throw $exception;
}
// get playbook category id
$playbookCategoryId = $this->getPlaybookCategoryId($activity, $crmActivity);
$data = [
'crm_provider_id' => $crmActivity['id'],
'playbook_category_id' => $playbookCategoryId,
];
if (! $this->updateActivity($activity, $data)) {
return null;
}
$this->crmManipulator->updateRelatedCrmActivity($activity, $crmActivity);
$activity->refresh();
return $activity;
}
private function isActivityApplicable(Activity $activity): bool
{
if (! $activity->isTypeConference()) {
return false;
}
if (! $this->hasMinimumFilterRequirements($activity)) {
$this->logger->info('[Hubspot] Fetch related activity - not enough filter data', [
'activityId' => $activity->getUuid(),
]);
return false;
}
// The related activity is already linked.
if ($activity->hasCrmProviderId()) {
return false;
}
if (! $activity->getUser()->getProfile() instanceof Profile) {
$this->logger->info('[Hubspot] Fetch related activity - activity user has no crm profile', [
'activityId' => $activity->getUuid(),
]);
return false;
}
return true;
}
private function hasMinimumFilterRequirements(Activity $activity): bool
{
// We need to know when it started to determine when to look for related activity.
if (! $activity->hasActualStartTime()) {
return false;
}
if (! $activity->hasActualEndTime()) {
return false;
}
// At least one prospect should be associated to the activity
if ($activity->hasContact() || $activity->hasAccount()) {
return true;
}
return false;
}
public function fetchRelatedActivity(Activity $activity): array
{
$this->logger->info('[Hubspot] Fetch related activity - searching related CRM activity', [
'activityId' => $activity->getUuid(),
]);
$payloadData = $this->getPayloadFilterData($activity);
try {
$payload = $this->payloadBuilder->getFindRelatedActivityPayload($payloadData, self::OBJECT_MEETING);
$response = $this->client->getPaginatedData($payload, self::OBJECT_MEETING);
$crmActivities = $response['results'];
} catch (Exception $e) {
$this->logger->error('[Hubspot] Fetch related activity - failed', [
'activityId' => $activity->getUuid(),
'reason' => $e->getMessage(),
]);
throw $e;
}
if (empty($crmActivities)) {
$this->logger->info('[Hubspot] Fetch related activity - no CRM related activity found', [
'activityId' => $activity->getUuid(),
'payload' => $payload,
]);
return [];
}
$relatedActivity = $this->getBestCrmActivityCandidate($crmActivities, $payloadData);
if (empty($relatedActivity)) {
$this->logger->info('[Hubspot] Fetch related activity - no related activity candidate', [
'activityId' => $activity->getUuid(),
]);
return [];
}
$this->logger->info('[Hubspot] Fetch related activity - found related CRM activity', [
'activityId' => $activity->getUuid(),
'crmActivity' => $relatedActivity,
]);
return $relatedActivity;
}
/**
* @param Activity $activity
*
* @return array
*/
protected function getPayloadFilterData(Activity $activity): array
{
[$from, $to] = $this->getActivityTimeFilterRange($activity);
$data = [
'from' => $from->getPreciseTimestamp(3),
'to' => $to->getPreciseTimestamp(3),
'contact' => $activity->hasContact() ? $activity->getContact()->getCrmProviderId() : null,
'company' => $activity->hasAccount() ? $activity->getAccount()->getCrmProviderId() : null,
'deal' => $activity->hasOpportunity() ? $activity->getOpportunity()->getCrmProviderId() : null,
'owner' => $activity->getUser()->getProfile()->getCrmProviderId(),
'activity' => $activity->getUuid(),
];
return array_filter($data);
}
private function getActivityTimeFilterRange(Activity $activity): array
{
if ($activity->hasScheduledStartTime() && $activity->hasScheduledEndTime()) {
$calendarEvent = $activity->getCalendarEvent();
if ($calendarEvent instanceof CalendarEvent) {
$from = $calendarEvent->getStartTime();
$to = $calendarEvent->getEndTime()->addMinutes(15);
} else {
$from = Carbon::instance($activity->getScheduledStartTime());
$to = Carbon::instance($activity->getScheduledEndTime())->addMinutes(15);
}
return [$from, $to];
}
$from = Carbon::instance($activity->getActualStartTime());
$to = Carbon::instance($activity->getActualEndTime());
return [$from, $to];
}
/**
* Determines the best CRM activity candidate based on the number of matching prospects and the owner.
*
* @param array $crmActivities List of CRM activities to evaluate.
* @param array $data Data containing the prospects and owner information.
*
* @return array The best CRM activity candidate.
*/
private function getBestCrmActivityCandidate(array $crmActivities, array $data): array
{
$prospectCount = count(array_intersect_key($data, array_flip(['contact', 'company', 'deal'])));
$bestCandidate = null;
$maxMatchingProspects = 0;
$validActivitiesCount = count($crmActivities);
if ($validActivitiesCount > 1) {
$this->logger->info('[Hubspot] Fetch related activity - determining the best candidate', [
'activityId' => $data['activity'],
'activitiesCount' => $validActivitiesCount,
'crmActivities' => $crmActivities,
]);
}
foreach ($crmActivities as $crmActivity) {
if ($this->isOwnerMismatch($crmActivity, $data)) {
$validActivitiesCount--;
continue;
}
if ($validActivitiesCount === 1) {
return $crmActivity;
}
if ($prospectCount > 1) {
$matchingProspects = $this->countMatchingProspects($crmActivity, $data);
if ($matchingProspects > $maxMatchingProspects) {
$maxMatchingProspects = $matchingProspects;
$bestCandidate = $crmActivity;
}
} else {
return $crmActivity;
}
}
return $bestCandidate ?? [];
}
private function isOwnerMismatch(array $crmActivity, array $data): bool
{
if ($crmActivity['properties']['hubspot_owner_id'] !== $data['owner']) {
$this->logger->info('[Hubspot] Fetch related activity - owner mismatch', [
'activityId' => $data['activity'],
'crmActivity' => $crmActivity['id'],
'jiminnyOwner' => $data['owner'],
'hubspotOwner' => $crmActivity['properties']['hubspot_owner_id'],
]);
return true;
}
return false;
}
private function countMatchingProspects(array $crmActivity, array $data): int
{
try {
$crmActivityWithAssociations = $this->client->getMeeting($crmActivity['id']);
} catch (ApiException $e) {
$this->logger->error('[Hubspot] Fetch related activity - failed to get meeting associations', [
'activityId' => $data['activity'],
'crmActivity' => $crmActivity['id'],
'reason' => $e->getMessage(),
]);
return 0;
}
$associations = $crmActivityWithAssociations->getAssociations();
$matchingProspects = 0;
$matchingProspects += $this->countMatchingAssociation($data, $associations, 'company', 'companies');
$matchingProspects += $this->countMatchingAssociation($data, $associations, 'contact', 'contacts');
$matchingProspects += $this->countMatchingAssociation($data, $associations, 'deal', 'deals');
return $matchingProspects;
}
private function countMatchingAssociation(array $data, array $associations, string $dataKey, string $associationKey): int
{
if (isset($data[$dataKey]) && isset($associations[$associationKey])) {
$associatedItems = $associations[$associationKey]->getResults();
foreach ($associatedItems as $associatedItem) {
if ($data[$dataKey] == $associatedItem->getId()) {
return 1;
}
}
}
return 0;
}
private function getPlaybookCategoryId(Activity $activity, array $crmActivity): ?int
{
if (! $activity->hasUser()) {
$this->logger->info('[Hubspot] Fetch related activity - activity has no user', [
'activityId' => $activity->getUuid(),
]);
return null;
}
$playbookResolver = app(PlaybookResolver::class);
$playbook = $playbookResolver->resolvePlaybookByUser($activity->getUser());
if (! $playbook instanceof Playbook) {
$this->logger->info('[Hubspot] Fetch related activity - playbook not set for user', [
'activityId' => $activity->getUuid(),
]);
return null;
}
if (! $playbook->getActivityField() instanceof Field) {
$this->logger->info('[Hubspot] Fetch related activity - playbook activity field not set', [
'activityId' => $activity->getUuid(),
]);
return null;
}
$playbookCategory = null;
$activityTypeSource = null;
$playbookActivityFieldName = $playbook->getActivityField()->getCrmProviderId();
if ($playbookActivityFieldName === 'activityType') {
$playbookActivityFieldName = self::DEFAULT_ACTIVITY_TYPE;
}
// First attempt to match playbook category from crm activity
if (! empty($crmActivity['properties'][$playbookActivityFieldName])) {
$playbookCategoryRepository = app(PlaybookCategoryRepository::class);
$playbookCategory = $playbookCategoryRepository->getPlaybookCategoryByName(
$playbook,
strval($crmActivity['properties'][$playbookActivityFieldName])
);
$activityTypeSource = 'crm';
}
// If not matched get playbook category from Jiminny activity
if (! $playbookCategory instanceof PlaybookCategory && $activity->hasActivityType()) {
$playbookCategory = $activity->getActivityType();
$activityTypeSource = 'jiminny';
}
if ($playbookCategory instanceof PlaybookCategory) {
$this->logger->info('[Hubspot] Fetch related activity - Playbook category matched', [
'activityId' => $activity->getUuid(),
'playbookCategoryId' => $playbookCategory->getId(),
'activityFieldName' => $playbookActivityFieldName,
'crmActivity' => $crmActivity['id'],
'source' => $activityTypeSource,
]);
return $playbookCategory->getId();
}
$this->logger->info('[Hubspot] Fetch related activity - Playbook category not found', [
'activityId' => $activity->getUuid(),
'activityFieldName' => $playbookActivityFieldName,
'crmActivity' => $crmActivity['id'],
]);
return null;
}
private function updateActivity(Activity $activity, array $data): bool
{
$activityRepository = app(ActivityRepository::class);
$alreadyLoggedActivity = $activityRepository->findUserActivityByCrmProviderId(
$activity->getUser(),
$data['crm_provider_id']
);
if ($alreadyLoggedActivity instanceof Activity) {
$this->logger->info('[Hubspot] Fetch related activity - activity already linked', [
'currentActivityId' => $activity->getUuid(),
'linkedActivityId' => $alreadyLoggedActivity->getUuid(),
'crmActivityId' => $data['crm_provider_id'],
]);
// Return false to prevent updating the remote CRM engagement. This can happen when two Jiminny
// activities share the same calendar event (e.g., rescheduled meetings). Without this, the first
// activity's summary would be overwritten by the second activity.
return false;
}
try {
$activityRepository->update($activity, $data);
} catch (Exception $e) {
$this->logger->error('[Hubspot] Fetch related activity - activity update failed', [
'activityId' => $activity->getUuid(),
'reason' => $e->getMessage(),
]);
return false;
}
$this->logger->info('[Hubspot] Fetch related activity - activity updated', [
'activityId' => $activity->getUuid(),
'data' => $data,
]);
return true;
}
}
Project
Project
New File or Directory…
Expand Selected
Collapse All
Options
Hide...
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"bounds":{"left":0.025930852,"top":0.019952115,"width":0.03856383,"height":0.025538707},"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JY-20725-handle-HS-search-rate-limit, menu","depth":5,"bounds":{"left":0.064494684,"top":0.019952115,"width":0.09541223,"height":0.025538707},"on_screen":true,"help_text":"Git Branch: JY-20725-handle-HS-search-rate-limit","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"bounds":{"left":0.8081782,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"AskJiminnyReportActivityServiceTest","depth":6,"bounds":{"left":0.8234708,"top":0.019952115,"width":0.09208777,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'AskJiminnyReportActivityServiceTest'","depth":6,"bounds":{"left":0.9155585,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'AskJiminnyReportActivityServiceTest'","depth":6,"bounds":{"left":0.9268617,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"bounds":{"left":0.9381649,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"bounds":{"left":0.96609044,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"bounds":{"left":0.9773936,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"IDE and Project Settings","depth":5,"bounds":{"left":0.9886968,"top":0.019952115,"width":0.011303186,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.042220745,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"19","depth":4,"bounds":{"left":0.6615692,"top":0.10055866,"width":0.009640957,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"bounds":{"left":0.67287236,"top":0.09896249,"width":0.00731383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"bounds":{"left":0.68018615,"top":0.09896249,"width":0.006981383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {\n\"headers\":{\n\"Date\":[\"Thu,07 May 2026 14:21:15 GMT\"],\n \"Content-Type\":[\"application/json;charset=utf-8\"],\n \"Transfer-Encoding\":[\"chunked\"],\n \"Connection\":[\"keep-alive\"],\n \"CF-Ray\":[\"9f80deb8db60dc3a-SOF\"],\n \"CF-Cache-Status\":[\"DYNAMIC\"],\n \"Strict-Transport-Security\":[\"max-age=31536000; includeSubDomains; preload\"],\n \"Vary\":[\"origin,\n accept-encoding\"],\n \"access-control-allow-credentials\":[\"false\"],\n \"server-timing\":[\"hcid;desc=\\\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\\\",\n cfr;desc=\\\"9f80deb8e7c6dc3a-IAD\\\"\"],\n \"x-content-type-options\":[\"nosniff\"],\n \"x-hubspot-correlation-id\":[\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\"],\n \"Set-Cookie\":[\"__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-1.0.1.1-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,\n 07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None\"],\n \"Report-To\":[\"{\n\\\"endpoints\\\":[{\n\\\"url\\\":\\\"https:\\\\/\\\\/a.nel.cloudflare.com\\\\/report\\\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\\\"}],\n\\\"group\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"NEL\":[\"{\n\\\"success_fraction\\\":0.01,\n\\\"report_to\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"Server\":[\"cloudflare\"]}} {\n\"correlation_id\":\"95236535-ec98-4541-b92a-adfa73b69eab\",\n\"trace_id\":\"c7ab8365-903f-46d4-9403-0e5b551e3545\"}","depth":4,"bounds":{"left":0.37632978,"top":0.09736632,"width":0.5728058,"height":0.8818835},"on_screen":true,"lines":[{"char_start":207,"char_count":30,"bounds":{"left":0.37632978,"top":0.0,"width":0.07513298,"height":0.014365523}},{"char_start":237,"char_count":36,"bounds":{"left":0.37632978,"top":0.0,"width":0.09075798,"height":0.014365523}},{"char_start":273,"char_count":32,"bounds":{"left":0.37632978,"top":0.0,"width":0.080119684,"height":0.014365523}},{"char_start":305,"char_count":79,"bounds":{"left":0.37632978,"top":0.0,"width":0.20212767,"height":0.014365523}},{"char_start":384,"char_count":18,"bounds":{"left":0.37632978,"top":0.0,"width":0.043882977,"height":0.014365523}},{"char_start":402,"char_count":21,"bounds":{"left":0.37632978,"top":0.0,"width":0.051861703,"height":0.014365523}},{"char_start":423,"char_count":48,"bounds":{"left":0.37632978,"top":0.008778931,"width":0.12167553,"height":0.014365523}},{"char_start":471,"char_count":72,"bounds":{"left":0.37632978,"top":0.026336791,"width":0.18384309,"height":0.014365523}},{"char_start":543,"char_count":40,"bounds":{"left":0.37632978,"top":0.043894652,"width":0.10106383,"height":0.014365523}},{"char_start":583,"char_count":41,"bounds":{"left":0.37632978,"top":0.061452515,"width":0.10372341,"height":0.014365523}},{"char_start":624,"char_count":72,"bounds":{"left":0.37632978,"top":0.079010375,"width":0.18384309,"height":0.014365523}},{"char_start":696,"char_count":219,"bounds":{"left":0.37632978,"top":0.096568234,"width":0.56515956,"height":0.014365523}},{"char_start":915,"char_count":83,"bounds":{"left":0.37632978,"top":0.11412609,"width":0.21243352,"height":0.014365523}},{"char_start":998,"char_count":20,"bounds":{"left":0.37632978,"top":0.13168396,"width":0.04920213,"height":0.014365523}},{"char_start":1018,"char_count":17,"bounds":{"left":0.37632978,"top":0.14924182,"width":0.041223403,"height":0.014365523}},{"char_start":1035,"char_count":203,"bounds":{"left":0.37632978,"top":0.16679968,"width":0.52360374,"height":0.014365523}},{"char_start":1238,"char_count":22,"bounds":{"left":0.37632978,"top":0.18435754,"width":0.05418883,"height":0.014365523}},{"char_start":1260,"char_count":23,"bounds":{"left":0.37632978,"top":0.2019154,"width":0.056848403,"height":0.014365523}},{"char_start":1283,"char_count":10,"bounds":{"left":0.37632978,"top":0.21947326,"width":0.023271276,"height":0.014365523}},{"char_start":1293,"char_count":27,"bounds":{"left":0.37632978,"top":0.23703113,"width":0.06715426,"height":0.014365523}},{"char_start":1320,"char_count":26,"bounds":{"left":0.37632978,"top":0.254589,"width":0.06482713,"height":0.014365523}},{"char_start":1346,"char_count":23,"bounds":{"left":0.37632978,"top":0.27214685,"width":0.056848403,"height":0.014365523}},{"char_start":1369,"char_count":28,"bounds":{"left":0.37632978,"top":0.2897047,"width":0.06981383,"height":0.014365523}},{"char_start":1397,"char_count":57,"bounds":{"left":0.37632978,"top":0.30726257,"width":0.14494681,"height":0.014365523}}],"value":"[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {\n\"headers\":{\n\"Date\":[\"Thu,07 May 2026 14:21:15 GMT\"],\n \"Content-Type\":[\"application/json;charset=utf-8\"],\n \"Transfer-Encoding\":[\"chunked\"],\n \"Connection\":[\"keep-alive\"],\n \"CF-Ray\":[\"9f80deb8db60dc3a-SOF\"],\n \"CF-Cache-Status\":[\"DYNAMIC\"],\n \"Strict-Transport-Security\":[\"max-age=31536000; includeSubDomains; preload\"],\n \"Vary\":[\"origin,\n accept-encoding\"],\n \"access-control-allow-credentials\":[\"false\"],\n \"server-timing\":[\"hcid;desc=\\\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\\\",\n cfr;desc=\\\"9f80deb8e7c6dc3a-IAD\\\"\"],\n \"x-content-type-options\":[\"nosniff\"],\n \"x-hubspot-correlation-id\":[\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\"],\n \"Set-Cookie\":[\"__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-1.0.1.1-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,\n 07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None\"],\n \"Report-To\":[\"{\n\\\"endpoints\\\":[{\n\\\"url\\\":\\\"https:\\\\/\\\\/a.nel.cloudflare.com\\\\/report\\\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\\\"}],\n\\\"group\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"NEL\":[\"{\n\\\"success_fraction\\\":0.01,\n\\\"report_to\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"Server\":[\"cloudflare\"]}} {\n\"correlation_id\":\"95236535-ec98-4541-b92a-adfa73b69eab\",\n\"trace_id\":\"c7ab8365-903f-46d4-9403-0e5b551e3545\"}","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.042220745,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"3","depth":4,"bounds":{"left":0.32214096,"top":0.19952115,"width":0.007978723,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"11","depth":4,"bounds":{"left":0.33211437,"top":0.19952115,"width":0.008976064,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"bounds":{"left":0.34275267,"top":0.19792499,"width":0.00731383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"bounds":{"left":0.35006648,"top":0.19792499,"width":0.006981383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\ndeclare(strict_types=1);\n\nnamespace Jiminny\\Services\\Crm\\Hubspot;\n\nuse Carbon\\Carbon;\nuse Exception;\nuse HubSpot\\Client\\Crm\\Objects\\ApiException;\nuse Jiminny\\Integrations\\PlaybookResolver;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Calendar\\CalendarEvent;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Models\\Crm\\Profile;\nuse Jiminny\\Models\\Playbook;\nuse Jiminny\\Models\\PlaybookCategory;\nuse Jiminny\\Repositories\\ActivityRepository;\nuse Jiminny\\Repositories\\PlaybookCategoryRepository;\nuse Psr\\Log\\LoggerInterface;\n\nclass SyncRelatedActivityManager\n{\n public const string OBJECT_MEETING = 'meetings';\n public const string DEFAULT_ACTIVITY_TYPE = 'hs_activity_type';\n private RemoteCrmObjectManipulator $crmManipulator;\n\n public function __construct(\n private readonly Client $client,\n private readonly PayloadBuilder $payloadBuilder,\n private readonly LoggerInterface $logger\n ) {\n $this->crmManipulator = app(RemoteCrmObjectManipulator::class, [\n 'client' => $this->client,\n 'logger' => $this->logger,\n ]);\n }\n\n public function fetchAndAssociateRelatedActivity(Activity $activity): ?Activity\n {\n // Validate if the activity is applicable.\n if ($this->isActivityApplicable($activity) === false) {\n $this->logger->info('[Hubspot] Fetch related activity - not applicable', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n return null;\n }\n\n // Find and fetch related activity.\n try {\n $crmActivity = $this->fetchRelatedActivity($activity);\n if (empty($crmActivity)) {\n return null;\n }\n } catch (Exception $exception) {\n $this->logger->error('[Hubspot] Fetch related activity - failed', [\n 'activityId' => $activity->getUuid(),\n 'reason' => $exception->getMessage(),\n ]);\n\n throw $exception;\n }\n\n // get playbook category id\n $playbookCategoryId = $this->getPlaybookCategoryId($activity, $crmActivity);\n\n $data = [\n 'crm_provider_id' => $crmActivity['id'],\n 'playbook_category_id' => $playbookCategoryId,\n ];\n\n if (! $this->updateActivity($activity, $data)) {\n return null;\n }\n\n $this->crmManipulator->updateRelatedCrmActivity($activity, $crmActivity);\n\n $activity->refresh();\n\n return $activity;\n }\n\n private function isActivityApplicable(Activity $activity): bool\n {\n if (! $activity->isTypeConference()) {\n return false;\n }\n\n if (! $this->hasMinimumFilterRequirements($activity)) {\n $this->logger->info('[Hubspot] Fetch related activity - not enough filter data', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n return false;\n }\n\n // The related activity is already linked.\n if ($activity->hasCrmProviderId()) {\n return false;\n }\n\n if (! $activity->getUser()->getProfile() instanceof Profile) {\n $this->logger->info('[Hubspot] Fetch related activity - activity user has no crm profile', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n return false;\n }\n\n return true;\n }\n\n private function hasMinimumFilterRequirements(Activity $activity): bool\n {\n // We need to know when it started to determine when to look for related activity.\n if (! $activity->hasActualStartTime()) {\n return false;\n }\n\n if (! $activity->hasActualEndTime()) {\n return false;\n }\n\n // At least one prospect should be associated to the activity\n if ($activity->hasContact() || $activity->hasAccount()) {\n return true;\n }\n\n return false;\n }\n\n public function fetchRelatedActivity(Activity $activity): array\n {\n $this->logger->info('[Hubspot] Fetch related activity - searching related CRM activity', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n $payloadData = $this->getPayloadFilterData($activity);\n\n try {\n $payload = $this->payloadBuilder->getFindRelatedActivityPayload($payloadData, self::OBJECT_MEETING);\n $response = $this->client->getPaginatedData($payload, self::OBJECT_MEETING);\n $crmActivities = $response['results'];\n } catch (Exception $e) {\n $this->logger->error('[Hubspot] Fetch related activity - failed', [\n 'activityId' => $activity->getUuid(),\n 'reason' => $e->getMessage(),\n ]);\n\n throw $e;\n }\n\n if (empty($crmActivities)) {\n $this->logger->info('[Hubspot] Fetch related activity - no CRM related activity found', [\n 'activityId' => $activity->getUuid(),\n 'payload' => $payload,\n ]);\n\n return [];\n }\n\n $relatedActivity = $this->getBestCrmActivityCandidate($crmActivities, $payloadData);\n if (empty($relatedActivity)) {\n $this->logger->info('[Hubspot] Fetch related activity - no related activity candidate', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n return [];\n }\n\n $this->logger->info('[Hubspot] Fetch related activity - found related CRM activity', [\n 'activityId' => $activity->getUuid(),\n 'crmActivity' => $relatedActivity,\n ]);\n\n return $relatedActivity;\n }\n\n /**\n * @param Activity $activity\n *\n * @return array\n */\n protected function getPayloadFilterData(Activity $activity): array\n {\n [$from, $to] = $this->getActivityTimeFilterRange($activity);\n\n $data = [\n 'from' => $from->getPreciseTimestamp(3),\n 'to' => $to->getPreciseTimestamp(3),\n 'contact' => $activity->hasContact() ? $activity->getContact()->getCrmProviderId() : null,\n 'company' => $activity->hasAccount() ? $activity->getAccount()->getCrmProviderId() : null,\n 'deal' => $activity->hasOpportunity() ? $activity->getOpportunity()->getCrmProviderId() : null,\n 'owner' => $activity->getUser()->getProfile()->getCrmProviderId(),\n 'activity' => $activity->getUuid(),\n ];\n\n return array_filter($data);\n }\n\n private function getActivityTimeFilterRange(Activity $activity): array\n {\n if ($activity->hasScheduledStartTime() && $activity->hasScheduledEndTime()) {\n $calendarEvent = $activity->getCalendarEvent();\n\n if ($calendarEvent instanceof CalendarEvent) {\n $from = $calendarEvent->getStartTime();\n $to = $calendarEvent->getEndTime()->addMinutes(15);\n } else {\n $from = Carbon::instance($activity->getScheduledStartTime());\n $to = Carbon::instance($activity->getScheduledEndTime())->addMinutes(15);\n }\n\n return [$from, $to];\n }\n\n $from = Carbon::instance($activity->getActualStartTime());\n $to = Carbon::instance($activity->getActualEndTime());\n\n return [$from, $to];\n }\n\n /**\n * Determines the best CRM activity candidate based on the number of matching prospects and the owner.\n *\n * @param array $crmActivities List of CRM activities to evaluate.\n * @param array $data Data containing the prospects and owner information.\n *\n * @return array The best CRM activity candidate.\n */\n private function getBestCrmActivityCandidate(array $crmActivities, array $data): array\n {\n $prospectCount = count(array_intersect_key($data, array_flip(['contact', 'company', 'deal'])));\n $bestCandidate = null;\n $maxMatchingProspects = 0;\n $validActivitiesCount = count($crmActivities);\n\n if ($validActivitiesCount > 1) {\n $this->logger->info('[Hubspot] Fetch related activity - determining the best candidate', [\n 'activityId' => $data['activity'],\n 'activitiesCount' => $validActivitiesCount,\n 'crmActivities' => $crmActivities,\n ]);\n }\n\n foreach ($crmActivities as $crmActivity) {\n if ($this->isOwnerMismatch($crmActivity, $data)) {\n $validActivitiesCount--;\n\n continue;\n }\n\n if ($validActivitiesCount === 1) {\n return $crmActivity;\n }\n\n if ($prospectCount > 1) {\n $matchingProspects = $this->countMatchingProspects($crmActivity, $data);\n if ($matchingProspects > $maxMatchingProspects) {\n $maxMatchingProspects = $matchingProspects;\n $bestCandidate = $crmActivity;\n }\n } else {\n return $crmActivity;\n }\n }\n\n return $bestCandidate ?? [];\n }\n\n private function isOwnerMismatch(array $crmActivity, array $data): bool\n {\n if ($crmActivity['properties']['hubspot_owner_id'] !== $data['owner']) {\n $this->logger->info('[Hubspot] Fetch related activity - owner mismatch', [\n 'activityId' => $data['activity'],\n 'crmActivity' => $crmActivity['id'],\n 'jiminnyOwner' => $data['owner'],\n 'hubspotOwner' => $crmActivity['properties']['hubspot_owner_id'],\n ]);\n\n return true;\n }\n\n return false;\n }\n\n private function countMatchingProspects(array $crmActivity, array $data): int\n {\n try {\n $crmActivityWithAssociations = $this->client->getMeeting($crmActivity['id']);\n } catch (ApiException $e) {\n $this->logger->error('[Hubspot] Fetch related activity - failed to get meeting associations', [\n 'activityId' => $data['activity'],\n 'crmActivity' => $crmActivity['id'],\n 'reason' => $e->getMessage(),\n ]);\n\n return 0;\n }\n\n $associations = $crmActivityWithAssociations->getAssociations();\n $matchingProspects = 0;\n\n $matchingProspects += $this->countMatchingAssociation($data, $associations, 'company', 'companies');\n $matchingProspects += $this->countMatchingAssociation($data, $associations, 'contact', 'contacts');\n $matchingProspects += $this->countMatchingAssociation($data, $associations, 'deal', 'deals');\n\n return $matchingProspects;\n }\n\n private function countMatchingAssociation(array $data, array $associations, string $dataKey, string $associationKey): int\n {\n if (isset($data[$dataKey]) && isset($associations[$associationKey])) {\n $associatedItems = $associations[$associationKey]->getResults();\n foreach ($associatedItems as $associatedItem) {\n if ($data[$dataKey] == $associatedItem->getId()) {\n\n return 1;\n }\n }\n }\n\n return 0;\n }\n\n private function getPlaybookCategoryId(Activity $activity, array $crmActivity): ?int\n {\n if (! $activity->hasUser()) {\n $this->logger->info('[Hubspot] Fetch related activity - activity has no user', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n return null;\n }\n\n $playbookResolver = app(PlaybookResolver::class);\n $playbook = $playbookResolver->resolvePlaybookByUser($activity->getUser());\n\n if (! $playbook instanceof Playbook) {\n $this->logger->info('[Hubspot] Fetch related activity - playbook not set for user', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n return null;\n }\n\n if (! $playbook->getActivityField() instanceof Field) {\n $this->logger->info('[Hubspot] Fetch related activity - playbook activity field not set', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n return null;\n }\n\n $playbookCategory = null;\n $activityTypeSource = null;\n\n $playbookActivityFieldName = $playbook->getActivityField()->getCrmProviderId();\n if ($playbookActivityFieldName === 'activityType') {\n $playbookActivityFieldName = self::DEFAULT_ACTIVITY_TYPE;\n }\n\n // First attempt to match playbook category from crm activity\n if (! empty($crmActivity['properties'][$playbookActivityFieldName])) {\n $playbookCategoryRepository = app(PlaybookCategoryRepository::class);\n $playbookCategory = $playbookCategoryRepository->getPlaybookCategoryByName(\n $playbook,\n strval($crmActivity['properties'][$playbookActivityFieldName])\n );\n $activityTypeSource = 'crm';\n }\n\n // If not matched get playbook category from Jiminny activity\n if (! $playbookCategory instanceof PlaybookCategory && $activity->hasActivityType()) {\n $playbookCategory = $activity->getActivityType();\n $activityTypeSource = 'jiminny';\n }\n\n if ($playbookCategory instanceof PlaybookCategory) {\n $this->logger->info('[Hubspot] Fetch related activity - Playbook category matched', [\n 'activityId' => $activity->getUuid(),\n 'playbookCategoryId' => $playbookCategory->getId(),\n 'activityFieldName' => $playbookActivityFieldName,\n 'crmActivity' => $crmActivity['id'],\n 'source' => $activityTypeSource,\n ]);\n\n return $playbookCategory->getId();\n }\n\n $this->logger->info('[Hubspot] Fetch related activity - Playbook category not found', [\n 'activityId' => $activity->getUuid(),\n 'activityFieldName' => $playbookActivityFieldName,\n 'crmActivity' => $crmActivity['id'],\n ]);\n\n return null;\n }\n\n private function updateActivity(Activity $activity, array $data): bool\n {\n $activityRepository = app(ActivityRepository::class);\n\n $alreadyLoggedActivity = $activityRepository->findUserActivityByCrmProviderId(\n $activity->getUser(),\n $data['crm_provider_id']\n );\n\n if ($alreadyLoggedActivity instanceof Activity) {\n $this->logger->info('[Hubspot] Fetch related activity - activity already linked', [\n 'currentActivityId' => $activity->getUuid(),\n 'linkedActivityId' => $alreadyLoggedActivity->getUuid(),\n 'crmActivityId' => $data['crm_provider_id'],\n ]);\n\n // Return false to prevent updating the remote CRM engagement. This can happen when two Jiminny\n // activities share the same calendar event (e.g., rescheduled meetings). Without this, the first\n // activity's summary would be overwritten by the second activity.\n return false;\n }\n\n try {\n $activityRepository->update($activity, $data);\n } catch (Exception $e) {\n $this->logger->error('[Hubspot] Fetch related activity - activity update failed', [\n 'activityId' => $activity->getUuid(),\n 'reason' => $e->getMessage(),\n ]);\n\n return false;\n }\n\n $this->logger->info('[Hubspot] Fetch related activity - activity updated', [\n 'activityId' => $activity->getUuid(),\n 'data' => $data,\n ]);\n\n return true;\n }\n}","depth":4,"bounds":{"left":0.122340426,"top":0.005586592,"width":0.34474733,"height":0.99441344},"on_screen":true,"value":"<?php\n\ndeclare(strict_types=1);\n\nnamespace Jiminny\\Services\\Crm\\Hubspot;\n\nuse Carbon\\Carbon;\nuse Exception;\nuse HubSpot\\Client\\Crm\\Objects\\ApiException;\nuse Jiminny\\Integrations\\PlaybookResolver;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Calendar\\CalendarEvent;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Models\\Crm\\Profile;\nuse Jiminny\\Models\\Playbook;\nuse Jiminny\\Models\\PlaybookCategory;\nuse Jiminny\\Repositories\\ActivityRepository;\nuse Jiminny\\Repositories\\PlaybookCategoryRepository;\nuse Psr\\Log\\LoggerInterface;\n\nclass SyncRelatedActivityManager\n{\n public const string OBJECT_MEETING = 'meetings';\n public const string DEFAULT_ACTIVITY_TYPE = 'hs_activity_type';\n private RemoteCrmObjectManipulator $crmManipulator;\n\n public function __construct(\n private readonly Client $client,\n private readonly PayloadBuilder $payloadBuilder,\n private readonly LoggerInterface $logger\n ) {\n $this->crmManipulator = app(RemoteCrmObjectManipulator::class, [\n 'client' => $this->client,\n 'logger' => $this->logger,\n ]);\n }\n\n public function fetchAndAssociateRelatedActivity(Activity $activity): ?Activity\n {\n // Validate if the activity is applicable.\n if ($this->isActivityApplicable($activity) === false) {\n $this->logger->info('[Hubspot] Fetch related activity - not applicable', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n return null;\n }\n\n // Find and fetch related activity.\n try {\n $crmActivity = $this->fetchRelatedActivity($activity);\n if (empty($crmActivity)) {\n return null;\n }\n } catch (Exception $exception) {\n $this->logger->error('[Hubspot] Fetch related activity - failed', [\n 'activityId' => $activity->getUuid(),\n 'reason' => $exception->getMessage(),\n ]);\n\n throw $exception;\n }\n\n // get playbook category id\n $playbookCategoryId = $this->getPlaybookCategoryId($activity, $crmActivity);\n\n $data = [\n 'crm_provider_id' => $crmActivity['id'],\n 'playbook_category_id' => $playbookCategoryId,\n ];\n\n if (! $this->updateActivity($activity, $data)) {\n return null;\n }\n\n $this->crmManipulator->updateRelatedCrmActivity($activity, $crmActivity);\n\n $activity->refresh();\n\n return $activity;\n }\n\n private function isActivityApplicable(Activity $activity): bool\n {\n if (! $activity->isTypeConference()) {\n return false;\n }\n\n if (! $this->hasMinimumFilterRequirements($activity)) {\n $this->logger->info('[Hubspot] Fetch related activity - not enough filter data', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n return false;\n }\n\n // The related activity is already linked.\n if ($activity->hasCrmProviderId()) {\n return false;\n }\n\n if (! $activity->getUser()->getProfile() instanceof Profile) {\n $this->logger->info('[Hubspot] Fetch related activity - activity user has no crm profile', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n return false;\n }\n\n return true;\n }\n\n private function hasMinimumFilterRequirements(Activity $activity): bool\n {\n // We need to know when it started to determine when to look for related activity.\n if (! $activity->hasActualStartTime()) {\n return false;\n }\n\n if (! $activity->hasActualEndTime()) {\n return false;\n }\n\n // At least one prospect should be associated to the activity\n if ($activity->hasContact() || $activity->hasAccount()) {\n return true;\n }\n\n return false;\n }\n\n public function fetchRelatedActivity(Activity $activity): array\n {\n $this->logger->info('[Hubspot] Fetch related activity - searching related CRM activity', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n $payloadData = $this->getPayloadFilterData($activity);\n\n try {\n $payload = $this->payloadBuilder->getFindRelatedActivityPayload($payloadData, self::OBJECT_MEETING);\n $response = $this->client->getPaginatedData($payload, self::OBJECT_MEETING);\n $crmActivities = $response['results'];\n } catch (Exception $e) {\n $this->logger->error('[Hubspot] Fetch related activity - failed', [\n 'activityId' => $activity->getUuid(),\n 'reason' => $e->getMessage(),\n ]);\n\n throw $e;\n }\n\n if (empty($crmActivities)) {\n $this->logger->info('[Hubspot] Fetch related activity - no CRM related activity found', [\n 'activityId' => $activity->getUuid(),\n 'payload' => $payload,\n ]);\n\n return [];\n }\n\n $relatedActivity = $this->getBestCrmActivityCandidate($crmActivities, $payloadData);\n if (empty($relatedActivity)) {\n $this->logger->info('[Hubspot] Fetch related activity - no related activity candidate', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n return [];\n }\n\n $this->logger->info('[Hubspot] Fetch related activity - found related CRM activity', [\n 'activityId' => $activity->getUuid(),\n 'crmActivity' => $relatedActivity,\n ]);\n\n return $relatedActivity;\n }\n\n /**\n * @param Activity $activity\n *\n * @return array\n */\n protected function getPayloadFilterData(Activity $activity): array\n {\n [$from, $to] = $this->getActivityTimeFilterRange($activity);\n\n $data = [\n 'from' => $from->getPreciseTimestamp(3),\n 'to' => $to->getPreciseTimestamp(3),\n 'contact' => $activity->hasContact() ? $activity->getContact()->getCrmProviderId() : null,\n 'company' => $activity->hasAccount() ? $activity->getAccount()->getCrmProviderId() : null,\n 'deal' => $activity->hasOpportunity() ? $activity->getOpportunity()->getCrmProviderId() : null,\n 'owner' => $activity->getUser()->getProfile()->getCrmProviderId(),\n 'activity' => $activity->getUuid(),\n ];\n\n return array_filter($data);\n }\n\n private function getActivityTimeFilterRange(Activity $activity): array\n {\n if ($activity->hasScheduledStartTime() && $activity->hasScheduledEndTime()) {\n $calendarEvent = $activity->getCalendarEvent();\n\n if ($calendarEvent instanceof CalendarEvent) {\n $from = $calendarEvent->getStartTime();\n $to = $calendarEvent->getEndTime()->addMinutes(15);\n } else {\n $from = Carbon::instance($activity->getScheduledStartTime());\n $to = Carbon::instance($activity->getScheduledEndTime())->addMinutes(15);\n }\n\n return [$from, $to];\n }\n\n $from = Carbon::instance($activity->getActualStartTime());\n $to = Carbon::instance($activity->getActualEndTime());\n\n return [$from, $to];\n }\n\n /**\n * Determines the best CRM activity candidate based on the number of matching prospects and the owner.\n *\n * @param array $crmActivities List of CRM activities to evaluate.\n * @param array $data Data containing the prospects and owner information.\n *\n * @return array The best CRM activity candidate.\n */\n private function getBestCrmActivityCandidate(array $crmActivities, array $data): array\n {\n $prospectCount = count(array_intersect_key($data, array_flip(['contact', 'company', 'deal'])));\n $bestCandidate = null;\n $maxMatchingProspects = 0;\n $validActivitiesCount = count($crmActivities);\n\n if ($validActivitiesCount > 1) {\n $this->logger->info('[Hubspot] Fetch related activity - determining the best candidate', [\n 'activityId' => $data['activity'],\n 'activitiesCount' => $validActivitiesCount,\n 'crmActivities' => $crmActivities,\n ]);\n }\n\n foreach ($crmActivities as $crmActivity) {\n if ($this->isOwnerMismatch($crmActivity, $data)) {\n $validActivitiesCount--;\n\n continue;\n }\n\n if ($validActivitiesCount === 1) {\n return $crmActivity;\n }\n\n if ($prospectCount > 1) {\n $matchingProspects = $this->countMatchingProspects($crmActivity, $data);\n if ($matchingProspects > $maxMatchingProspects) {\n $maxMatchingProspects = $matchingProspects;\n $bestCandidate = $crmActivity;\n }\n } else {\n return $crmActivity;\n }\n }\n\n return $bestCandidate ?? [];\n }\n\n private function isOwnerMismatch(array $crmActivity, array $data): bool\n {\n if ($crmActivity['properties']['hubspot_owner_id'] !== $data['owner']) {\n $this->logger->info('[Hubspot] Fetch related activity - owner mismatch', [\n 'activityId' => $data['activity'],\n 'crmActivity' => $crmActivity['id'],\n 'jiminnyOwner' => $data['owner'],\n 'hubspotOwner' => $crmActivity['properties']['hubspot_owner_id'],\n ]);\n\n return true;\n }\n\n return false;\n }\n\n private function countMatchingProspects(array $crmActivity, array $data): int\n {\n try {\n $crmActivityWithAssociations = $this->client->getMeeting($crmActivity['id']);\n } catch (ApiException $e) {\n $this->logger->error('[Hubspot] Fetch related activity - failed to get meeting associations', [\n 'activityId' => $data['activity'],\n 'crmActivity' => $crmActivity['id'],\n 'reason' => $e->getMessage(),\n ]);\n\n return 0;\n }\n\n $associations = $crmActivityWithAssociations->getAssociations();\n $matchingProspects = 0;\n\n $matchingProspects += $this->countMatchingAssociation($data, $associations, 'company', 'companies');\n $matchingProspects += $this->countMatchingAssociation($data, $associations, 'contact', 'contacts');\n $matchingProspects += $this->countMatchingAssociation($data, $associations, 'deal', 'deals');\n\n return $matchingProspects;\n }\n\n private function countMatchingAssociation(array $data, array $associations, string $dataKey, string $associationKey): int\n {\n if (isset($data[$dataKey]) && isset($associations[$associationKey])) {\n $associatedItems = $associations[$associationKey]->getResults();\n foreach ($associatedItems as $associatedItem) {\n if ($data[$dataKey] == $associatedItem->getId()) {\n\n return 1;\n }\n }\n }\n\n return 0;\n }\n\n private function getPlaybookCategoryId(Activity $activity, array $crmActivity): ?int\n {\n if (! $activity->hasUser()) {\n $this->logger->info('[Hubspot] Fetch related activity - activity has no user', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n return null;\n }\n\n $playbookResolver = app(PlaybookResolver::class);\n $playbook = $playbookResolver->resolvePlaybookByUser($activity->getUser());\n\n if (! $playbook instanceof Playbook) {\n $this->logger->info('[Hubspot] Fetch related activity - playbook not set for user', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n return null;\n }\n\n if (! $playbook->getActivityField() instanceof Field) {\n $this->logger->info('[Hubspot] Fetch related activity - playbook activity field not set', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n return null;\n }\n\n $playbookCategory = null;\n $activityTypeSource = null;\n\n $playbookActivityFieldName = $playbook->getActivityField()->getCrmProviderId();\n if ($playbookActivityFieldName === 'activityType') {\n $playbookActivityFieldName = self::DEFAULT_ACTIVITY_TYPE;\n }\n\n // First attempt to match playbook category from crm activity\n if (! empty($crmActivity['properties'][$playbookActivityFieldName])) {\n $playbookCategoryRepository = app(PlaybookCategoryRepository::class);\n $playbookCategory = $playbookCategoryRepository->getPlaybookCategoryByName(\n $playbook,\n strval($crmActivity['properties'][$playbookActivityFieldName])\n );\n $activityTypeSource = 'crm';\n }\n\n // If not matched get playbook category from Jiminny activity\n if (! $playbookCategory instanceof PlaybookCategory && $activity->hasActivityType()) {\n $playbookCategory = $activity->getActivityType();\n $activityTypeSource = 'jiminny';\n }\n\n if ($playbookCategory instanceof PlaybookCategory) {\n $this->logger->info('[Hubspot] Fetch related activity - Playbook category matched', [\n 'activityId' => $activity->getUuid(),\n 'playbookCategoryId' => $playbookCategory->getId(),\n 'activityFieldName' => $playbookActivityFieldName,\n 'crmActivity' => $crmActivity['id'],\n 'source' => $activityTypeSource,\n ]);\n\n return $playbookCategory->getId();\n }\n\n $this->logger->info('[Hubspot] Fetch related activity - Playbook category not found', [\n 'activityId' => $activity->getUuid(),\n 'activityFieldName' => $playbookActivityFieldName,\n 'crmActivity' => $crmActivity['id'],\n ]);\n\n return null;\n }\n\n private function updateActivity(Activity $activity, array $data): bool\n {\n $activityRepository = app(ActivityRepository::class);\n\n $alreadyLoggedActivity = $activityRepository->findUserActivityByCrmProviderId(\n $activity->getUser(),\n $data['crm_provider_id']\n );\n\n if ($alreadyLoggedActivity instanceof Activity) {\n $this->logger->info('[Hubspot] Fetch related activity - activity already linked', [\n 'currentActivityId' => $activity->getUuid(),\n 'linkedActivityId' => $alreadyLoggedActivity->getUuid(),\n 'crmActivityId' => $data['crm_provider_id'],\n ]);\n\n // Return false to prevent updating the remote CRM engagement. This can happen when two Jiminny\n // activities share the same calendar event (e.g., rescheduled meetings). Without this, the first\n // activity's summary would be overwritten by the second activity.\n return false;\n }\n\n try {\n $activityRepository->update($activity, $data);\n } catch (Exception $e) {\n $this->logger->error('[Hubspot] Fetch related activity - activity update failed', [\n 'activityId' => $activity->getUuid(),\n 'reason' => $e->getMessage(),\n ]);\n\n return false;\n }\n\n $this->logger->info('[Hubspot] Fetch related activity - activity updated', [\n 'activityId' => $activity->getUuid(),\n 'data' => $data,\n ]);\n\n return true;\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":true,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Project","depth":3,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Project","depth":3,"bounds":{"left":0.011968086,"top":0.047885075,"width":0.024268618,"height":0.024740623},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"New File or Directory…","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Expand Selected","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Collapse All","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Options","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
-2435399166852945830
|
-3377671218453133244
|
click
|
accessibility
|
NULL
|
Project: faVsco.js, menu
JY-20725-handle-HS-search Project: faVsco.js, menu
JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
19
Previous Highlighted Error
Next Highlighted Error
[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {
"headers":{
"Date":["Thu,07 May 2026 14:21:15 GMT"],
"Content-Type":["application/json;charset=utf-8"],
"Transfer-Encoding":["chunked"],
"Connection":["keep-alive"],
"CF-Ray":["9f80deb8db60dc3a-SOF"],
"CF-Cache-Status":["DYNAMIC"],
"Strict-Transport-Security":["max-age=31536000; includeSubDomains; preload"],
"Vary":["origin,
accept-encoding"],
"access-control-allow-credentials":["false"],
"server-timing":["hcid;desc=\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\",
cfr;desc=\"9f80deb8e7c6dc3a-IAD\""],
"x-content-type-options":["nosniff"],
"x-hubspot-correlation-id":["019e02d0-6fd8-7812-bdba-885b7ccb3ee3"],
"Set-Cookie":["__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-[IP_ADDRESS]-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,
07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None"],
"Report-To":["{
\"endpoints\":[{
\"url\":\"https:\\/\\/a.nel.cloudflare.com\\/report\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\"}],
\"group\":\"cf-nel\",
\"max_age\":604800}"],
"NEL":["{
\"success_fraction\":0.01,
\"report_to\":\"cf-nel\",
\"max_age\":604800}"],
"Server":["cloudflare"]}} {
"correlation_id":"95236535-ec98-4541-b92a-adfa73b69eab",
"trace_id":"c7ab8365-903f-46d4-9403-0e5b551e3545"}
Sync Changes
Hide This Notification
Code changed:
Hide
3
11
Previous Highlighted Error
Next Highlighted Error
<?php
declare(strict_types=1);
namespace Jiminny\Services\Crm\Hubspot;
use Carbon\Carbon;
use Exception;
use HubSpot\Client\Crm\Objects\ApiException;
use Jiminny\Integrations\PlaybookResolver;
use Jiminny\Models\Activity;
use Jiminny\Models\Calendar\CalendarEvent;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\Profile;
use Jiminny\Models\Playbook;
use Jiminny\Models\PlaybookCategory;
use Jiminny\Repositories\ActivityRepository;
use Jiminny\Repositories\PlaybookCategoryRepository;
use Psr\Log\LoggerInterface;
class SyncRelatedActivityManager
{
public const string OBJECT_MEETING = 'meetings';
public const string DEFAULT_ACTIVITY_TYPE = 'hs_activity_type';
private RemoteCrmObjectManipulator $crmManipulator;
public function __construct(
private readonly Client $client,
private readonly PayloadBuilder $payloadBuilder,
private readonly LoggerInterface $logger
) {
$this->crmManipulator = app(RemoteCrmObjectManipulator::class, [
'client' => $this->client,
'logger' => $this->logger,
]);
}
public function fetchAndAssociateRelatedActivity(Activity $activity): ?Activity
{
// Validate if the activity is applicable.
if ($this->isActivityApplicable($activity) === false) {
$this->logger->info('[Hubspot] Fetch related activity - not applicable', [
'activityId' => $activity->getUuid(),
]);
return null;
}
// Find and fetch related activity.
try {
$crmActivity = $this->fetchRelatedActivity($activity);
if (empty($crmActivity)) {
return null;
}
} catch (Exception $exception) {
$this->logger->error('[Hubspot] Fetch related activity - failed', [
'activityId' => $activity->getUuid(),
'reason' => $exception->getMessage(),
]);
throw $exception;
}
// get playbook category id
$playbookCategoryId = $this->getPlaybookCategoryId($activity, $crmActivity);
$data = [
'crm_provider_id' => $crmActivity['id'],
'playbook_category_id' => $playbookCategoryId,
];
if (! $this->updateActivity($activity, $data)) {
return null;
}
$this->crmManipulator->updateRelatedCrmActivity($activity, $crmActivity);
$activity->refresh();
return $activity;
}
private function isActivityApplicable(Activity $activity): bool
{
if (! $activity->isTypeConference()) {
return false;
}
if (! $this->hasMinimumFilterRequirements($activity)) {
$this->logger->info('[Hubspot] Fetch related activity - not enough filter data', [
'activityId' => $activity->getUuid(),
]);
return false;
}
// The related activity is already linked.
if ($activity->hasCrmProviderId()) {
return false;
}
if (! $activity->getUser()->getProfile() instanceof Profile) {
$this->logger->info('[Hubspot] Fetch related activity - activity user has no crm profile', [
'activityId' => $activity->getUuid(),
]);
return false;
}
return true;
}
private function hasMinimumFilterRequirements(Activity $activity): bool
{
// We need to know when it started to determine when to look for related activity.
if (! $activity->hasActualStartTime()) {
return false;
}
if (! $activity->hasActualEndTime()) {
return false;
}
// At least one prospect should be associated to the activity
if ($activity->hasContact() || $activity->hasAccount()) {
return true;
}
return false;
}
public function fetchRelatedActivity(Activity $activity): array
{
$this->logger->info('[Hubspot] Fetch related activity - searching related CRM activity', [
'activityId' => $activity->getUuid(),
]);
$payloadData = $this->getPayloadFilterData($activity);
try {
$payload = $this->payloadBuilder->getFindRelatedActivityPayload($payloadData, self::OBJECT_MEETING);
$response = $this->client->getPaginatedData($payload, self::OBJECT_MEETING);
$crmActivities = $response['results'];
} catch (Exception $e) {
$this->logger->error('[Hubspot] Fetch related activity - failed', [
'activityId' => $activity->getUuid(),
'reason' => $e->getMessage(),
]);
throw $e;
}
if (empty($crmActivities)) {
$this->logger->info('[Hubspot] Fetch related activity - no CRM related activity found', [
'activityId' => $activity->getUuid(),
'payload' => $payload,
]);
return [];
}
$relatedActivity = $this->getBestCrmActivityCandidate($crmActivities, $payloadData);
if (empty($relatedActivity)) {
$this->logger->info('[Hubspot] Fetch related activity - no related activity candidate', [
'activityId' => $activity->getUuid(),
]);
return [];
}
$this->logger->info('[Hubspot] Fetch related activity - found related CRM activity', [
'activityId' => $activity->getUuid(),
'crmActivity' => $relatedActivity,
]);
return $relatedActivity;
}
/**
* @param Activity $activity
*
* @return array
*/
protected function getPayloadFilterData(Activity $activity): array
{
[$from, $to] = $this->getActivityTimeFilterRange($activity);
$data = [
'from' => $from->getPreciseTimestamp(3),
'to' => $to->getPreciseTimestamp(3),
'contact' => $activity->hasContact() ? $activity->getContact()->getCrmProviderId() : null,
'company' => $activity->hasAccount() ? $activity->getAccount()->getCrmProviderId() : null,
'deal' => $activity->hasOpportunity() ? $activity->getOpportunity()->getCrmProviderId() : null,
'owner' => $activity->getUser()->getProfile()->getCrmProviderId(),
'activity' => $activity->getUuid(),
];
return array_filter($data);
}
private function getActivityTimeFilterRange(Activity $activity): array
{
if ($activity->hasScheduledStartTime() && $activity->hasScheduledEndTime()) {
$calendarEvent = $activity->getCalendarEvent();
if ($calendarEvent instanceof CalendarEvent) {
$from = $calendarEvent->getStartTime();
$to = $calendarEvent->getEndTime()->addMinutes(15);
} else {
$from = Carbon::instance($activity->getScheduledStartTime());
$to = Carbon::instance($activity->getScheduledEndTime())->addMinutes(15);
}
return [$from, $to];
}
$from = Carbon::instance($activity->getActualStartTime());
$to = Carbon::instance($activity->getActualEndTime());
return [$from, $to];
}
/**
* Determines the best CRM activity candidate based on the number of matching prospects and the owner.
*
* @param array $crmActivities List of CRM activities to evaluate.
* @param array $data Data containing the prospects and owner information.
*
* @return array The best CRM activity candidate.
*/
private function getBestCrmActivityCandidate(array $crmActivities, array $data): array
{
$prospectCount = count(array_intersect_key($data, array_flip(['contact', 'company', 'deal'])));
$bestCandidate = null;
$maxMatchingProspects = 0;
$validActivitiesCount = count($crmActivities);
if ($validActivitiesCount > 1) {
$this->logger->info('[Hubspot] Fetch related activity - determining the best candidate', [
'activityId' => $data['activity'],
'activitiesCount' => $validActivitiesCount,
'crmActivities' => $crmActivities,
]);
}
foreach ($crmActivities as $crmActivity) {
if ($this->isOwnerMismatch($crmActivity, $data)) {
$validActivitiesCount--;
continue;
}
if ($validActivitiesCount === 1) {
return $crmActivity;
}
if ($prospectCount > 1) {
$matchingProspects = $this->countMatchingProspects($crmActivity, $data);
if ($matchingProspects > $maxMatchingProspects) {
$maxMatchingProspects = $matchingProspects;
$bestCandidate = $crmActivity;
}
} else {
return $crmActivity;
}
}
return $bestCandidate ?? [];
}
private function isOwnerMismatch(array $crmActivity, array $data): bool
{
if ($crmActivity['properties']['hubspot_owner_id'] !== $data['owner']) {
$this->logger->info('[Hubspot] Fetch related activity - owner mismatch', [
'activityId' => $data['activity'],
'crmActivity' => $crmActivity['id'],
'jiminnyOwner' => $data['owner'],
'hubspotOwner' => $crmActivity['properties']['hubspot_owner_id'],
]);
return true;
}
return false;
}
private function countMatchingProspects(array $crmActivity, array $data): int
{
try {
$crmActivityWithAssociations = $this->client->getMeeting($crmActivity['id']);
} catch (ApiException $e) {
$this->logger->error('[Hubspot] Fetch related activity - failed to get meeting associations', [
'activityId' => $data['activity'],
'crmActivity' => $crmActivity['id'],
'reason' => $e->getMessage(),
]);
return 0;
}
$associations = $crmActivityWithAssociations->getAssociations();
$matchingProspects = 0;
$matchingProspects += $this->countMatchingAssociation($data, $associations, 'company', 'companies');
$matchingProspects += $this->countMatchingAssociation($data, $associations, 'contact', 'contacts');
$matchingProspects += $this->countMatchingAssociation($data, $associations, 'deal', 'deals');
return $matchingProspects;
}
private function countMatchingAssociation(array $data, array $associations, string $dataKey, string $associationKey): int
{
if (isset($data[$dataKey]) && isset($associations[$associationKey])) {
$associatedItems = $associations[$associationKey]->getResults();
foreach ($associatedItems as $associatedItem) {
if ($data[$dataKey] == $associatedItem->getId()) {
return 1;
}
}
}
return 0;
}
private function getPlaybookCategoryId(Activity $activity, array $crmActivity): ?int
{
if (! $activity->hasUser()) {
$this->logger->info('[Hubspot] Fetch related activity - activity has no user', [
'activityId' => $activity->getUuid(),
]);
return null;
}
$playbookResolver = app(PlaybookResolver::class);
$playbook = $playbookResolver->resolvePlaybookByUser($activity->getUser());
if (! $playbook instanceof Playbook) {
$this->logger->info('[Hubspot] Fetch related activity - playbook not set for user', [
'activityId' => $activity->getUuid(),
]);
return null;
}
if (! $playbook->getActivityField() instanceof Field) {
$this->logger->info('[Hubspot] Fetch related activity - playbook activity field not set', [
'activityId' => $activity->getUuid(),
]);
return null;
}
$playbookCategory = null;
$activityTypeSource = null;
$playbookActivityFieldName = $playbook->getActivityField()->getCrmProviderId();
if ($playbookActivityFieldName === 'activityType') {
$playbookActivityFieldName = self::DEFAULT_ACTIVITY_TYPE;
}
// First attempt to match playbook category from crm activity
if (! empty($crmActivity['properties'][$playbookActivityFieldName])) {
$playbookCategoryRepository = app(PlaybookCategoryRepository::class);
$playbookCategory = $playbookCategoryRepository->getPlaybookCategoryByName(
$playbook,
strval($crmActivity['properties'][$playbookActivityFieldName])
);
$activityTypeSource = 'crm';
}
// If not matched get playbook category from Jiminny activity
if (! $playbookCategory instanceof PlaybookCategory && $activity->hasActivityType()) {
$playbookCategory = $activity->getActivityType();
$activityTypeSource = 'jiminny';
}
if ($playbookCategory instanceof PlaybookCategory) {
$this->logger->info('[Hubspot] Fetch related activity - Playbook category matched', [
'activityId' => $activity->getUuid(),
'playbookCategoryId' => $playbookCategory->getId(),
'activityFieldName' => $playbookActivityFieldName,
'crmActivity' => $crmActivity['id'],
'source' => $activityTypeSource,
]);
return $playbookCategory->getId();
}
$this->logger->info('[Hubspot] Fetch related activity - Playbook category not found', [
'activityId' => $activity->getUuid(),
'activityFieldName' => $playbookActivityFieldName,
'crmActivity' => $crmActivity['id'],
]);
return null;
}
private function updateActivity(Activity $activity, array $data): bool
{
$activityRepository = app(ActivityRepository::class);
$alreadyLoggedActivity = $activityRepository->findUserActivityByCrmProviderId(
$activity->getUser(),
$data['crm_provider_id']
);
if ($alreadyLoggedActivity instanceof Activity) {
$this->logger->info('[Hubspot] Fetch related activity - activity already linked', [
'currentActivityId' => $activity->getUuid(),
'linkedActivityId' => $alreadyLoggedActivity->getUuid(),
'crmActivityId' => $data['crm_provider_id'],
]);
// Return false to prevent updating the remote CRM engagement. This can happen when two Jiminny
// activities share the same calendar event (e.g., rescheduled meetings). Without this, the first
// activity's summary would be overwritten by the second activity.
return false;
}
try {
$activityRepository->update($activity, $data);
} catch (Exception $e) {
$this->logger->error('[Hubspot] Fetch related activity - activity update failed', [
'activityId' => $activity->getUuid(),
'reason' => $e->getMessage(),
]);
return false;
}
$this->logger->info('[Hubspot] Fetch related activity - activity updated', [
'activityId' => $activity->getUuid(),
'data' => $data,
]);
return true;
}
}
Project
Project
New File or Directory…
Expand Selected
Collapse All
Options
Hide...
|
9812
|
NULL
|
NULL
|
NULL
|
|
9812
|
444
|
12
|
2026-05-08T13:35:01.050417+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-08/1778 /Users/lukas/.screenpipe/data/data/2026-05-08/1778247301050_m2.jpg...
|
PhpStorm
|
faVsco.js – SyncRelatedActivityManager.php
|
1
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
JY-20725-handle-HS-search Project: faVsco.js, menu
JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
19
Previous Highlighted Error
Next Highlighted Error
[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {
"headers":{
"Date":["Thu,07 May 2026 14:21:15 GMT"],
"Content-Type":["application/json;charset=utf-8"],
"Transfer-Encoding":["chunked"],
"Connection":["keep-alive"],
"CF-Ray":["9f80deb8db60dc3a-SOF"],
"CF-Cache-Status":["DYNAMIC"],
"Strict-Transport-Security":["max-age=31536000; includeSubDomains; preload"],
"Vary":["origin,
accept-encoding"],
"access-control-allow-credentials":["false"],
"server-timing":["hcid;desc=\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\",
cfr;desc=\"9f80deb8e7c6dc3a-IAD\""],
"x-content-type-options":["nosniff"],
"x-hubspot-correlation-id":["019e02d0-6fd8-7812-bdba-885b7ccb3ee3"],
"Set-Cookie":["__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-[IP_ADDRESS]-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,
07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None"],
"Report-To":["{
\"endpoints\":[{
\"url\":\"https:\\/\\/a.nel.cloudflare.com\\/report\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\"}],
\"group\":\"cf-nel\",
\"max_age\":604800}"],
"NEL":["{
\"success_fraction\":0.01,
\"report_to\":\"cf-nel\",
\"max_age\":604800}"],
"Server":["cloudflare"]}} {
"correlation_id":"95236535-ec98-4541-b92a-adfa73b69eab",
"trace_id":"c7ab8365-903f-46d4-9403-0e5b551e3545"}...
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"bounds":{"left":0.025930852,"top":0.019952115,"width":0.03856383,"height":0.025538707},"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JY-20725-handle-HS-search-rate-limit, menu","depth":5,"bounds":{"left":0.064494684,"top":0.019952115,"width":0.09541223,"height":0.025538707},"on_screen":true,"help_text":"Git Branch: JY-20725-handle-HS-search-rate-limit","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"bounds":{"left":0.8081782,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"AskJiminnyReportActivityServiceTest","depth":6,"bounds":{"left":0.8234708,"top":0.019952115,"width":0.09208777,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'AskJiminnyReportActivityServiceTest'","depth":6,"bounds":{"left":0.9155585,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'AskJiminnyReportActivityServiceTest'","depth":6,"bounds":{"left":0.9268617,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"bounds":{"left":0.9381649,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"bounds":{"left":0.96609044,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"bounds":{"left":0.9773936,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"IDE and Project Settings","depth":5,"bounds":{"left":0.9886968,"top":0.019952115,"width":0.011303186,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.042220745,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"19","depth":4,"bounds":{"left":0.6615692,"top":0.10055866,"width":0.009640957,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"bounds":{"left":0.67287236,"top":0.09896249,"width":0.00731383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"bounds":{"left":0.68018615,"top":0.09896249,"width":0.006981383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {\n\"headers\":{\n\"Date\":[\"Thu,07 May 2026 14:21:15 GMT\"],\n \"Content-Type\":[\"application/json;charset=utf-8\"],\n \"Transfer-Encoding\":[\"chunked\"],\n \"Connection\":[\"keep-alive\"],\n \"CF-Ray\":[\"9f80deb8db60dc3a-SOF\"],\n \"CF-Cache-Status\":[\"DYNAMIC\"],\n \"Strict-Transport-Security\":[\"max-age=31536000; includeSubDomains; preload\"],\n \"Vary\":[\"origin,\n accept-encoding\"],\n \"access-control-allow-credentials\":[\"false\"],\n \"server-timing\":[\"hcid;desc=\\\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\\\",\n cfr;desc=\\\"9f80deb8e7c6dc3a-IAD\\\"\"],\n \"x-content-type-options\":[\"nosniff\"],\n \"x-hubspot-correlation-id\":[\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\"],\n \"Set-Cookie\":[\"__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-1.0.1.1-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,\n 07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None\"],\n \"Report-To\":[\"{\n\\\"endpoints\\\":[{\n\\\"url\\\":\\\"https:\\\\/\\\\/a.nel.cloudflare.com\\\\/report\\\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\\\"}],\n\\\"group\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"NEL\":[\"{\n\\\"success_fraction\\\":0.01,\n\\\"report_to\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"Server\":[\"cloudflare\"]}} {\n\"correlation_id\":\"95236535-ec98-4541-b92a-adfa73b69eab\",\n\"trace_id\":\"c7ab8365-903f-46d4-9403-0e5b551e3545\"}","depth":4,"bounds":{"left":0.37632978,"top":0.09736632,"width":0.5728058,"height":0.8818835},"on_screen":true,"lines":[{"char_start":207,"char_count":30,"bounds":{"left":0.37632978,"top":0.0,"width":0.07513298,"height":0.014365523}},{"char_start":237,"char_count":36,"bounds":{"left":0.37632978,"top":0.0,"width":0.09075798,"height":0.014365523}},{"char_start":273,"char_count":32,"bounds":{"left":0.37632978,"top":0.0,"width":0.080119684,"height":0.014365523}},{"char_start":305,"char_count":79,"bounds":{"left":0.37632978,"top":0.0,"width":0.20212767,"height":0.014365523}},{"char_start":384,"char_count":18,"bounds":{"left":0.37632978,"top":0.0,"width":0.043882977,"height":0.014365523}},{"char_start":402,"char_count":21,"bounds":{"left":0.37632978,"top":0.0,"width":0.051861703,"height":0.014365523}},{"char_start":423,"char_count":48,"bounds":{"left":0.37632978,"top":0.008778931,"width":0.12167553,"height":0.014365523}},{"char_start":471,"char_count":72,"bounds":{"left":0.37632978,"top":0.026336791,"width":0.18384309,"height":0.014365523}},{"char_start":543,"char_count":40,"bounds":{"left":0.37632978,"top":0.043894652,"width":0.10106383,"height":0.014365523}},{"char_start":583,"char_count":41,"bounds":{"left":0.37632978,"top":0.061452515,"width":0.10372341,"height":0.014365523}},{"char_start":624,"char_count":72,"bounds":{"left":0.37632978,"top":0.079010375,"width":0.18384309,"height":0.014365523}},{"char_start":696,"char_count":219,"bounds":{"left":0.37632978,"top":0.096568234,"width":0.56515956,"height":0.014365523}},{"char_start":915,"char_count":83,"bounds":{"left":0.37632978,"top":0.11412609,"width":0.21243352,"height":0.014365523}},{"char_start":998,"char_count":20,"bounds":{"left":0.37632978,"top":0.13168396,"width":0.04920213,"height":0.014365523}},{"char_start":1018,"char_count":17,"bounds":{"left":0.37632978,"top":0.14924182,"width":0.041223403,"height":0.014365523}},{"char_start":1035,"char_count":203,"bounds":{"left":0.37632978,"top":0.16679968,"width":0.52360374,"height":0.014365523}},{"char_start":1238,"char_count":22,"bounds":{"left":0.37632978,"top":0.18435754,"width":0.05418883,"height":0.014365523}},{"char_start":1260,"char_count":23,"bounds":{"left":0.37632978,"top":0.2019154,"width":0.056848403,"height":0.014365523}},{"char_start":1283,"char_count":10,"bounds":{"left":0.37632978,"top":0.21947326,"width":0.023271276,"height":0.014365523}},{"char_start":1293,"char_count":27,"bounds":{"left":0.37632978,"top":0.23703113,"width":0.06715426,"height":0.014365523}},{"char_start":1320,"char_count":26,"bounds":{"left":0.37632978,"top":0.254589,"width":0.06482713,"height":0.014365523}},{"char_start":1346,"char_count":23,"bounds":{"left":0.37632978,"top":0.27214685,"width":0.056848403,"height":0.014365523}},{"char_start":1369,"char_count":28,"bounds":{"left":0.37632978,"top":0.2897047,"width":0.06981383,"height":0.014365523}},{"char_start":1397,"char_count":57,"bounds":{"left":0.37632978,"top":0.30726257,"width":0.14494681,"height":0.014365523}}],"value":"[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {\n\"headers\":{\n\"Date\":[\"Thu,07 May 2026 14:21:15 GMT\"],\n \"Content-Type\":[\"application/json;charset=utf-8\"],\n \"Transfer-Encoding\":[\"chunked\"],\n \"Connection\":[\"keep-alive\"],\n \"CF-Ray\":[\"9f80deb8db60dc3a-SOF\"],\n \"CF-Cache-Status\":[\"DYNAMIC\"],\n \"Strict-Transport-Security\":[\"max-age=31536000; includeSubDomains; preload\"],\n \"Vary\":[\"origin,\n accept-encoding\"],\n \"access-control-allow-credentials\":[\"false\"],\n \"server-timing\":[\"hcid;desc=\\\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\\\",\n cfr;desc=\\\"9f80deb8e7c6dc3a-IAD\\\"\"],\n \"x-content-type-options\":[\"nosniff\"],\n \"x-hubspot-correlation-id\":[\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\"],\n \"Set-Cookie\":[\"__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-1.0.1.1-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,\n 07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None\"],\n \"Report-To\":[\"{\n\\\"endpoints\\\":[{\n\\\"url\\\":\\\"https:\\\\/\\\\/a.nel.cloudflare.com\\\\/report\\\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\\\"}],\n\\\"group\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"NEL\":[\"{\n\\\"success_fraction\\\":0.01,\n\\\"report_to\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"Server\":[\"cloudflare\"]}} {\n\"correlation_id\":\"95236535-ec98-4541-b92a-adfa73b69eab\",\n\"trace_id\":\"c7ab8365-903f-46d4-9403-0e5b551e3545\"}","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
6337657672782761712
|
-3048904810292590134
|
click
|
accessibility
|
NULL
|
Project: faVsco.js, menu
JY-20725-handle-HS-search Project: faVsco.js, menu
JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
19
Previous Highlighted Error
Next Highlighted Error
[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {
"headers":{
"Date":["Thu,07 May 2026 14:21:15 GMT"],
"Content-Type":["application/json;charset=utf-8"],
"Transfer-Encoding":["chunked"],
"Connection":["keep-alive"],
"CF-Ray":["9f80deb8db60dc3a-SOF"],
"CF-Cache-Status":["DYNAMIC"],
"Strict-Transport-Security":["max-age=31536000; includeSubDomains; preload"],
"Vary":["origin,
accept-encoding"],
"access-control-allow-credentials":["false"],
"server-timing":["hcid;desc=\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\",
cfr;desc=\"9f80deb8e7c6dc3a-IAD\""],
"x-content-type-options":["nosniff"],
"x-hubspot-correlation-id":["019e02d0-6fd8-7812-bdba-885b7ccb3ee3"],
"Set-Cookie":["__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-[IP_ADDRESS]-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,
07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None"],
"Report-To":["{
\"endpoints\":[{
\"url\":\"https:\\/\\/a.nel.cloudflare.com\\/report\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\"}],
\"group\":\"cf-nel\",
\"max_age\":604800}"],
"NEL":["{
\"success_fraction\":0.01,
\"report_to\":\"cf-nel\",
\"max_age\":604800}"],
"Server":["cloudflare"]}} {
"correlation_id":"95236535-ec98-4541-b92a-adfa73b69eab",
"trace_id":"c7ab8365-903f-46d4-9403-0e5b551e3545"}...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
9811
|
443
|
6
|
2026-05-08T13:35:00.954208+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-08/1778 /Users/lukas/.screenpipe/data/data/2026-05-08/1778247300954_m1.jpg...
|
PhpStorm
|
faVsco.js – SyncRelatedActivityManager.php
|
1
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
iTerm2Shell EditViewSessionScripts|ProfilesWindo iTerm2Shell EditViewSessionScripts|ProfilesWindowHelp‹ >0 lobl100% C8Fri 8 May 16:35:02APP (-zsh)DOCKER© 81DEV (docker)882JY-20773-fix-automated-reports-user-pilot-trackingJY-20157-AJ-report-not-send-notificationJY-20508-notify-before-AJ-report-expirationJY-20372-ai-reports-promotion-pagesJY-20352-sync-opportunities-without-a-local-owner-user-id-is-nullJY-20738-debug-AJ-tracking-UPAPP (-zsh)-zshJY-18909-automated-reports-ask-jiminnyJY-20692-fix-integration-app-[API_KEY]@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ co -b JY-20725-handle-HS-search-rate-limitSwitched to a new branch 'JY-20725-handle-HS-search-rate-limit'Lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (JY-20725-handle-HS-search-rate-limit) $ I• 84screenpipe*•$5-zsh₴6APP...
|
NULL
|
8749721208712901594
|
NULL
|
click
|
ocr
|
NULL
|
iTerm2Shell EditViewSessionScripts|ProfilesWindo iTerm2Shell EditViewSessionScripts|ProfilesWindowHelp‹ >0 lobl100% C8Fri 8 May 16:35:02APP (-zsh)DOCKER© 81DEV (docker)882JY-20773-fix-automated-reports-user-pilot-trackingJY-20157-AJ-report-not-send-notificationJY-20508-notify-before-AJ-report-expirationJY-20372-ai-reports-promotion-pagesJY-20352-sync-opportunities-without-a-local-owner-user-id-is-nullJY-20738-debug-AJ-tracking-UPAPP (-zsh)-zshJY-18909-automated-reports-ask-jiminnyJY-20692-fix-integration-app-[API_KEY]@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ co -b JY-20725-handle-HS-search-rate-limitSwitched to a new branch 'JY-20725-handle-HS-search-rate-limit'Lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (JY-20725-handle-HS-search-rate-limit) $ I• 84screenpipe*•$5-zsh₴6APP...
|
9808
|
NULL
|
NULL
|
NULL
|
|
9810
|
444
|
11
|
2026-05-08T13:34:45.328383+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-08/1778 /Users/lukas/.screenpipe/data/data/2026-05-08/1778247285328_m2.jpg...
|
PhpStorm
|
faVsco.js – Service.php
|
1
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
JY-20725-handle-HS-search Project: faVsco.js, menu
JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
19
Previous Highlighted Error
Next Highlighted Error
[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {
"headers":{
"Date":["Thu,07 May 2026 14:21:15 GMT"],
"Content-Type":["application/json;charset=utf-8"],
"Transfer-Encoding":["chunked"],
"Connection":["keep-alive"],
"CF-Ray":["9f80deb8db60dc3a-SOF"],
"CF-Cache-Status":["DYNAMIC"],
"Strict-Transport-Security":["max-age=31536000; includeSubDomains; preload"],
"Vary":["origin,
accept-encoding"],
"access-control-allow-credentials":["false"],
"server-timing":["hcid;desc=\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\",
cfr;desc=\"9f80deb8e7c6dc3a-IAD\""],
"x-content-type-options":["nosniff"],
"x-hubspot-correlation-id":["019e02d0-6fd8-7812-bdba-885b7ccb3ee3"],
"Set-Cookie":["__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-[IP_ADDRESS]-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,
07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None"],
"Report-To":["{
\"endpoints\":[{
\"url\":\"https:\\/\\/a.nel.cloudflare.com\\/report\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\"}],
\"group\":\"cf-nel\",
\"max_age\":604800}"],
"NEL":["{
\"success_fraction\":0.01,
\"report_to\":\"cf-nel\",
\"max_age\":604800}"],
"Server":["cloudflare"]}} {
"correlation_id":"95236535-ec98-4541-b92a-adfa73b69eab",
"trace_id":"c7ab8365-903f-46d4-9403-0e5b551e3545"}
Code changed:
Hide
Sync Changes
Hide This Notification
7
48
1
33
1
Previous Highlighted Error
Next Highlighted Error
<?php
namespace Jiminny\Services\Crm\Hubspot;
use Carbon\Carbon;
use Exception;
use Generator;
use GuzzleHttp\Exception\RequestException;
use Illuminate\Support\Facades\Cache;
use InvalidArgumentException;
use Jiminny\Contracts\Repositories\TeamRepository;
use Jiminny\Contracts\Services\Crm\ClientInterface;
use Jiminny\Contracts\Services\Crm\FetchRelatedActivityInterface;
use Jiminny\Contracts\Services\Crm\LayoutManagementInterface;
use Jiminny\Contracts\Services\Crm\MatchCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\Provider\HubspotInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityLookupInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityManipulationInterface;
use Jiminny\Contracts\Services\Crm\SavePlaybackLinkToCrmInterface;
use Jiminny\Contracts\Services\Crm\SendSummaryToCrmInterface;
use Jiminny\Contracts\Services\Crm\SettingsInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmMetadataInterface;
use Jiminny\Contracts\Services\Crm\VerifyTaskExistsInterface;
use Jiminny\Exceptions\CrmException;
use Jiminny\Exceptions\HttpNotFoundException;
use Jiminny\Jobs\Crm\NoteObject;
use Jiminny\Models\Account;
use Jiminny\Models\Activity;
use Jiminny\Models\Contact;
use Jiminny\Models\Contracts\ActivityContract;
use Jiminny\Models\Crm\BusinessProcess;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\FieldData;
use Jiminny\Models\Crm\Layout;
use Jiminny\Models\Crm\Profile;
use Jiminny\Models\Lead;
use Jiminny\Models\Opportunity;
use Jiminny\Models\Participant;
use Jiminny\Models\Playbook;
use Jiminny\Models\SocialAccount;
use Jiminny\Models\Stage;
use Jiminny\Models\User;
use Jiminny\Repositories\Crm\CrmEntityRepository;
use Jiminny\Repositories\Crm\FieldRepository;
use Jiminny\Repositories\Crm\ProfileRepository;
use Jiminny\Repositories\ParticipantRepository;
use Jiminny\Services\Avatar\ProspectPhotoPathService;
use Jiminny\Services\Crm\BaseService;
use Jiminny\Services\Crm\Hubspot\Actions\SyncArchivedProfilesAction;
use Jiminny\Services\Crm\Hubspot\Fields\ValueNormalizer;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\OpportunitySyncTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\SyncCrmEntitiesTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\SyncFieldsTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\WriteCrmTrait;
use Jiminny\Services\Crm\MatchDomainByEmailInterface;
use Jiminny\Services\Crm\OpportunitySyncStrategyResolver;
use Jiminny\Services\Crm\ResolveCompanyNameByEmailTrait;
use Jiminny\Utils\PlaybackUrlBuilder;
use Sentry;
use SevenShores\Hubspot\Exceptions\BadRequest;
use Throwable;
use UnexpectedValueException;
/**
* @phpstan-type CrmFieldDefinition array{
* name: string,
* label: string,
* description: string,
* type: string,
* fieldType: string,
* hidden: bool,
* showCurrencySymbol: bool,
* options: array<array{
* id: string,
* label: string,
* value?: string,
* }
*/
class Service extends BaseService implements
HubspotInterface,
SyncCrmEntitiesInterface,
SyncCrmMetadataInterface,
SendSummaryToCrmInterface,
MatchDomainByEmailInterface,
SavePlaybackLinkToCrmInterface,
RemoteEntityManipulationInterface,
FetchRelatedActivityInterface,
LayoutManagementInterface,
SettingsInterface,
MatchCrmEntitiesInterface,
RemoteEntityLookupInterface,
VerifyTaskExistsInterface
{
use ResolveCompanyNameByEmailTrait;
use SyncCrmEntitiesTrait;
use WriteCrmTrait;
use SyncFieldsTrait;
use OpportunitySyncTrait;
private const int ENGAGEMENT_BODY_MAX_LENGTH = 65536;
private const string LOG_DATE_FORMAT = 'Y-m-d H:i:s';
private const int BATCH_UPDATE_LIMIT = 100;
private const string TEN_SECONDLY_ROLLING_POLICY = 'TEN_SECONDLY_ROLLING';
private const int TEN_SECONDLY_ROLLING_LIMIT = 10;
private const string TYPE_NOTE = 'NOTE';
private const string TYPE_MEETING = 'MEETING';
private const string TYPE_CALL = 'CALL';
private const string API_URL = '[URL_WITH_CREDENTIALS] ClientInterface|Client
*/
protected $client;
protected OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;
protected CrmEntityRepository $crmEntityRepository;
protected ProspectPhotoPathService $prospectPhotoPathService;
private SyncFieldAction $syncFieldAction;
private PayloadBuilder $payloadBuilder;
private SyncRelatedActivityManager $syncRelatedActivityManager;
private SyncArchivedProfilesAction $syncArchivedProfilesAction;
private WebhookSyncBatchProcessor $batchProcessor;
public function __construct(
Client $client,
SyncFieldAction $syncFieldAction,
PayloadBuilder $payloadBuilder,
ProspectPhotoPathService $prospectPhotoPathService,
SyncArchivedProfilesAction $syncArchivedProfilesAction,
WebhookSyncBatchProcessor $batchProcessor,
) {
parent::__construct();
$this->client = $client;
$this->syncFieldAction = $syncFieldAction;
$this->prospectPhotoPathService = $prospectPhotoPathService;
$this->payloadBuilder = $payloadBuilder;
$this->syncArchivedProfilesAction = $syncArchivedProfilesAction;
$this->batchProcessor = $batchProcessor;
$this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [
'client' => $this->client,
]);
$this->syncRelatedActivityManager = app(SyncRelatedActivityManager::class, [
'client' => $this->client,
'payloadBuilder' => $this->payloadBuilder,
'logger' => $this->logger,
]);
$this->crmEntityRepository = app(CrmEntityRepository::class);
$this->dealFieldsService = app(DealFieldsService::class);
}
public function getDisplayName(): string
{
return 'HubSpot';
}
protected function getOAuthAccount(User $user): ?SocialAccount
{
// In this case, the Account Owner is always the connection for any API operations.
$owner = $user->team->owner;
return $owner->getSocialAccount(SocialAccount::PROVIDER_HUBSPOT);
}
public function getClient(): Client
{
/** @var Client */
return $this->client;
}
/**
* Convert raw field data into a format compatible with CRM APIs.
*
* @param bool $internal Direction of the conversion.
* True is pulling from CRM, false normalize before sending to CRM.
*/
public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string
{
return ValueNormalizer::normalize(
fieldType: $fieldType,
fieldValue: $fieldValue,
isInbound: $internal,
);
}
/**
* @inheritdoc
*/
public function getDefaultFields(string $activityType): array
{
$fields = [];
if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {
$defaultFields = FieldDefinitions::defaultTaskFields();
// This lazy creates these fields if not already setup.
foreach ($defaultFields as $defaultField) {
$fields[] = $this->config->fields()->firstOrCreate($defaultField);
}
}
return $fields;
}
/**
* @inheritdoc
*/
public function getDefaultActivityField(string $activityType): Field
{
/** @var Field $activityField */
$activityField = $this->config->fields()->where([
'crm_provider_id' => 'activityType',
'object_type' => $activityType,
])->first();
return $activityField;
}
/**
* @inheritdoc
*/
public function getSupportedPlaybookTypes(): array
{
return [Playbook::ACTIVITY_TYPE_TASK];
}
/**
* @inheritdoc
*/
public function getDefaultActivityLayoutFields(string $activityType, string $layoutType): array
{
$fields = [];
if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {
// Outcome should always be provided calls/meetings.
$fieldData = [
[
'crm_provider_id' => $layoutType === Layout::TYPE_SOFTPHONE_SUMMARY ? 'disposition' : 'meetingOutcome',
'object_type' => Field::OBJECT_TASK,
],
];
foreach ($fieldData as $data) {
$field = $this->config->fields()->where($data)->first();
// Only add the field if it is created, which it should be.
if ($field) {
$fields[] = $field;
}
}
}
return $fields;
}
public function getDealInsightsFields(): array
{
return FieldDefinitions::dealInsightsFields();
}
protected function getDefaultFollowupLayoutFields(string $activityType): array
{
$fields = [];
$fieldRepo = app(FieldRepository::class);
$fieldData = FieldDefinitions::followupFieldsFilter();
foreach ($fieldData as $data) {
$field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $data);
// Only add the field if it is created, which it should be.
if ($field) {
$fields[] = $field;
}
}
return $fields;
}
/**
* @inheritdoc
*/
public function syncField(Field $field): void
{
switch ($field->object_type) {
case Field::OBJECT_ACCOUNT:
$crmField = $this->client->getInstance()->companyProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_CONTACT:
$crmField = $this->client->getInstance()->contactProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_OPPORTUNITY:
$crmField = $this->client->getInstance()->dealProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_TASK:
$this->syncSingleTaskField($field);
return;
default:
return;
}
$this->syncFieldAction->execute($field, $crmField->toArray());
}
/**
* @param array<array{
* id:string,
* label:string,
* value?:string
* }> $options
*
* @throws CrmException
*
* @return FieldData[]
*
*/
public function importPicklistValues(
Field $field,
array $options = [['id' => '', 'label' => '', 'value' => '']],
): array {
if (! empty($options[0]['id']) || ! empty($options[0]['value'])) {
// We already have the options, no need to fetch them again
return $this->importOptions($field, $options);
}
$options = [];
switch ($field->getObjectType()) {
case Field::OBJECT_ACCOUNT:
$options = $this->getClient()->fetchPropertyOptions('company', $field->getCrmProviderId());
break;
case Field::OBJECT_CONTACT:
$options = $this->getClient()->fetchPropertyOptions('contact', $field->getCrmProviderId());
break;
case Field::OBJECT_OPPORTUNITY:
// Hubspot has different endpoint for stages
$options = $this->getClient()->fetchOpportunityFieldOptions($field);
break;
case Field::OBJECT_TASK:
if ($field->getCrmProviderId() === 'disposition') {
$options = $this->getClient()->fetchDispositionFieldOptions();
} elseif (in_array($field->getCrmProviderId(), ['meetingOutcome', 'activityType'])) {
$options = $this->getClient()->fetchMeetingOutcomeFieldOptions($field);
}
break;
default:
$this->logger->warning('Invalid object type', [
'object_type' => $field->getObjectType(),
'field_id' => $field->getId(),
]);
throw new CrmException('Invalid object type');
}
return $this->importOptions($field, $options);
}
/**
* @inheritdoc
*/
public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage
{
$missingStage = null;
try {
// Use the HubSpot API client instead of the SDK crmPipelines() method
$endpoint = self::getDealsPipelinesEndpoint();
$pipelinesResponse = $this->client->getInstance()->getClient()->request('GET', $endpoint);
$pipelines = $pipelinesResponse->data->results;
} catch (RequestException|BadRequest $exception) {
throw $exception;
}
foreach ($pipelines as $pipeline) {
$stages = [];
// We create a business process to contain the pipeline, and store all stages against it.
$p = ResponseNormalize::normalizePipeline($pipeline);
// Create/update business process for this pipeline
$businessProcess = $this->config->businessProcesses()->updateOrCreate([
'crm_provider_id' => $p['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($p['label'], 0, 150),
'type' => BusinessProcess::TYPE_OPPORTUNITY,
'is_selectable' => $p['active'],
]);
// A record type is really a clone of the business process, used to store which record uses which pipeline.
// Create/update record type clone
$this->config->recordTypes()->updateOrCreate([
'crm_provider_id' => $p['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($p['label'], 0, 150),
'is_selectable' => $p['active'],
'business_process_id' => $businessProcess->id ?? null,
]);
// Stages - fetch all existing stages upfront to avoid N+1 queries
$existingStages = $this->config->stages()
->withTrashed()
->where('type', Stage::TYPE_OPPORTUNITY)
->get()
->keyBy('crm_provider_id');
foreach ($p['stages'] as $dealStage) {
$s = ResponseNormalize::normalizeDealStage($dealStage);
/** @var ?Stage $existingStage */
$existingStage = $existingStages->get($s['id']);
// Restore soft-deleted stages that are now active in HubSpot
if ($existingStage?->trashed() && $s['active']) {
$existingStage->restore();
}
// Upsert stage (updates soft-deleted records without restoring them)
$stage = $this->config->stages()->withTrashed()->updateOrCreate([
'crm_provider_id' => $s['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($s['label'], 0, 50),
'label' => mb_strimwidth($s['label'], 0, 191),
'type' => Stage::TYPE_OPPORTUNITY,
'sequence' => $s['displayOrder'],
'is_selectable' => $s['active'],
'probability' => $s['probability'] * 100,
]);
if ($missingStageName === $s['id']) {
$missingStage = $stage;
}
$stages[] = $stage->id;
}
$businessProcess->stages()->sync($stages);
}
return $missingStage;
}
/**
* @inheritdoc
*/
public function syncOrganization(): void
{
try {
$endpoint = '[URL_WITH_CREDENTIALS]
*/
public function find(string $name, array $scopes): array
{
$count = $this->limit ?? 20;
$offset = $this->offset ?? 0;
/** @var array<int, array<string, mixed>> */
return Cache::remember(
key: $this->team->getId() . $name . $count . $offset,
ttl: 300,
callback: function () use ($name, $offset, $count): array {
$data = [];
// Use the new V3 API to find contacts based on additional fields.
foreach (['companies', 'contacts'] as $objectType) {
$payload = $this->generateNameSearchPayload($name, $offset, $count);
$type = $objectType === 'companies' ? 'account' : 'contact';
try {
$response = $this->client->search($objectType, $payload);
// Build mapped list.
foreach ($response['results'] as $object) {
$properties = $object['properties'];
$objectName = $this->buildContactName($properties);
$record = [
'crmId' => $object['id'],
// Pass crmUrl to the FE, needed for success message in the extension when you log activity.
'crmUrl' => $this->generateProviderUrl($object['id'], $type),
'name' => $objectName,
'prospectType' => $type,
'phoneNumbers' => [],
];
if ($type === 'account') {
$record['industry'] = $properties['industry'] ?? null;
} else {
$record['title'] = $properties['jobtitle'] ?? null;
$record['organization'] = $properties['company'] ?? null;
}
$countryCode = $this->buildContactCountry($properties);
$parsedNumber = $this->buildContactPhone($countryCode, $properties);
// Add phone number to record.
if (! empty($parsedNumber['phone'])) {
$record['phoneNumbers'][] = [
'number' => $parsedNumber['phone'],
'nationalFormat' => phone_national($countryCode, $parsedNumber['phone']),
'type' => 'phone',
];
}
// Add mobile phone number to record.
if (! empty($properties['mobilephone'])) {
$mobileNumber = phone_e164($countryCode, $properties['mobilephone']);
if ($mobileNumber !== null) {
$record['phoneNumbers'][] = [
'number' => $mobileNumber,
'nationalFormat' => phone_national($countryCode, $mobileNumber),
'type' => 'mobile',
];
}
}
$data[] = $record;
}
} catch (BadRequest $e) {
$this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [
'teamId' => $this->team->getUuid(),
'request' => $payload,
'reason' => $e->getMessage(),
]);
throw $e;
}
}
return $data;
},
);
}
/**
* @inheritdoc
*/
public function findOpportunities(?string $crmAccountId, ?string $crmContactId, ?int $userId = null): array
{
$data = [];
$ownerData = [];
$ownerId = null;
if ($crmAccountId === null) {
return $data;
}
if ($userId) {
$profileRepository = app(ProfileRepository::class);
$profile = $profileRepository->findProfileByUserId($this->config, $userId);
$ownerId = $profile instanceof Profile ? $profile->getCrmProviderId() : null;
}
$closedStages = $this->getClosedDealStages();
$payload = $this->payloadBuilder->generateOpportunitiesSearchPayload(
$this->config,
$crmAccountId,
$closedStages,
);
$results = $this->client->getPaginatedData($payload, 'deals');
foreach ($results['results'] as $object) {
$properties = $object['properties'];
$amount = null;
if (empty($properties['amount']) === false) {
$currency = $properties['deal_currency_code'] ?? $this->config->default_currency;
// Values can contain commas and any junk so strip them.
$value = (float) preg_replace('/[^\d.]/', '', $properties['amount']);
$amount = formatCurrency($value, $currency);
}
$businessProcess = $this->config
->businessProcesses()
->where('crm_provider_id', $properties['pipeline'])
->first();
if ($businessProcess === null) {
// Import it.
$stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $properties['dealstage']);
$businessProcess = $this->config
->businessProcesses()
->where('crm_provider_id', $properties['pipeline'])
->first();
} else {
$stage = $businessProcess
->stages()
->where('crm_provider_id', $properties['dealstage'])
->where('type', Stage::TYPE_OPPORTUNITY)
->first();
if ($stage === null) {
// Import it.
$stage = $this->importStages(null, $properties['dealstage']);
}
}
$recordType = null;
if ($businessProcess) {
$recordType = $businessProcess->recordTypes()->first();
}
$isWon = in_array($properties['dealstage'], $closedStages['won']);
$isLost = in_array($properties['dealstage'], $closedStages['lost']);
$record = [
'crmId' => $object['id'],
'name' => $properties['dealname'] ?? 'Unknown Deal',
'value' => $amount,
'won' => $isWon,
'closed' => $isWon || $isLost,
'stage' => [
'id' => $stage?->getUuid() ?? '',
'name' => $stage?->getName() ?? '',
],
];
if ($recordType) {
$record += [
'recordType' => [
'id' => $recordType->id_string,
'name' => $recordType->name,
],
];
}
if ($ownerId && isset($properties['hubspot_owner_id']) && $properties['hubspot_owner_id'] === $ownerId) {
$ownerData[] = $record;
}
$data[] = $record;
}
if (! empty($ownerData)) {
return $ownerData;
}
return $data;
}
/**
* @inheritdoc
*/
public function getTasks(?string $objectType, string $objectId, ?string $opportunityId): array
{
$data = [];
switch ($objectType) {
case 'contact':
$hsObject = 'contact';
break;
case 'account':
$hsObject = 'company';
break;
default:
// This is a hack to prioritise and override a contact/company with a deal.
if ($opportunityId) {
$hsObject = 'deal';
$objectId = $opportunityId;
} else {
throw new InvalidArgumentException('Object type not supported.');
}
}
$engagementTypes = ['meetings', 'tasks'];
foreach ($engagementTypes as $engagementType) {
$payload = $this->payloadBuilder->getLinkToTaskPayload($hsObject, $objectId, $engagementType);
$this->logger->info('[HubSpot] CRM Search requested', [
'request' => $payload,
]);
$engagements = $this->client->getPaginatedData($payload, $engagementType);
foreach ($engagements['results'] as $engagement) {
if ($engagementType == 'meetings') {
$title = $engagement['properties']['hs_meeting_title'] ?? 'Scheduled meeting';
} elseif ($engagementType == 'tasks') {
$title = $engagement['properties']['hs_task_subject'];
} else {
$title = 'Scheduled meeting';
}
$data[] = [
'crmId' => $engagement['id'],
'subject' => $title,
'due' => $engagement['properties']['hs_timestamp'],
'type' => $engagement['properties']['hs_activity_type'] ?? null,
];
}
}
usort($data, function ($item1, $item2) {
return $item2['due'] <=> $item1['due'];
});
return $data;
}
/**
* Try to find CRM Objects using email address
*
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchExactlyByEmail(string $email, ?int $userId = null): ?array
{
$contactProperties = [
'email',
'firstname',
'lastname',
'country',
'phone',
'mobilephone',
'jobtitle',
'hubspot_owner_id',
'associatedcompanyid',
'photo',
];
$contact = null;
$account = null;
try {
$hsContact = $this->getClient()->getContactByEmail($email, $contactProperties);
if ($hsContact) {
$contact = $this->importContact($hsContact);
$account = $contact->account;
}
$data = $this->convertCrmData($contact, $account, $userId);
return ! empty(array_filter($data)) ? $data : null;
} catch (BadRequest $e) {
$this->logger->warning('[HubSpot] Search failed', [
'team_id' => $this->team->getId(),
'search_identifier' => $email,
'reason' => $e->getMessage(),
]);
}
return null;
}
public function getDomain(string $email): ?string
{
return $this->getDomainFromEmail($email);
}
/**
* Try to find CRM objects using domain name of the email address
*
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchByDomain(string $domain, ?int $userId = null): ?array
{
$companyName = $domain;
// Try to find a company matching their email domain.
$companyProperties = [
'country',
'phone',
'name',
'hs_avatar_filemanager_key',
'industry',
'hubspot_owner_id',
'domain',
];
try {
$hsAccounts = $this->client
->getInstance()
->companies()
->searchByDomain($companyName, $companyProperties);
} catch (Throwable $e) {
$this->logger->info('[HubSpot] Search failed', [
'error' => $e->getMessage(),
'domain' => $domain,
]);
return null;
}
$account = null;
// If there are multiple accounts, don't guess, we'll ask later.
if (\count($hsAccounts->data->results) === 1) {
// Persist this remote object.
$account = $this->syncAccount($hsAccounts->data->results[0]->companyId);
}
$data = $this->convertCrmData(null, $account, $userId);
return ! empty(array_filter($data)) ? $data : null;
}
/**
* @return array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
protected function convertCrmData(?Contact $contact, ?Account $account, ?int $userId = null): array
{
$countryCode = null;
if ($contact && $contact->country_code) {
$countryCode = $contact->country_code;
} elseif ($account && $account->country_code) {
$countryCode = $account->country_code;
}
try {
$hsOpportunities = $this->findOpportunities(
$account ? $account->crm_provider_id : null,
$contact ? $contact->crm_provider_id : null,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
// If there are multiple opportunities, don't guess, we'll ask later.
$opportunity = null;
$stage = null;
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
protected function getCacheKey(string $object, ?int $userId = null): ?string
{
$key = $this->team->getId() . $object;
$keySuffix = $this->getOwnerKeySuffix($userId);
return $key . $keySuffix;
}
private function getOwnerKeySuffix(?int $userId = null): string
{
return $userId === null ? '' : (string) $userId;
}
/**
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
*}
*/
public function matchByPhone(string $phone, ?string $rawPhoneNumber = null, ?int $userId = null): ?array
{
if (str_contains($phone, '**')) {
return null;
}
// trim all whitespaces if present so the lookup doesn't fail
$phone = str_replace(' ', '', $phone);
// Check if the user is internal.
if ($this->isPhoneNumberOfTeamMember($phone)) {
return null;
}
$response = $this->searchForPhoneNumber($phone);
if (empty($response)) {
return null;
}
// This would ideally importContact instead but the response type differs.
$contact = $this->findAndSyncContact($response['results'][0]['id']);
if (! $contact instanceof Contact) {
return null;
}
$account = $contact->account;
$countryCode = $contact->country_code ?? $account->country_code ?? null;
try {
$hsOpportunities = $this->findOpportunities(
$account?->crm_provider_id,
$contact->crm_provider_id,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
$opportunity = null;
$stage = null;
try {
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
} catch (Exception $e) {
$this->logger->debug('[HubSpot] Opportunity failed to sync.', [
'reason' => $e->getMessage(),
]);
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
private function isPhoneNumberOfTeamMember(string $phone): bool
{
$teamRepository = app(TeamRepository::class);
$user = $teamRepository->findTeamMemberByPhone($this->team, $phone);
if ($user instanceof User) {
return true;
}
return false;
}
private function findAndSyncContact(string $crmId): ?Contact
{
try {
return $this->syncContact($crmId);
} catch (Exception $exception) {
$this->logger->info('[HubSpot] Phone match failed', [
'reason' => $exception->getMessage(),
]);
return null;
}
}
private function hasResults(array $response): bool
{
return isset($response['total']) && is_numeric($response['total']) && $response['total'] > 0;
}
private function searchForPhoneNumber(string $phone): array
{
// Normalizes the provided phone number for the API search.
$normalizedPhone = $this->normalizePhoneNumber($phone);
$payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone);
$this->logger->info('[HubSpot] Phone match search triggered', [
'phone' => $phone,
'normalizedPhone' => $normalizedPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($normalizedPhone, $payload);
if (! $this->hasResults($response)) {
$nationalPhone = preg_replace('/\D/', '', phone_national(null, $phone));
$payload = $this->payloadBuilder->generatePhoneSearchPayload($nationalPhone);
$this->logger->info('[HubSpot] Phone match national number search triggered', [
'phone' => $phone,
'nationalPhone' => $nationalPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($phone, $payload);
}
if (! $this->hasResults($response)) {
$payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone, true);
$this->logger->info('[HubSpot] Phone match alternative search triggered', [
'phone' => $phone,
'normalizedPhone' => $normalizedPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($phone, $payload);
}
return $this->hasResults($response) ? $response : [];
}
private function handlePhoneSearchRequest(string $phone, array $payload): array
{
try {
return $this->client->search('contacts', $payload);
} catch (Exception $exception) {
$this->logger->info('[HubSpot] Phone match failed', [
'phone' => $phone,
'reason' => $exception->getMessage(),
]);
return [];
}
}
private function normalizePhoneNumber(string $phone): string
{
return ltrim(phone_e164(null, $phone), '+0');
}
/**
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchByName(string $name, ?int $userId = null): ?array
{
// Don't waste time searching for single character strings.
if (\strlen($name) <= 1) {
return null;
}
$cacheKey = $this->getCacheKey($name, $userId);
$result = Cache::remember($cacheKey, 60, function () use ($name, $userId) {
$payload = $this->payloadBuilder->generateSearchContactsByNamePayload(
$name,
$this->getContactFields()
);
$hsContacts = $this->client->getPaginatedData($payload, 'contact');
if (empty($hsContacts['results'])) {
return false;
}
$contact = $this->importContact($hsContacts['results'][0]);
if ($contact === null) {
return false;
}
$account = $contact->account;
$countryCode = $contact->country_code ?? $account->country_code ?? null;
try {
$hsOpportunities = $this->findOpportunities(
$account ? $account->crm_provider_id : null,
$contact->crm_provider_id,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
$opportunity = null;
$stage = null;
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
});
return is_array($result) ? $result : null;
}
private function convertActivityAssociations(Activity $activity): array
{
return [
'contactIds' => $this->getParticipantsIds($activity),
'companyIds' => $activity->hasAccount() ? [$activity->account->crm_provider_id] : [],
'dealIds' => $activity->hasOpportunity() ? [$activity->opportunity->crm_provider_id] : [],
'ownerIds' => [],
];
}
private function getParticipantsIds(Activity $activity): array
{
$attendees = [];
$participantRepository = app(ParticipantRepository::class);
$participants = $participantRepository->getParticipantsWhoEnteredMeeting($activity);
foreach ($participants as $participant) {
if ($participant->user_id || $participant->isCoach()) {
continue;
}
$contact = $participant->contact()->first();
if ($contact && $contact->crm_provider_id) {
$attendees[] = $contact->crm_provider_id;
} else {
if (! empty($participant->name)) {
$attendeeData = $this->fetchMissingAttendeeInfo($participant);
}
if (! empty($attendeeData['id'])) {
$attendees[] = $attendeeData['id'];
}
}
}
if ($activity->hasContact()) {
$attendees[] = $activity->contact->crm_provider_id;
}
return array_unique($attendees);
}
private function fetchMissingAttendeeInfo(Participant $participant): array
{
// Check if we need to look inside an account context.
$activity = $participant->getActivity();
$companyId = $activity->hasAccount() ? $activity->getAccount()->crm_provider_id : null;
// First check the local data.
/** @var Contact[] $contacts */
$contacts = $this->team->contacts()
->with('account')
->where('name', $participant->name)
->whereNotNull('email')
->get();
foreach ($contacts as $contact) {
// If we have a company in scope, check the contact is associated to it.
if (
$companyId !== null
&& ($contact->account_id === null || $companyId !== $contact->account->crm_provider_id)
) {
continue;
}
return [
'id' => $contact->crm_provider_id,
'email' => $contact->email,
];
}
$payload = $this->generateNameSearchPayload($participant->name, 0, 20);
try {
$response = $this->client->getNewInstance()->crm()->contacts()->searchApi()->doSearch($payload);
// TODO add some logic to choose the most suitable contact if multiple
foreach ($response['results'] as $object) {
$properties = $object['properties'];
if (empty($object['properties']) === false) {
// Check the company matches the contact.
// Todo: Move this check inside the API search.
if ($companyId !== null && $companyId !== $properties['associatedcompanyid']) {
continue;
}
return [
'id' => $object['id'],
'email' => $properties['email'],
];
}
}
} catch (Exception $e) {
$this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [
'teamId' => $this->team->id_string,
'request' => $payload,
'reason' => $e->getMessage(),
]);
}
return [];
}
/**
* Store transcripts as note engagement.
*
* @throws Exception
*/
public function createTranscriptNotes(Activity $activity): void
{
// For HS no need to check if Crm profile - Log Notes field is enabled
// We only check if store_transcript toggle is enabled on crm profile.
$engagement = [
'active' => true,
'ownerId' => $this->profile->crm_provider_id,
'timestamp' => $activity->created_at->tz($activity->user->timezone)->getTimestamp() * 1000,
'type' => 'NOTE',
];
// Generate activity transcription.
$transcriptionData = $this->generateTranscription($activity);
// Truncate Notes with max notes length because transcription text could be very long.
$transcripts = mb_strimwidth($transcriptionData, 0, static::ENGAGEMENT_BODY_MAX_LENGTH);
$metadata = [
'body' => $transcripts,
];
$associations = $this->convertActivityAssociations($activity);
try {
$hsEngagement = $this->client
->getInstance()
->engagements()
->create($engagement, $associations, $metadata);
$this->logCrmEngagementManipulation(self::ACTION_CREATE, $engagement, $metadata, $associations);
$noteId = $hsEngagement->data->engagement->id;
// Store crm logged id in transcription.
$transcription = $activity->getTranscription();
$transcription->crm_activity_id = $noteId;
$transcription->save();
} catch (Exception $e) {
Sentry::captureException($e);
}
}
/*
* @inheritdoc
*/
public function updateRecord(string $objectType, string $objectId, array $data, array $headers = []): void
{
$payload = [
'properties' => $data,
];
try {
switch ($objectType) {
case FieldData::OBJECT_OPPORTUNITY:
$this->client->getNewInstance()->crm()->deals()->basicApi()->update($objectId, $payload);
break;
case FieldData::OBJECT_CONTACT:
$this->client->getNewInstance()->crm()->contacts()->basicApi()->update($objectId, $payload);
break;
case FieldData::OBJECT_ACCOUNT:
$this->client->getNewInstance()->crm()->companies()->basicApi()->update($objectId, $payload);
break;
case FieldData::OBJECT_TASK:
// Endpoint for Engagements not ready
$engagements = [
'type' => 'TASK',
];
$metadata = $data;
$this->client->getInstance()->engagements()->update($objectId, $engagements, $metadata);
$this->logCrmEngagementManipulation(
self::ACTION_UPDATE,
['crmId...
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"bounds":{"left":0.025930852,"top":0.019952115,"width":0.03856383,"height":0.025538707},"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JY-20725-handle-HS-search-rate-limit, menu","depth":5,"bounds":{"left":0.064494684,"top":0.019952115,"width":0.09541223,"height":0.025538707},"on_screen":true,"help_text":"Git Branch: JY-20725-handle-HS-search-rate-limit","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"bounds":{"left":0.8081782,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"AskJiminnyReportActivityServiceTest","depth":6,"bounds":{"left":0.8234708,"top":0.019952115,"width":0.09208777,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'AskJiminnyReportActivityServiceTest'","depth":6,"bounds":{"left":0.9155585,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'AskJiminnyReportActivityServiceTest'","depth":6,"bounds":{"left":0.9268617,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"bounds":{"left":0.9381649,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"bounds":{"left":0.96609044,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"bounds":{"left":0.9773936,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"IDE and Project Settings","depth":5,"bounds":{"left":0.9886968,"top":0.019952115,"width":0.011303186,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.042220745,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"19","depth":4,"bounds":{"left":0.6615692,"top":0.10055866,"width":0.009640957,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"bounds":{"left":0.67287236,"top":0.09896249,"width":0.00731383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"bounds":{"left":0.68018615,"top":0.09896249,"width":0.006981383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {\n\"headers\":{\n\"Date\":[\"Thu,07 May 2026 14:21:15 GMT\"],\n \"Content-Type\":[\"application/json;charset=utf-8\"],\n \"Transfer-Encoding\":[\"chunked\"],\n \"Connection\":[\"keep-alive\"],\n \"CF-Ray\":[\"9f80deb8db60dc3a-SOF\"],\n \"CF-Cache-Status\":[\"DYNAMIC\"],\n \"Strict-Transport-Security\":[\"max-age=31536000; includeSubDomains; preload\"],\n \"Vary\":[\"origin,\n accept-encoding\"],\n \"access-control-allow-credentials\":[\"false\"],\n \"server-timing\":[\"hcid;desc=\\\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\\\",\n cfr;desc=\\\"9f80deb8e7c6dc3a-IAD\\\"\"],\n \"x-content-type-options\":[\"nosniff\"],\n \"x-hubspot-correlation-id\":[\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\"],\n \"Set-Cookie\":[\"__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-1.0.1.1-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,\n 07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None\"],\n \"Report-To\":[\"{\n\\\"endpoints\\\":[{\n\\\"url\\\":\\\"https:\\\\/\\\\/a.nel.cloudflare.com\\\\/report\\\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\\\"}],\n\\\"group\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"NEL\":[\"{\n\\\"success_fraction\\\":0.01,\n\\\"report_to\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"Server\":[\"cloudflare\"]}} {\n\"correlation_id\":\"95236535-ec98-4541-b92a-adfa73b69eab\",\n\"trace_id\":\"c7ab8365-903f-46d4-9403-0e5b551e3545\"}","depth":4,"bounds":{"left":0.37632978,"top":0.09736632,"width":0.5728058,"height":0.8818835},"on_screen":true,"lines":[{"char_start":207,"char_count":30,"bounds":{"left":0.37632978,"top":0.0,"width":0.07513298,"height":0.014365523}},{"char_start":237,"char_count":36,"bounds":{"left":0.37632978,"top":0.0,"width":0.09075798,"height":0.014365523}},{"char_start":273,"char_count":32,"bounds":{"left":0.37632978,"top":0.0,"width":0.080119684,"height":0.014365523}},{"char_start":305,"char_count":79,"bounds":{"left":0.37632978,"top":0.0,"width":0.20212767,"height":0.014365523}},{"char_start":384,"char_count":18,"bounds":{"left":0.37632978,"top":0.0,"width":0.043882977,"height":0.014365523}},{"char_start":402,"char_count":21,"bounds":{"left":0.37632978,"top":0.0,"width":0.051861703,"height":0.014365523}},{"char_start":423,"char_count":48,"bounds":{"left":0.37632978,"top":0.008778931,"width":0.12167553,"height":0.014365523}},{"char_start":471,"char_count":72,"bounds":{"left":0.37632978,"top":0.026336791,"width":0.18384309,"height":0.014365523}},{"char_start":543,"char_count":40,"bounds":{"left":0.37632978,"top":0.043894652,"width":0.10106383,"height":0.014365523}},{"char_start":583,"char_count":41,"bounds":{"left":0.37632978,"top":0.061452515,"width":0.10372341,"height":0.014365523}},{"char_start":624,"char_count":72,"bounds":{"left":0.37632978,"top":0.079010375,"width":0.18384309,"height":0.014365523}},{"char_start":696,"char_count":219,"bounds":{"left":0.37632978,"top":0.096568234,"width":0.56515956,"height":0.014365523}},{"char_start":915,"char_count":83,"bounds":{"left":0.37632978,"top":0.11412609,"width":0.21243352,"height":0.014365523}},{"char_start":998,"char_count":20,"bounds":{"left":0.37632978,"top":0.13168396,"width":0.04920213,"height":0.014365523}},{"char_start":1018,"char_count":17,"bounds":{"left":0.37632978,"top":0.14924182,"width":0.041223403,"height":0.014365523}},{"char_start":1035,"char_count":203,"bounds":{"left":0.37632978,"top":0.16679968,"width":0.52360374,"height":0.014365523}},{"char_start":1238,"char_count":22,"bounds":{"left":0.37632978,"top":0.18435754,"width":0.05418883,"height":0.014365523}},{"char_start":1260,"char_count":23,"bounds":{"left":0.37632978,"top":0.2019154,"width":0.056848403,"height":0.014365523}},{"char_start":1283,"char_count":10,"bounds":{"left":0.37632978,"top":0.21947326,"width":0.023271276,"height":0.014365523}},{"char_start":1293,"char_count":27,"bounds":{"left":0.37632978,"top":0.23703113,"width":0.06715426,"height":0.014365523}},{"char_start":1320,"char_count":26,"bounds":{"left":0.37632978,"top":0.254589,"width":0.06482713,"height":0.014365523}},{"char_start":1346,"char_count":23,"bounds":{"left":0.37632978,"top":0.27214685,"width":0.056848403,"height":0.014365523}},{"char_start":1369,"char_count":28,"bounds":{"left":0.37632978,"top":0.2897047,"width":0.06981383,"height":0.014365523}},{"char_start":1397,"char_count":57,"bounds":{"left":0.37632978,"top":0.30726257,"width":0.14494681,"height":0.014365523}}],"value":"[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {\n\"headers\":{\n\"Date\":[\"Thu,07 May 2026 14:21:15 GMT\"],\n \"Content-Type\":[\"application/json;charset=utf-8\"],\n \"Transfer-Encoding\":[\"chunked\"],\n \"Connection\":[\"keep-alive\"],\n \"CF-Ray\":[\"9f80deb8db60dc3a-SOF\"],\n \"CF-Cache-Status\":[\"DYNAMIC\"],\n \"Strict-Transport-Security\":[\"max-age=31536000; includeSubDomains; preload\"],\n \"Vary\":[\"origin,\n accept-encoding\"],\n \"access-control-allow-credentials\":[\"false\"],\n \"server-timing\":[\"hcid;desc=\\\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\\\",\n cfr;desc=\\\"9f80deb8e7c6dc3a-IAD\\\"\"],\n \"x-content-type-options\":[\"nosniff\"],\n \"x-hubspot-correlation-id\":[\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\"],\n \"Set-Cookie\":[\"__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-1.0.1.1-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,\n 07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None\"],\n \"Report-To\":[\"{\n\\\"endpoints\\\":[{\n\\\"url\\\":\\\"https:\\\\/\\\\/a.nel.cloudflare.com\\\\/report\\\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\\\"}],\n\\\"group\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"NEL\":[\"{\n\\\"success_fraction\\\":0.01,\n\\\"report_to\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"Server\":[\"cloudflare\"]}} {\n\"correlation_id\":\"95236535-ec98-4541-b92a-adfa73b69eab\",\n\"trace_id\":\"c7ab8365-903f-46d4-9403-0e5b551e3545\"}","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.042220745,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"7","depth":4,"bounds":{"left":0.29022607,"top":0.17478053,"width":0.0076462766,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"48","depth":4,"bounds":{"left":0.29986703,"top":0.17478053,"width":0.010305851,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"1","depth":4,"bounds":{"left":0.31216756,"top":0.17478053,"width":0.00731383,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"33","depth":4,"bounds":{"left":0.32147607,"top":0.17478053,"width":0.010305851,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"1","depth":4,"bounds":{"left":0.3337766,"top":0.17478053,"width":0.00731383,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"bounds":{"left":0.34275267,"top":0.17318435,"width":0.00731383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"bounds":{"left":0.35006648,"top":0.17318435,"width":0.006981383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\nnamespace Jiminny\\Services\\Crm\\Hubspot;\n\nuse Carbon\\Carbon;\nuse Exception;\nuse Generator;\nuse GuzzleHttp\\Exception\\RequestException;\nuse Illuminate\\Support\\Facades\\Cache;\nuse InvalidArgumentException;\nuse Jiminny\\Contracts\\Repositories\\TeamRepository;\nuse Jiminny\\Contracts\\Services\\Crm\\ClientInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\FetchRelatedActivityInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\LayoutManagementInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\MatchCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\Provider\\HubspotInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityLookupInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityManipulationInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SavePlaybackLinkToCrmInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SendSummaryToCrmInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SettingsInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmMetadataInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\VerifyTaskExistsInterface;\nuse Jiminny\\Exceptions\\CrmException;\nuse Jiminny\\Exceptions\\HttpNotFoundException;\nuse Jiminny\\Jobs\\Crm\\NoteObject;\nuse Jiminny\\Models\\Account;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Contact;\nuse Jiminny\\Models\\Contracts\\ActivityContract;\nuse Jiminny\\Models\\Crm\\BusinessProcess;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Models\\Crm\\FieldData;\nuse Jiminny\\Models\\Crm\\Layout;\nuse Jiminny\\Models\\Crm\\Profile;\nuse Jiminny\\Models\\Lead;\nuse Jiminny\\Models\\Opportunity;\nuse Jiminny\\Models\\Participant;\nuse Jiminny\\Models\\Playbook;\nuse Jiminny\\Models\\SocialAccount;\nuse Jiminny\\Models\\Stage;\nuse Jiminny\\Models\\User;\nuse Jiminny\\Repositories\\Crm\\CrmEntityRepository;\nuse Jiminny\\Repositories\\Crm\\FieldRepository;\nuse Jiminny\\Repositories\\Crm\\ProfileRepository;\nuse Jiminny\\Repositories\\ParticipantRepository;\nuse Jiminny\\Services\\Avatar\\ProspectPhotoPathService;\nuse Jiminny\\Services\\Crm\\BaseService;\nuse Jiminny\\Services\\Crm\\Hubspot\\Actions\\SyncArchivedProfilesAction;\nuse Jiminny\\Services\\Crm\\Hubspot\\Fields\\ValueNormalizer;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\OpportunitySyncTrait;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\SyncCrmEntitiesTrait;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\SyncFieldsTrait;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\WriteCrmTrait;\nuse Jiminny\\Services\\Crm\\MatchDomainByEmailInterface;\nuse Jiminny\\Services\\Crm\\OpportunitySyncStrategyResolver;\nuse Jiminny\\Services\\Crm\\ResolveCompanyNameByEmailTrait;\nuse Jiminny\\Utils\\PlaybackUrlBuilder;\nuse Sentry;\nuse SevenShores\\Hubspot\\Exceptions\\BadRequest;\nuse Throwable;\nuse UnexpectedValueException;\n\n/**\n * @phpstan-type CrmFieldDefinition array{\n * name: string,\n * label: string,\n * description: string,\n * type: string,\n * fieldType: string,\n * hidden: bool,\n * showCurrencySymbol: bool,\n * options: array<array{\n * id: string,\n * label: string,\n * value?: string,\n * }\n */\nclass Service extends BaseService implements\n HubspotInterface,\n SyncCrmEntitiesInterface,\n SyncCrmMetadataInterface,\n SendSummaryToCrmInterface,\n MatchDomainByEmailInterface,\n SavePlaybackLinkToCrmInterface,\n RemoteEntityManipulationInterface,\n FetchRelatedActivityInterface,\n LayoutManagementInterface,\n SettingsInterface,\n MatchCrmEntitiesInterface,\n RemoteEntityLookupInterface,\n VerifyTaskExistsInterface\n{\n use ResolveCompanyNameByEmailTrait;\n use SyncCrmEntitiesTrait;\n use WriteCrmTrait;\n use SyncFieldsTrait;\n use OpportunitySyncTrait;\n\n private const int ENGAGEMENT_BODY_MAX_LENGTH = 65536;\n\n private const string LOG_DATE_FORMAT = 'Y-m-d H:i:s';\n private const int BATCH_UPDATE_LIMIT = 100;\n private const string TEN_SECONDLY_ROLLING_POLICY = 'TEN_SECONDLY_ROLLING';\n private const int TEN_SECONDLY_ROLLING_LIMIT = 10;\n\n private const string TYPE_NOTE = 'NOTE';\n\n private const string TYPE_MEETING = 'MEETING';\n\n private const string TYPE_CALL = 'CALL';\n\n private const string API_URL = 'https://api.hubapi.com';\n\n // NB: v1 is legacy - v3 is the newest\n private const string ENDPOINT_PIPELINES = '/crm-pipelines/v1/pipelines/';\n private const string PIPELINE_OBJECT_TYPE_DEALS = 'deals';\n\n private const int TASK_VERIFICATION_CACHE_TTL = 86400; // 1 day\n\n /**\n * @var ClientInterface|Client\n */\n protected $client;\n protected OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;\n protected CrmEntityRepository $crmEntityRepository;\n protected ProspectPhotoPathService $prospectPhotoPathService;\n\n private SyncFieldAction $syncFieldAction;\n private PayloadBuilder $payloadBuilder;\n private SyncRelatedActivityManager $syncRelatedActivityManager;\n private SyncArchivedProfilesAction $syncArchivedProfilesAction;\n private WebhookSyncBatchProcessor $batchProcessor;\n\n public function __construct(\n Client $client,\n SyncFieldAction $syncFieldAction,\n PayloadBuilder $payloadBuilder,\n ProspectPhotoPathService $prospectPhotoPathService,\n SyncArchivedProfilesAction $syncArchivedProfilesAction,\n WebhookSyncBatchProcessor $batchProcessor,\n ) {\n parent::__construct();\n\n $this->client = $client;\n $this->syncFieldAction = $syncFieldAction;\n $this->prospectPhotoPathService = $prospectPhotoPathService;\n $this->payloadBuilder = $payloadBuilder;\n $this->syncArchivedProfilesAction = $syncArchivedProfilesAction;\n $this->batchProcessor = $batchProcessor;\n $this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [\n 'client' => $this->client,\n ]);\n $this->syncRelatedActivityManager = app(SyncRelatedActivityManager::class, [\n 'client' => $this->client,\n 'payloadBuilder' => $this->payloadBuilder,\n 'logger' => $this->logger,\n ]);\n $this->crmEntityRepository = app(CrmEntityRepository::class);\n $this->dealFieldsService = app(DealFieldsService::class);\n }\n\n public function getDisplayName(): string\n {\n return 'HubSpot';\n }\n\n protected function getOAuthAccount(User $user): ?SocialAccount\n {\n // In this case, the Account Owner is always the connection for any API operations.\n $owner = $user->team->owner;\n\n return $owner->getSocialAccount(SocialAccount::PROVIDER_HUBSPOT);\n }\n\n public function getClient(): Client\n {\n /** @var Client */\n return $this->client;\n }\n\n /**\n * Convert raw field data into a format compatible with CRM APIs.\n *\n * @param bool $internal Direction of the conversion.\n * True is pulling from CRM, false normalize before sending to CRM.\n */\n public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string\n {\n return ValueNormalizer::normalize(\n fieldType: $fieldType,\n fieldValue: $fieldValue,\n isInbound: $internal,\n );\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultFields(string $activityType): array\n {\n $fields = [];\n\n if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {\n $defaultFields = FieldDefinitions::defaultTaskFields();\n\n // This lazy creates these fields if not already setup.\n foreach ($defaultFields as $defaultField) {\n $fields[] = $this->config->fields()->firstOrCreate($defaultField);\n }\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultActivityField(string $activityType): Field\n {\n /** @var Field $activityField */\n $activityField = $this->config->fields()->where([\n 'crm_provider_id' => 'activityType',\n 'object_type' => $activityType,\n ])->first();\n\n return $activityField;\n }\n\n /**\n * @inheritdoc\n */\n public function getSupportedPlaybookTypes(): array\n {\n return [Playbook::ACTIVITY_TYPE_TASK];\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultActivityLayoutFields(string $activityType, string $layoutType): array\n {\n $fields = [];\n\n if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {\n // Outcome should always be provided calls/meetings.\n $fieldData = [\n [\n 'crm_provider_id' => $layoutType === Layout::TYPE_SOFTPHONE_SUMMARY ? 'disposition' : 'meetingOutcome',\n 'object_type' => Field::OBJECT_TASK,\n ],\n ];\n\n foreach ($fieldData as $data) {\n $field = $this->config->fields()->where($data)->first();\n\n // Only add the field if it is created, which it should be.\n if ($field) {\n $fields[] = $field;\n }\n }\n }\n\n return $fields;\n }\n\n public function getDealInsightsFields(): array\n {\n return FieldDefinitions::dealInsightsFields();\n }\n\n protected function getDefaultFollowupLayoutFields(string $activityType): array\n {\n $fields = [];\n $fieldRepo = app(FieldRepository::class);\n $fieldData = FieldDefinitions::followupFieldsFilter();\n\n foreach ($fieldData as $data) {\n $field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $data);\n\n // Only add the field if it is created, which it should be.\n if ($field) {\n $fields[] = $field;\n }\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function syncField(Field $field): void\n {\n switch ($field->object_type) {\n case Field::OBJECT_ACCOUNT:\n $crmField = $this->client->getInstance()->companyProperties()->get($field->crm_provider_id);\n\n break;\n case Field::OBJECT_CONTACT:\n $crmField = $this->client->getInstance()->contactProperties()->get($field->crm_provider_id);\n\n break;\n case Field::OBJECT_OPPORTUNITY:\n $crmField = $this->client->getInstance()->dealProperties()->get($field->crm_provider_id);\n\n break;\n case Field::OBJECT_TASK:\n $this->syncSingleTaskField($field);\n\n return;\n default:\n return;\n }\n\n $this->syncFieldAction->execute($field, $crmField->toArray());\n }\n\n /**\n * @param array<array{\n * id:string,\n * label:string,\n * value?:string\n * }> $options\n *\n * @throws CrmException\n *\n * @return FieldData[]\n *\n */\n public function importPicklistValues(\n Field $field,\n array $options = [['id' => '', 'label' => '', 'value' => '']],\n ): array {\n if (! empty($options[0]['id']) || ! empty($options[0]['value'])) {\n // We already have the options, no need to fetch them again\n return $this->importOptions($field, $options);\n }\n\n $options = [];\n\n switch ($field->getObjectType()) {\n case Field::OBJECT_ACCOUNT:\n $options = $this->getClient()->fetchPropertyOptions('company', $field->getCrmProviderId());\n\n break;\n\n case Field::OBJECT_CONTACT:\n $options = $this->getClient()->fetchPropertyOptions('contact', $field->getCrmProviderId());\n\n break;\n\n case Field::OBJECT_OPPORTUNITY:\n // Hubspot has different endpoint for stages\n $options = $this->getClient()->fetchOpportunityFieldOptions($field);\n\n break;\n\n case Field::OBJECT_TASK:\n if ($field->getCrmProviderId() === 'disposition') {\n $options = $this->getClient()->fetchDispositionFieldOptions();\n } elseif (in_array($field->getCrmProviderId(), ['meetingOutcome', 'activityType'])) {\n $options = $this->getClient()->fetchMeetingOutcomeFieldOptions($field);\n }\n\n break;\n\n default:\n $this->logger->warning('Invalid object type', [\n 'object_type' => $field->getObjectType(),\n 'field_id' => $field->getId(),\n ]);\n\n throw new CrmException('Invalid object type');\n }\n\n return $this->importOptions($field, $options);\n }\n\n /**\n * @inheritdoc\n */\n public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage\n {\n $missingStage = null;\n\n try {\n // Use the HubSpot API client instead of the SDK crmPipelines() method\n $endpoint = self::getDealsPipelinesEndpoint();\n $pipelinesResponse = $this->client->getInstance()->getClient()->request('GET', $endpoint);\n $pipelines = $pipelinesResponse->data->results;\n } catch (RequestException|BadRequest $exception) {\n throw $exception;\n }\n\n foreach ($pipelines as $pipeline) {\n $stages = [];\n\n // We create a business process to contain the pipeline, and store all stages against it.\n $p = ResponseNormalize::normalizePipeline($pipeline);\n\n // Create/update business process for this pipeline\n $businessProcess = $this->config->businessProcesses()->updateOrCreate([\n 'crm_provider_id' => $p['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($p['label'], 0, 150),\n 'type' => BusinessProcess::TYPE_OPPORTUNITY,\n 'is_selectable' => $p['active'],\n ]);\n\n // A record type is really a clone of the business process, used to store which record uses which pipeline.\n // Create/update record type clone\n $this->config->recordTypes()->updateOrCreate([\n 'crm_provider_id' => $p['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($p['label'], 0, 150),\n 'is_selectable' => $p['active'],\n 'business_process_id' => $businessProcess->id ?? null,\n ]);\n\n // Stages - fetch all existing stages upfront to avoid N+1 queries\n $existingStages = $this->config->stages()\n ->withTrashed()\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->get()\n ->keyBy('crm_provider_id');\n\n foreach ($p['stages'] as $dealStage) {\n $s = ResponseNormalize::normalizeDealStage($dealStage);\n\n /** @var ?Stage $existingStage */\n $existingStage = $existingStages->get($s['id']);\n\n // Restore soft-deleted stages that are now active in HubSpot\n if ($existingStage?->trashed() && $s['active']) {\n $existingStage->restore();\n }\n\n // Upsert stage (updates soft-deleted records without restoring them)\n $stage = $this->config->stages()->withTrashed()->updateOrCreate([\n 'crm_provider_id' => $s['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($s['label'], 0, 50),\n 'label' => mb_strimwidth($s['label'], 0, 191),\n 'type' => Stage::TYPE_OPPORTUNITY,\n 'sequence' => $s['displayOrder'],\n 'is_selectable' => $s['active'],\n 'probability' => $s['probability'] * 100,\n ]);\n\n if ($missingStageName === $s['id']) {\n $missingStage = $stage;\n }\n\n $stages[] = $stage->id;\n }\n\n $businessProcess->stages()->sync($stages);\n }\n\n return $missingStage;\n }\n\n /**\n * @inheritdoc\n */\n public function syncOrganization(): void\n {\n try {\n $endpoint = 'https://api.hubapi.com/integrations/v1/me';\n $response = $this->client->getInstance()->getClient()->request('get', $endpoint);\n\n $accountData = $response->data;\n $this->config->update(['default_currency' => $accountData->currency]);\n } catch (BadRequest $e) {\n throw new CrmException('Could not sync the organization.', $e->getCode(), $e);\n }\n }\n\n /**\n * @inheritdoc\n *\n * @throws CrmException\n */\n public function syncProfiles(?User $userToSearch = null): ?Profile\n {\n $this->syncArchivedProfilesAction->execute($this->team, $this->client, $this->config);\n\n try {\n $owners = $this->client->getOwners();\n } catch (\\HubSpot\\Client\\Crm\\Owners\\ApiException $e) {\n $this->logger->error('[HubSpot] Could not sync the profiles.', [\n 'team_id' => $this->team->getId(),\n 'reason' => $e->getMessage(),\n ]);\n\n throw new CrmException('Could not sync the profiles.', $e->getCode(), $e);\n }\n\n $profileRepository = app(ProfileRepository::class);\n $teamRepository = app(TeamRepository::class);\n\n foreach ($owners as $owner) {\n if ($owner->getArchived()) {\n // not supposed to fetch archived, but log anyway\n $this->logger->warning('[HubSpot] Found archived owner', [\n 'crm_provider_id' => $owner->getId(),\n 'email' => $owner->getEmail(),\n ]);\n\n continue;\n }\n\n $email = $owner->getEmail();\n if ($email === null) {\n continue;\n }\n\n $user = $teamRepository->findActiveTeamMemberByEmail($this->team, $email);\n\n if (! $user instanceof User) {\n continue;\n }\n\n $profile = $profileRepository->updateOrCreateProfile($user, [\n 'crm_configuration_id' => $this->config->getId(),\n 'crm_provider_id' => $owner->getId(),\n ]);\n\n if ($userToSearch && $userToSearch->getId() === $user->getId()) {\n return $profile;\n }\n }\n\n return null;\n }\n\n private function generateNameSearchPayload(string $name, int $offset, int $limit): array\n {\n $payload = [\n 'query' => $name,\n 'sorts' => [\n [\n 'propertyName' => 'modifieddate',\n 'direction' => 'DESCENDING',\n ],\n ],\n 'properties' => [\n 'email',\n 'firstname',\n 'lastname',\n 'country',\n 'phone',\n 'mobilephone',\n 'jobtitle',\n 'hubspot_owner_id',\n 'associatedcompanyid',\n 'photo',\n 'industry',\n 'name',\n 'company',\n ],\n 'limit' => $limit,\n 'after' => $offset,\n ];\n\n $this->logger->debug('[HubSpot] CRM Search requested', [\n 'request' => $payload,\n ]);\n\n return $payload;\n }\n\n /**\n * @inheritdoc\n */\n public function find(string $name, array $scopes): array\n {\n $count = $this->limit ?? 20;\n $offset = $this->offset ?? 0;\n\n /** @var array<int, array<string, mixed>> */\n return Cache::remember(\n key: $this->team->getId() . $name . $count . $offset,\n ttl: 300,\n callback: function () use ($name, $offset, $count): array {\n $data = [];\n\n // Use the new V3 API to find contacts based on additional fields.\n foreach (['companies', 'contacts'] as $objectType) {\n $payload = $this->generateNameSearchPayload($name, $offset, $count);\n $type = $objectType === 'companies' ? 'account' : 'contact';\n\n try {\n $response = $this->client->search($objectType, $payload);\n\n // Build mapped list.\n foreach ($response['results'] as $object) {\n $properties = $object['properties'];\n\n $objectName = $this->buildContactName($properties);\n\n $record = [\n 'crmId' => $object['id'],\n // Pass crmUrl to the FE, needed for success message in the extension when you log activity.\n 'crmUrl' => $this->generateProviderUrl($object['id'], $type),\n 'name' => $objectName,\n 'prospectType' => $type,\n 'phoneNumbers' => [],\n ];\n\n if ($type === 'account') {\n $record['industry'] = $properties['industry'] ?? null;\n } else {\n $record['title'] = $properties['jobtitle'] ?? null;\n $record['organization'] = $properties['company'] ?? null;\n }\n\n $countryCode = $this->buildContactCountry($properties);\n $parsedNumber = $this->buildContactPhone($countryCode, $properties);\n\n // Add phone number to record.\n if (! empty($parsedNumber['phone'])) {\n $record['phoneNumbers'][] = [\n 'number' => $parsedNumber['phone'],\n 'nationalFormat' => phone_national($countryCode, $parsedNumber['phone']),\n 'type' => 'phone',\n ];\n }\n\n // Add mobile phone number to record.\n if (! empty($properties['mobilephone'])) {\n $mobileNumber = phone_e164($countryCode, $properties['mobilephone']);\n if ($mobileNumber !== null) {\n $record['phoneNumbers'][] = [\n 'number' => $mobileNumber,\n 'nationalFormat' => phone_national($countryCode, $mobileNumber),\n 'type' => 'mobile',\n ];\n }\n }\n\n $data[] = $record;\n }\n } catch (BadRequest $e) {\n $this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [\n 'teamId' => $this->team->getUuid(),\n 'request' => $payload,\n 'reason' => $e->getMessage(),\n ]);\n\n throw $e;\n }\n }\n\n return $data;\n },\n );\n }\n\n\n /**\n * @inheritdoc\n */\n public function findOpportunities(?string $crmAccountId, ?string $crmContactId, ?int $userId = null): array\n {\n $data = [];\n $ownerData = [];\n $ownerId = null;\n\n if ($crmAccountId === null) {\n return $data;\n }\n\n if ($userId) {\n $profileRepository = app(ProfileRepository::class);\n $profile = $profileRepository->findProfileByUserId($this->config, $userId);\n\n $ownerId = $profile instanceof Profile ? $profile->getCrmProviderId() : null;\n }\n\n $closedStages = $this->getClosedDealStages();\n $payload = $this->payloadBuilder->generateOpportunitiesSearchPayload(\n $this->config,\n $crmAccountId,\n $closedStages,\n );\n\n $results = $this->client->getPaginatedData($payload, 'deals');\n\n foreach ($results['results'] as $object) {\n $properties = $object['properties'];\n\n $amount = null;\n if (empty($properties['amount']) === false) {\n $currency = $properties['deal_currency_code'] ?? $this->config->default_currency;\n\n // Values can contain commas and any junk so strip them.\n $value = (float) preg_replace('/[^\\d.]/', '', $properties['amount']);\n $amount = formatCurrency($value, $currency);\n }\n\n $businessProcess = $this->config\n ->businessProcesses()\n ->where('crm_provider_id', $properties['pipeline'])\n ->first();\n\n if ($businessProcess === null) {\n // Import it.\n $stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $properties['dealstage']);\n $businessProcess = $this->config\n ->businessProcesses()\n ->where('crm_provider_id', $properties['pipeline'])\n ->first();\n } else {\n $stage = $businessProcess\n ->stages()\n ->where('crm_provider_id', $properties['dealstage'])\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->first();\n\n if ($stage === null) {\n // Import it.\n $stage = $this->importStages(null, $properties['dealstage']);\n }\n }\n\n $recordType = null;\n if ($businessProcess) {\n $recordType = $businessProcess->recordTypes()->first();\n }\n\n $isWon = in_array($properties['dealstage'], $closedStages['won']);\n $isLost = in_array($properties['dealstage'], $closedStages['lost']);\n\n $record = [\n 'crmId' => $object['id'],\n 'name' => $properties['dealname'] ?? 'Unknown Deal',\n 'value' => $amount,\n 'won' => $isWon,\n 'closed' => $isWon || $isLost,\n 'stage' => [\n 'id' => $stage?->getUuid() ?? '',\n 'name' => $stage?->getName() ?? '',\n ],\n ];\n\n if ($recordType) {\n $record += [\n 'recordType' => [\n 'id' => $recordType->id_string,\n 'name' => $recordType->name,\n ],\n ];\n }\n\n if ($ownerId && isset($properties['hubspot_owner_id']) && $properties['hubspot_owner_id'] === $ownerId) {\n $ownerData[] = $record;\n }\n\n $data[] = $record;\n }\n\n if (! empty($ownerData)) {\n return $ownerData;\n }\n\n return $data;\n }\n\n /**\n * @inheritdoc\n */\n public function getTasks(?string $objectType, string $objectId, ?string $opportunityId): array\n {\n $data = [];\n switch ($objectType) {\n case 'contact':\n $hsObject = 'contact';\n\n break;\n case 'account':\n $hsObject = 'company';\n\n break;\n default:\n // This is a hack to prioritise and override a contact/company with a deal.\n if ($opportunityId) {\n $hsObject = 'deal';\n $objectId = $opportunityId;\n } else {\n throw new InvalidArgumentException('Object type not supported.');\n }\n }\n\n $engagementTypes = ['meetings', 'tasks'];\n\n foreach ($engagementTypes as $engagementType) {\n $payload = $this->payloadBuilder->getLinkToTaskPayload($hsObject, $objectId, $engagementType);\n\n $this->logger->info('[HubSpot] CRM Search requested', [\n 'request' => $payload,\n ]);\n\n $engagements = $this->client->getPaginatedData($payload, $engagementType);\n\n foreach ($engagements['results'] as $engagement) {\n if ($engagementType == 'meetings') {\n $title = $engagement['properties']['hs_meeting_title'] ?? 'Scheduled meeting';\n } elseif ($engagementType == 'tasks') {\n $title = $engagement['properties']['hs_task_subject'];\n } else {\n $title = 'Scheduled meeting';\n }\n\n $data[] = [\n 'crmId' => $engagement['id'],\n 'subject' => $title,\n 'due' => $engagement['properties']['hs_timestamp'],\n 'type' => $engagement['properties']['hs_activity_type'] ?? null,\n ];\n }\n }\n\n usort($data, function ($item1, $item2) {\n return $item2['due'] <=> $item1['due'];\n });\n\n return $data;\n }\n\n /**\n * Try to find CRM Objects using email address\n *\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n public function matchExactlyByEmail(string $email, ?int $userId = null): ?array\n {\n $contactProperties = [\n 'email',\n 'firstname',\n 'lastname',\n 'country',\n 'phone',\n 'mobilephone',\n 'jobtitle',\n 'hubspot_owner_id',\n 'associatedcompanyid',\n 'photo',\n ];\n $contact = null;\n $account = null;\n\n try {\n $hsContact = $this->getClient()->getContactByEmail($email, $contactProperties);\n\n if ($hsContact) {\n $contact = $this->importContact($hsContact);\n $account = $contact->account;\n }\n\n $data = $this->convertCrmData($contact, $account, $userId);\n\n return ! empty(array_filter($data)) ? $data : null;\n } catch (BadRequest $e) {\n $this->logger->warning('[HubSpot] Search failed', [\n 'team_id' => $this->team->getId(),\n 'search_identifier' => $email,\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return null;\n }\n\n public function getDomain(string $email): ?string\n {\n return $this->getDomainFromEmail($email);\n }\n\n /**\n * Try to find CRM objects using domain name of the email address\n *\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n public function matchByDomain(string $domain, ?int $userId = null): ?array\n {\n $companyName = $domain;\n\n // Try to find a company matching their email domain.\n $companyProperties = [\n 'country',\n 'phone',\n 'name',\n 'hs_avatar_filemanager_key',\n 'industry',\n 'hubspot_owner_id',\n 'domain',\n ];\n\n try {\n $hsAccounts = $this->client\n ->getInstance()\n ->companies()\n ->searchByDomain($companyName, $companyProperties);\n } catch (Throwable $e) {\n $this->logger->info('[HubSpot] Search failed', [\n 'error' => $e->getMessage(),\n 'domain' => $domain,\n ]);\n\n return null;\n }\n\n $account = null;\n // If there are multiple accounts, don't guess, we'll ask later.\n if (\\count($hsAccounts->data->results) === 1) {\n // Persist this remote object.\n $account = $this->syncAccount($hsAccounts->data->results[0]->companyId);\n }\n\n $data = $this->convertCrmData(null, $account, $userId);\n\n return ! empty(array_filter($data)) ? $data : null;\n }\n\n /**\n * @return array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n protected function convertCrmData(?Contact $contact, ?Account $account, ?int $userId = null): array\n {\n $countryCode = null;\n if ($contact && $contact->country_code) {\n $countryCode = $contact->country_code;\n } elseif ($account && $account->country_code) {\n $countryCode = $account->country_code;\n }\n\n try {\n $hsOpportunities = $this->findOpportunities(\n $account ? $account->crm_provider_id : null,\n $contact ? $contact->crm_provider_id : null,\n $userId\n );\n } catch (Exception $e) {\n $hsOpportunities = [];\n }\n\n // If there are multiple opportunities, don't guess, we'll ask later.\n $opportunity = null;\n $stage = null;\n if (! empty($hsOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n }\n\n protected function getCacheKey(string $object, ?int $userId = null): ?string\n {\n $key = $this->team->getId() . $object;\n $keySuffix = $this->getOwnerKeySuffix($userId);\n\n return $key . $keySuffix;\n }\n\n private function getOwnerKeySuffix(?int $userId = null): string\n {\n return $userId === null ? '' : (string) $userId;\n }\n\n /**\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n *}\n */\n public function matchByPhone(string $phone, ?string $rawPhoneNumber = null, ?int $userId = null): ?array\n {\n if (str_contains($phone, '**')) {\n return null;\n }\n\n // trim all whitespaces if present so the lookup doesn't fail\n $phone = str_replace(' ', '', $phone);\n\n // Check if the user is internal.\n if ($this->isPhoneNumberOfTeamMember($phone)) {\n return null;\n }\n\n $response = $this->searchForPhoneNumber($phone);\n if (empty($response)) {\n return null;\n }\n\n // This would ideally importContact instead but the response type differs.\n $contact = $this->findAndSyncContact($response['results'][0]['id']);\n if (! $contact instanceof Contact) {\n return null;\n }\n\n $account = $contact->account;\n $countryCode = $contact->country_code ?? $account->country_code ?? null;\n\n try {\n $hsOpportunities = $this->findOpportunities(\n $account?->crm_provider_id,\n $contact->crm_provider_id,\n $userId\n );\n } catch (Exception $e) {\n $hsOpportunities = [];\n }\n\n $opportunity = null;\n $stage = null;\n\n try {\n if (! empty($hsOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n } catch (Exception $e) {\n $this->logger->debug('[HubSpot] Opportunity failed to sync.', [\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n }\n\n private function isPhoneNumberOfTeamMember(string $phone): bool\n {\n $teamRepository = app(TeamRepository::class);\n $user = $teamRepository->findTeamMemberByPhone($this->team, $phone);\n\n if ($user instanceof User) {\n return true;\n }\n\n return false;\n }\n\n private function findAndSyncContact(string $crmId): ?Contact\n {\n try {\n return $this->syncContact($crmId);\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Phone match failed', [\n 'reason' => $exception->getMessage(),\n ]);\n\n return null;\n }\n }\n\n private function hasResults(array $response): bool\n {\n return isset($response['total']) && is_numeric($response['total']) && $response['total'] > 0;\n }\n\n private function searchForPhoneNumber(string $phone): array\n {\n // Normalizes the provided phone number for the API search.\n $normalizedPhone = $this->normalizePhoneNumber($phone);\n $payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone);\n\n $this->logger->info('[HubSpot] Phone match search triggered', [\n 'phone' => $phone,\n 'normalizedPhone' => $normalizedPhone,\n 'payload' => $payload,\n ]);\n\n $response = $this->handlePhoneSearchRequest($normalizedPhone, $payload);\n\n if (! $this->hasResults($response)) {\n $nationalPhone = preg_replace('/\\D/', '', phone_national(null, $phone));\n $payload = $this->payloadBuilder->generatePhoneSearchPayload($nationalPhone);\n\n $this->logger->info('[HubSpot] Phone match national number search triggered', [\n 'phone' => $phone,\n 'nationalPhone' => $nationalPhone,\n 'payload' => $payload,\n ]);\n\n $response = $this->handlePhoneSearchRequest($phone, $payload);\n }\n\n if (! $this->hasResults($response)) {\n $payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone, true);\n\n $this->logger->info('[HubSpot] Phone match alternative search triggered', [\n 'phone' => $phone,\n 'normalizedPhone' => $normalizedPhone,\n 'payload' => $payload,\n ]);\n\n $response = $this->handlePhoneSearchRequest($phone, $payload);\n }\n\n return $this->hasResults($response) ? $response : [];\n }\n\n private function handlePhoneSearchRequest(string $phone, array $payload): array\n {\n try {\n return $this->client->search('contacts', $payload);\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Phone match failed', [\n 'phone' => $phone,\n 'reason' => $exception->getMessage(),\n ]);\n\n return [];\n }\n }\n\n private function normalizePhoneNumber(string $phone): string\n {\n return ltrim(phone_e164(null, $phone), '+0');\n }\n\n /**\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n public function matchByName(string $name, ?int $userId = null): ?array\n {\n // Don't waste time searching for single character strings.\n if (\\strlen($name) <= 1) {\n return null;\n }\n\n $cacheKey = $this->getCacheKey($name, $userId);\n\n $result = Cache::remember($cacheKey, 60, function () use ($name, $userId) {\n $payload = $this->payloadBuilder->generateSearchContactsByNamePayload(\n $name,\n $this->getContactFields()\n );\n\n $hsContacts = $this->client->getPaginatedData($payload, 'contact');\n if (empty($hsContacts['results'])) {\n return false;\n }\n\n $contact = $this->importContact($hsContacts['results'][0]);\n if ($contact === null) {\n return false;\n }\n\n $account = $contact->account;\n $countryCode = $contact->country_code ?? $account->country_code ?? null;\n\n try {\n $hsOpportunities = $this->findOpportunities(\n $account ? $account->crm_provider_id : null,\n $contact->crm_provider_id,\n $userId\n );\n } catch (Exception $e) {\n $hsOpportunities = [];\n }\n\n $opportunity = null;\n $stage = null;\n if (! empty($hsOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n });\n\n return is_array($result) ? $result : null;\n }\n\n\n private function convertActivityAssociations(Activity $activity): array\n {\n return [\n 'contactIds' => $this->getParticipantsIds($activity),\n 'companyIds' => $activity->hasAccount() ? [$activity->account->crm_provider_id] : [],\n 'dealIds' => $activity->hasOpportunity() ? [$activity->opportunity->crm_provider_id] : [],\n 'ownerIds' => [],\n ];\n }\n\n private function getParticipantsIds(Activity $activity): array\n {\n $attendees = [];\n\n $participantRepository = app(ParticipantRepository::class);\n $participants = $participantRepository->getParticipantsWhoEnteredMeeting($activity);\n foreach ($participants as $participant) {\n if ($participant->user_id || $participant->isCoach()) {\n continue;\n }\n\n $contact = $participant->contact()->first();\n if ($contact && $contact->crm_provider_id) {\n $attendees[] = $contact->crm_provider_id;\n } else {\n if (! empty($participant->name)) {\n $attendeeData = $this->fetchMissingAttendeeInfo($participant);\n }\n if (! empty($attendeeData['id'])) {\n $attendees[] = $attendeeData['id'];\n }\n }\n }\n\n if ($activity->hasContact()) {\n $attendees[] = $activity->contact->crm_provider_id;\n }\n\n return array_unique($attendees);\n }\n\n private function fetchMissingAttendeeInfo(Participant $participant): array\n {\n // Check if we need to look inside an account context.\n $activity = $participant->getActivity();\n $companyId = $activity->hasAccount() ? $activity->getAccount()->crm_provider_id : null;\n\n // First check the local data.\n /** @var Contact[] $contacts */\n $contacts = $this->team->contacts()\n ->with('account')\n ->where('name', $participant->name)\n ->whereNotNull('email')\n ->get();\n\n foreach ($contacts as $contact) {\n // If we have a company in scope, check the contact is associated to it.\n if (\n $companyId !== null\n && ($contact->account_id === null || $companyId !== $contact->account->crm_provider_id)\n ) {\n continue;\n }\n\n return [\n 'id' => $contact->crm_provider_id,\n 'email' => $contact->email,\n ];\n }\n\n $payload = $this->generateNameSearchPayload($participant->name, 0, 20);\n\n try {\n $response = $this->client->getNewInstance()->crm()->contacts()->searchApi()->doSearch($payload);\n\n // TODO add some logic to choose the most suitable contact if multiple\n foreach ($response['results'] as $object) {\n $properties = $object['properties'];\n if (empty($object['properties']) === false) {\n // Check the company matches the contact.\n // Todo: Move this check inside the API search.\n if ($companyId !== null && $companyId !== $properties['associatedcompanyid']) {\n continue;\n }\n\n return [\n 'id' => $object['id'],\n 'email' => $properties['email'],\n ];\n }\n }\n } catch (Exception $e) {\n $this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [\n 'teamId' => $this->team->id_string,\n 'request' => $payload,\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return [];\n }\n\n /**\n * Store transcripts as note engagement.\n *\n * @throws Exception\n */\n public function createTranscriptNotes(Activity $activity): void\n {\n // For HS no need to check if Crm profile - Log Notes field is enabled\n // We only check if store_transcript toggle is enabled on crm profile.\n $engagement = [\n 'active' => true,\n 'ownerId' => $this->profile->crm_provider_id,\n 'timestamp' => $activity->created_at->tz($activity->user->timezone)->getTimestamp() * 1000,\n 'type' => 'NOTE',\n ];\n\n // Generate activity transcription.\n $transcriptionData = $this->generateTranscription($activity);\n\n // Truncate Notes with max notes length because transcription text could be very long.\n $transcripts = mb_strimwidth($transcriptionData, 0, static::ENGAGEMENT_BODY_MAX_LENGTH);\n\n $metadata = [\n 'body' => $transcripts,\n ];\n\n $associations = $this->convertActivityAssociations($activity);\n\n try {\n $hsEngagement = $this->client\n ->getInstance()\n ->engagements()\n ->create($engagement, $associations, $metadata);\n\n $this->logCrmEngagementManipulation(self::ACTION_CREATE, $engagement, $metadata, $associations);\n\n $noteId = $hsEngagement->data->engagement->id;\n\n // Store crm logged id in transcription.\n $transcription = $activity->getTranscription();\n $transcription->crm_activity_id = $noteId;\n $transcription->save();\n } catch (Exception $e) {\n Sentry::captureException($e);\n }\n }\n\n /*\n * @inheritdoc\n */\n public function updateRecord(string $objectType, string $objectId, array $data, array $headers = []): void\n {\n $payload = [\n 'properties' => $data,\n ];\n\n try {\n switch ($objectType) {\n case FieldData::OBJECT_OPPORTUNITY:\n $this->client->getNewInstance()->crm()->deals()->basicApi()->update($objectId, $payload);\n\n break;\n case FieldData::OBJECT_CONTACT:\n $this->client->getNewInstance()->crm()->contacts()->basicApi()->update($objectId, $payload);\n\n break;\n\n case FieldData::OBJECT_ACCOUNT:\n $this->client->getNewInstance()->crm()->companies()->basicApi()->update($objectId, $payload);\n\n break;\n\n case FieldData::OBJECT_TASK:\n // Endpoint for Engagements not ready\n $engagements = [\n 'type' => 'TASK',\n ];\n $metadata = $data;\n $this->client->getInstance()->engagements()->update($objectId, $engagements, $metadata);\n\n $this->logCrmEngagementManipulation(\n self::ACTION_UPDATE,\n ['crmId' => $objectId],\n $metadata,\n );\n\n break;\n\n default:\n throw new UnexpectedValueException('Unsupported object type \"' . $objectType . '\"');\n }\n } catch (\\HubSpot\\Client\\Crm\\Deals\\ApiException $apiException) {\n $errorMessage = $apiException->getMessage();\n if ($apiException->getResponseBody()) {\n $responseBody = json_decode($apiException->getResponseBody(), true, 512, JSON_THROW_ON_ERROR);\n $errorMessage = $responseBody['message'] ?? $apiException->getMessage();\n }\n\n $this->logger->error(\n '[HubSpot] Update record failed',\n [\n 'objectType' => $objectType,\n 'objectId' => $objectId,\n 'payload' => $payload,\n 'reason' => $errorMessage,\n 'team' => $this->team->getUuid(),\n ]\n );\n\n throw new CrmException($errorMessage);\n }\n }\n\n /*\n * @inheritdoc\n */\n public function getRecord(string $objectType, string $objectId, array $fields = []): array\n {\n switch ($objectType) {\n case FieldData::OBJECT_OPPORTUNITY:\n return $this->client->getInstance()->deals()->getById($objectId)->toArray();\n\n case FieldData::OBJECT_CONTACT:\n return $this->client->getInstance()->contacts()->getById($objectId)->toArray();\n\n case FieldData::OBJECT_ACCOUNT:\n return $this->client->getInstance()->companies()->getById($objectId)->toArray();\n\n case FieldData::OBJECT_TASK:\n return $this->client->getInstance()->engagements()->get($objectId)->toArray();\n\n default:\n throw new UnexpectedValueException('Unsupported object type \"' . $objectType . '\"');\n }\n }\n\n /**\n * @throws BadRequest\n * @throws CrmException\n */\n public function updateStage($crmObject, Stage $stage): void\n {\n $payload = [\n 'properties' => [\n [\n 'name' => 'dealstage',\n 'value' => $stage->crm_provider_id,\n ],\n ],\n ];\n\n try {\n $this->client->getInstance()->deals()->update($crmObject->crm_provider_id, $payload);\n } catch (BadRequest $badRequest) {\n if ($badRequest->getCode() === 403) {\n throw new CrmException(\n \"Sorry, you don't have permission to update this stage.\",\n $badRequest->getCode(),\n $badRequest,\n );\n }\n\n $this->logger->warning('[HubSpot] Stage update failed', [\n 'dealId' => $crmObject->crm_provider_id,\n 'payload' => $payload,\n 'message' => $badRequest->getMessage(),\n ]);\n\n throw $badRequest;\n }\n }\n\n public function generateProviderUrl(string $providerId, string $objectType): ?string\n {\n $url = null;\n $baseUrl = 'https://app.hubspot.com/contacts/' . $this->config->crm_provider_id . '/';\n\n switch ($objectType) {\n case 'account':\n $url = $baseUrl . 'company/' . $providerId;\n\n break;\n\n case 'contact':\n $url = $baseUrl . 'contact/' . $providerId;\n\n break;\n\n case 'opportunity':\n $url = $baseUrl . 'deal/' . $providerId;\n\n break;\n\n case 'task':\n case 'activity':\n return null;\n\n // This should not be deep-linked as per JMNY-3934.\n //$url = $baseUrl.'tasks/list/view/all/?taskId='.$providerId;\n break;\n }\n\n return $url;\n }\n\n public function searchCalls(Carbon $from, Carbon $to, string $activityProvider): array\n {\n $this->logger->info('[HubSpot] Search calls', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n ]);\n\n $calls = [];\n $page = 1;\n\n do {\n try {\n $payload = $this->payloadBuilder->generateGetCallsPayload($from, $to, $activityProvider, $page);\n $response = $this->client->search('calls', $payload);\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Search calls failed', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n 'reason' => $exception->getMessage(),\n ]);\n $response = null;\n }\n\n $responseResults = empty($response['results']) ? [] : $response['results'];\n $calls = array_merge($calls, $responseResults);\n $page++;\n } while (! empty($responseResults));\n\n return $calls;\n }\n\n public function searchCallsForPeriodByPage(Carbon $from, Carbon $to, int $page, bool $retry = true)\n {\n try {\n $payload = $this->payloadBuilder->generateSearchCallsByPeriodPayload($from, $to, $page);\n\n return $this->client->search('calls', $payload);\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Search calls for period failed', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n 'reason' => $exception->getMessage(),\n 'retry' => $retry,\n ]);\n\n if ($retry) {\n sleep(1);\n\n return $this->searchCallsForPeriodByPage($from, $to, $page, false);\n }\n\n return null;\n }\n }\n\n public function searchCallsForPeriod(Carbon $from, Carbon $to): Generator\n {\n $this->logger->info('[HubSpot] Search calls for period', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n ]);\n\n $page = 1;\n\n do {\n $response = $this->searchCallsForPeriodByPage($from, $to, $page);\n\n $responseResults = empty($response['results']) ? [] : $response['results'];\n\n $associationContacts = $this->getAssociationDataForCollection($responseResults, 'calls', 'contacts');\n $associationCompanies = $this->getAssociationDataForCollection($responseResults, 'calls', 'companies');\n $associationDeals = $this->getAssociationDataForCollection($responseResults, 'calls', 'deals');\n\n foreach ($responseResults as $call) {\n $call['associations'] = [\n 'contacts' => $this->importAssociationData($call, $associationContacts),\n 'companies' => $this->importAssociationData($call, $associationCompanies),\n 'deals' => $this->importAssociationData($call, $associationDeals),\n ];\n\n yield $call;\n }\n $page++;\n } while (! empty($responseResults));\n }\n\n public function getCall(string $callId): array\n {\n $this->logger->info('[HubSpot] Get call', [\n 'call_id' => $callId,\n ]);\n\n $searchAttributes = $this->payloadBuilder->getSearchCallAttributes();\n $endpoint = sprintf(\n 'https://api.hubapi.com/crm/v3/objects/calls/%s',\n $callId,\n );\n\n try {\n $response = $this->client->getInstance()->getClient()->request(\n 'GET',\n $endpoint,\n [],\n sprintf(\n 'properties=%s&associations=contacts,companies,deals',\n implode(',', $searchAttributes),\n ),\n );\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Get call failed', [\n 'call_id' => $callId,\n 'reason' => $exception->getMessage(),\n ]);\n $response = null;\n }\n\n return empty($response) ? [] : $response->toArray();\n }\n\n public function bulkAddPlaybackURLToDescription(array $crmUpdateData): array\n {\n $crmUpdateBatches = array_chunk($crmUpdateData, self::BATCH_UPDATE_LIMIT);\n\n $updatedCrmIds = [];\n\n foreach ($crmUpdateBatches as $crmBatch) {\n $payload = $this->payloadBuilder->generatePlaybackAddUrlBatchPayload($crmBatch);\n $updateSuccess = $this->bulkAddPlaybackURLToDescriptionRequest($payload);\n if ($updateSuccess) {\n $updatedCrmIds = array_merge($updatedCrmIds, array_column($crmBatch, 'crm_id'));\n }\n }\n\n return $updatedCrmIds;\n }\n\n private function bulkAddPlaybackURLToDescriptionRequest(array $payload, bool $retry = true): bool\n {\n try {\n $this->client->getNewInstance()->crm()->objects()->batchApi()->update('calls', $payload);\n\n return true;\n } catch (\\HubSpot\\Client\\Crm\\Objects\\ApiException $e) {\n $response = json_decode($e->getResponseBody(), true);\n $retryAfter =\n isset($response['policyName'])\n && $response['policyName'] == self::TEN_SECONDLY_ROLLING_POLICY\n ? self::TEN_SECONDLY_ROLLING_LIMIT\n : 1;\n } catch (Exception $e) {\n $retryAfter = 1;\n }\n\n $this->logger->warning('[HubSpot] Bulk add playback url to CRM failed', [\n 'reason' => $e->getMessage(),\n 'retry' => $retry,\n ]);\n\n if ($retry) {\n sleep($retryAfter);\n\n return $this->bulkAddPlaybackURLToDescriptionRequest($payload, false);\n }\n\n return false;\n }\n\n /**\n * Sometimes we have secondly rate limit error, then retry request after 1 second\n */\n public function searchCallByRecordingURLToken(string $playbackURLToken, bool $retry = true): array\n {\n $payload = $this->payloadBuilder->generateSearchCallByTokenPayload($playbackURLToken);\n\n $this->logger->info('[HubSpot] CRM Search by playback URL token requested', [\n 'request' => $payload,\n ]);\n\n try {\n $response = $this->client->search('calls', $payload);\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Search by playback URL token failed', [\n 'playbackURLToken' => $playbackURLToken,\n 'reason' => $exception->getMessage(),\n 'retry' => $retry,\n ]);\n\n if ($retry) {\n sleep(1);\n\n return $this->searchCallByRecordingURLToken($playbackURLToken, false);\n }\n\n return [];\n }\n\n return empty($response['results']) ? [] : $response['results'][0];\n }\n\n /**\n * Generate transcription for the activity.\n */\n private function generateTranscription(Activity $activity): string\n {\n if (! $this->config->store_transcript) {\n // If sending transcription to activity toggle is disabled\n return '';\n }\n\n $transcriptionSegments = $this->transcriptionService->findTranscriptionByActivity($activity);\n\n if ($transcriptionSegments->isEmpty()) {\n return '';\n }\n\n $transcription = sprintf(\n '<p><strong>Transcript for %s</strong></p><p></p>',\n $activity->title ?? $activity->activity_title,\n );\n\n $roomOwnerParticipant = $activity->findParticipantRoomOwner();\n $roomOwnerParticipantId = $roomOwnerParticipant !== null\n ? $roomOwnerParticipant->getId()\n : null;\n\n\n $transcription .= $transcriptionSegments\n ->map(static function (array $transcriptionSegment) use ($roomOwnerParticipantId): string {\n $isOrganiser = $roomOwnerParticipantId === $transcriptionSegment['participantId']\n && $roomOwnerParticipantId !== null;\n $transcriptColor = $isOrganiser ? '#000000' : '#f0415a';\n\n return sprintf(\n '<span style=\"color: %s;\">%s | </span>%s',\n $transcriptColor,\n $transcriptionSegment['formattedStartsAt'],\n $transcriptionSegment['transcript'],\n );\n })\n ->implode('<br />');\n\n return $transcription;\n }\n\n /**\n * @param array<array{\n * id: string,\n * label: string,\n * value?: string,\n * }> $options\n *\n * @return FieldData[]\n */\n private function importOptions(Field $field, array $options): array\n {\n $fieldValues = [];\n $values = [];\n $sequence = 0;\n\n foreach ($options as $option) {\n $values[] = [\n 'value' => $option['value'] ?? $option['id'],\n 'label' => substr($option['label'], 0, 255),\n 'sequence' => $sequence++,\n ];\n }\n\n $fieldsToPurge = $field->values()->get()->pluck('value')->toArray();\n\n foreach ($values as $value) {\n $value['value'] = substr($value['value'], 0, 255);\n $fieldValues[] = $field->values()->updateOrCreate([\n 'value' => $value['value'],\n ], $value);\n\n // Remove this value from the ones we are going to purge.\n if (($key = array_search($value['value'], $fieldsToPurge, false)) !== false) {\n unset($fieldsToPurge[$key]);\n }\n }\n\n // Delete the old values that are no longer used.\n $field->values()->whereIn('value', $fieldsToPurge)->delete();\n\n return $fieldValues;\n }\n\n public function saveTranscriptionSummaryAsNote(\n ActivityContract $activity,\n string $title,\n string $body,\n ?string $objectId,\n ?NoteObject $noteObject = null,\n ): ?string {\n if ($noteObject === null || $objectId === null) {\n return null;\n }\n\n /** @var User $user */\n $user = $activity->getUser();\n\n $profile = $this->assignCrmOwner($user, $activity);\n if (! $profile instanceof Profile) {\n return null;\n }\n\n $timestamp = Carbon::now($user->getTimezone())->getTimestamp() * 1000;\n $engagement = [\n 'active' => true,\n 'ownerId' => $profile->getAttribute('crm_provider_id'),\n 'timestamp' => $timestamp,\n 'type' => 'NOTE',\n ];\n\n // Truncate Notes with max notes length because transcription text could be very long.\n $body = mb_strimwidth($body, 0, self::ENGAGEMENT_BODY_MAX_LENGTH);\n $metadata = [\n 'body' => $body,\n ];\n\n $associations = $this->convertActivityAssociations($activity);\n\n try {\n $hsActivityId = $this->client->createNote(\n body: $body,\n ownerId: $profile->getCrmProviderId(),\n timestamp: $timestamp,\n objectId: $objectId,\n noteObject: $noteObject,\n );\n\n $this->logCrmEngagementManipulation(self::ACTION_CREATE, $engagement, $metadata, $associations);\n\n $this->logger->info('[HubSpot] Saving Transcription Summary as Note', [\n 'activity' => $activity->getUuid(),\n 'crmActivity' => $hsActivityId,\n ]);\n\n return $hsActivityId;\n } catch (Exception $e) {\n Sentry::captureException($e);\n }\n\n return null;\n }\n\n public function attachSummaryToActivity(ActivityContract $activity, string $summaryTitle, string $summaryContents): bool\n {\n $this->logger->info('[HubSpot] Attaching summary to activity', [\n 'activity' => $activity->getUuid(),\n 'summary_content' => $summaryContents,\n ]);\n\n if (! $activity instanceof Activity) {\n throw new InvalidArgumentException('Expected instance of Activity');\n }\n\n $summary = '<p><strong>' . $summaryTitle . '</strong></p>';\n $summary .= '<p>' . $summaryContents . '</p>';\n $metadata = $this->buildMetadataForSummaryUpdate($activity, $summary);\n\n try {\n $type = $this->matchActivityEngagementType($activity);\n $engagement = ['type' => $type];\n\n $this->client->updateEngagement($activity->getCrmProviderId(), $engagement, $metadata);\n } catch (Exception $e) {\n $this->logger->warning('[HubSpot] Update summary failed', [\n 'activity' => $activity->getUuid(),\n 'reason' => $e->getMessage(),\n ]);\n\n return false;\n }\n\n $this->logCrmEngagementManipulation(\n self::ACTION_UPDATE,\n ['crmId' => $activity->getCrmProviderId()],\n $metadata,\n );\n\n return true;\n }\n\n private function buildMetadataForSummaryUpdate(Activity $activity, string $summary): array\n {\n $descriptionField = $activity->getType() === Activity::TYPE_CONFERENCE ? 'internalMeetingNotes' : 'body';\n $engagement = $this->client->getEngagementData($activity->getCrmProviderId());\n // Meeting without internalMeetingNotes might mean it just does not have any notes;\n $description = $engagement['metadata'][$descriptionField] ?? null;\n\n if (empty($description)) {\n $data = $summary;\n } else {\n // avoid playbook url link to Jiminny being sent twice in the activity description\n $targetUrl = PlaybackUrlBuilder::build($activity);\n\n if (str_contains($description, $targetUrl)) {\n $jiminnyUrl = '<p><a href=\"' . $targetUrl . '\" title=\"Play at Jiminny\">Play at Jiminny</a></p>';\n $summary = str_replace($jiminnyUrl, '', $summary);\n\n $this->logger->info('[HubSpot] Summary modified', [\n 'activity' => $activity->getUuid(),\n 'target_url' => $jiminnyUrl,\n 'modified_summary_content' => $summary,\n ]);\n }\n\n $data = $description . '<p></p>' . $summary;\n }\n\n return [\n $descriptionField => $data,\n ];\n }\n\n public function fetchAndAssociateRelatedActivity(Activity $activity): ?Activity\n {\n return $this->syncRelatedActivityManager->fetchAndAssociateRelatedActivity($activity);\n }\n\n public function fetchRelatedActivity(Activity $activity): array\n {\n return [];\n }\n\n public function getDealsInBulk(array $dealIds): array\n {\n $payload = $this->payloadBuilder->getDealsInBulkPayload($dealIds);\n\n return $this->client->getPaginatedData($payload, 'deals');\n }\n\n /**\n * Extract deal IDs from HubSpot search response.\n *\n * @param array $hubspotResponse The raw HubSpot search API response.\n * @param bool $includeArchived Whether to include archived deals (default: false).\n *\n * @return string[] Array of deal IDs as strings.\n */\n public function extractDealIds(array $hubspotResponse, bool $includeArchived = false): array\n {\n if (empty($hubspotResponse['results'])) {\n return [];\n }\n\n return array_values(\n array_map(\n fn ($deal) => $deal['id'],\n array_filter(\n $hubspotResponse['results'],\n fn ($deal) => $includeArchived || empty($deal['archived'])\n )\n )\n );\n }\n\n public function matchActivityEngagementType(Activity $activity): string\n {\n return match ($activity->getType()) {\n Activity::TYPE_CONFERENCE => self::TYPE_MEETING,\n Activity::TYPE_SOFTPHONE, Activity::TYPE_SOFTPHONE_INBOUND => self::TYPE_CALL,\n default => self::TYPE_NOTE,\n };\n }\n\n private function assignCrmOwner(User $user, ActivityContract $activity): ?Profile\n {\n $profile = $user->getProfile();\n if ($profile instanceof Profile) {\n return $profile;\n }\n\n $this->logger->info('[HubSpot] Unable to save summary. No profile', [\n 'activity' => $activity->getUuid(),\n ]);\n\n return null;\n }\n\n private static function getDealsPipelinesEndpoint(): string\n {\n return self::API_URL . self::ENDPOINT_PIPELINES . self::PIPELINE_OBJECT_TYPE_DEALS;\n }\n\n public function verifyTaskExists(Activity $activity): bool\n {\n $crmProviderId = $activity->getCrmProviderId();\n $cacheKey = \"crm_task_exists:{$this->config->getId()}:$crmProviderId\";\n\n return Cache::remember($cacheKey, self::TASK_VERIFICATION_CACHE_TTL, function () use ($crmProviderId) {\n try {\n $engagement = $this->client->getEngagementData($crmProviderId);\n\n return ! empty($engagement);\n } catch (HttpNotFoundException|BadRequest) {\n // Engagement not found in CRM - this is expected and permanent\n $this->logger->info('[Hubspot] Engagement not found during verification', [\n 'engagement_id' => $crmProviderId,\n 'config_id' => $this->config->getId(),\n ]);\n\n return false;\n }\n // Let other exceptions (network errors, rate limits, etc.) bubble up for retry\n });\n }\n}","depth":4,"on_screen":true,"value":"<?php\n\nnamespace Jiminny\\Services\\Crm\\Hubspot;\n\nuse Carbon\\Carbon;\nuse Exception;\nuse Generator;\nuse GuzzleHttp\\Exception\\RequestException;\nuse Illuminate\\Support\\Facades\\Cache;\nuse InvalidArgumentException;\nuse Jiminny\\Contracts\\Repositories\\TeamRepository;\nuse Jiminny\\Contracts\\Services\\Crm\\ClientInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\FetchRelatedActivityInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\LayoutManagementInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\MatchCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\Provider\\HubspotInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityLookupInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityManipulationInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SavePlaybackLinkToCrmInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SendSummaryToCrmInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SettingsInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmMetadataInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\VerifyTaskExistsInterface;\nuse Jiminny\\Exceptions\\CrmException;\nuse Jiminny\\Exceptions\\HttpNotFoundException;\nuse Jiminny\\Jobs\\Crm\\NoteObject;\nuse Jiminny\\Models\\Account;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Contact;\nuse Jiminny\\Models\\Contracts\\ActivityContract;\nuse Jiminny\\Models\\Crm\\BusinessProcess;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Models\\Crm\\FieldData;\nuse Jiminny\\Models\\Crm\\Layout;\nuse Jiminny\\Models\\Crm\\Profile;\nuse Jiminny\\Models\\Lead;\nuse Jiminny\\Models\\Opportunity;\nuse Jiminny\\Models\\Participant;\nuse Jiminny\\Models\\Playbook;\nuse Jiminny\\Models\\SocialAccount;\nuse Jiminny\\Models\\Stage;\nuse Jiminny\\Models\\User;\nuse Jiminny\\Repositories\\Crm\\CrmEntityRepository;\nuse Jiminny\\Repositories\\Crm\\FieldRepository;\nuse Jiminny\\Repositories\\Crm\\ProfileRepository;\nuse Jiminny\\Repositories\\ParticipantRepository;\nuse Jiminny\\Services\\Avatar\\ProspectPhotoPathService;\nuse Jiminny\\Services\\Crm\\BaseService;\nuse Jiminny\\Services\\Crm\\Hubspot\\Actions\\SyncArchivedProfilesAction;\nuse Jiminny\\Services\\Crm\\Hubspot\\Fields\\ValueNormalizer;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\OpportunitySyncTrait;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\SyncCrmEntitiesTrait;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\SyncFieldsTrait;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\WriteCrmTrait;\nuse Jiminny\\Services\\Crm\\MatchDomainByEmailInterface;\nuse Jiminny\\Services\\Crm\\OpportunitySyncStrategyResolver;\nuse Jiminny\\Services\\Crm\\ResolveCompanyNameByEmailTrait;\nuse Jiminny\\Utils\\PlaybackUrlBuilder;\nuse Sentry;\nuse SevenShores\\Hubspot\\Exceptions\\BadRequest;\nuse Throwable;\nuse UnexpectedValueException;\n\n/**\n * @phpstan-type CrmFieldDefinition array{\n * name: string,\n * label: string,\n * description: string,\n * type: string,\n * fieldType: string,\n * hidden: bool,\n * showCurrencySymbol: bool,\n * options: array<array{\n * id: string,\n * label: string,\n * value?: string,\n * }\n */\nclass Service extends BaseService implements\n HubspotInterface,\n SyncCrmEntitiesInterface,\n SyncCrmMetadataInterface,\n SendSummaryToCrmInterface,\n MatchDomainByEmailInterface,\n SavePlaybackLinkToCrmInterface,\n RemoteEntityManipulationInterface,\n FetchRelatedActivityInterface,\n LayoutManagementInterface,\n SettingsInterface,\n MatchCrmEntitiesInterface,\n RemoteEntityLookupInterface,\n VerifyTaskExistsInterface\n{\n use ResolveCompanyNameByEmailTrait;\n use SyncCrmEntitiesTrait;\n use WriteCrmTrait;\n use SyncFieldsTrait;\n use OpportunitySyncTrait;\n\n private const int ENGAGEMENT_BODY_MAX_LENGTH = 65536;\n\n private const string LOG_DATE_FORMAT = 'Y-m-d H:i:s';\n private const int BATCH_UPDATE_LIMIT = 100;\n private const string TEN_SECONDLY_ROLLING_POLICY = 'TEN_SECONDLY_ROLLING';\n private const int TEN_SECONDLY_ROLLING_LIMIT = 10;\n\n private const string TYPE_NOTE = 'NOTE';\n\n private const string TYPE_MEETING = 'MEETING';\n\n private const string TYPE_CALL = 'CALL';\n\n private const string API_URL = 'https://api.hubapi.com';\n\n // NB: v1 is legacy - v3 is the newest\n private const string ENDPOINT_PIPELINES = '/crm-pipelines/v1/pipelines/';\n private const string PIPELINE_OBJECT_TYPE_DEALS = 'deals';\n\n private const int TASK_VERIFICATION_CACHE_TTL = 86400; // 1 day\n\n /**\n * @var ClientInterface|Client\n */\n protected $client;\n protected OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;\n protected CrmEntityRepository $crmEntityRepository;\n protected ProspectPhotoPathService $prospectPhotoPathService;\n\n private SyncFieldAction $syncFieldAction;\n private PayloadBuilder $payloadBuilder;\n private SyncRelatedActivityManager $syncRelatedActivityManager;\n private SyncArchivedProfilesAction $syncArchivedProfilesAction;\n private WebhookSyncBatchProcessor $batchProcessor;\n\n public function __construct(\n Client $client,\n SyncFieldAction $syncFieldAction,\n PayloadBuilder $payloadBuilder,\n ProspectPhotoPathService $prospectPhotoPathService,\n SyncArchivedProfilesAction $syncArchivedProfilesAction,\n WebhookSyncBatchProcessor $batchProcessor,\n ) {\n parent::__construct();\n\n $this->client = $client;\n $this->syncFieldAction = $syncFieldAction;\n $this->prospectPhotoPathService = $prospectPhotoPathService;\n $this->payloadBuilder = $payloadBuilder;\n $this->syncArchivedProfilesAction = $syncArchivedProfilesAction;\n $this->batchProcessor = $batchProcessor;\n $this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [\n 'client' => $this->client,\n ]);\n $this->syncRelatedActivityManager = app(SyncRelatedActivityManager::class, [\n 'client' => $this->client,\n 'payloadBuilder' => $this->payloadBuilder,\n 'logger' => $this->logger,\n ]);\n $this->crmEntityRepository = app(CrmEntityRepository::class);\n $this->dealFieldsService = app(DealFieldsService::class);\n }\n\n public function getDisplayName(): string\n {\n return 'HubSpot';\n }\n\n protected function getOAuthAccount(User $user): ?SocialAccount\n {\n // In this case, the Account Owner is always the connection for any API operations.\n $owner = $user->team->owner;\n\n return $owner->getSocialAccount(SocialAccount::PROVIDER_HUBSPOT);\n }\n\n public function getClient(): Client\n {\n /** @var Client */\n return $this->client;\n }\n\n /**\n * Convert raw field data into a format compatible with CRM APIs.\n *\n * @param bool $internal Direction of the conversion.\n * True is pulling from CRM, false normalize before sending to CRM.\n */\n public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string\n {\n return ValueNormalizer::normalize(\n fieldType: $fieldType,\n fieldValue: $fieldValue,\n isInbound: $internal,\n );\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultFields(string $activityType): array\n {\n $fields = [];\n\n if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {\n $defaultFields = FieldDefinitions::defaultTaskFields();\n\n // This lazy creates these fields if not already setup.\n foreach ($defaultFields as $defaultField) {\n $fields[] = $this->config->fields()->firstOrCreate($defaultField);\n }\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultActivityField(string $activityType): Field\n {\n /** @var Field $activityField */\n $activityField = $this->config->fields()->where([\n 'crm_provider_id' => 'activityType',\n 'object_type' => $activityType,\n ])->first();\n\n return $activityField;\n }\n\n /**\n * @inheritdoc\n */\n public function getSupportedPlaybookTypes(): array\n {\n return [Playbook::ACTIVITY_TYPE_TASK];\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultActivityLayoutFields(string $activityType, string $layoutType): array\n {\n $fields = [];\n\n if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {\n // Outcome should always be provided calls/meetings.\n $fieldData = [\n [\n 'crm_provider_id' => $layoutType === Layout::TYPE_SOFTPHONE_SUMMARY ? 'disposition' : 'meetingOutcome',\n 'object_type' => Field::OBJECT_TASK,\n ],\n ];\n\n foreach ($fieldData as $data) {\n $field = $this->config->fields()->where($data)->first();\n\n // Only add the field if it is created, which it should be.\n if ($field) {\n $fields[] = $field;\n }\n }\n }\n\n return $fields;\n }\n\n public function getDealInsightsFields(): array\n {\n return FieldDefinitions::dealInsightsFields();\n }\n\n protected function getDefaultFollowupLayoutFields(string $activityType): array\n {\n $fields = [];\n $fieldRepo = app(FieldRepository::class);\n $fieldData = FieldDefinitions::followupFieldsFilter();\n\n foreach ($fieldData as $data) {\n $field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $data);\n\n // Only add the field if it is created, which it should be.\n if ($field) {\n $fields[] = $field;\n }\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function syncField(Field $field): void\n {\n switch ($field->object_type) {\n case Field::OBJECT_ACCOUNT:\n $crmField = $this->client->getInstance()->companyProperties()->get($field->crm_provider_id);\n\n break;\n case Field::OBJECT_CONTACT:\n $crmField = $this->client->getInstance()->contactProperties()->get($field->crm_provider_id);\n\n break;\n case Field::OBJECT_OPPORTUNITY:\n $crmField = $this->client->getInstance()->dealProperties()->get($field->crm_provider_id);\n\n break;\n case Field::OBJECT_TASK:\n $this->syncSingleTaskField($field);\n\n return;\n default:\n return;\n }\n\n $this->syncFieldAction->execute($field, $crmField->toArray());\n }\n\n /**\n * @param array<array{\n * id:string,\n * label:string,\n * value?:string\n * }> $options\n *\n * @throws CrmException\n *\n * @return FieldData[]\n *\n */\n public function importPicklistValues(\n Field $field,\n array $options = [['id' => '', 'label' => '', 'value' => '']],\n ): array {\n if (! empty($options[0]['id']) || ! empty($options[0]['value'])) {\n // We already have the options, no need to fetch them again\n return $this->importOptions($field, $options);\n }\n\n $options = [];\n\n switch ($field->getObjectType()) {\n case Field::OBJECT_ACCOUNT:\n $options = $this->getClient()->fetchPropertyOptions('company', $field->getCrmProviderId());\n\n break;\n\n case Field::OBJECT_CONTACT:\n $options = $this->getClient()->fetchPropertyOptions('contact', $field->getCrmProviderId());\n\n break;\n\n case Field::OBJECT_OPPORTUNITY:\n // Hubspot has different endpoint for stages\n $options = $this->getClient()->fetchOpportunityFieldOptions($field);\n\n break;\n\n case Field::OBJECT_TASK:\n if ($field->getCrmProviderId() === 'disposition') {\n $options = $this->getClient()->fetchDispositionFieldOptions();\n } elseif (in_array($field->getCrmProviderId(), ['meetingOutcome', 'activityType'])) {\n $options = $this->getClient()->fetchMeetingOutcomeFieldOptions($field);\n }\n\n break;\n\n default:\n $this->logger->warning('Invalid object type', [\n 'object_type' => $field->getObjectType(),\n 'field_id' => $field->getId(),\n ]);\n\n throw new CrmException('Invalid object type');\n }\n\n return $this->importOptions($field, $options);\n }\n\n /**\n * @inheritdoc\n */\n public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage\n {\n $missingStage = null;\n\n try {\n // Use the HubSpot API client instead of the SDK crmPipelines() method\n $endpoint = self::getDealsPipelinesEndpoint();\n $pipelinesResponse = $this->client->getInstance()->getClient()->request('GET', $endpoint);\n $pipelines = $pipelinesResponse->data->results;\n } catch (RequestException|BadRequest $exception) {\n throw $exception;\n }\n\n foreach ($pipelines as $pipeline) {\n $stages = [];\n\n // We create a business process to contain the pipeline, and store all stages against it.\n $p = ResponseNormalize::normalizePipeline($pipeline);\n\n // Create/update business process for this pipeline\n $businessProcess = $this->config->businessProcesses()->updateOrCreate([\n 'crm_provider_id' => $p['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($p['label'], 0, 150),\n 'type' => BusinessProcess::TYPE_OPPORTUNITY,\n 'is_selectable' => $p['active'],\n ]);\n\n // A record type is really a clone of the business process, used to store which record uses which pipeline.\n // Create/update record type clone\n $this->config->recordTypes()->updateOrCreate([\n 'crm_provider_id' => $p['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($p['label'], 0, 150),\n 'is_selectable' => $p['active'],\n 'business_process_id' => $businessProcess->id ?? null,\n ]);\n\n // Stages - fetch all existing stages upfront to avoid N+1 queries\n $existingStages = $this->config->stages()\n ->withTrashed()\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->get()\n ->keyBy('crm_provider_id');\n\n foreach ($p['stages'] as $dealStage) {\n $s = ResponseNormalize::normalizeDealStage($dealStage);\n\n /** @var ?Stage $existingStage */\n $existingStage = $existingStages->get($s['id']);\n\n // Restore soft-deleted stages that are now active in HubSpot\n if ($existingStage?->trashed() && $s['active']) {\n $existingStage->restore();\n }\n\n // Upsert stage (updates soft-deleted records without restoring them)\n $stage = $this->config->stages()->withTrashed()->updateOrCreate([\n 'crm_provider_id' => $s['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($s['label'], 0, 50),\n 'label' => mb_strimwidth($s['label'], 0, 191),\n 'type' => Stage::TYPE_OPPORTUNITY,\n 'sequence' => $s['displayOrder'],\n 'is_selectable' => $s['active'],\n 'probability' => $s['probability'] * 100,\n ]);\n\n if ($missingStageName === $s['id']) {\n $missingStage = $stage;\n }\n\n $stages[] = $stage->id;\n }\n\n $businessProcess->stages()->sync($stages);\n }\n\n return $missingStage;\n }\n\n /**\n * @inheritdoc\n */\n public function syncOrganization(): void\n {\n try {\n $endpoint = 'https://api.hubapi.com/integrations/v1/me';\n $response = $this->client->getInstance()->getClient()->request('get', $endpoint);\n\n $accountData = $response->data;\n $this->config->update(['default_currency' => $accountData->currency]);\n } catch (BadRequest $e) {\n throw new CrmException('Could not sync the organization.', $e->getCode(), $e);\n }\n }\n\n /**\n * @inheritdoc\n *\n * @throws CrmException\n */\n public function syncProfiles(?User $userToSearch = null): ?Profile\n {\n $this->syncArchivedProfilesAction->execute($this->team, $this->client, $this->config);\n\n try {\n $owners = $this->client->getOwners();\n } catch (\\HubSpot\\Client\\Crm\\Owners\\ApiException $e) {\n $this->logger->error('[HubSpot] Could not sync the profiles.', [\n 'team_id' => $this->team->getId(),\n 'reason' => $e->getMessage(),\n ]);\n\n throw new CrmException('Could not sync the profiles.', $e->getCode(), $e);\n }\n\n $profileRepository = app(ProfileRepository::class);\n $teamRepository = app(TeamRepository::class);\n\n foreach ($owners as $owner) {\n if ($owner->getArchived()) {\n // not supposed to fetch archived, but log anyway\n $this->logger->warning('[HubSpot] Found archived owner', [\n 'crm_provider_id' => $owner->getId(),\n 'email' => $owner->getEmail(),\n ]);\n\n continue;\n }\n\n $email = $owner->getEmail();\n if ($email === null) {\n continue;\n }\n\n $user = $teamRepository->findActiveTeamMemberByEmail($this->team, $email);\n\n if (! $user instanceof User) {\n continue;\n }\n\n $profile = $profileRepository->updateOrCreateProfile($user, [\n 'crm_configuration_id' => $this->config->getId(),\n 'crm_provider_id' => $owner->getId(),\n ]);\n\n if ($userToSearch && $userToSearch->getId() === $user->getId()) {\n return $profile;\n }\n }\n\n return null;\n }\n\n private function generateNameSearchPayload(string $name, int $offset, int $limit): array\n {\n $payload = [\n 'query' => $name,\n 'sorts' => [\n [\n 'propertyName' => 'modifieddate',\n 'direction' => 'DESCENDING',\n ],\n ],\n 'properties' => [\n 'email',\n 'firstname',\n 'lastname',\n 'country',\n 'phone',\n 'mobilephone',\n 'jobtitle',\n 'hubspot_owner_id',\n 'associatedcompanyid',\n 'photo',\n 'industry',\n 'name',\n 'company',\n ],\n 'limit' => $limit,\n 'after' => $offset,\n ];\n\n $this->logger->debug('[HubSpot] CRM Search requested', [\n 'request' => $payload,\n ]);\n\n return $payload;\n }\n\n /**\n * @inheritdoc\n */\n public function find(string $name, array $scopes): array\n {\n $count = $this->limit ?? 20;\n $offset = $this->offset ?? 0;\n\n /** @var array<int, array<string, mixed>> */\n return Cache::remember(\n key: $this->team->getId() . $name . $count . $offset,\n ttl: 300,\n callback: function () use ($name, $offset, $count): array {\n $data = [];\n\n // Use the new V3 API to find contacts based on additional fields.\n foreach (['companies', 'contacts'] as $objectType) {\n $payload = $this->generateNameSearchPayload($name, $offset, $count);\n $type = $objectType === 'companies' ? 'account' : 'contact';\n\n try {\n $response = $this->client->search($objectType, $payload);\n\n // Build mapped list.\n foreach ($response['results'] as $object) {\n $properties = $object['properties'];\n\n $objectName = $this->buildContactName($properties);\n\n $record = [\n 'crmId' => $object['id'],\n // Pass crmUrl to the FE, needed for success message in the extension when you log activity.\n 'crmUrl' => $this->generateProviderUrl($object['id'], $type),\n 'name' => $objectName,\n 'prospectType' => $type,\n 'phoneNumbers' => [],\n ];\n\n if ($type === 'account') {\n $record['industry'] = $properties['industry'] ?? null;\n } else {\n $record['title'] = $properties['jobtitle'] ?? null;\n $record['organization'] = $properties['company'] ?? null;\n }\n\n $countryCode = $this->buildContactCountry($properties);\n $parsedNumber = $this->buildContactPhone($countryCode, $properties);\n\n // Add phone number to record.\n if (! empty($parsedNumber['phone'])) {\n $record['phoneNumbers'][] = [\n 'number' => $parsedNumber['phone'],\n 'nationalFormat' => phone_national($countryCode, $parsedNumber['phone']),\n 'type' => 'phone',\n ];\n }\n\n // Add mobile phone number to record.\n if (! empty($properties['mobilephone'])) {\n $mobileNumber = phone_e164($countryCode, $properties['mobilephone']);\n if ($mobileNumber !== null) {\n $record['phoneNumbers'][] = [\n 'number' => $mobileNumber,\n 'nationalFormat' => phone_national($countryCode, $mobileNumber),\n 'type' => 'mobile',\n ];\n }\n }\n\n $data[] = $record;\n }\n } catch (BadRequest $e) {\n $this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [\n 'teamId' => $this->team->getUuid(),\n 'request' => $payload,\n 'reason' => $e->getMessage(),\n ]);\n\n throw $e;\n }\n }\n\n return $data;\n },\n );\n }\n\n\n /**\n * @inheritdoc\n */\n public function findOpportunities(?string $crmAccountId, ?string $crmContactId, ?int $userId = null): array\n {\n $data = [];\n $ownerData = [];\n $ownerId = null;\n\n if ($crmAccountId === null) {\n return $data;\n }\n\n if ($userId) {\n $profileRepository = app(ProfileRepository::class);\n $profile = $profileRepository->findProfileByUserId($this->config, $userId);\n\n $ownerId = $profile instanceof Profile ? $profile->getCrmProviderId() : null;\n }\n\n $closedStages = $this->getClosedDealStages();\n $payload = $this->payloadBuilder->generateOpportunitiesSearchPayload(\n $this->config,\n $crmAccountId,\n $closedStages,\n );\n\n $results = $this->client->getPaginatedData($payload, 'deals');\n\n foreach ($results['results'] as $object) {\n $properties = $object['properties'];\n\n $amount = null;\n if (empty($properties['amount']) === false) {\n $currency = $properties['deal_currency_code'] ?? $this->config->default_currency;\n\n // Values can contain commas and any junk so strip them.\n $value = (float) preg_replace('/[^\\d.]/', '', $properties['amount']);\n $amount = formatCurrency($value, $currency);\n }\n\n $businessProcess = $this->config\n ->businessProcesses()\n ->where('crm_provider_id', $properties['pipeline'])\n ->first();\n\n if ($businessProcess === null) {\n // Import it.\n $stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $properties['dealstage']);\n $businessProcess = $this->config\n ->businessProcesses()\n ->where('crm_provider_id', $properties['pipeline'])\n ->first();\n } else {\n $stage = $businessProcess\n ->stages()\n ->where('crm_provider_id', $properties['dealstage'])\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->first();\n\n if ($stage === null) {\n // Import it.\n $stage = $this->importStages(null, $properties['dealstage']);\n }\n }\n\n $recordType = null;\n if ($businessProcess) {\n $recordType = $businessProcess->recordTypes()->first();\n }\n\n $isWon = in_array($properties['dealstage'], $closedStages['won']);\n $isLost = in_array($properties['dealstage'], $closedStages['lost']);\n\n $record = [\n 'crmId' => $object['id'],\n 'name' => $properties['dealname'] ?? 'Unknown Deal',\n 'value' => $amount,\n 'won' => $isWon,\n 'closed' => $isWon || $isLost,\n 'stage' => [\n 'id' => $stage?->getUuid() ?? '',\n 'name' => $stage?->getName() ?? '',\n ],\n ];\n\n if ($recordType) {\n $record += [\n 'recordType' => [\n 'id' => $recordType->id_string,\n 'name' => $recordType->name,\n ],\n ];\n }\n\n if ($ownerId && isset($properties['hubspot_owner_id']) && $properties['hubspot_owner_id'] === $ownerId) {\n $ownerData[] = $record;\n }\n\n $data[] = $record;\n }\n\n if (! empty($ownerData)) {\n return $ownerData;\n }\n\n return $data;\n }\n\n /**\n * @inheritdoc\n */\n public function getTasks(?string $objectType, string $objectId, ?string $opportunityId): array\n {\n $data = [];\n switch ($objectType) {\n case 'contact':\n $hsObject = 'contact';\n\n break;\n case 'account':\n $hsObject = 'company';\n\n break;\n default:\n // This is a hack to prioritise and override a contact/company with a deal.\n if ($opportunityId) {\n $hsObject = 'deal';\n $objectId = $opportunityId;\n } else {\n throw new InvalidArgumentException('Object type not supported.');\n }\n }\n\n $engagementTypes = ['meetings', 'tasks'];\n\n foreach ($engagementTypes as $engagementType) {\n $payload = $this->payloadBuilder->getLinkToTaskPayload($hsObject, $objectId, $engagementType);\n\n $this->logger->info('[HubSpot] CRM Search requested', [\n 'request' => $payload,\n ]);\n\n $engagements = $this->client->getPaginatedData($payload, $engagementType);\n\n foreach ($engagements['results'] as $engagement) {\n if ($engagementType == 'meetings') {\n $title = $engagement['properties']['hs_meeting_title'] ?? 'Scheduled meeting';\n } elseif ($engagementType == 'tasks') {\n $title = $engagement['properties']['hs_task_subject'];\n } else {\n $title = 'Scheduled meeting';\n }\n\n $data[] = [\n 'crmId' => $engagement['id'],\n 'subject' => $title,\n 'due' => $engagement['properties']['hs_timestamp'],\n 'type' => $engagement['properties']['hs_activity_type'] ?? null,\n ];\n }\n }\n\n usort($data, function ($item1, $item2) {\n return $item2['due'] <=> $item1['due'];\n });\n\n return $data;\n }\n\n /**\n * Try to find CRM Objects using email address\n *\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n public function matchExactlyByEmail(string $email, ?int $userId = null): ?array\n {\n $contactProperties = [\n 'email',\n 'firstname',\n 'lastname',\n 'country',\n 'phone',\n 'mobilephone',\n 'jobtitle',\n 'hubspot_owner_id',\n 'associatedcompanyid',\n 'photo',\n ];\n $contact = null;\n $account = null;\n\n try {\n $hsContact = $this->getClient()->getContactByEmail($email, $contactProperties);\n\n if ($hsContact) {\n $contact = $this->importContact($hsContact);\n $account = $contact->account;\n }\n\n $data = $this->convertCrmData($contact, $account, $userId);\n\n return ! empty(array_filter($data)) ? $data : null;\n } catch (BadRequest $e) {\n $this->logger->warning('[HubSpot] Search failed', [\n 'team_id' => $this->team->getId(),\n 'search_identifier' => $email,\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return null;\n }\n\n public function getDomain(string $email): ?string\n {\n return $this->getDomainFromEmail($email);\n }\n\n /**\n * Try to find CRM objects using domain name of the email address\n *\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n public function matchByDomain(string $domain, ?int $userId = null): ?array\n {\n $companyName = $domain;\n\n // Try to find a company matching their email domain.\n $companyProperties = [\n 'country',\n 'phone',\n 'name',\n 'hs_avatar_filemanager_key',\n 'industry',\n 'hubspot_owner_id',\n 'domain',\n ];\n\n try {\n $hsAccounts = $this->client\n ->getInstance()\n ->companies()\n ->searchByDomain($companyName, $companyProperties);\n } catch (Throwable $e) {\n $this->logger->info('[HubSpot] Search failed', [\n 'error' => $e->getMessage(),\n 'domain' => $domain,\n ]);\n\n return null;\n }\n\n $account = null;\n // If there are multiple accounts, don't guess, we'll ask later.\n if (\\count($hsAccounts->data->results) === 1) {\n // Persist this remote object.\n $account = $this->syncAccount($hsAccounts->data->results[0]->companyId);\n }\n\n $data = $this->convertCrmData(null, $account, $userId);\n\n return ! empty(array_filter($data)) ? $data : null;\n }\n\n /**\n * @return array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n protected function convertCrmData(?Contact $contact, ?Account $account, ?int $userId = null): array\n {\n $countryCode = null;\n if ($contact && $contact->country_code) {\n $countryCode = $contact->country_code;\n } elseif ($account && $account->country_code) {\n $countryCode = $account->country_code;\n }\n\n try {\n $hsOpportunities = $this->findOpportunities(\n $account ? $account->crm_provider_id : null,\n $contact ? $contact->crm_provider_id : null,\n $userId\n );\n } catch (Exception $e) {\n $hsOpportunities = [];\n }\n\n // If there are multiple opportunities, don't guess, we'll ask later.\n $opportunity = null;\n $stage = null;\n if (! empty($hsOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n }\n\n protected function getCacheKey(string $object, ?int $userId = null): ?string\n {\n $key = $this->team->getId() . $object;\n $keySuffix = $this->getOwnerKeySuffix($userId);\n\n return $key . $keySuffix;\n }\n\n private function getOwnerKeySuffix(?int $userId = null): string\n {\n return $userId === null ? '' : (string) $userId;\n }\n\n /**\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n *}\n */\n public function matchByPhone(string $phone, ?string $rawPhoneNumber = null, ?int $userId = null): ?array\n {\n if (str_contains($phone, '**')) {\n return null;\n }\n\n // trim all whitespaces if present so the lookup doesn't fail\n $phone = str_replace(' ', '', $phone);\n\n // Check if the user is internal.\n if ($this->isPhoneNumberOfTeamMember($phone)) {\n return null;\n }\n\n $response = $this->searchForPhoneNumber($phone);\n if (empty($response)) {\n return null;\n }\n\n // This would ideally importContact instead but the response type differs.\n $contact = $this->findAndSyncContact($response['results'][0]['id']);\n if (! $contact instanceof Contact) {\n return null;\n }\n\n $account = $contact->account;\n $countryCode = $contact->country_code ?? $account->country_code ?? null;\n\n try {\n $hsOpportunities = $this->findOpportunities(\n $account?->crm_provider_id,\n $contact->crm_provider_id,\n $userId\n );\n } catch (Exception $e) {\n $hsOpportunities = [];\n }\n\n $opportunity = null;\n $stage = null;\n\n try {\n if (! empty($hsOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n } catch (Exception $e) {\n $this->logger->debug('[HubSpot] Opportunity failed to sync.', [\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n }\n\n private function isPhoneNumberOfTeamMember(string $phone): bool\n {\n $teamRepository = app(TeamRepository::class);\n $user = $teamRepository->findTeamMemberByPhone($this->team, $phone);\n\n if ($user instanceof User) {\n return true;\n }\n\n return false;\n }\n\n private function findAndSyncContact(string $crmId): ?Contact\n {\n try {\n return $this->syncContact($crmId);\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Phone match failed', [\n 'reason' => $exception->getMessage(),\n ]);\n\n return null;\n }\n }\n\n private function hasResults(array $response): bool\n {\n return isset($response['total']) && is_numeric($response['total']) && $response['total'] > 0;\n }\n\n private function searchForPhoneNumber(string $phone): array\n {\n // Normalizes the provided phone number for the API search.\n $normalizedPhone = $this->normalizePhoneNumber($phone);\n $payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone);\n\n $this->logger->info('[HubSpot] Phone match search triggered', [\n 'phone' => $phone,\n 'normalizedPhone' => $normalizedPhone,\n 'payload' => $payload,\n ]);\n\n $response = $this->handlePhoneSearchRequest($normalizedPhone, $payload);\n\n if (! $this->hasResults($response)) {\n $nationalPhone = preg_replace('/\\D/', '', phone_national(null, $phone));\n $payload = $this->payloadBuilder->generatePhoneSearchPayload($nationalPhone);\n\n $this->logger->info('[HubSpot] Phone match national number search triggered', [\n 'phone' => $phone,\n 'nationalPhone' => $nationalPhone,\n 'payload' => $payload,\n ]);\n\n $response = $this->handlePhoneSearchRequest($phone, $payload);\n }\n\n if (! $this->hasResults($response)) {\n $payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone, true);\n\n $this->logger->info('[HubSpot] Phone match alternative search triggered', [\n 'phone' => $phone,\n 'normalizedPhone' => $normalizedPhone,\n 'payload' => $payload,\n ]);\n\n $response = $this->handlePhoneSearchRequest($phone, $payload);\n }\n\n return $this->hasResults($response) ? $response : [];\n }\n\n private function handlePhoneSearchRequest(string $phone, array $payload): array\n {\n try {\n return $this->client->search('contacts', $payload);\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Phone match failed', [\n 'phone' => $phone,\n 'reason' => $exception->getMessage(),\n ]);\n\n return [];\n }\n }\n\n private function normalizePhoneNumber(string $phone): string\n {\n return ltrim(phone_e164(null, $phone), '+0');\n }\n\n /**\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n public function matchByName(string $name, ?int $userId = null): ?array\n {\n // Don't waste time searching for single character strings.\n if (\\strlen($name) <= 1) {\n return null;\n }\n\n $cacheKey = $this->getCacheKey($name, $userId);\n\n $result = Cache::remember($cacheKey, 60, function () use ($name, $userId) {\n $payload = $this->payloadBuilder->generateSearchContactsByNamePayload(\n $name,\n $this->getContactFields()\n );\n\n $hsContacts = $this->client->getPaginatedData($payload, 'contact');\n if (empty($hsContacts['results'])) {\n return false;\n }\n\n $contact = $this->importContact($hsContacts['results'][0]);\n if ($contact === null) {\n return false;\n }\n\n $account = $contact->account;\n $countryCode = $contact->country_code ?? $account->country_code ?? null;\n\n try {\n $hsOpportunities = $this->findOpportunities(\n $account ? $account->crm_provider_id : null,\n $contact->crm_provider_id,\n $userId\n );\n } catch (Exception $e) {\n $hsOpportunities = [];\n }\n\n $opportunity = null;\n $stage = null;\n if (! empty($hsOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n });\n\n return is_array($result) ? $result : null;\n }\n\n\n private function convertActivityAssociations(Activity $activity): array\n {\n return [\n 'contactIds' => $this->getParticipantsIds($activity),\n 'companyIds' => $activity->hasAccount() ? [$activity->account->crm_provider_id] : [],\n 'dealIds' => $activity->hasOpportunity() ? [$activity->opportunity->crm_provider_id] : [],\n 'ownerIds' => [],\n ];\n }\n\n private function getParticipantsIds(Activity $activity): array\n {\n $attendees = [];\n\n $participantRepository = app(ParticipantRepository::class);\n $participants = $participantRepository->getParticipantsWhoEnteredMeeting($activity);\n foreach ($participants as $participant) {\n if ($participant->user_id || $participant->isCoach()) {\n continue;\n }\n\n $contact = $participant->contact()->first();\n if ($contact && $contact->crm_provider_id) {\n $attendees[] = $contact->crm_provider_id;\n } else {\n if (! empty($participant->name)) {\n $attendeeData = $this->fetchMissingAttendeeInfo($participant);\n }\n if (! empty($attendeeData['id'])) {\n $attendees[] = $attendeeData['id'];\n }\n }\n }\n\n if ($activity->hasContact()) {\n $attendees[] = $activity->contact->crm_provider_id;\n }\n\n return array_unique($attendees);\n }\n\n private function fetchMissingAttendeeInfo(Participant $participant): array\n {\n // Check if we need to look inside an account context.\n $activity = $participant->getActivity();\n $companyId = $activity->hasAccount() ? $activity->getAccount()->crm_provider_id : null;\n\n // First check the local data.\n /** @var Contact[] $contacts */\n $contacts = $this->team->contacts()\n ->with('account')\n ->where('name', $participant->name)\n ->whereNotNull('email')\n ->get();\n\n foreach ($contacts as $contact) {\n // If we have a company in scope, check the contact is associated to it.\n if (\n $companyId !== null\n && ($contact->account_id === null || $companyId !== $contact->account->crm_provider_id)\n ) {\n continue;\n }\n\n return [\n 'id' => $contact->crm_provider_id,\n 'email' => $contact->email,\n ];\n }\n\n $payload = $this->generateNameSearchPayload($participant->name, 0, 20);\n\n try {\n $response = $this->client->getNewInstance()->crm()->contacts()->searchApi()->doSearch($payload);\n\n // TODO add some logic to choose the most suitable contact if multiple\n foreach ($response['results'] as $object) {\n $properties = $object['properties'];\n if (empty($object['properties']) === false) {\n // Check the company matches the contact.\n // Todo: Move this check inside the API search.\n if ($companyId !== null && $companyId !== $properties['associatedcompanyid']) {\n continue;\n }\n\n return [\n 'id' => $object['id'],\n 'email' => $properties['email'],\n ];\n }\n }\n } catch (Exception $e) {\n $this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [\n 'teamId' => $this->team->id_string,\n 'request' => $payload,\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return [];\n }\n\n /**\n * Store transcripts as note engagement.\n *\n * @throws Exception\n */\n public function createTranscriptNotes(Activity $activity): void\n {\n // For HS no need to check if Crm profile - Log Notes field is enabled\n // We only check if store_transcript toggle is enabled on crm profile.\n $engagement = [\n 'active' => true,\n 'ownerId' => $this->profile->crm_provider_id,\n 'timestamp' => $activity->created_at->tz($activity->user->timezone)->getTimestamp() * 1000,\n 'type' => 'NOTE',\n ];\n\n // Generate activity transcription.\n $transcriptionData = $this->generateTranscription($activity);\n\n // Truncate Notes with max notes length because transcription text could be very long.\n $transcripts = mb_strimwidth($transcriptionData, 0, static::ENGAGEMENT_BODY_MAX_LENGTH);\n\n $metadata = [\n 'body' => $transcripts,\n ];\n\n $associations = $this->convertActivityAssociations($activity);\n\n try {\n $hsEngagement = $this->client\n ->getInstance()\n ->engagements()\n ->create($engagement, $associations, $metadata);\n\n $this->logCrmEngagementManipulation(self::ACTION_CREATE, $engagement, $metadata, $associations);\n\n $noteId = $hsEngagement->data->engagement->id;\n\n // Store crm logged id in transcription.\n $transcription = $activity->getTranscription();\n $transcription->crm_activity_id = $noteId;\n $transcription->save();\n } catch (Exception $e) {\n Sentry::captureException($e);\n }\n }\n\n /*\n * @inheritdoc\n */\n public function updateRecord(string $objectType, string $objectId, array $data, array $headers = []): void\n {\n $payload = [\n 'properties' => $data,\n ];\n\n try {\n switch ($objectType) {\n case FieldData::OBJECT_OPPORTUNITY:\n $this->client->getNewInstance()->crm()->deals()->basicApi()->update($objectId, $payload);\n\n break;\n case FieldData::OBJECT_CONTACT:\n $this->client->getNewInstance()->crm()->contacts()->basicApi()->update($objectId, $payload);\n\n break;\n\n case FieldData::OBJECT_ACCOUNT:\n $this->client->getNewInstance()->crm()->companies()->basicApi()->update($objectId, $payload);\n\n break;\n\n case FieldData::OBJECT_TASK:\n // Endpoint for Engagements not ready\n $engagements = [\n 'type' => 'TASK',\n ];\n $metadata = $data;\n $this->client->getInstance()->engagements()->update($objectId, $engagements, $metadata);\n\n $this->logCrmEngagementManipulation(\n self::ACTION_UPDATE,\n ['crmId' => $objectId],\n $metadata,\n );\n\n break;\n\n default:\n throw new UnexpectedValueException('Unsupported object type \"' . $objectType . '\"');\n }\n } catch (\\HubSpot\\Client\\Crm\\Deals\\ApiException $apiException) {\n $errorMessage = $apiException->getMessage();\n if ($apiException->getResponseBody()) {\n $responseBody = json_decode($apiException->getResponseBody(), true, 512, JSON_THROW_ON_ERROR);\n $errorMessage = $responseBody['message'] ?? $apiException->getMessage();\n }\n\n $this->logger->error(\n '[HubSpot] Update record failed',\n [\n 'objectType' => $objectType,\n 'objectId' => $objectId,\n 'payload' => $payload,\n 'reason' => $errorMessage,\n 'team' => $this->team->getUuid(),\n ]\n );\n\n throw new CrmException($errorMessage);\n }\n }\n\n /*\n * @inheritdoc\n */\n public function getRecord(string $objectType, string $objectId, array $fields = []): array\n {\n switch ($objectType) {\n case FieldData::OBJECT_OPPORTUNITY:\n return $this->client->getInstance()->deals()->getById($objectId)->toArray();\n\n case FieldData::OBJECT_CONTACT:\n return $this->client->getInstance()->contacts()->getById($objectId)->toArray();\n\n case FieldData::OBJECT_ACCOUNT:\n return $this->client->getInstance()->companies()->getById($objectId)->toArray();\n\n case FieldData::OBJECT_TASK:\n return $this->client->getInstance()->engagements()->get($objectId)->toArray();\n\n default:\n throw new UnexpectedValueException('Unsupported object type \"' . $objectType . '\"');\n }\n }\n\n /**\n * @throws BadRequest\n * @throws CrmException\n */\n public function updateStage($crmObject, Stage $stage): void\n {\n $payload = [\n 'properties' => [\n [\n 'name' => 'dealstage',\n 'value' => $stage->crm_provider_id,\n ],\n ],\n ];\n\n try {\n $this->client->getInstance()->deals()->update($crmObject->crm_provider_id, $payload);\n } catch (BadRequest $badRequest) {\n if ($badRequest->getCode() === 403) {\n throw new CrmException(\n \"Sorry, you don't have permission to update this stage.\",\n $badRequest->getCode(),\n $badRequest,\n );\n }\n\n $this->logger->warning('[HubSpot] Stage update failed', [\n 'dealId' => $crmObject->crm_provider_id,\n 'payload' => $payload,\n 'message' => $badRequest->getMessage(),\n ]);\n\n throw $badRequest;\n }\n }\n\n public function generateProviderUrl(string $providerId, string $objectType): ?string\n {\n $url = null;\n $baseUrl = 'https://app.hubspot.com/contacts/' . $this->config->crm_provider_id . '/';\n\n switch ($objectType) {\n case 'account':\n $url = $baseUrl . 'company/' . $providerId;\n\n break;\n\n case 'contact':\n $url = $baseUrl . 'contact/' . $providerId;\n\n break;\n\n case 'opportunity':\n $url = $baseUrl . 'deal/' . $providerId;\n\n break;\n\n case 'task':\n case 'activity':\n return null;\n\n // This should not be deep-linked as per JMNY-3934.\n //$url = $baseUrl.'tasks/list/view/all/?taskId='.$providerId;\n break;\n }\n\n return $url;\n }\n\n public function searchCalls(Carbon $from, Carbon $to, string $activityProvider): array\n {\n $this->logger->info('[HubSpot] Search calls', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n ]);\n\n $calls = [];\n $page = 1;\n\n do {\n try {\n $payload = $this->payloadBuilder->generateGetCallsPayload($from, $to, $activityProvider, $page);\n $response = $this->client->search('calls', $payload);\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Search calls failed', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n 'reason' => $exception->getMessage(),\n ]);\n $response = null;\n }\n\n $responseResults = empty($response['results']) ? [] : $response['results'];\n $calls = array_merge($calls, $responseResults);\n $page++;\n } while (! empty($responseResults));\n\n return $calls;\n }\n\n public function searchCallsForPeriodByPage(Carbon $from, Carbon $to, int $page, bool $retry = true)\n {\n try {\n $payload = $this->payloadBuilder->generateSearchCallsByPeriodPayload($from, $to, $page);\n\n return $this->client->search('calls', $payload);\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Search calls for period failed', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n 'reason' => $exception->getMessage(),\n 'retry' => $retry,\n ]);\n\n if ($retry) {\n sleep(1);\n\n return $this->searchCallsForPeriodByPage($from, $to, $page, false);\n }\n\n return null;\n }\n }\n\n public function searchCallsForPeriod(Carbon $from, Carbon $to): Generator\n {\n $this->logger->info('[HubSpot] Search calls for period', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n ]);\n\n $page = 1;\n\n do {\n $response = $this->searchCallsForPeriodByPage($from, $to, $page);\n\n $responseResults = empty($response['results']) ? [] : $response['results'];\n\n $associationContacts = $this->getAssociationDataForCollection($responseResults, 'calls', 'contacts');\n $associationCompanies = $this->getAssociationDataForCollection($responseResults, 'calls', 'companies');\n $associationDeals = $this->getAssociationDataForCollection($responseResults, 'calls', 'deals');\n\n foreach ($responseResults as $call) {\n $call['associations'] = [\n 'contacts' => $this->importAssociationData($call, $associationContacts),\n 'companies' => $this->importAssociationData($call, $associationCompanies),\n 'deals' => $this->importAssociationData($call, $associationDeals),\n ];\n\n yield $call;\n }\n $page++;\n } while (! empty($responseResults));\n }\n\n public function getCall(string $callId): array\n {\n $this->logger->info('[HubSpot] Get call', [\n 'call_id' => $callId,\n ]);\n\n $searchAttributes = $this->payloadBuilder->getSearchCallAttributes();\n $endpoint = sprintf(\n 'https://api.hubapi.com/crm/v3/objects/calls/%s',\n $callId,\n );\n\n try {\n $response = $this->client->getInstance()->getClient()->request(\n 'GET',\n $endpoint,\n [],\n sprintf(\n 'properties=%s&associations=contacts,companies,deals',\n implode(',', $searchAttributes),\n ),\n );\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Get call failed', [\n 'call_id' => $callId,\n 'reason' => $exception->getMessage(),\n ]);\n $response = null;\n }\n\n return empty($response) ? [] : $response->toArray();\n }\n\n public function bulkAddPlaybackURLToDescription(array $crmUpdateData): array\n {\n $crmUpdateBatches = array_chunk($crmUpdateData, self::BATCH_UPDATE_LIMIT);\n\n $updatedCrmIds = [];\n\n foreach ($crmUpdateBatches as $crmBatch) {\n $payload = $this->payloadBuilder->generatePlaybackAddUrlBatchPayload($crmBatch);\n $updateSuccess = $this->bulkAddPlaybackURLToDescriptionRequest($payload);\n if ($updateSuccess) {\n $updatedCrmIds = array_merge($updatedCrmIds, array_column($crmBatch, 'crm_id'));\n }\n }\n\n return $updatedCrmIds;\n }\n\n private function bulkAddPlaybackURLToDescriptionRequest(array $payload, bool $retry = true): bool\n {\n try {\n $this->client->getNewInstance()->crm()->objects()->batchApi()->update('calls', $payload);\n\n return true;\n } catch (\\HubSpot\\Client\\Crm\\Objects\\ApiException $e) {\n $response = json_decode($e->getResponseBody(), true);\n $retryAfter =\n isset($response['policyName'])\n && $response['policyName'] == self::TEN_SECONDLY_ROLLING_POLICY\n ? self::TEN_SECONDLY_ROLLING_LIMIT\n : 1;\n } catch (Exception $e) {\n $retryAfter = 1;\n }\n\n $this->logger->warning('[HubSpot] Bulk add playback url to CRM failed', [\n 'reason' => $e->getMessage(),\n 'retry' => $retry,\n ]);\n\n if ($retry) {\n sleep($retryAfter);\n\n return $this->bulkAddPlaybackURLToDescriptionRequest($payload, false);\n }\n\n return false;\n }\n\n /**\n * Sometimes we have secondly rate limit error, then retry request after 1 second\n */\n public function searchCallByRecordingURLToken(string $playbackURLToken, bool $retry = true): array\n {\n $payload = $this->payloadBuilder->generateSearchCallByTokenPayload($playbackURLToken);\n\n $this->logger->info('[HubSpot] CRM Search by playback URL token requested', [\n 'request' => $payload,\n ]);\n\n try {\n $response = $this->client->search('calls', $payload);\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Search by playback URL token failed', [\n 'playbackURLToken' => $playbackURLToken,\n 'reason' => $exception->getMessage(),\n 'retry' => $retry,\n ]);\n\n if ($retry) {\n sleep(1);\n\n return $this->searchCallByRecordingURLToken($playbackURLToken, false);\n }\n\n return [];\n }\n\n return empty($response['results']) ? [] : $response['results'][0];\n }\n\n /**\n * Generate transcription for the activity.\n */\n private function generateTranscription(Activity $activity): string\n {\n if (! $this->config->store_transcript) {\n // If sending transcription to activity toggle is disabled\n return '';\n }\n\n $transcriptionSegments = $this->transcriptionService->findTranscriptionByActivity($activity);\n\n if ($transcriptionSegments->isEmpty()) {\n return '';\n }\n\n $transcription = sprintf(\n '<p><strong>Transcript for %s</strong></p><p></p>',\n $activity->title ?? $activity->activity_title,\n );\n\n $roomOwnerParticipant = $activity->findParticipantRoomOwner();\n $roomOwnerParticipantId = $roomOwnerParticipant !== null\n ? $roomOwnerParticipant->getId()\n : null;\n\n\n $transcription .= $transcriptionSegments\n ->map(static function (array $transcriptionSegment) use ($roomOwnerParticipantId): string {\n $isOrganiser = $roomOwnerParticipantId === $transcriptionSegment['participantId']\n && $roomOwnerParticipantId !== null;\n $transcriptColor = $isOrganiser ? '#000000' : '#f0415a';\n\n return sprintf(\n '<span style=\"color: %s;\">%s | </span>%s',\n $transcriptColor,\n $transcriptionSegment['formattedStartsAt'],\n $transcriptionSegment['transcript'],\n );\n })\n ->implode('<br />');\n\n return $transcription;\n }\n\n /**\n * @param array<array{\n * id: string,\n * label: string,\n * value?: string,\n * }> $options\n *\n * @return FieldData[]\n */\n private function importOptions(Field $field, array $options): array\n {\n $fieldValues = [];\n $values = [];\n $sequence = 0;\n\n foreach ($options as $option) {\n $values[] = [\n 'value' => $option['value'] ?? $option['id'],\n 'label' => substr($option['label'], 0, 255),\n 'sequence' => $sequence++,\n ];\n }\n\n $fieldsToPurge = $field->values()->get()->pluck('value')->toArray();\n\n foreach ($values as $value) {\n $value['value'] = substr($value['value'], 0, 255);\n $fieldValues[] = $field->values()->updateOrCreate([\n 'value' => $value['value'],\n ], $value);\n\n // Remove this value from the ones we are going to purge.\n if (($key = array_search($value['value'], $fieldsToPurge, false)) !== false) {\n unset($fieldsToPurge[$key]);\n }\n }\n\n // Delete the old values that are no longer used.\n $field->values()->whereIn('value', $fieldsToPurge)->delete();\n\n return $fieldValues;\n }\n\n public function saveTranscriptionSummaryAsNote(\n ActivityContract $activity,\n string $title,\n string $body,\n ?string $objectId,\n ?NoteObject $noteObject = null,\n ): ?string {\n if ($noteObject === null || $objectId === null) {\n return null;\n }\n\n /** @var User $user */\n $user = $activity->getUser();\n\n $profile = $this->assignCrmOwner($user, $activity);\n if (! $profile instanceof Profile) {\n return null;\n }\n\n $timestamp = Carbon::now($user->getTimezone())->getTimestamp() * 1000;\n $engagement = [\n 'active' => true,\n 'ownerId' => $profile->getAttribute('crm_provider_id'),\n 'timestamp' => $timestamp,\n 'type' => 'NOTE',\n ];\n\n // Truncate Notes with max notes length because transcription text could be very long.\n $body = mb_strimwidth($body, 0, self::ENGAGEMENT_BODY_MAX_LENGTH);\n $metadata = [\n 'body' => $body,\n ];\n\n $associations = $this->convertActivityAssociations($activity);\n\n try {\n $hsActivityId = $this->client->createNote(\n body: $body,\n ownerId: $profile->getCrmProviderId(),\n timestamp: $timestamp,\n objectId: $objectId,\n noteObject: $noteObject,\n );\n\n $this->logCrmEngagementManipulation(self::ACTION_CREATE, $engagement, $metadata, $associations);\n\n $this->logger->info('[HubSpot] Saving Transcription Summary as Note', [\n 'activity' => $activity->getUuid(),\n 'crmActivity' => $hsActivityId,\n ]);\n\n return $hsActivityId;\n } catch (Exception $e) {\n Sentry::captureException($e);\n }\n\n return null;\n }\n\n public function attachSummaryToActivity(ActivityContract $activity, string $summaryTitle, string $summaryContents): bool\n {\n $this->logger->info('[HubSpot] Attaching summary to activity', [\n 'activity' => $activity->getUuid(),\n 'summary_content' => $summaryContents,\n ]);\n\n if (! $activity instanceof Activity) {\n throw new InvalidArgumentException('Expected instance of Activity');\n }\n\n $summary = '<p><strong>' . $summaryTitle . '</strong></p>';\n $summary .= '<p>' . $summaryContents . '</p>';\n $metadata = $this->buildMetadataForSummaryUpdate($activity, $summary);\n\n try {\n $type = $this->matchActivityEngagementType($activity);\n $engagement = ['type' => $type];\n\n $this->client->updateEngagement($activity->getCrmProviderId(), $engagement, $metadata);\n } catch (Exception $e) {\n $this->logger->warning('[HubSpot] Update summary failed', [\n 'activity' => $activity->getUuid(),\n 'reason' => $e->getMessage(),\n ]);\n\n return false;\n }\n\n $this->logCrmEngagementManipulation(\n self::ACTION_UPDATE,\n ['crmId' => $activity->getCrmProviderId()],\n $metadata,\n );\n\n return true;\n }\n\n private function buildMetadataForSummaryUpdate(Activity $activity, string $summary): array\n {\n $descriptionField = $activity->getType() === Activity::TYPE_CONFERENCE ? 'internalMeetingNotes' : 'body';\n $engagement = $this->client->getEngagementData($activity->getCrmProviderId());\n // Meeting without internalMeetingNotes might mean it just does not have any notes;\n $description = $engagement['metadata'][$descriptionField] ?? null;\n\n if (empty($description)) {\n $data = $summary;\n } else {\n // avoid playbook url link to Jiminny being sent twice in the activity description\n $targetUrl = PlaybackUrlBuilder::build($activity);\n\n if (str_contains($description, $targetUrl)) {\n $jiminnyUrl = '<p><a href=\"' . $targetUrl . '\" title=\"Play at Jiminny\">Play at Jiminny</a></p>';\n $summary = str_replace($jiminnyUrl, '', $summary);\n\n $this->logger->info('[HubSpot] Summary modified', [\n 'activity' => $activity->getUuid(),\n 'target_url' => $jiminnyUrl,\n 'modified_summary_content' => $summary,\n ]);\n }\n\n $data = $description . '<p></p>' . $summary;\n }\n\n return [\n $descriptionField => $data,\n ];\n }\n\n public function fetchAndAssociateRelatedActivity(Activity $activity): ?Activity\n {\n return $this->syncRelatedActivityManager->fetchAndAssociateRelatedActivity($activity);\n }\n\n public function fetchRelatedActivity(Activity $activity): array\n {\n return [];\n }\n\n public function getDealsInBulk(array $dealIds): array\n {\n $payload = $this->payloadBuilder->getDealsInBulkPayload($dealIds);\n\n return $this->client->getPaginatedData($payload, 'deals');\n }\n\n /**\n * Extract deal IDs from HubSpot search response.\n *\n * @param array $hubspotResponse The raw HubSpot search API response.\n * @param bool $includeArchived Whether to include archived deals (default: false).\n *\n * @return string[] Array of deal IDs as strings.\n */\n public function extractDealIds(array $hubspotResponse, bool $includeArchived = false): array\n {\n if (empty($hubspotResponse['results'])) {\n return [];\n }\n\n return array_values(\n array_map(\n fn ($deal) => $deal['id'],\n array_filter(\n $hubspotResponse['results'],\n fn ($deal) => $includeArchived || empty($deal['archived'])\n )\n )\n );\n }\n\n public function matchActivityEngagementType(Activity $activity): string\n {\n return match ($activity->getType()) {\n Activity::TYPE_CONFERENCE => self::TYPE_MEETING,\n Activity::TYPE_SOFTPHONE, Activity::TYPE_SOFTPHONE_INBOUND => self::TYPE_CALL,\n default => self::TYPE_NOTE,\n };\n }\n\n private function assignCrmOwner(User $user, ActivityContract $activity): ?Profile\n {\n $profile = $user->getProfile();\n if ($profile instanceof Profile) {\n return $profile;\n }\n\n $this->logger->info('[HubSpot] Unable to save summary. No profile', [\n 'activity' => $activity->getUuid(),\n ]);\n\n return null;\n }\n\n private static function getDealsPipelinesEndpoint(): string\n {\n return self::API_URL . self::ENDPOINT_PIPELINES . self::PIPELINE_OBJECT_TYPE_DEALS;\n }\n\n public function verifyTaskExists(Activity $activity): bool\n {\n $crmProviderId = $activity->getCrmProviderId();\n $cacheKey = \"crm_task_exists:{$this->config->getId()}:$crmProviderId\";\n\n return Cache::remember($cacheKey, self::TASK_VERIFICATION_CACHE_TTL, function () use ($crmProviderId) {\n try {\n $engagement = $this->client->getEngagementData($crmProviderId);\n\n return ! empty($engagement);\n } catch (HttpNotFoundException|BadRequest) {\n // Engagement not found in CRM - this is expected and permanent\n $this->logger->info('[Hubspot] Engagement not found during verification', [\n 'engagement_id' => $crmProviderId,\n 'config_id' => $this->config->getId(),\n ]);\n\n return false;\n }\n // Let other exceptions (network errors, rate limits, etc.) bubble up for retry\n });\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":true,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Project","depth":3,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Project","depth":3,"bounds":{"left":0.011968086,"top":0.047885075,"width":0.024268618,"height":0.024740623},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"New File or Directory…","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Expand Selected","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Collapse All","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Options","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
-3175715562603377888
|
-465054261620631449
|
visual_change
|
accessibility
|
NULL
|
Project: faVsco.js, menu
JY-20725-handle-HS-search Project: faVsco.js, menu
JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
19
Previous Highlighted Error
Next Highlighted Error
[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {
"headers":{
"Date":["Thu,07 May 2026 14:21:15 GMT"],
"Content-Type":["application/json;charset=utf-8"],
"Transfer-Encoding":["chunked"],
"Connection":["keep-alive"],
"CF-Ray":["9f80deb8db60dc3a-SOF"],
"CF-Cache-Status":["DYNAMIC"],
"Strict-Transport-Security":["max-age=31536000; includeSubDomains; preload"],
"Vary":["origin,
accept-encoding"],
"access-control-allow-credentials":["false"],
"server-timing":["hcid;desc=\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\",
cfr;desc=\"9f80deb8e7c6dc3a-IAD\""],
"x-content-type-options":["nosniff"],
"x-hubspot-correlation-id":["019e02d0-6fd8-7812-bdba-885b7ccb3ee3"],
"Set-Cookie":["__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-[IP_ADDRESS]-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,
07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None"],
"Report-To":["{
\"endpoints\":[{
\"url\":\"https:\\/\\/a.nel.cloudflare.com\\/report\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\"}],
\"group\":\"cf-nel\",
\"max_age\":604800}"],
"NEL":["{
\"success_fraction\":0.01,
\"report_to\":\"cf-nel\",
\"max_age\":604800}"],
"Server":["cloudflare"]}} {
"correlation_id":"95236535-ec98-4541-b92a-adfa73b69eab",
"trace_id":"c7ab8365-903f-46d4-9403-0e5b551e3545"}
Code changed:
Hide
Sync Changes
Hide This Notification
7
48
1
33
1
Previous Highlighted Error
Next Highlighted Error
<?php
namespace Jiminny\Services\Crm\Hubspot;
use Carbon\Carbon;
use Exception;
use Generator;
use GuzzleHttp\Exception\RequestException;
use Illuminate\Support\Facades\Cache;
use InvalidArgumentException;
use Jiminny\Contracts\Repositories\TeamRepository;
use Jiminny\Contracts\Services\Crm\ClientInterface;
use Jiminny\Contracts\Services\Crm\FetchRelatedActivityInterface;
use Jiminny\Contracts\Services\Crm\LayoutManagementInterface;
use Jiminny\Contracts\Services\Crm\MatchCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\Provider\HubspotInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityLookupInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityManipulationInterface;
use Jiminny\Contracts\Services\Crm\SavePlaybackLinkToCrmInterface;
use Jiminny\Contracts\Services\Crm\SendSummaryToCrmInterface;
use Jiminny\Contracts\Services\Crm\SettingsInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmMetadataInterface;
use Jiminny\Contracts\Services\Crm\VerifyTaskExistsInterface;
use Jiminny\Exceptions\CrmException;
use Jiminny\Exceptions\HttpNotFoundException;
use Jiminny\Jobs\Crm\NoteObject;
use Jiminny\Models\Account;
use Jiminny\Models\Activity;
use Jiminny\Models\Contact;
use Jiminny\Models\Contracts\ActivityContract;
use Jiminny\Models\Crm\BusinessProcess;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\FieldData;
use Jiminny\Models\Crm\Layout;
use Jiminny\Models\Crm\Profile;
use Jiminny\Models\Lead;
use Jiminny\Models\Opportunity;
use Jiminny\Models\Participant;
use Jiminny\Models\Playbook;
use Jiminny\Models\SocialAccount;
use Jiminny\Models\Stage;
use Jiminny\Models\User;
use Jiminny\Repositories\Crm\CrmEntityRepository;
use Jiminny\Repositories\Crm\FieldRepository;
use Jiminny\Repositories\Crm\ProfileRepository;
use Jiminny\Repositories\ParticipantRepository;
use Jiminny\Services\Avatar\ProspectPhotoPathService;
use Jiminny\Services\Crm\BaseService;
use Jiminny\Services\Crm\Hubspot\Actions\SyncArchivedProfilesAction;
use Jiminny\Services\Crm\Hubspot\Fields\ValueNormalizer;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\OpportunitySyncTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\SyncCrmEntitiesTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\SyncFieldsTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\WriteCrmTrait;
use Jiminny\Services\Crm\MatchDomainByEmailInterface;
use Jiminny\Services\Crm\OpportunitySyncStrategyResolver;
use Jiminny\Services\Crm\ResolveCompanyNameByEmailTrait;
use Jiminny\Utils\PlaybackUrlBuilder;
use Sentry;
use SevenShores\Hubspot\Exceptions\BadRequest;
use Throwable;
use UnexpectedValueException;
/**
* @phpstan-type CrmFieldDefinition array{
* name: string,
* label: string,
* description: string,
* type: string,
* fieldType: string,
* hidden: bool,
* showCurrencySymbol: bool,
* options: array<array{
* id: string,
* label: string,
* value?: string,
* }
*/
class Service extends BaseService implements
HubspotInterface,
SyncCrmEntitiesInterface,
SyncCrmMetadataInterface,
SendSummaryToCrmInterface,
MatchDomainByEmailInterface,
SavePlaybackLinkToCrmInterface,
RemoteEntityManipulationInterface,
FetchRelatedActivityInterface,
LayoutManagementInterface,
SettingsInterface,
MatchCrmEntitiesInterface,
RemoteEntityLookupInterface,
VerifyTaskExistsInterface
{
use ResolveCompanyNameByEmailTrait;
use SyncCrmEntitiesTrait;
use WriteCrmTrait;
use SyncFieldsTrait;
use OpportunitySyncTrait;
private const int ENGAGEMENT_BODY_MAX_LENGTH = 65536;
private const string LOG_DATE_FORMAT = 'Y-m-d H:i:s';
private const int BATCH_UPDATE_LIMIT = 100;
private const string TEN_SECONDLY_ROLLING_POLICY = 'TEN_SECONDLY_ROLLING';
private const int TEN_SECONDLY_ROLLING_LIMIT = 10;
private const string TYPE_NOTE = 'NOTE';
private const string TYPE_MEETING = 'MEETING';
private const string TYPE_CALL = 'CALL';
private const string API_URL = '[URL_WITH_CREDENTIALS] ClientInterface|Client
*/
protected $client;
protected OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;
protected CrmEntityRepository $crmEntityRepository;
protected ProspectPhotoPathService $prospectPhotoPathService;
private SyncFieldAction $syncFieldAction;
private PayloadBuilder $payloadBuilder;
private SyncRelatedActivityManager $syncRelatedActivityManager;
private SyncArchivedProfilesAction $syncArchivedProfilesAction;
private WebhookSyncBatchProcessor $batchProcessor;
public function __construct(
Client $client,
SyncFieldAction $syncFieldAction,
PayloadBuilder $payloadBuilder,
ProspectPhotoPathService $prospectPhotoPathService,
SyncArchivedProfilesAction $syncArchivedProfilesAction,
WebhookSyncBatchProcessor $batchProcessor,
) {
parent::__construct();
$this->client = $client;
$this->syncFieldAction = $syncFieldAction;
$this->prospectPhotoPathService = $prospectPhotoPathService;
$this->payloadBuilder = $payloadBuilder;
$this->syncArchivedProfilesAction = $syncArchivedProfilesAction;
$this->batchProcessor = $batchProcessor;
$this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [
'client' => $this->client,
]);
$this->syncRelatedActivityManager = app(SyncRelatedActivityManager::class, [
'client' => $this->client,
'payloadBuilder' => $this->payloadBuilder,
'logger' => $this->logger,
]);
$this->crmEntityRepository = app(CrmEntityRepository::class);
$this->dealFieldsService = app(DealFieldsService::class);
}
public function getDisplayName(): string
{
return 'HubSpot';
}
protected function getOAuthAccount(User $user): ?SocialAccount
{
// In this case, the Account Owner is always the connection for any API operations.
$owner = $user->team->owner;
return $owner->getSocialAccount(SocialAccount::PROVIDER_HUBSPOT);
}
public function getClient(): Client
{
/** @var Client */
return $this->client;
}
/**
* Convert raw field data into a format compatible with CRM APIs.
*
* @param bool $internal Direction of the conversion.
* True is pulling from CRM, false normalize before sending to CRM.
*/
public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string
{
return ValueNormalizer::normalize(
fieldType: $fieldType,
fieldValue: $fieldValue,
isInbound: $internal,
);
}
/**
* @inheritdoc
*/
public function getDefaultFields(string $activityType): array
{
$fields = [];
if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {
$defaultFields = FieldDefinitions::defaultTaskFields();
// This lazy creates these fields if not already setup.
foreach ($defaultFields as $defaultField) {
$fields[] = $this->config->fields()->firstOrCreate($defaultField);
}
}
return $fields;
}
/**
* @inheritdoc
*/
public function getDefaultActivityField(string $activityType): Field
{
/** @var Field $activityField */
$activityField = $this->config->fields()->where([
'crm_provider_id' => 'activityType',
'object_type' => $activityType,
])->first();
return $activityField;
}
/**
* @inheritdoc
*/
public function getSupportedPlaybookTypes(): array
{
return [Playbook::ACTIVITY_TYPE_TASK];
}
/**
* @inheritdoc
*/
public function getDefaultActivityLayoutFields(string $activityType, string $layoutType): array
{
$fields = [];
if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {
// Outcome should always be provided calls/meetings.
$fieldData = [
[
'crm_provider_id' => $layoutType === Layout::TYPE_SOFTPHONE_SUMMARY ? 'disposition' : 'meetingOutcome',
'object_type' => Field::OBJECT_TASK,
],
];
foreach ($fieldData as $data) {
$field = $this->config->fields()->where($data)->first();
// Only add the field if it is created, which it should be.
if ($field) {
$fields[] = $field;
}
}
}
return $fields;
}
public function getDealInsightsFields(): array
{
return FieldDefinitions::dealInsightsFields();
}
protected function getDefaultFollowupLayoutFields(string $activityType): array
{
$fields = [];
$fieldRepo = app(FieldRepository::class);
$fieldData = FieldDefinitions::followupFieldsFilter();
foreach ($fieldData as $data) {
$field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $data);
// Only add the field if it is created, which it should be.
if ($field) {
$fields[] = $field;
}
}
return $fields;
}
/**
* @inheritdoc
*/
public function syncField(Field $field): void
{
switch ($field->object_type) {
case Field::OBJECT_ACCOUNT:
$crmField = $this->client->getInstance()->companyProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_CONTACT:
$crmField = $this->client->getInstance()->contactProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_OPPORTUNITY:
$crmField = $this->client->getInstance()->dealProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_TASK:
$this->syncSingleTaskField($field);
return;
default:
return;
}
$this->syncFieldAction->execute($field, $crmField->toArray());
}
/**
* @param array<array{
* id:string,
* label:string,
* value?:string
* }> $options
*
* @throws CrmException
*
* @return FieldData[]
*
*/
public function importPicklistValues(
Field $field,
array $options = [['id' => '', 'label' => '', 'value' => '']],
): array {
if (! empty($options[0]['id']) || ! empty($options[0]['value'])) {
// We already have the options, no need to fetch them again
return $this->importOptions($field, $options);
}
$options = [];
switch ($field->getObjectType()) {
case Field::OBJECT_ACCOUNT:
$options = $this->getClient()->fetchPropertyOptions('company', $field->getCrmProviderId());
break;
case Field::OBJECT_CONTACT:
$options = $this->getClient()->fetchPropertyOptions('contact', $field->getCrmProviderId());
break;
case Field::OBJECT_OPPORTUNITY:
// Hubspot has different endpoint for stages
$options = $this->getClient()->fetchOpportunityFieldOptions($field);
break;
case Field::OBJECT_TASK:
if ($field->getCrmProviderId() === 'disposition') {
$options = $this->getClient()->fetchDispositionFieldOptions();
} elseif (in_array($field->getCrmProviderId(), ['meetingOutcome', 'activityType'])) {
$options = $this->getClient()->fetchMeetingOutcomeFieldOptions($field);
}
break;
default:
$this->logger->warning('Invalid object type', [
'object_type' => $field->getObjectType(),
'field_id' => $field->getId(),
]);
throw new CrmException('Invalid object type');
}
return $this->importOptions($field, $options);
}
/**
* @inheritdoc
*/
public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage
{
$missingStage = null;
try {
// Use the HubSpot API client instead of the SDK crmPipelines() method
$endpoint = self::getDealsPipelinesEndpoint();
$pipelinesResponse = $this->client->getInstance()->getClient()->request('GET', $endpoint);
$pipelines = $pipelinesResponse->data->results;
} catch (RequestException|BadRequest $exception) {
throw $exception;
}
foreach ($pipelines as $pipeline) {
$stages = [];
// We create a business process to contain the pipeline, and store all stages against it.
$p = ResponseNormalize::normalizePipeline($pipeline);
// Create/update business process for this pipeline
$businessProcess = $this->config->businessProcesses()->updateOrCreate([
'crm_provider_id' => $p['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($p['label'], 0, 150),
'type' => BusinessProcess::TYPE_OPPORTUNITY,
'is_selectable' => $p['active'],
]);
// A record type is really a clone of the business process, used to store which record uses which pipeline.
// Create/update record type clone
$this->config->recordTypes()->updateOrCreate([
'crm_provider_id' => $p['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($p['label'], 0, 150),
'is_selectable' => $p['active'],
'business_process_id' => $businessProcess->id ?? null,
]);
// Stages - fetch all existing stages upfront to avoid N+1 queries
$existingStages = $this->config->stages()
->withTrashed()
->where('type', Stage::TYPE_OPPORTUNITY)
->get()
->keyBy('crm_provider_id');
foreach ($p['stages'] as $dealStage) {
$s = ResponseNormalize::normalizeDealStage($dealStage);
/** @var ?Stage $existingStage */
$existingStage = $existingStages->get($s['id']);
// Restore soft-deleted stages that are now active in HubSpot
if ($existingStage?->trashed() && $s['active']) {
$existingStage->restore();
}
// Upsert stage (updates soft-deleted records without restoring them)
$stage = $this->config->stages()->withTrashed()->updateOrCreate([
'crm_provider_id' => $s['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($s['label'], 0, 50),
'label' => mb_strimwidth($s['label'], 0, 191),
'type' => Stage::TYPE_OPPORTUNITY,
'sequence' => $s['displayOrder'],
'is_selectable' => $s['active'],
'probability' => $s['probability'] * 100,
]);
if ($missingStageName === $s['id']) {
$missingStage = $stage;
}
$stages[] = $stage->id;
}
$businessProcess->stages()->sync($stages);
}
return $missingStage;
}
/**
* @inheritdoc
*/
public function syncOrganization(): void
{
try {
$endpoint = '[URL_WITH_CREDENTIALS]
*/
public function find(string $name, array $scopes): array
{
$count = $this->limit ?? 20;
$offset = $this->offset ?? 0;
/** @var array<int, array<string, mixed>> */
return Cache::remember(
key: $this->team->getId() . $name . $count . $offset,
ttl: 300,
callback: function () use ($name, $offset, $count): array {
$data = [];
// Use the new V3 API to find contacts based on additional fields.
foreach (['companies', 'contacts'] as $objectType) {
$payload = $this->generateNameSearchPayload($name, $offset, $count);
$type = $objectType === 'companies' ? 'account' : 'contact';
try {
$response = $this->client->search($objectType, $payload);
// Build mapped list.
foreach ($response['results'] as $object) {
$properties = $object['properties'];
$objectName = $this->buildContactName($properties);
$record = [
'crmId' => $object['id'],
// Pass crmUrl to the FE, needed for success message in the extension when you log activity.
'crmUrl' => $this->generateProviderUrl($object['id'], $type),
'name' => $objectName,
'prospectType' => $type,
'phoneNumbers' => [],
];
if ($type === 'account') {
$record['industry'] = $properties['industry'] ?? null;
} else {
$record['title'] = $properties['jobtitle'] ?? null;
$record['organization'] = $properties['company'] ?? null;
}
$countryCode = $this->buildContactCountry($properties);
$parsedNumber = $this->buildContactPhone($countryCode, $properties);
// Add phone number to record.
if (! empty($parsedNumber['phone'])) {
$record['phoneNumbers'][] = [
'number' => $parsedNumber['phone'],
'nationalFormat' => phone_national($countryCode, $parsedNumber['phone']),
'type' => 'phone',
];
}
// Add mobile phone number to record.
if (! empty($properties['mobilephone'])) {
$mobileNumber = phone_e164($countryCode, $properties['mobilephone']);
if ($mobileNumber !== null) {
$record['phoneNumbers'][] = [
'number' => $mobileNumber,
'nationalFormat' => phone_national($countryCode, $mobileNumber),
'type' => 'mobile',
];
}
}
$data[] = $record;
}
} catch (BadRequest $e) {
$this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [
'teamId' => $this->team->getUuid(),
'request' => $payload,
'reason' => $e->getMessage(),
]);
throw $e;
}
}
return $data;
},
);
}
/**
* @inheritdoc
*/
public function findOpportunities(?string $crmAccountId, ?string $crmContactId, ?int $userId = null): array
{
$data = [];
$ownerData = [];
$ownerId = null;
if ($crmAccountId === null) {
return $data;
}
if ($userId) {
$profileRepository = app(ProfileRepository::class);
$profile = $profileRepository->findProfileByUserId($this->config, $userId);
$ownerId = $profile instanceof Profile ? $profile->getCrmProviderId() : null;
}
$closedStages = $this->getClosedDealStages();
$payload = $this->payloadBuilder->generateOpportunitiesSearchPayload(
$this->config,
$crmAccountId,
$closedStages,
);
$results = $this->client->getPaginatedData($payload, 'deals');
foreach ($results['results'] as $object) {
$properties = $object['properties'];
$amount = null;
if (empty($properties['amount']) === false) {
$currency = $properties['deal_currency_code'] ?? $this->config->default_currency;
// Values can contain commas and any junk so strip them.
$value = (float) preg_replace('/[^\d.]/', '', $properties['amount']);
$amount = formatCurrency($value, $currency);
}
$businessProcess = $this->config
->businessProcesses()
->where('crm_provider_id', $properties['pipeline'])
->first();
if ($businessProcess === null) {
// Import it.
$stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $properties['dealstage']);
$businessProcess = $this->config
->businessProcesses()
->where('crm_provider_id', $properties['pipeline'])
->first();
} else {
$stage = $businessProcess
->stages()
->where('crm_provider_id', $properties['dealstage'])
->where('type', Stage::TYPE_OPPORTUNITY)
->first();
if ($stage === null) {
// Import it.
$stage = $this->importStages(null, $properties['dealstage']);
}
}
$recordType = null;
if ($businessProcess) {
$recordType = $businessProcess->recordTypes()->first();
}
$isWon = in_array($properties['dealstage'], $closedStages['won']);
$isLost = in_array($properties['dealstage'], $closedStages['lost']);
$record = [
'crmId' => $object['id'],
'name' => $properties['dealname'] ?? 'Unknown Deal',
'value' => $amount,
'won' => $isWon,
'closed' => $isWon || $isLost,
'stage' => [
'id' => $stage?->getUuid() ?? '',
'name' => $stage?->getName() ?? '',
],
];
if ($recordType) {
$record += [
'recordType' => [
'id' => $recordType->id_string,
'name' => $recordType->name,
],
];
}
if ($ownerId && isset($properties['hubspot_owner_id']) && $properties['hubspot_owner_id'] === $ownerId) {
$ownerData[] = $record;
}
$data[] = $record;
}
if (! empty($ownerData)) {
return $ownerData;
}
return $data;
}
/**
* @inheritdoc
*/
public function getTasks(?string $objectType, string $objectId, ?string $opportunityId): array
{
$data = [];
switch ($objectType) {
case 'contact':
$hsObject = 'contact';
break;
case 'account':
$hsObject = 'company';
break;
default:
// This is a hack to prioritise and override a contact/company with a deal.
if ($opportunityId) {
$hsObject = 'deal';
$objectId = $opportunityId;
} else {
throw new InvalidArgumentException('Object type not supported.');
}
}
$engagementTypes = ['meetings', 'tasks'];
foreach ($engagementTypes as $engagementType) {
$payload = $this->payloadBuilder->getLinkToTaskPayload($hsObject, $objectId, $engagementType);
$this->logger->info('[HubSpot] CRM Search requested', [
'request' => $payload,
]);
$engagements = $this->client->getPaginatedData($payload, $engagementType);
foreach ($engagements['results'] as $engagement) {
if ($engagementType == 'meetings') {
$title = $engagement['properties']['hs_meeting_title'] ?? 'Scheduled meeting';
} elseif ($engagementType == 'tasks') {
$title = $engagement['properties']['hs_task_subject'];
} else {
$title = 'Scheduled meeting';
}
$data[] = [
'crmId' => $engagement['id'],
'subject' => $title,
'due' => $engagement['properties']['hs_timestamp'],
'type' => $engagement['properties']['hs_activity_type'] ?? null,
];
}
}
usort($data, function ($item1, $item2) {
return $item2['due'] <=> $item1['due'];
});
return $data;
}
/**
* Try to find CRM Objects using email address
*
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchExactlyByEmail(string $email, ?int $userId = null): ?array
{
$contactProperties = [
'email',
'firstname',
'lastname',
'country',
'phone',
'mobilephone',
'jobtitle',
'hubspot_owner_id',
'associatedcompanyid',
'photo',
];
$contact = null;
$account = null;
try {
$hsContact = $this->getClient()->getContactByEmail($email, $contactProperties);
if ($hsContact) {
$contact = $this->importContact($hsContact);
$account = $contact->account;
}
$data = $this->convertCrmData($contact, $account, $userId);
return ! empty(array_filter($data)) ? $data : null;
} catch (BadRequest $e) {
$this->logger->warning('[HubSpot] Search failed', [
'team_id' => $this->team->getId(),
'search_identifier' => $email,
'reason' => $e->getMessage(),
]);
}
return null;
}
public function getDomain(string $email): ?string
{
return $this->getDomainFromEmail($email);
}
/**
* Try to find CRM objects using domain name of the email address
*
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchByDomain(string $domain, ?int $userId = null): ?array
{
$companyName = $domain;
// Try to find a company matching their email domain.
$companyProperties = [
'country',
'phone',
'name',
'hs_avatar_filemanager_key',
'industry',
'hubspot_owner_id',
'domain',
];
try {
$hsAccounts = $this->client
->getInstance()
->companies()
->searchByDomain($companyName, $companyProperties);
} catch (Throwable $e) {
$this->logger->info('[HubSpot] Search failed', [
'error' => $e->getMessage(),
'domain' => $domain,
]);
return null;
}
$account = null;
// If there are multiple accounts, don't guess, we'll ask later.
if (\count($hsAccounts->data->results) === 1) {
// Persist this remote object.
$account = $this->syncAccount($hsAccounts->data->results[0]->companyId);
}
$data = $this->convertCrmData(null, $account, $userId);
return ! empty(array_filter($data)) ? $data : null;
}
/**
* @return array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
protected function convertCrmData(?Contact $contact, ?Account $account, ?int $userId = null): array
{
$countryCode = null;
if ($contact && $contact->country_code) {
$countryCode = $contact->country_code;
} elseif ($account && $account->country_code) {
$countryCode = $account->country_code;
}
try {
$hsOpportunities = $this->findOpportunities(
$account ? $account->crm_provider_id : null,
$contact ? $contact->crm_provider_id : null,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
// If there are multiple opportunities, don't guess, we'll ask later.
$opportunity = null;
$stage = null;
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
protected function getCacheKey(string $object, ?int $userId = null): ?string
{
$key = $this->team->getId() . $object;
$keySuffix = $this->getOwnerKeySuffix($userId);
return $key . $keySuffix;
}
private function getOwnerKeySuffix(?int $userId = null): string
{
return $userId === null ? '' : (string) $userId;
}
/**
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
*}
*/
public function matchByPhone(string $phone, ?string $rawPhoneNumber = null, ?int $userId = null): ?array
{
if (str_contains($phone, '**')) {
return null;
}
// trim all whitespaces if present so the lookup doesn't fail
$phone = str_replace(' ', '', $phone);
// Check if the user is internal.
if ($this->isPhoneNumberOfTeamMember($phone)) {
return null;
}
$response = $this->searchForPhoneNumber($phone);
if (empty($response)) {
return null;
}
// This would ideally importContact instead but the response type differs.
$contact = $this->findAndSyncContact($response['results'][0]['id']);
if (! $contact instanceof Contact) {
return null;
}
$account = $contact->account;
$countryCode = $contact->country_code ?? $account->country_code ?? null;
try {
$hsOpportunities = $this->findOpportunities(
$account?->crm_provider_id,
$contact->crm_provider_id,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
$opportunity = null;
$stage = null;
try {
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
} catch (Exception $e) {
$this->logger->debug('[HubSpot] Opportunity failed to sync.', [
'reason' => $e->getMessage(),
]);
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
private function isPhoneNumberOfTeamMember(string $phone): bool
{
$teamRepository = app(TeamRepository::class);
$user = $teamRepository->findTeamMemberByPhone($this->team, $phone);
if ($user instanceof User) {
return true;
}
return false;
}
private function findAndSyncContact(string $crmId): ?Contact
{
try {
return $this->syncContact($crmId);
} catch (Exception $exception) {
$this->logger->info('[HubSpot] Phone match failed', [
'reason' => $exception->getMessage(),
]);
return null;
}
}
private function hasResults(array $response): bool
{
return isset($response['total']) && is_numeric($response['total']) && $response['total'] > 0;
}
private function searchForPhoneNumber(string $phone): array
{
// Normalizes the provided phone number for the API search.
$normalizedPhone = $this->normalizePhoneNumber($phone);
$payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone);
$this->logger->info('[HubSpot] Phone match search triggered', [
'phone' => $phone,
'normalizedPhone' => $normalizedPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($normalizedPhone, $payload);
if (! $this->hasResults($response)) {
$nationalPhone = preg_replace('/\D/', '', phone_national(null, $phone));
$payload = $this->payloadBuilder->generatePhoneSearchPayload($nationalPhone);
$this->logger->info('[HubSpot] Phone match national number search triggered', [
'phone' => $phone,
'nationalPhone' => $nationalPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($phone, $payload);
}
if (! $this->hasResults($response)) {
$payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone, true);
$this->logger->info('[HubSpot] Phone match alternative search triggered', [
'phone' => $phone,
'normalizedPhone' => $normalizedPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($phone, $payload);
}
return $this->hasResults($response) ? $response : [];
}
private function handlePhoneSearchRequest(string $phone, array $payload): array
{
try {
return $this->client->search('contacts', $payload);
} catch (Exception $exception) {
$this->logger->info('[HubSpot] Phone match failed', [
'phone' => $phone,
'reason' => $exception->getMessage(),
]);
return [];
}
}
private function normalizePhoneNumber(string $phone): string
{
return ltrim(phone_e164(null, $phone), '+0');
}
/**
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchByName(string $name, ?int $userId = null): ?array
{
// Don't waste time searching for single character strings.
if (\strlen($name) <= 1) {
return null;
}
$cacheKey = $this->getCacheKey($name, $userId);
$result = Cache::remember($cacheKey, 60, function () use ($name, $userId) {
$payload = $this->payloadBuilder->generateSearchContactsByNamePayload(
$name,
$this->getContactFields()
);
$hsContacts = $this->client->getPaginatedData($payload, 'contact');
if (empty($hsContacts['results'])) {
return false;
}
$contact = $this->importContact($hsContacts['results'][0]);
if ($contact === null) {
return false;
}
$account = $contact->account;
$countryCode = $contact->country_code ?? $account->country_code ?? null;
try {
$hsOpportunities = $this->findOpportunities(
$account ? $account->crm_provider_id : null,
$contact->crm_provider_id,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
$opportunity = null;
$stage = null;
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
});
return is_array($result) ? $result : null;
}
private function convertActivityAssociations(Activity $activity): array
{
return [
'contactIds' => $this->getParticipantsIds($activity),
'companyIds' => $activity->hasAccount() ? [$activity->account->crm_provider_id] : [],
'dealIds' => $activity->hasOpportunity() ? [$activity->opportunity->crm_provider_id] : [],
'ownerIds' => [],
];
}
private function getParticipantsIds(Activity $activity): array
{
$attendees = [];
$participantRepository = app(ParticipantRepository::class);
$participants = $participantRepository->getParticipantsWhoEnteredMeeting($activity);
foreach ($participants as $participant) {
if ($participant->user_id || $participant->isCoach()) {
continue;
}
$contact = $participant->contact()->first();
if ($contact && $contact->crm_provider_id) {
$attendees[] = $contact->crm_provider_id;
} else {
if (! empty($participant->name)) {
$attendeeData = $this->fetchMissingAttendeeInfo($participant);
}
if (! empty($attendeeData['id'])) {
$attendees[] = $attendeeData['id'];
}
}
}
if ($activity->hasContact()) {
$attendees[] = $activity->contact->crm_provider_id;
}
return array_unique($attendees);
}
private function fetchMissingAttendeeInfo(Participant $participant): array
{
// Check if we need to look inside an account context.
$activity = $participant->getActivity();
$companyId = $activity->hasAccount() ? $activity->getAccount()->crm_provider_id : null;
// First check the local data.
/** @var Contact[] $contacts */
$contacts = $this->team->contacts()
->with('account')
->where('name', $participant->name)
->whereNotNull('email')
->get();
foreach ($contacts as $contact) {
// If we have a company in scope, check the contact is associated to it.
if (
$companyId !== null
&& ($contact->account_id === null || $companyId !== $contact->account->crm_provider_id)
) {
continue;
}
return [
'id' => $contact->crm_provider_id,
'email' => $contact->email,
];
}
$payload = $this->generateNameSearchPayload($participant->name, 0, 20);
try {
$response = $this->client->getNewInstance()->crm()->contacts()->searchApi()->doSearch($payload);
// TODO add some logic to choose the most suitable contact if multiple
foreach ($response['results'] as $object) {
$properties = $object['properties'];
if (empty($object['properties']) === false) {
// Check the company matches the contact.
// Todo: Move this check inside the API search.
if ($companyId !== null && $companyId !== $properties['associatedcompanyid']) {
continue;
}
return [
'id' => $object['id'],
'email' => $properties['email'],
];
}
}
} catch (Exception $e) {
$this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [
'teamId' => $this->team->id_string,
'request' => $payload,
'reason' => $e->getMessage(),
]);
}
return [];
}
/**
* Store transcripts as note engagement.
*
* @throws Exception
*/
public function createTranscriptNotes(Activity $activity): void
{
// For HS no need to check if Crm profile - Log Notes field is enabled
// We only check if store_transcript toggle is enabled on crm profile.
$engagement = [
'active' => true,
'ownerId' => $this->profile->crm_provider_id,
'timestamp' => $activity->created_at->tz($activity->user->timezone)->getTimestamp() * 1000,
'type' => 'NOTE',
];
// Generate activity transcription.
$transcriptionData = $this->generateTranscription($activity);
// Truncate Notes with max notes length because transcription text could be very long.
$transcripts = mb_strimwidth($transcriptionData, 0, static::ENGAGEMENT_BODY_MAX_LENGTH);
$metadata = [
'body' => $transcripts,
];
$associations = $this->convertActivityAssociations($activity);
try {
$hsEngagement = $this->client
->getInstance()
->engagements()
->create($engagement, $associations, $metadata);
$this->logCrmEngagementManipulation(self::ACTION_CREATE, $engagement, $metadata, $associations);
$noteId = $hsEngagement->data->engagement->id;
// Store crm logged id in transcription.
$transcription = $activity->getTranscription();
$transcription->crm_activity_id = $noteId;
$transcription->save();
} catch (Exception $e) {
Sentry::captureException($e);
}
}
/*
* @inheritdoc
*/
public function updateRecord(string $objectType, string $objectId, array $data, array $headers = []): void
{
$payload = [
'properties' => $data,
];
try {
switch ($objectType) {
case FieldData::OBJECT_OPPORTUNITY:
$this->client->getNewInstance()->crm()->deals()->basicApi()->update($objectId, $payload);
break;
case FieldData::OBJECT_CONTACT:
$this->client->getNewInstance()->crm()->contacts()->basicApi()->update($objectId, $payload);
break;
case FieldData::OBJECT_ACCOUNT:
$this->client->getNewInstance()->crm()->companies()->basicApi()->update($objectId, $payload);
break;
case FieldData::OBJECT_TASK:
// Endpoint for Engagements not ready
$engagements = [
'type' => 'TASK',
];
$metadata = $data;
$this->client->getInstance()->engagements()->update($objectId, $engagements, $metadata);
$this->logCrmEngagementManipulation(
self::ACTION_UPDATE,
['crmId...
|
9809
|
NULL
|
NULL
|
NULL
|
|
9809
|
444
|
10
|
2026-05-08T13:34:34.823745+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-08/1778 /Users/lukas/.screenpipe/data/data/2026-05-08/1778247274823_m2.jpg...
|
PhpStorm
|
faVsco.js – Service.php
|
1
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
JY-20725-handle-HS-search Project: faVsco.js, menu
JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
19
Previous Highlighted Error
Next Highlighted Error
[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {
"headers":{
"Date":["Thu,07 May 2026 14:21:15 GMT"],
"Content-Type":["application/json;charset=utf-8"],
"Transfer-Encoding":["chunked"],
"Connection":["keep-alive"],
"CF-Ray":["9f80deb8db60dc3a-SOF"],
"CF-Cache-Status":["DYNAMIC"],
"Strict-Transport-Security":["max-age=31536000; includeSubDomains; preload"],
"Vary":["origin,
accept-encoding"],
"access-control-allow-credentials":["false"],
"server-timing":["hcid;desc=\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\",
cfr;desc=\"9f80deb8e7c6dc3a-IAD\""],
"x-content-type-options":["nosniff"],
"x-hubspot-correlation-id":["019e02d0-6fd8-7812-bdba-885b7ccb3ee3"],
"Set-Cookie":["__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-[IP_ADDRESS]-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,
07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None"],
"Report-To":["{
\"endpoints\":[{
\"url\":\"https:\\/\\/a.nel.cloudflare.com\\/report\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\"}],
\"group\":\"cf-nel\",
\"max_age\":604800}"],
"NEL":["{
\"success_fraction\":0.01,
\"report_to\":\"cf-nel\",
\"max_age\":604800}"],
"Server":["cloudflare"]}} {
"correlation_id":"95236535-ec98-4541-b92a-adfa73b69eab",
"trace_id":"c7ab8365-903f-46d4-9403-0e5b551e3545"}...
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"bounds":{"left":0.025930852,"top":0.019952115,"width":0.03856383,"height":0.025538707},"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JY-20725-handle-HS-search-rate-limit, menu","depth":5,"bounds":{"left":0.064494684,"top":0.019952115,"width":0.09541223,"height":0.025538707},"on_screen":true,"help_text":"Git Branch: JY-20725-handle-HS-search-rate-limit","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"bounds":{"left":0.8081782,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"AskJiminnyReportActivityServiceTest","depth":6,"bounds":{"left":0.8234708,"top":0.019952115,"width":0.09208777,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'AskJiminnyReportActivityServiceTest'","depth":6,"bounds":{"left":0.9155585,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'AskJiminnyReportActivityServiceTest'","depth":6,"bounds":{"left":0.9268617,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"bounds":{"left":0.9381649,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"bounds":{"left":0.96609044,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"bounds":{"left":0.9773936,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"IDE and Project Settings","depth":5,"bounds":{"left":0.9886968,"top":0.019952115,"width":0.011303186,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.042220745,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"19","depth":4,"bounds":{"left":0.6615692,"top":0.10055866,"width":0.009640957,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"bounds":{"left":0.67287236,"top":0.09896249,"width":0.00731383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"bounds":{"left":0.68018615,"top":0.09896249,"width":0.006981383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {\n\"headers\":{\n\"Date\":[\"Thu,07 May 2026 14:21:15 GMT\"],\n \"Content-Type\":[\"application/json;charset=utf-8\"],\n \"Transfer-Encoding\":[\"chunked\"],\n \"Connection\":[\"keep-alive\"],\n \"CF-Ray\":[\"9f80deb8db60dc3a-SOF\"],\n \"CF-Cache-Status\":[\"DYNAMIC\"],\n \"Strict-Transport-Security\":[\"max-age=31536000; includeSubDomains; preload\"],\n \"Vary\":[\"origin,\n accept-encoding\"],\n \"access-control-allow-credentials\":[\"false\"],\n \"server-timing\":[\"hcid;desc=\\\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\\\",\n cfr;desc=\\\"9f80deb8e7c6dc3a-IAD\\\"\"],\n \"x-content-type-options\":[\"nosniff\"],\n \"x-hubspot-correlation-id\":[\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\"],\n \"Set-Cookie\":[\"__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-1.0.1.1-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,\n 07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None\"],\n \"Report-To\":[\"{\n\\\"endpoints\\\":[{\n\\\"url\\\":\\\"https:\\\\/\\\\/a.nel.cloudflare.com\\\\/report\\\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\\\"}],\n\\\"group\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"NEL\":[\"{\n\\\"success_fraction\\\":0.01,\n\\\"report_to\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"Server\":[\"cloudflare\"]}} {\n\"correlation_id\":\"95236535-ec98-4541-b92a-adfa73b69eab\",\n\"trace_id\":\"c7ab8365-903f-46d4-9403-0e5b551e3545\"}","depth":4,"bounds":{"left":0.37632978,"top":0.0,"width":0.5728058,"height":0.632083},"on_screen":true,"lines":[{"char_start":624,"char_count":72,"bounds":{"left":0.37632978,"top":0.0,"width":0.18384309,"height":0.014365523}},{"char_start":696,"char_count":219,"bounds":{"left":0.37632978,"top":0.0,"width":0.56515956,"height":0.014365523}},{"char_start":915,"char_count":83,"bounds":{"left":0.37632978,"top":0.0,"width":0.21243352,"height":0.014365523}},{"char_start":998,"char_count":20,"bounds":{"left":0.37632978,"top":0.0,"width":0.04920213,"height":0.014365523}},{"char_start":1018,"char_count":17,"bounds":{"left":0.37632978,"top":0.0,"width":0.041223403,"height":0.014365523}},{"char_start":1035,"char_count":203,"bounds":{"left":0.37632978,"top":0.0,"width":0.52360374,"height":0.014365523}},{"char_start":1238,"char_count":22,"bounds":{"left":0.37632978,"top":0.007980846,"width":0.05418883,"height":0.014365523}},{"char_start":1260,"char_count":23,"bounds":{"left":0.37632978,"top":0.025538707,"width":0.056848403,"height":0.014365523}},{"char_start":1283,"char_count":10,"bounds":{"left":0.37632978,"top":0.04309657,"width":0.023271276,"height":0.014365523}},{"char_start":1293,"char_count":27,"bounds":{"left":0.37632978,"top":0.060654428,"width":0.06715426,"height":0.014365523}},{"char_start":1320,"char_count":26,"bounds":{"left":0.37632978,"top":0.07821229,"width":0.06482713,"height":0.014365523}},{"char_start":1346,"char_count":23,"bounds":{"left":0.37632978,"top":0.09577015,"width":0.056848403,"height":0.014365523}},{"char_start":1369,"char_count":28,"bounds":{"left":0.37632978,"top":0.11332801,"width":0.06981383,"height":0.014365523}},{"char_start":1397,"char_count":57,"bounds":{"left":0.37632978,"top":0.13088587,"width":0.14494681,"height":0.014365523}}],"value":"[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {\n\"headers\":{\n\"Date\":[\"Thu,07 May 2026 14:21:15 GMT\"],\n \"Content-Type\":[\"application/json;charset=utf-8\"],\n \"Transfer-Encoding\":[\"chunked\"],\n \"Connection\":[\"keep-alive\"],\n \"CF-Ray\":[\"9f80deb8db60dc3a-SOF\"],\n \"CF-Cache-Status\":[\"DYNAMIC\"],\n \"Strict-Transport-Security\":[\"max-age=31536000; includeSubDomains; preload\"],\n \"Vary\":[\"origin,\n accept-encoding\"],\n \"access-control-allow-credentials\":[\"false\"],\n \"server-timing\":[\"hcid;desc=\\\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\\\",\n cfr;desc=\\\"9f80deb8e7c6dc3a-IAD\\\"\"],\n \"x-content-type-options\":[\"nosniff\"],\n \"x-hubspot-correlation-id\":[\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\"],\n \"Set-Cookie\":[\"__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-1.0.1.1-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,\n 07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None\"],\n \"Report-To\":[\"{\n\\\"endpoints\\\":[{\n\\\"url\\\":\\\"https:\\\\/\\\\/a.nel.cloudflare.com\\\\/report\\\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\\\"}],\n\\\"group\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"NEL\":[\"{\n\\\"success_fraction\\\":0.01,\n\\\"report_to\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"Server\":[\"cloudflare\"]}} {\n\"correlation_id\":\"95236535-ec98-4541-b92a-adfa73b69eab\",\n\"trace_id\":\"c7ab8365-903f-46d4-9403-0e5b551e3545\"}","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
6337657672782761712
|
-3048904810292590134
|
visual_change
|
accessibility
|
NULL
|
Project: faVsco.js, menu
JY-20725-handle-HS-search Project: faVsco.js, menu
JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
19
Previous Highlighted Error
Next Highlighted Error
[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {
"headers":{
"Date":["Thu,07 May 2026 14:21:15 GMT"],
"Content-Type":["application/json;charset=utf-8"],
"Transfer-Encoding":["chunked"],
"Connection":["keep-alive"],
"CF-Ray":["9f80deb8db60dc3a-SOF"],
"CF-Cache-Status":["DYNAMIC"],
"Strict-Transport-Security":["max-age=31536000; includeSubDomains; preload"],
"Vary":["origin,
accept-encoding"],
"access-control-allow-credentials":["false"],
"server-timing":["hcid;desc=\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\",
cfr;desc=\"9f80deb8e7c6dc3a-IAD\""],
"x-content-type-options":["nosniff"],
"x-hubspot-correlation-id":["019e02d0-6fd8-7812-bdba-885b7ccb3ee3"],
"Set-Cookie":["__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-[IP_ADDRESS]-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,
07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None"],
"Report-To":["{
\"endpoints\":[{
\"url\":\"https:\\/\\/a.nel.cloudflare.com\\/report\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\"}],
\"group\":\"cf-nel\",
\"max_age\":604800}"],
"NEL":["{
\"success_fraction\":0.01,
\"report_to\":\"cf-nel\",
\"max_age\":604800}"],
"Server":["cloudflare"]}} {
"correlation_id":"95236535-ec98-4541-b92a-adfa73b69eab",
"trace_id":"c7ab8365-903f-46d4-9403-0e5b551e3545"}...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
9808
|
443
|
5
|
2026-05-08T13:34:18.917723+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-08/1778 /Users/lukas/.screenpipe/data/data/2026-05-08/1778247258917_m1.jpg...
|
PhpStorm
|
faVsco.js – Service.php
|
1
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
JY-20725-handle-HS-search Project: faVsco.js, menu
JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
19
Previous Highlighted Error
Next Highlighted Error
[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {
"headers":{
"Date":["Thu,07 May 2026 14:21:15 GMT"],
"Content-Type":["application/json;charset=utf-8"],
"Transfer-Encoding":["chunked"],
"Connection":["keep-alive"],
"CF-Ray":["9f80deb8db60dc3a-SOF"],
"CF-Cache-Status":["DYNAMIC"],
"Strict-Transport-Security":["max-age=31536000; includeSubDomains; preload"],
"Vary":["origin,
accept-encoding"],
"access-control-allow-credentials":["false"],
"server-timing":["hcid;desc=\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\",
cfr;desc=\"9f80deb8e7c6dc3a-IAD\""],
"x-content-type-options":["nosniff"],
"x-hubspot-correlation-id":["019e02d0-6fd8-7812-bdba-885b7ccb3ee3"],
"Set-Cookie":["__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-[IP_ADDRESS]-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,
07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None"],
"Report-To":["{
\"endpoints\":[{
\"url\":\"https:\\/\\/a.nel.cloudflare.com\\/report\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\"}],
\"group\":\"cf-nel\",
\"max_age\":604800}"],
"NEL":["{
\"success_fraction\":0.01,
\"report_to\":\"cf-nel\",
\"max_age\":604800}"],
"Server":["cloudflare"]}} {
"correlation_id":"95236535-ec98-4541-b92a-adfa73b69eab",
"trace_id":"c7ab8365-903f-46d4-9403-0e5b551e3545"}
Code changed:
Hide
Sync Changes
Hide This Notification
7
48
1
33
1
Previous Highlighted Error
Next Highlighted Error
<?php
namespace Jiminny\Services\Crm\Hubspot;
use Carbon\Carbon;
use Exception;
use Generator;
use GuzzleHttp\Exception\RequestException;
use Illuminate\Support\Facades\Cache;
use InvalidArgumentException;
use Jiminny\Contracts\Repositories\TeamRepository;
use Jiminny\Contracts\Services\Crm\ClientInterface;
use Jiminny\Contracts\Services\Crm\FetchRelatedActivityInterface;
use Jiminny\Contracts\Services\Crm\LayoutManagementInterface;
use Jiminny\Contracts\Services\Crm\MatchCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\Provider\HubspotInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityLookupInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityManipulationInterface;
use Jiminny\Contracts\Services\Crm\SavePlaybackLinkToCrmInterface;
use Jiminny\Contracts\Services\Crm\SendSummaryToCrmInterface;
use Jiminny\Contracts\Services\Crm\SettingsInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmMetadataInterface;
use Jiminny\Contracts\Services\Crm\VerifyTaskExistsInterface;
use Jiminny\Exceptions\CrmException;
use Jiminny\Exceptions\HttpNotFoundException;
use Jiminny\Jobs\Crm\NoteObject;
use Jiminny\Models\Account;
use Jiminny\Models\Activity;
use Jiminny\Models\Contact;
use Jiminny\Models\Contracts\ActivityContract;
use Jiminny\Models\Crm\BusinessProcess;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\FieldData;
use Jiminny\Models\Crm\Layout;
use Jiminny\Models\Crm\Profile;
use Jiminny\Models\Lead;
use Jiminny\Models\Opportunity;
use Jiminny\Models\Participant;
use Jiminny\Models\Playbook;
use Jiminny\Models\SocialAccount;
use Jiminny\Models\Stage;
use Jiminny\Models\User;
use Jiminny\Repositories\Crm\CrmEntityRepository;
use Jiminny\Repositories\Crm\FieldRepository;
use Jiminny\Repositories\Crm\ProfileRepository;
use Jiminny\Repositories\ParticipantRepository;
use Jiminny\Services\Avatar\ProspectPhotoPathService;
use Jiminny\Services\Crm\BaseService;
use Jiminny\Services\Crm\Hubspot\Actions\SyncArchivedProfilesAction;
use Jiminny\Services\Crm\Hubspot\Fields\ValueNormalizer;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\OpportunitySyncTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\SyncCrmEntitiesTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\SyncFieldsTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\WriteCrmTrait;
use Jiminny\Services\Crm\MatchDomainByEmailInterface;
use Jiminny\Services\Crm\OpportunitySyncStrategyResolver;
use Jiminny\Services\Crm\ResolveCompanyNameByEmailTrait;
use Jiminny\Utils\PlaybackUrlBuilder;
use Sentry;
use SevenShores\Hubspot\Exceptions\BadRequest;
use Throwable;
use UnexpectedValueException;
/**
* @phpstan-type CrmFieldDefinition array{
* name: string,
* label: string,
* description: string,
* type: string,
* fieldType: string,
* hidden: bool,
* showCurrencySymbol: bool,
* options: array<array{
* id: string,
* label: string,
* value?: string,
* }
*/
class Service extends BaseService implements
HubspotInterface,
SyncCrmEntitiesInterface,
SyncCrmMetadataInterface,
SendSummaryToCrmInterface,
MatchDomainByEmailInterface,
SavePlaybackLinkToCrmInterface,
RemoteEntityManipulationInterface,
FetchRelatedActivityInterface,
LayoutManagementInterface,
SettingsInterface,
MatchCrmEntitiesInterface,
RemoteEntityLookupInterface,
VerifyTaskExistsInterface
{
use ResolveCompanyNameByEmailTrait;
use SyncCrmEntitiesTrait;
use WriteCrmTrait;
use SyncFieldsTrait;
use OpportunitySyncTrait;
private const int ENGAGEMENT_BODY_MAX_LENGTH = 65536;
private const string LOG_DATE_FORMAT = 'Y-m-d H:i:s';
private const int BATCH_UPDATE_LIMIT = 100;
private const string TEN_SECONDLY_ROLLING_POLICY = 'TEN_SECONDLY_ROLLING';
private const int TEN_SECONDLY_ROLLING_LIMIT = 10;
private const string TYPE_NOTE = 'NOTE';
private const string TYPE_MEETING = 'MEETING';
private const string TYPE_CALL = 'CALL';
private const string API_URL = '[URL_WITH_CREDENTIALS] ClientInterface|Client
*/
protected $client;
protected OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;
protected CrmEntityRepository $crmEntityRepository;
protected ProspectPhotoPathService $prospectPhotoPathService;
private SyncFieldAction $syncFieldAction;
private PayloadBuilder $payloadBuilder;
private SyncRelatedActivityManager $syncRelatedActivityManager;
private SyncArchivedProfilesAction $syncArchivedProfilesAction;
private WebhookSyncBatchProcessor $batchProcessor;
public function __construct(
Client $client,
SyncFieldAction $syncFieldAction,
PayloadBuilder $payloadBuilder,
ProspectPhotoPathService $prospectPhotoPathService,
SyncArchivedProfilesAction $syncArchivedProfilesAction,
WebhookSyncBatchProcessor $batchProcessor,
) {
parent::__construct();
$this->client = $client;
$this->syncFieldAction = $syncFieldAction;
$this->prospectPhotoPathService = $prospectPhotoPathService;
$this->payloadBuilder = $payloadBuilder;
$this->syncArchivedProfilesAction = $syncArchivedProfilesAction;
$this->batchProcessor = $batchProcessor;
$this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [
'client' => $this->client,
]);
$this->syncRelatedActivityManager = app(SyncRelatedActivityManager::class, [
'client' => $this->client,
'payloadBuilder' => $this->payloadBuilder,
'logger' => $this->logger,
]);
$this->crmEntityRepository = app(CrmEntityRepository::class);
$this->dealFieldsService = app(DealFieldsService::class);
}
public function getDisplayName(): string
{
return 'HubSpot';
}
protected function getOAuthAccount(User $user): ?SocialAccount
{
// In this case, the Account Owner is always the connection for any API operations.
$owner = $user->team->owner;
return $owner->getSocialAccount(SocialAccount::PROVIDER_HUBSPOT);
}
public function getClient(): Client
{
/** @var Client */
return $this->client;
}
/**
* Convert raw field data into a format compatible with CRM APIs.
*
* @param bool $internal Direction of the conversion.
* True is pulling from CRM, false normalize before sending to CRM.
*/
public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string
{
return ValueNormalizer::normalize(
fieldType: $fieldType,
fieldValue: $fieldValue,
isInbound: $internal,
);
}
/**
* @inheritdoc
*/
public function getDefaultFields(string $activityType): array
{
$fields = [];
if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {
$defaultFields = FieldDefinitions::defaultTaskFields();
// This lazy creates these fields if not already setup.
foreach ($defaultFields as $defaultField) {
$fields[] = $this->config->fields()->firstOrCreate($defaultField);
}
}
return $fields;
}
/**
* @inheritdoc
*/
public function getDefaultActivityField(string $activityType): Field
{
/** @var Field $activityField */
$activityField = $this->config->fields()->where([
'crm_provider_id' => 'activityType',
'object_type' => $activityType,
])->first();
return $activityField;
}
/**
* @inheritdoc
*/
public function getSupportedPlaybookTypes(): array
{
return [Playbook::ACTIVITY_TYPE_TASK];
}
/**
* @inheritdoc
*/
public function getDefaultActivityLayoutFields(string $activityType, string $layoutType): array
{
$fields = [];
if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {
// Outcome should always be provided calls/meetings.
$fieldData = [
[
'crm_provider_id' => $layoutType === Layout::TYPE_SOFTPHONE_SUMMARY ? 'disposition' : 'meetingOutcome',
'object_type' => Field::OBJECT_TASK,
],
];
foreach ($fieldData as $data) {
$field = $this->config->fields()->where($data)->first();
// Only add the field if it is created, which it should be.
if ($field) {
$fields[] = $field;
}
}
}
return $fields;
}
public function getDealInsightsFields(): array
{
return FieldDefinitions::dealInsightsFields();
}
protected function getDefaultFollowupLayoutFields(string $activityType): array
{
$fields = [];
$fieldRepo = app(FieldRepository::class);
$fieldData = FieldDefinitions::followupFieldsFilter();
foreach ($fieldData as $data) {
$field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $data);
// Only add the field if it is created, which it should be.
if ($field) {
$fields[] = $field;
}
}
return $fields;
}
/**
* @inheritdoc
*/
public function syncField(Field $field): void
{
switch ($field->object_type) {
case Field::OBJECT_ACCOUNT:
$crmField = $this->client->getInstance()->companyProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_CONTACT:
$crmField = $this->client->getInstance()->contactProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_OPPORTUNITY:
$crmField = $this->client->getInstance()->dealProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_TASK:
$this->syncSingleTaskField($field);
return;
default:
return;
}
$this->syncFieldAction->execute($field, $crmField->toArray());
}
/**
* @param array<array{
* id:string,
* label:string,
* value?:string
* }> $options
*
* @throws CrmException
*
* @return FieldData[]
*
*/
public function importPicklistValues(
Field $field,
array $options = [['id' => '', 'label' => '', 'value' => '']],
): array {
if (! empty($options[0]['id']) || ! empty($options[0]['value'])) {
// We already have the options, no need to fetch them again
return $this->importOptions($field, $options);
}
$options = [];
switch ($field->getObjectType()) {
case Field::OBJECT_ACCOUNT:
$options = $this->getClient()->fetchPropertyOptions('company', $field->getCrmProviderId());
break;
case Field::OBJECT_CONTACT:
$options = $this->getClient()->fetchPropertyOptions('contact', $field->getCrmProviderId());
break;
case Field::OBJECT_OPPORTUNITY:
// Hubspot has different endpoint for stages
$options = $this->getClient()->fetchOpportunityFieldOptions($field);
break;
case Field::OBJECT_TASK:
if ($field->getCrmProviderId() === 'disposition') {
$options = $this->getClient()->fetchDispositionFieldOptions();
} elseif (in_array($field->getCrmProviderId(), ['meetingOutcome', 'activityType'])) {
$options = $this->getClient()->fetchMeetingOutcomeFieldOptions($field);
}
break;
default:
$this->logger->warning('Invalid object type', [
'object_type' => $field->getObjectType(),
'field_id' => $field->getId(),
]);
throw new CrmException('Invalid object type');
}
return $this->importOptions($field, $options);
}
/**
* @inheritdoc
*/
public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage
{
$missingStage = null;
try {
// Use the HubSpot API client instead of the SDK crmPipelines() method
$endpoint = self::getDealsPipelinesEndpoint();
$pipelinesResponse = $this->client->getInstance()->getClient()->request('GET', $endpoint);
$pipelines = $pipelinesResponse->data->results;
} catch (RequestException|BadRequest $exception) {
throw $exception;
}
foreach ($pipelines as $pipeline) {
$stages = [];
// We create a business process to contain the pipeline, and store all stages against it.
$p = ResponseNormalize::normalizePipeline($pipeline);
// Create/update business process for this pipeline
$businessProcess = $this->config->businessProcesses()->updateOrCreate([
'crm_provider_id' => $p['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($p['label'], 0, 150),
'type' => BusinessProcess::TYPE_OPPORTUNITY,
'is_selectable' => $p['active'],
]);
// A record type is really a clone of the business process, used to store which record uses which pipeline.
// Create/update record type clone
$this->config->recordTypes()->updateOrCreate([
'crm_provider_id' => $p['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($p['label'], 0, 150),
'is_selectable' => $p['active'],
'business_process_id' => $businessProcess->id ?? null,
]);
// Stages - fetch all existing stages upfront to avoid N+1 queries
$existingStages = $this->config->stages()
->withTrashed()
->where('type', Stage::TYPE_OPPORTUNITY)
->get()
->keyBy('crm_provider_id');
foreach ($p['stages'] as $dealStage) {
$s = ResponseNormalize::normalizeDealStage($dealStage);
/** @var ?Stage $existingStage */
$existingStage = $existingStages->get($s['id']);
// Restore soft-deleted stages that are now active in HubSpot
if ($existingStage?->trashed() && $s['active']) {
$existingStage->restore();
}
// Upsert stage (updates soft-deleted records without restoring them)
$stage = $this->config->stages()->withTrashed()->updateOrCreate([
'crm_provider_id' => $s['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($s['label'], 0, 50),
'label' => mb_strimwidth($s['label'], 0, 191),
'type' => Stage::TYPE_OPPORTUNITY,
'sequence' => $s['displayOrder'],
'is_selectable' => $s['active'],
'probability' => $s['probability'] * 100,
]);
if ($missingStageName === $s['id']) {
$missingStage = $stage;
}
$stages[] = $stage->id;
}
$businessProcess->stages()->sync($stages);
}
return $missingStage;
}
/**
* @inheritdoc
*/
public function syncOrganization(): void
{
try {
$endpoint = '[URL_WITH_CREDENTIALS]
*/
public function find(string $name, array $scopes): array
{
$count = $this->limit ?? 20;
$offset = $this->offset ?? 0;
/** @var array<int, array<string, mixed>> */
return Cache::remember(
key: $this->team->getId() . $name . $count . $offset,
ttl: 300,
callback: function () use ($name, $offset, $count): array {
$data = [];
// Use the new V3 API to find contacts based on additional fields.
foreach (['companies', 'contacts'] as $objectType) {
$payload = $this->generateNameSearchPayload($name, $offset, $count);
$type = $objectType === 'companies' ? 'account' : 'contact';
try {
$response = $this->client->search($objectType, $payload);
// Build mapped list.
foreach ($response['results'] as $object) {
$properties = $object['properties'];
$objectName = $this->buildContactName($properties);
$record = [
'crmId' => $object['id'],
// Pass crmUrl to the FE, needed for success message in the extension when you log activity.
'crmUrl' => $this->generateProviderUrl($object['id'], $type),
'name' => $objectName,
'prospectType' => $type,
'phoneNumbers' => [],
];
if ($type === 'account') {
$record['industry'] = $properties['industry'] ?? null;
} else {
$record['title'] = $properties['jobtitle'] ?? null;
$record['organization'] = $properties['company'] ?? null;
}
$countryCode = $this->buildContactCountry($properties);
$parsedNumber = $this->buildContactPhone($countryCode, $properties);
// Add phone number to record.
if (! empty($parsedNumber['phone'])) {
$record['phoneNumbers'][] = [
'number' => $parsedNumber['phone'],
'nationalFormat' => phone_national($countryCode, $parsedNumber['phone']),
'type' => 'phone',
];
}
// Add mobile phone number to record.
if (! empty($properties['mobilephone'])) {
$mobileNumber = phone_e164($countryCode, $properties['mobilephone']);
if ($mobileNumber !== null) {
$record['phoneNumbers'][] = [
'number' => $mobileNumber,
'nationalFormat' => phone_national($countryCode, $mobileNumber),
'type' => 'mobile',
];
}
}
$data[] = $record;
}
} catch (BadRequest $e) {
$this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [
'teamId' => $this->team->getUuid(),
'request' => $payload,
'reason' => $e->getMessage(),
]);
throw $e;
}
}
return $data;
},
);
}
/**
* @inheritdoc
*/
public function findOpportunities(?string $crmAccountId, ?string $crmContactId, ?int $userId = null): array
{
$data = [];
$ownerData = [];
$ownerId = null;
if ($crmAccountId === null) {
return $data;
}
if ($userId) {
$profileRepository = app(ProfileRepository::class);
$profile = $profileRepository->findProfileByUserId($this->config, $userId);
$ownerId = $profile instanceof Profile ? $profile->getCrmProviderId() : null;
}
$closedStages = $this->getClosedDealStages();
$payload = $this->payloadBuilder->generateOpportunitiesSearchPayload(
$this->config,
$crmAccountId,
$closedStages,
);
$results = $this->client->getPaginatedData($payload, 'deals');
foreach ($results['results'] as $object) {
$properties = $object['properties'];
$amount = null;
if (empty($properties['amount']) === false) {
$currency = $properties['deal_currency_code'] ?? $this->config->default_currency;
// Values can contain commas and any junk so strip them.
$value = (float) preg_replace('/[^\d.]/', '', $properties['amount']);
$amount = formatCurrency($value, $currency);
}
$businessProcess = $this->config
->businessProcesses()
->where('crm_provider_id', $properties['pipeline'])
->first();
if ($businessProcess === null) {
// Import it.
$stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $properties['dealstage']);
$businessProcess = $this->config
->businessProcesses()
->where('crm_provider_id', $properties['pipeline'])
->first();
} else {
$stage = $businessProcess
->stages()
->where('crm_provider_id', $properties['dealstage'])
->where('type', Stage::TYPE_OPPORTUNITY)
->first();
if ($stage === null) {
// Import it.
$stage = $this->importStages(null, $properties['dealstage']);
}
}
$recordType = null;
if ($businessProcess) {
$recordType = $businessProcess->recordTypes()->first();
}
$isWon = in_array($properties['dealstage'], $closedStages['won']);
$isLost = in_array($properties['dealstage'], $closedStages['lost']);
$record = [
'crmId' => $object['id'],
'name' => $properties['dealname'] ?? 'Unknown Deal',
'value' => $amount,
'won' => $isWon,
'closed' => $isWon || $isLost,
'stage' => [
'id' => $stage?->getUuid() ?? '',
'name' => $stage?->getName() ?? '',
],
];
if ($recordType) {
$record += [
'recordType' => [
'id' => $recordType->id_string,
'name' => $recordType->name,
],
];
}
if ($ownerId && isset($properties['hubspot_owner_id']) && $properties['hubspot_owner_id'] === $ownerId) {
$ownerData[] = $record;
}
$data[] = $record;
}
if (! empty($ownerData)) {
return $ownerData;
}
return $data;
}
/**
* @inheritdoc
*/
public function getTasks(?string $objectType, string $objectId, ?string $opportunityId): array
{
$data = [];
switch ($objectType) {
case 'contact':
$hsObject = 'contact';
break;
case 'account':
$hsObject = 'company';
break;
default:
// This is a hack to prioritise and override a contact/company with a deal.
if ($opportunityId) {
$hsObject = 'deal';
$objectId = $opportunityId;
} else {
throw new InvalidArgumentException('Object type not supported.');
}
}
$engagementTypes = ['meetings', 'tasks'];
foreach ($engagementTypes as $engagementType) {
$payload = $this->payloadBuilder->getLinkToTaskPayload($hsObject, $objectId, $engagementType);
$this->logger->info('[HubSpot] CRM Search requested', [
'request' => $payload,
]);
$engagements = $this->client->getPaginatedData($payload, $engagementType);
foreach ($engagements['results'] as $engagement) {
if ($engagementType == 'meetings') {
$title = $engagement['properties']['hs_meeting_title'] ?? 'Scheduled meeting';
} elseif ($engagementType == 'tasks') {
$title = $engagement['properties']['hs_task_subject'];
} else {
$title = 'Scheduled meeting';
}
$data[] = [
'crmId' => $engagement['id'],
'subject' => $title,
'due' => $engagement['properties']['hs_timestamp'],
'type' => $engagement['properties']['hs_activity_type'] ?? null,
];
}
}
usort($data, function ($item1, $item2) {
return $item2['due'] <=> $item1['due'];
});
return $data;
}
/**
* Try to find CRM Objects using email address
*
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchExactlyByEmail(string $email, ?int $userId = null): ?array
{
$contactProperties = [
'email',
'firstname',
'lastname',
'country',
'phone',
'mobilephone',
'jobtitle',
'hubspot_owner_id',
'associatedcompanyid',
'photo',
];
$contact = null;
$account = null;
try {
$hsContact = $this->getClient()->getContactByEmail($email, $contactProperties);
if ($hsContact) {
$contact = $this->importContact($hsContact);
$account = $contact->account;
}
$data = $this->convertCrmData($contact, $account, $userId);
return ! empty(array_filter($data)) ? $data : null;
} catch (BadRequest $e) {
$this->logger->warning('[HubSpot] Search failed', [
'team_id' => $this->team->getId(),
'search_identifier' => $email,
'reason' => $e->getMessage(),
]);
}
return null;
}
public function getDomain(string $email): ?string
{
return $this->getDomainFromEmail($email);
}
/**
* Try to find CRM objects using domain name of the email address
*
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchByDomain(string $domain, ?int $userId = null): ?array
{
$companyName = $domain;
// Try to find a company matching their email domain.
$companyProperties = [
'country',
'phone',
'name',
'hs_avatar_filemanager_key',
'industry',
'hubspot_owner_id',
'domain',
];
try {
$hsAccounts = $this->client
->getInstance()
->companies()
->searchByDomain($companyName, $companyProperties);
} catch (Throwable $e) {
$this->logger->info('[HubSpot] Search failed', [
'error' => $e->getMessage(),
'domain' => $domain,
]);
return null;
}
$account = null;
// If there are multiple accounts, don't guess, we'll ask later.
if (\count($hsAccounts->data->results) === 1) {
// Persist this remote object.
$account = $this->syncAccount($hsAccounts->data->results[0]->companyId);
}
$data = $this->convertCrmData(null, $account, $userId);
return ! empty(array_filter($data)) ? $data : null;
}
/**
* @return array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
protected function convertCrmData(?Contact $contact, ?Account $account, ?int $userId = null): array
{
$countryCode = null;
if ($contact && $contact->country_code) {
$countryCode = $contact->country_code;
} elseif ($account && $account->country_code) {
$countryCode = $account->country_code;
}
try {
$hsOpportunities = $this->findOpportunities(
$account ? $account->crm_provider_id : null,
$contact ? $contact->crm_provider_id : null,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
// If there are multiple opportunities, don't guess, we'll ask later.
$opportunity = null;
$stage = null;
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
protected function getCacheKey(string $object, ?int $userId = null): ?string
{
$key = $this->team->getId() . $object;
$keySuffix = $this->getOwnerKeySuffix($userId);
return $key . $keySuffix;
}
private function getOwnerKeySuffix(?int $userId = null): string
{
return $userId === null ? '' : (string) $userId;
}
/**
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
*}
*/
public function matchByPhone(string $phone, ?string $rawPhoneNumber = null, ?int $userId = null): ?array
{
if (str_contains($phone, '**')) {
return null;
}
// trim all whitespaces if present so the lookup doesn't fail
$phone = str_replace(' ', '', $phone);
// Check if the user is internal.
if ($this->isPhoneNumberOfTeamMember($phone)) {
return null;
}
$response = $this->searchForPhoneNumber($phone);
if (empty($response)) {
return null;
}
// This would ideally importContact instead but the response type differs.
$contact = $this->findAndSyncContact($response['results'][0]['id']);
if (! $contact instanceof Contact) {
return null;
}
$account = $contact->account;
$countryCode = $contact->country_code ?? $account->country_code ?? null;
try {
$hsOpportunities = $this->findOpportunities(
$account?->crm_provider_id,
$contact->crm_provider_id,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
$opportunity = null;
$stage = null;
try {
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
} catch (Exception $e) {
$this->logger->debug('[HubSpot] Opportunity failed to sync.', [
'reason' => $e->getMessage(),
]);
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
private function isPhoneNumberOfTeamMember(string $phone): bool
{
$teamRepository = app(TeamRepository::class);
$user = $teamRepository->findTeamMemberByPhone($this->team, $phone);
if ($user instanceof User) {
return true;
}
return false;
}
private function findAndSyncContact(string $crmId): ?Contact
{
try {
return $this->syncContact($crmId);
} catch (Exception $exception) {
$this->logger->info('[HubSpot] Phone match failed', [
'reason' => $exception->getMessage(),
]);
return null;
}
}
private function hasResults(array $response): bool
{
return isset($response['total']) && is_numeric($response['total']) && $response['total'] > 0;
}
private function searchForPhoneNumber(string $phone): array
{
// Normalizes the provided phone number for the API search.
$normalizedPhone = $this->normalizePhoneNumber($phone);
$payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone);
$this->logger->info('[HubSpot] Phone match search triggered', [
'phone' => $phone,
'normalizedPhone' => $normalizedPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($normalizedPhone, $payload);
if (! $this->hasResults($response)) {
$nationalPhone = preg_replace('/\D/', '', phone_national(null, $phone));
$payload = $this->payloadBuilder->generatePhoneSearchPayload($nationalPhone);
$this->logger->info('[HubSpot] Phone match national number search triggered', [
'phone' => $phone,
'nationalPhone' => $nationalPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($phone, $payload);
}
if (! $this->hasResults($response)) {
$payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone, true);
$this->logger->info('[HubSpot] Phone match alternative search triggered', [
'phone' => $phone,
'normalizedPhone' => $normalizedPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($phone, $payload);
}
return $this->hasResults($response) ? $response : [];
}
private function handlePhoneSearchRequest(string $phone, array $payload): array
{
try {
return $this->client->search('contacts', $payload);
} catch (Exception $exception) {
$this->logger->info('[HubSpot] Phone match failed', [
'phone' => $phone,
'reason' => $exception->getMessage(),
]);
return [];
}
}
private function normalizePhoneNumber(string $phone): string
{
return ltrim(phone_e164(null, $phone), '+0');
}
/**
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchByName(string $name, ?int $userId = null): ?array
{
// Don't waste time searching for single character strings.
if (\strlen($name) <= 1) {
return null;
}
$cacheKey = $this->getCacheKey($name, $userId);
$result = Cache::remember($cacheKey, 60, function () use ($name, $userId) {
$payload = $this->payloadBuilder->generateSearchContactsByNamePayload(
$name,
$this->getContactFields()
);
$hsContacts = $this->client->getPaginatedData($payload, 'contact');
if (empty($hsContacts['results'])) {
return false;
}
$contact = $this->importContact($hsContacts['results'][0]);
if ($contact === null) {
return false;
}
$account = $contact->account;
$countryCode = $contact->country_code ?? $account->country_code ?? null;
try {
$hsOpportunities = $this->findOpportunities(
$account ? $account->crm_provider_id : null,
$contact->crm_provider_id,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
$opportunity = null;
$stage = null;
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
});
return is_array($result) ? $result : null;
}
private function convertActivityAssociations(Activity $activity): array
{
return [
'contactIds' => $this->getParticipantsIds($activity),
'companyIds' => $activity->hasAccount() ? [$activity->account->crm_provider_id] : [],
'dealIds' => $activity->hasOpportunity() ? [$activity->opportunity->crm_provider_id] : [],
'ownerIds' => [],
];
}
private function getParticipantsIds(Activity $activity): array
{
$attendees = [];
$participantRepository = app(ParticipantRepository::class);
$participants = $participantRepository->getParticipantsWhoEnteredMeeting($activity);
foreach ($participants as $participant) {
if ($participant->user_id || $participant->isCoach()) {
continue;
}
$contact = $participant->contact()->first();
if ($contact && $contact->crm_provider_id) {
$attendees[] = $contact->crm_provider_id;
} else {
if (! empty($participant->name)) {
$attendeeData = $this->fetchMissingAttendeeInfo($participant);
}
if (! empty($attendeeData['id'])) {
$attendees[] = $attendeeData['id'];
}
}
}
if ($activity->hasContact()) {
$attendees[] = $activity->contact->crm_provider_id;
}
return array_unique($attendees);
}
private function fetchMissingAttendeeInfo(Participant $participant): array
{
// Check if we need to look inside an account context.
$activity = $participant->getActivity();
$companyId = $activity->hasAccount() ? $activity->getAccount()->crm_provider_id : null;
// First check the local data.
/** @var Contact[] $contacts */
$contacts = $this->team->contacts()
->with('account')
->where('name', $participant->name)
->whereNotNull('email')
->get();
foreach ($contacts as $contact) {
// If we have a company in scope, check the contact is associated to it.
if (
$companyId !== null
&& ($contact->account_id === null || $companyId !== $contact->account->crm_provider_id)
) {
continue;
}
return [
'id' => $contact->crm_provider_id,
'email' => $contact->email,
];
}
$payload = $this->generateNameSearchPayload($participant->name, 0, 20);
try {
$response = $this->client->getNewInstance()->crm()->contacts()->searchApi()->doSearch($payload);
// TODO add some logic to choose the most suitable contact if multiple
foreach ($response['results'] as $object) {
$properties = $object['properties'];
if (empty($object['properties']) === false) {
// Check the company matches the contact.
// Todo: Move this check inside the API search.
if ($companyId !== null && $companyId !== $properties['associatedcompanyid']) {
continue;
}
return [
'id' => $object['id'],
'email' => $properties['email'],
];
}
}
} catch (Exception $e) {
$this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [
'teamId' => $this->team->id_string,
'request' => $payload,
'reason' => $e->getMessage(),
]);
}
return [];
}
/**
* Store transcripts as note engagement.
*
* @throws Exception
*/
public function createTranscriptNotes(Activity $activity): void
{
// For HS no need to check if Crm profile - Log Notes field is enabled
// We only check if store_transcript toggle is enabled on crm profile.
$engagement = [
'active' => true,
'ownerId' => $this->profile->crm_provider_id,
'timestamp' => $activity->created_at->tz($activity->user->timezone)->getTimestamp() * 1000,
'type' => 'NOTE',
];
// Generate activity transcription.
$transcriptionData = $this->generateTranscription($activity);
// Truncate Notes with max notes length because transcription text could be very long.
$transcripts = mb_strimwidth($transcriptionData, 0, static::ENGAGEMENT_BODY_MAX_LENGTH);
$metadata = [
'body' => $transcripts,
];
$associations = $this->convertActivityAssociations($activity);
try {
$hsEngagement = $this->client
->getInstance()
->engagements()
->create($engagement, $associations, $metadata);
$this->logCrmEngagementManipulation(self::ACTION_CREATE, $engagement, $metadata, $associations);
$noteId = $hsEngagement->data->engagement->id;
// Store crm logged id in transcription.
$transcription = $activity->getTranscription();
$transcription->crm_activity_id = $noteId;
$transcription->save();
} catch (Exception $e) {
Sentry::captureException($e);
}
}
/*
* @inheritdoc
*/
public function updateRecord(string $objectType, string $objectId, array $data, array $headers = []): void
{
$payload = [
'properties' => $data,
];
try {
switch ($objectType) {
case FieldData::OBJECT_OPPORTUNITY:
$this->client->getNewInstance()->crm()->deals()->basicApi()->update($objectId, $payload);
break;
case FieldData::OBJECT_CONTACT:
$this->client->getNewInstance()->crm()->contacts()->basicApi()->update($objectId, $payload);
break;
case FieldData::OBJECT_ACCOUNT:
$this->client->getNewInstance()->crm()->companies()->basicApi()->update($objectId, $payload);
break;
case FieldData::OBJECT_TASK:
// Endpoint for Engagements not ready
$engagements = [
'type' => 'TASK',
];
$metadata = $data;
$this->client->getInstance()->engagements()->update($objectId, $engagements, $metadata);
$this->logCrmEngagementManipulation(
self::ACTION_UPDATE,
['crmId...
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JY-20725-handle-HS-search-rate-limit, menu","depth":5,"on_screen":true,"help_text":"Git Branch: JY-20725-handle-HS-search-rate-limit","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"AskJiminnyReportActivityServiceTest","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'AskJiminnyReportActivityServiceTest'","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'AskJiminnyReportActivityServiceTest'","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"IDE and Project Settings","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.088194445,"height":0.027777778},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"19","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {\n\"headers\":{\n\"Date\":[\"Thu,07 May 2026 14:21:15 GMT\"],\n \"Content-Type\":[\"application/json;charset=utf-8\"],\n \"Transfer-Encoding\":[\"chunked\"],\n \"Connection\":[\"keep-alive\"],\n \"CF-Ray\":[\"9f80deb8db60dc3a-SOF\"],\n \"CF-Cache-Status\":[\"DYNAMIC\"],\n \"Strict-Transport-Security\":[\"max-age=31536000; includeSubDomains; preload\"],\n \"Vary\":[\"origin,\n accept-encoding\"],\n \"access-control-allow-credentials\":[\"false\"],\n \"server-timing\":[\"hcid;desc=\\\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\\\",\n cfr;desc=\\\"9f80deb8e7c6dc3a-IAD\\\"\"],\n \"x-content-type-options\":[\"nosniff\"],\n \"x-hubspot-correlation-id\":[\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\"],\n \"Set-Cookie\":[\"__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-1.0.1.1-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,\n 07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None\"],\n \"Report-To\":[\"{\n\\\"endpoints\\\":[{\n\\\"url\\\":\\\"https:\\\\/\\\\/a.nel.cloudflare.com\\\\/report\\\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\\\"}],\n\\\"group\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"NEL\":[\"{\n\\\"success_fraction\\\":0.01,\n\\\"report_to\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"Server\":[\"cloudflare\"]}} {\n\"correlation_id\":\"95236535-ec98-4541-b92a-adfa73b69eab\",\n\"trace_id\":\"c7ab8365-903f-46d4-9403-0e5b551e3545\"}","depth":4,"on_screen":true,"value":"[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {\n\"headers\":{\n\"Date\":[\"Thu,07 May 2026 14:21:15 GMT\"],\n \"Content-Type\":[\"application/json;charset=utf-8\"],\n \"Transfer-Encoding\":[\"chunked\"],\n \"Connection\":[\"keep-alive\"],\n \"CF-Ray\":[\"9f80deb8db60dc3a-SOF\"],\n \"CF-Cache-Status\":[\"DYNAMIC\"],\n \"Strict-Transport-Security\":[\"max-age=31536000; includeSubDomains; preload\"],\n \"Vary\":[\"origin,\n accept-encoding\"],\n \"access-control-allow-credentials\":[\"false\"],\n \"server-timing\":[\"hcid;desc=\\\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\\\",\n cfr;desc=\\\"9f80deb8e7c6dc3a-IAD\\\"\"],\n \"x-content-type-options\":[\"nosniff\"],\n \"x-hubspot-correlation-id\":[\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\"],\n \"Set-Cookie\":[\"__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-1.0.1.1-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,\n 07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None\"],\n \"Report-To\":[\"{\n\\\"endpoints\\\":[{\n\\\"url\\\":\\\"https:\\\\/\\\\/a.nel.cloudflare.com\\\\/report\\\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\\\"}],\n\\\"group\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"NEL\":[\"{\n\\\"success_fraction\\\":0.01,\n\\\"report_to\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"Server\":[\"cloudflare\"]}} {\n\"correlation_id\":\"95236535-ec98-4541-b92a-adfa73b69eab\",\n\"trace_id\":\"c7ab8365-903f-46d4-9403-0e5b551e3545\"}","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.088194445,"height":0.027777778},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"7","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"48","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"1","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"33","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"1","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\nnamespace Jiminny\\Services\\Crm\\Hubspot;\n\nuse Carbon\\Carbon;\nuse Exception;\nuse Generator;\nuse GuzzleHttp\\Exception\\RequestException;\nuse Illuminate\\Support\\Facades\\Cache;\nuse InvalidArgumentException;\nuse Jiminny\\Contracts\\Repositories\\TeamRepository;\nuse Jiminny\\Contracts\\Services\\Crm\\ClientInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\FetchRelatedActivityInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\LayoutManagementInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\MatchCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\Provider\\HubspotInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityLookupInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityManipulationInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SavePlaybackLinkToCrmInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SendSummaryToCrmInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SettingsInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmMetadataInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\VerifyTaskExistsInterface;\nuse Jiminny\\Exceptions\\CrmException;\nuse Jiminny\\Exceptions\\HttpNotFoundException;\nuse Jiminny\\Jobs\\Crm\\NoteObject;\nuse Jiminny\\Models\\Account;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Contact;\nuse Jiminny\\Models\\Contracts\\ActivityContract;\nuse Jiminny\\Models\\Crm\\BusinessProcess;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Models\\Crm\\FieldData;\nuse Jiminny\\Models\\Crm\\Layout;\nuse Jiminny\\Models\\Crm\\Profile;\nuse Jiminny\\Models\\Lead;\nuse Jiminny\\Models\\Opportunity;\nuse Jiminny\\Models\\Participant;\nuse Jiminny\\Models\\Playbook;\nuse Jiminny\\Models\\SocialAccount;\nuse Jiminny\\Models\\Stage;\nuse Jiminny\\Models\\User;\nuse Jiminny\\Repositories\\Crm\\CrmEntityRepository;\nuse Jiminny\\Repositories\\Crm\\FieldRepository;\nuse Jiminny\\Repositories\\Crm\\ProfileRepository;\nuse Jiminny\\Repositories\\ParticipantRepository;\nuse Jiminny\\Services\\Avatar\\ProspectPhotoPathService;\nuse Jiminny\\Services\\Crm\\BaseService;\nuse Jiminny\\Services\\Crm\\Hubspot\\Actions\\SyncArchivedProfilesAction;\nuse Jiminny\\Services\\Crm\\Hubspot\\Fields\\ValueNormalizer;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\OpportunitySyncTrait;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\SyncCrmEntitiesTrait;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\SyncFieldsTrait;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\WriteCrmTrait;\nuse Jiminny\\Services\\Crm\\MatchDomainByEmailInterface;\nuse Jiminny\\Services\\Crm\\OpportunitySyncStrategyResolver;\nuse Jiminny\\Services\\Crm\\ResolveCompanyNameByEmailTrait;\nuse Jiminny\\Utils\\PlaybackUrlBuilder;\nuse Sentry;\nuse SevenShores\\Hubspot\\Exceptions\\BadRequest;\nuse Throwable;\nuse UnexpectedValueException;\n\n/**\n * @phpstan-type CrmFieldDefinition array{\n * name: string,\n * label: string,\n * description: string,\n * type: string,\n * fieldType: string,\n * hidden: bool,\n * showCurrencySymbol: bool,\n * options: array<array{\n * id: string,\n * label: string,\n * value?: string,\n * }\n */\nclass Service extends BaseService implements\n HubspotInterface,\n SyncCrmEntitiesInterface,\n SyncCrmMetadataInterface,\n SendSummaryToCrmInterface,\n MatchDomainByEmailInterface,\n SavePlaybackLinkToCrmInterface,\n RemoteEntityManipulationInterface,\n FetchRelatedActivityInterface,\n LayoutManagementInterface,\n SettingsInterface,\n MatchCrmEntitiesInterface,\n RemoteEntityLookupInterface,\n VerifyTaskExistsInterface\n{\n use ResolveCompanyNameByEmailTrait;\n use SyncCrmEntitiesTrait;\n use WriteCrmTrait;\n use SyncFieldsTrait;\n use OpportunitySyncTrait;\n\n private const int ENGAGEMENT_BODY_MAX_LENGTH = 65536;\n\n private const string LOG_DATE_FORMAT = 'Y-m-d H:i:s';\n private const int BATCH_UPDATE_LIMIT = 100;\n private const string TEN_SECONDLY_ROLLING_POLICY = 'TEN_SECONDLY_ROLLING';\n private const int TEN_SECONDLY_ROLLING_LIMIT = 10;\n\n private const string TYPE_NOTE = 'NOTE';\n\n private const string TYPE_MEETING = 'MEETING';\n\n private const string TYPE_CALL = 'CALL';\n\n private const string API_URL = 'https://api.hubapi.com';\n\n // NB: v1 is legacy - v3 is the newest\n private const string ENDPOINT_PIPELINES = '/crm-pipelines/v1/pipelines/';\n private const string PIPELINE_OBJECT_TYPE_DEALS = 'deals';\n\n private const int TASK_VERIFICATION_CACHE_TTL = 86400; // 1 day\n\n /**\n * @var ClientInterface|Client\n */\n protected $client;\n protected OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;\n protected CrmEntityRepository $crmEntityRepository;\n protected ProspectPhotoPathService $prospectPhotoPathService;\n\n private SyncFieldAction $syncFieldAction;\n private PayloadBuilder $payloadBuilder;\n private SyncRelatedActivityManager $syncRelatedActivityManager;\n private SyncArchivedProfilesAction $syncArchivedProfilesAction;\n private WebhookSyncBatchProcessor $batchProcessor;\n\n public function __construct(\n Client $client,\n SyncFieldAction $syncFieldAction,\n PayloadBuilder $payloadBuilder,\n ProspectPhotoPathService $prospectPhotoPathService,\n SyncArchivedProfilesAction $syncArchivedProfilesAction,\n WebhookSyncBatchProcessor $batchProcessor,\n ) {\n parent::__construct();\n\n $this->client = $client;\n $this->syncFieldAction = $syncFieldAction;\n $this->prospectPhotoPathService = $prospectPhotoPathService;\n $this->payloadBuilder = $payloadBuilder;\n $this->syncArchivedProfilesAction = $syncArchivedProfilesAction;\n $this->batchProcessor = $batchProcessor;\n $this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [\n 'client' => $this->client,\n ]);\n $this->syncRelatedActivityManager = app(SyncRelatedActivityManager::class, [\n 'client' => $this->client,\n 'payloadBuilder' => $this->payloadBuilder,\n 'logger' => $this->logger,\n ]);\n $this->crmEntityRepository = app(CrmEntityRepository::class);\n $this->dealFieldsService = app(DealFieldsService::class);\n }\n\n public function getDisplayName(): string\n {\n return 'HubSpot';\n }\n\n protected function getOAuthAccount(User $user): ?SocialAccount\n {\n // In this case, the Account Owner is always the connection for any API operations.\n $owner = $user->team->owner;\n\n return $owner->getSocialAccount(SocialAccount::PROVIDER_HUBSPOT);\n }\n\n public function getClient(): Client\n {\n /** @var Client */\n return $this->client;\n }\n\n /**\n * Convert raw field data into a format compatible with CRM APIs.\n *\n * @param bool $internal Direction of the conversion.\n * True is pulling from CRM, false normalize before sending to CRM.\n */\n public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string\n {\n return ValueNormalizer::normalize(\n fieldType: $fieldType,\n fieldValue: $fieldValue,\n isInbound: $internal,\n );\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultFields(string $activityType): array\n {\n $fields = [];\n\n if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {\n $defaultFields = FieldDefinitions::defaultTaskFields();\n\n // This lazy creates these fields if not already setup.\n foreach ($defaultFields as $defaultField) {\n $fields[] = $this->config->fields()->firstOrCreate($defaultField);\n }\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultActivityField(string $activityType): Field\n {\n /** @var Field $activityField */\n $activityField = $this->config->fields()->where([\n 'crm_provider_id' => 'activityType',\n 'object_type' => $activityType,\n ])->first();\n\n return $activityField;\n }\n\n /**\n * @inheritdoc\n */\n public function getSupportedPlaybookTypes(): array\n {\n return [Playbook::ACTIVITY_TYPE_TASK];\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultActivityLayoutFields(string $activityType, string $layoutType): array\n {\n $fields = [];\n\n if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {\n // Outcome should always be provided calls/meetings.\n $fieldData = [\n [\n 'crm_provider_id' => $layoutType === Layout::TYPE_SOFTPHONE_SUMMARY ? 'disposition' : 'meetingOutcome',\n 'object_type' => Field::OBJECT_TASK,\n ],\n ];\n\n foreach ($fieldData as $data) {\n $field = $this->config->fields()->where($data)->first();\n\n // Only add the field if it is created, which it should be.\n if ($field) {\n $fields[] = $field;\n }\n }\n }\n\n return $fields;\n }\n\n public function getDealInsightsFields(): array\n {\n return FieldDefinitions::dealInsightsFields();\n }\n\n protected function getDefaultFollowupLayoutFields(string $activityType): array\n {\n $fields = [];\n $fieldRepo = app(FieldRepository::class);\n $fieldData = FieldDefinitions::followupFieldsFilter();\n\n foreach ($fieldData as $data) {\n $field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $data);\n\n // Only add the field if it is created, which it should be.\n if ($field) {\n $fields[] = $field;\n }\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function syncField(Field $field): void\n {\n switch ($field->object_type) {\n case Field::OBJECT_ACCOUNT:\n $crmField = $this->client->getInstance()->companyProperties()->get($field->crm_provider_id);\n\n break;\n case Field::OBJECT_CONTACT:\n $crmField = $this->client->getInstance()->contactProperties()->get($field->crm_provider_id);\n\n break;\n case Field::OBJECT_OPPORTUNITY:\n $crmField = $this->client->getInstance()->dealProperties()->get($field->crm_provider_id);\n\n break;\n case Field::OBJECT_TASK:\n $this->syncSingleTaskField($field);\n\n return;\n default:\n return;\n }\n\n $this->syncFieldAction->execute($field, $crmField->toArray());\n }\n\n /**\n * @param array<array{\n * id:string,\n * label:string,\n * value?:string\n * }> $options\n *\n * @throws CrmException\n *\n * @return FieldData[]\n *\n */\n public function importPicklistValues(\n Field $field,\n array $options = [['id' => '', 'label' => '', 'value' => '']],\n ): array {\n if (! empty($options[0]['id']) || ! empty($options[0]['value'])) {\n // We already have the options, no need to fetch them again\n return $this->importOptions($field, $options);\n }\n\n $options = [];\n\n switch ($field->getObjectType()) {\n case Field::OBJECT_ACCOUNT:\n $options = $this->getClient()->fetchPropertyOptions('company', $field->getCrmProviderId());\n\n break;\n\n case Field::OBJECT_CONTACT:\n $options = $this->getClient()->fetchPropertyOptions('contact', $field->getCrmProviderId());\n\n break;\n\n case Field::OBJECT_OPPORTUNITY:\n // Hubspot has different endpoint for stages\n $options = $this->getClient()->fetchOpportunityFieldOptions($field);\n\n break;\n\n case Field::OBJECT_TASK:\n if ($field->getCrmProviderId() === 'disposition') {\n $options = $this->getClient()->fetchDispositionFieldOptions();\n } elseif (in_array($field->getCrmProviderId(), ['meetingOutcome', 'activityType'])) {\n $options = $this->getClient()->fetchMeetingOutcomeFieldOptions($field);\n }\n\n break;\n\n default:\n $this->logger->warning('Invalid object type', [\n 'object_type' => $field->getObjectType(),\n 'field_id' => $field->getId(),\n ]);\n\n throw new CrmException('Invalid object type');\n }\n\n return $this->importOptions($field, $options);\n }\n\n /**\n * @inheritdoc\n */\n public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage\n {\n $missingStage = null;\n\n try {\n // Use the HubSpot API client instead of the SDK crmPipelines() method\n $endpoint = self::getDealsPipelinesEndpoint();\n $pipelinesResponse = $this->client->getInstance()->getClient()->request('GET', $endpoint);\n $pipelines = $pipelinesResponse->data->results;\n } catch (RequestException|BadRequest $exception) {\n throw $exception;\n }\n\n foreach ($pipelines as $pipeline) {\n $stages = [];\n\n // We create a business process to contain the pipeline, and store all stages against it.\n $p = ResponseNormalize::normalizePipeline($pipeline);\n\n // Create/update business process for this pipeline\n $businessProcess = $this->config->businessProcesses()->updateOrCreate([\n 'crm_provider_id' => $p['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($p['label'], 0, 150),\n 'type' => BusinessProcess::TYPE_OPPORTUNITY,\n 'is_selectable' => $p['active'],\n ]);\n\n // A record type is really a clone of the business process, used to store which record uses which pipeline.\n // Create/update record type clone\n $this->config->recordTypes()->updateOrCreate([\n 'crm_provider_id' => $p['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($p['label'], 0, 150),\n 'is_selectable' => $p['active'],\n 'business_process_id' => $businessProcess->id ?? null,\n ]);\n\n // Stages - fetch all existing stages upfront to avoid N+1 queries\n $existingStages = $this->config->stages()\n ->withTrashed()\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->get()\n ->keyBy('crm_provider_id');\n\n foreach ($p['stages'] as $dealStage) {\n $s = ResponseNormalize::normalizeDealStage($dealStage);\n\n /** @var ?Stage $existingStage */\n $existingStage = $existingStages->get($s['id']);\n\n // Restore soft-deleted stages that are now active in HubSpot\n if ($existingStage?->trashed() && $s['active']) {\n $existingStage->restore();\n }\n\n // Upsert stage (updates soft-deleted records without restoring them)\n $stage = $this->config->stages()->withTrashed()->updateOrCreate([\n 'crm_provider_id' => $s['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($s['label'], 0, 50),\n 'label' => mb_strimwidth($s['label'], 0, 191),\n 'type' => Stage::TYPE_OPPORTUNITY,\n 'sequence' => $s['displayOrder'],\n 'is_selectable' => $s['active'],\n 'probability' => $s['probability'] * 100,\n ]);\n\n if ($missingStageName === $s['id']) {\n $missingStage = $stage;\n }\n\n $stages[] = $stage->id;\n }\n\n $businessProcess->stages()->sync($stages);\n }\n\n return $missingStage;\n }\n\n /**\n * @inheritdoc\n */\n public function syncOrganization(): void\n {\n try {\n $endpoint = 'https://api.hubapi.com/integrations/v1/me';\n $response = $this->client->getInstance()->getClient()->request('get', $endpoint);\n\n $accountData = $response->data;\n $this->config->update(['default_currency' => $accountData->currency]);\n } catch (BadRequest $e) {\n throw new CrmException('Could not sync the organization.', $e->getCode(), $e);\n }\n }\n\n /**\n * @inheritdoc\n *\n * @throws CrmException\n */\n public function syncProfiles(?User $userToSearch = null): ?Profile\n {\n $this->syncArchivedProfilesAction->execute($this->team, $this->client, $this->config);\n\n try {\n $owners = $this->client->getOwners();\n } catch (\\HubSpot\\Client\\Crm\\Owners\\ApiException $e) {\n $this->logger->error('[HubSpot] Could not sync the profiles.', [\n 'team_id' => $this->team->getId(),\n 'reason' => $e->getMessage(),\n ]);\n\n throw new CrmException('Could not sync the profiles.', $e->getCode(), $e);\n }\n\n $profileRepository = app(ProfileRepository::class);\n $teamRepository = app(TeamRepository::class);\n\n foreach ($owners as $owner) {\n if ($owner->getArchived()) {\n // not supposed to fetch archived, but log anyway\n $this->logger->warning('[HubSpot] Found archived owner', [\n 'crm_provider_id' => $owner->getId(),\n 'email' => $owner->getEmail(),\n ]);\n\n continue;\n }\n\n $email = $owner->getEmail();\n if ($email === null) {\n continue;\n }\n\n $user = $teamRepository->findActiveTeamMemberByEmail($this->team, $email);\n\n if (! $user instanceof User) {\n continue;\n }\n\n $profile = $profileRepository->updateOrCreateProfile($user, [\n 'crm_configuration_id' => $this->config->getId(),\n 'crm_provider_id' => $owner->getId(),\n ]);\n\n if ($userToSearch && $userToSearch->getId() === $user->getId()) {\n return $profile;\n }\n }\n\n return null;\n }\n\n private function generateNameSearchPayload(string $name, int $offset, int $limit): array\n {\n $payload = [\n 'query' => $name,\n 'sorts' => [\n [\n 'propertyName' => 'modifieddate',\n 'direction' => 'DESCENDING',\n ],\n ],\n 'properties' => [\n 'email',\n 'firstname',\n 'lastname',\n 'country',\n 'phone',\n 'mobilephone',\n 'jobtitle',\n 'hubspot_owner_id',\n 'associatedcompanyid',\n 'photo',\n 'industry',\n 'name',\n 'company',\n ],\n 'limit' => $limit,\n 'after' => $offset,\n ];\n\n $this->logger->debug('[HubSpot] CRM Search requested', [\n 'request' => $payload,\n ]);\n\n return $payload;\n }\n\n /**\n * @inheritdoc\n */\n public function find(string $name, array $scopes): array\n {\n $count = $this->limit ?? 20;\n $offset = $this->offset ?? 0;\n\n /** @var array<int, array<string, mixed>> */\n return Cache::remember(\n key: $this->team->getId() . $name . $count . $offset,\n ttl: 300,\n callback: function () use ($name, $offset, $count): array {\n $data = [];\n\n // Use the new V3 API to find contacts based on additional fields.\n foreach (['companies', 'contacts'] as $objectType) {\n $payload = $this->generateNameSearchPayload($name, $offset, $count);\n $type = $objectType === 'companies' ? 'account' : 'contact';\n\n try {\n $response = $this->client->search($objectType, $payload);\n\n // Build mapped list.\n foreach ($response['results'] as $object) {\n $properties = $object['properties'];\n\n $objectName = $this->buildContactName($properties);\n\n $record = [\n 'crmId' => $object['id'],\n // Pass crmUrl to the FE, needed for success message in the extension when you log activity.\n 'crmUrl' => $this->generateProviderUrl($object['id'], $type),\n 'name' => $objectName,\n 'prospectType' => $type,\n 'phoneNumbers' => [],\n ];\n\n if ($type === 'account') {\n $record['industry'] = $properties['industry'] ?? null;\n } else {\n $record['title'] = $properties['jobtitle'] ?? null;\n $record['organization'] = $properties['company'] ?? null;\n }\n\n $countryCode = $this->buildContactCountry($properties);\n $parsedNumber = $this->buildContactPhone($countryCode, $properties);\n\n // Add phone number to record.\n if (! empty($parsedNumber['phone'])) {\n $record['phoneNumbers'][] = [\n 'number' => $parsedNumber['phone'],\n 'nationalFormat' => phone_national($countryCode, $parsedNumber['phone']),\n 'type' => 'phone',\n ];\n }\n\n // Add mobile phone number to record.\n if (! empty($properties['mobilephone'])) {\n $mobileNumber = phone_e164($countryCode, $properties['mobilephone']);\n if ($mobileNumber !== null) {\n $record['phoneNumbers'][] = [\n 'number' => $mobileNumber,\n 'nationalFormat' => phone_national($countryCode, $mobileNumber),\n 'type' => 'mobile',\n ];\n }\n }\n\n $data[] = $record;\n }\n } catch (BadRequest $e) {\n $this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [\n 'teamId' => $this->team->getUuid(),\n 'request' => $payload,\n 'reason' => $e->getMessage(),\n ]);\n\n throw $e;\n }\n }\n\n return $data;\n },\n );\n }\n\n\n /**\n * @inheritdoc\n */\n public function findOpportunities(?string $crmAccountId, ?string $crmContactId, ?int $userId = null): array\n {\n $data = [];\n $ownerData = [];\n $ownerId = null;\n\n if ($crmAccountId === null) {\n return $data;\n }\n\n if ($userId) {\n $profileRepository = app(ProfileRepository::class);\n $profile = $profileRepository->findProfileByUserId($this->config, $userId);\n\n $ownerId = $profile instanceof Profile ? $profile->getCrmProviderId() : null;\n }\n\n $closedStages = $this->getClosedDealStages();\n $payload = $this->payloadBuilder->generateOpportunitiesSearchPayload(\n $this->config,\n $crmAccountId,\n $closedStages,\n );\n\n $results = $this->client->getPaginatedData($payload, 'deals');\n\n foreach ($results['results'] as $object) {\n $properties = $object['properties'];\n\n $amount = null;\n if (empty($properties['amount']) === false) {\n $currency = $properties['deal_currency_code'] ?? $this->config->default_currency;\n\n // Values can contain commas and any junk so strip them.\n $value = (float) preg_replace('/[^\\d.]/', '', $properties['amount']);\n $amount = formatCurrency($value, $currency);\n }\n\n $businessProcess = $this->config\n ->businessProcesses()\n ->where('crm_provider_id', $properties['pipeline'])\n ->first();\n\n if ($businessProcess === null) {\n // Import it.\n $stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $properties['dealstage']);\n $businessProcess = $this->config\n ->businessProcesses()\n ->where('crm_provider_id', $properties['pipeline'])\n ->first();\n } else {\n $stage = $businessProcess\n ->stages()\n ->where('crm_provider_id', $properties['dealstage'])\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->first();\n\n if ($stage === null) {\n // Import it.\n $stage = $this->importStages(null, $properties['dealstage']);\n }\n }\n\n $recordType = null;\n if ($businessProcess) {\n $recordType = $businessProcess->recordTypes()->first();\n }\n\n $isWon = in_array($properties['dealstage'], $closedStages['won']);\n $isLost = in_array($properties['dealstage'], $closedStages['lost']);\n\n $record = [\n 'crmId' => $object['id'],\n 'name' => $properties['dealname'] ?? 'Unknown Deal',\n 'value' => $amount,\n 'won' => $isWon,\n 'closed' => $isWon || $isLost,\n 'stage' => [\n 'id' => $stage?->getUuid() ?? '',\n 'name' => $stage?->getName() ?? '',\n ],\n ];\n\n if ($recordType) {\n $record += [\n 'recordType' => [\n 'id' => $recordType->id_string,\n 'name' => $recordType->name,\n ],\n ];\n }\n\n if ($ownerId && isset($properties['hubspot_owner_id']) && $properties['hubspot_owner_id'] === $ownerId) {\n $ownerData[] = $record;\n }\n\n $data[] = $record;\n }\n\n if (! empty($ownerData)) {\n return $ownerData;\n }\n\n return $data;\n }\n\n /**\n * @inheritdoc\n */\n public function getTasks(?string $objectType, string $objectId, ?string $opportunityId): array\n {\n $data = [];\n switch ($objectType) {\n case 'contact':\n $hsObject = 'contact';\n\n break;\n case 'account':\n $hsObject = 'company';\n\n break;\n default:\n // This is a hack to prioritise and override a contact/company with a deal.\n if ($opportunityId) {\n $hsObject = 'deal';\n $objectId = $opportunityId;\n } else {\n throw new InvalidArgumentException('Object type not supported.');\n }\n }\n\n $engagementTypes = ['meetings', 'tasks'];\n\n foreach ($engagementTypes as $engagementType) {\n $payload = $this->payloadBuilder->getLinkToTaskPayload($hsObject, $objectId, $engagementType);\n\n $this->logger->info('[HubSpot] CRM Search requested', [\n 'request' => $payload,\n ]);\n\n $engagements = $this->client->getPaginatedData($payload, $engagementType);\n\n foreach ($engagements['results'] as $engagement) {\n if ($engagementType == 'meetings') {\n $title = $engagement['properties']['hs_meeting_title'] ?? 'Scheduled meeting';\n } elseif ($engagementType == 'tasks') {\n $title = $engagement['properties']['hs_task_subject'];\n } else {\n $title = 'Scheduled meeting';\n }\n\n $data[] = [\n 'crmId' => $engagement['id'],\n 'subject' => $title,\n 'due' => $engagement['properties']['hs_timestamp'],\n 'type' => $engagement['properties']['hs_activity_type'] ?? null,\n ];\n }\n }\n\n usort($data, function ($item1, $item2) {\n return $item2['due'] <=> $item1['due'];\n });\n\n return $data;\n }\n\n /**\n * Try to find CRM Objects using email address\n *\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n public function matchExactlyByEmail(string $email, ?int $userId = null): ?array\n {\n $contactProperties = [\n 'email',\n 'firstname',\n 'lastname',\n 'country',\n 'phone',\n 'mobilephone',\n 'jobtitle',\n 'hubspot_owner_id',\n 'associatedcompanyid',\n 'photo',\n ];\n $contact = null;\n $account = null;\n\n try {\n $hsContact = $this->getClient()->getContactByEmail($email, $contactProperties);\n\n if ($hsContact) {\n $contact = $this->importContact($hsContact);\n $account = $contact->account;\n }\n\n $data = $this->convertCrmData($contact, $account, $userId);\n\n return ! empty(array_filter($data)) ? $data : null;\n } catch (BadRequest $e) {\n $this->logger->warning('[HubSpot] Search failed', [\n 'team_id' => $this->team->getId(),\n 'search_identifier' => $email,\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return null;\n }\n\n public function getDomain(string $email): ?string\n {\n return $this->getDomainFromEmail($email);\n }\n\n /**\n * Try to find CRM objects using domain name of the email address\n *\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n public function matchByDomain(string $domain, ?int $userId = null): ?array\n {\n $companyName = $domain;\n\n // Try to find a company matching their email domain.\n $companyProperties = [\n 'country',\n 'phone',\n 'name',\n 'hs_avatar_filemanager_key',\n 'industry',\n 'hubspot_owner_id',\n 'domain',\n ];\n\n try {\n $hsAccounts = $this->client\n ->getInstance()\n ->companies()\n ->searchByDomain($companyName, $companyProperties);\n } catch (Throwable $e) {\n $this->logger->info('[HubSpot] Search failed', [\n 'error' => $e->getMessage(),\n 'domain' => $domain,\n ]);\n\n return null;\n }\n\n $account = null;\n // If there are multiple accounts, don't guess, we'll ask later.\n if (\\count($hsAccounts->data->results) === 1) {\n // Persist this remote object.\n $account = $this->syncAccount($hsAccounts->data->results[0]->companyId);\n }\n\n $data = $this->convertCrmData(null, $account, $userId);\n\n return ! empty(array_filter($data)) ? $data : null;\n }\n\n /**\n * @return array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n protected function convertCrmData(?Contact $contact, ?Account $account, ?int $userId = null): array\n {\n $countryCode = null;\n if ($contact && $contact->country_code) {\n $countryCode = $contact->country_code;\n } elseif ($account && $account->country_code) {\n $countryCode = $account->country_code;\n }\n\n try {\n $hsOpportunities = $this->findOpportunities(\n $account ? $account->crm_provider_id : null,\n $contact ? $contact->crm_provider_id : null,\n $userId\n );\n } catch (Exception $e) {\n $hsOpportunities = [];\n }\n\n // If there are multiple opportunities, don't guess, we'll ask later.\n $opportunity = null;\n $stage = null;\n if (! empty($hsOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n }\n\n protected function getCacheKey(string $object, ?int $userId = null): ?string\n {\n $key = $this->team->getId() . $object;\n $keySuffix = $this->getOwnerKeySuffix($userId);\n\n return $key . $keySuffix;\n }\n\n private function getOwnerKeySuffix(?int $userId = null): string\n {\n return $userId === null ? '' : (string) $userId;\n }\n\n /**\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n *}\n */\n public function matchByPhone(string $phone, ?string $rawPhoneNumber = null, ?int $userId = null): ?array\n {\n if (str_contains($phone, '**')) {\n return null;\n }\n\n // trim all whitespaces if present so the lookup doesn't fail\n $phone = str_replace(' ', '', $phone);\n\n // Check if the user is internal.\n if ($this->isPhoneNumberOfTeamMember($phone)) {\n return null;\n }\n\n $response = $this->searchForPhoneNumber($phone);\n if (empty($response)) {\n return null;\n }\n\n // This would ideally importContact instead but the response type differs.\n $contact = $this->findAndSyncContact($response['results'][0]['id']);\n if (! $contact instanceof Contact) {\n return null;\n }\n\n $account = $contact->account;\n $countryCode = $contact->country_code ?? $account->country_code ?? null;\n\n try {\n $hsOpportunities = $this->findOpportunities(\n $account?->crm_provider_id,\n $contact->crm_provider_id,\n $userId\n );\n } catch (Exception $e) {\n $hsOpportunities = [];\n }\n\n $opportunity = null;\n $stage = null;\n\n try {\n if (! empty($hsOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n } catch (Exception $e) {\n $this->logger->debug('[HubSpot] Opportunity failed to sync.', [\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n }\n\n private function isPhoneNumberOfTeamMember(string $phone): bool\n {\n $teamRepository = app(TeamRepository::class);\n $user = $teamRepository->findTeamMemberByPhone($this->team, $phone);\n\n if ($user instanceof User) {\n return true;\n }\n\n return false;\n }\n\n private function findAndSyncContact(string $crmId): ?Contact\n {\n try {\n return $this->syncContact($crmId);\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Phone match failed', [\n 'reason' => $exception->getMessage(),\n ]);\n\n return null;\n }\n }\n\n private function hasResults(array $response): bool\n {\n return isset($response['total']) && is_numeric($response['total']) && $response['total'] > 0;\n }\n\n private function searchForPhoneNumber(string $phone): array\n {\n // Normalizes the provided phone number for the API search.\n $normalizedPhone = $this->normalizePhoneNumber($phone);\n $payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone);\n\n $this->logger->info('[HubSpot] Phone match search triggered', [\n 'phone' => $phone,\n 'normalizedPhone' => $normalizedPhone,\n 'payload' => $payload,\n ]);\n\n $response = $this->handlePhoneSearchRequest($normalizedPhone, $payload);\n\n if (! $this->hasResults($response)) {\n $nationalPhone = preg_replace('/\\D/', '', phone_national(null, $phone));\n $payload = $this->payloadBuilder->generatePhoneSearchPayload($nationalPhone);\n\n $this->logger->info('[HubSpot] Phone match national number search triggered', [\n 'phone' => $phone,\n 'nationalPhone' => $nationalPhone,\n 'payload' => $payload,\n ]);\n\n $response = $this->handlePhoneSearchRequest($phone, $payload);\n }\n\n if (! $this->hasResults($response)) {\n $payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone, true);\n\n $this->logger->info('[HubSpot] Phone match alternative search triggered', [\n 'phone' => $phone,\n 'normalizedPhone' => $normalizedPhone,\n 'payload' => $payload,\n ]);\n\n $response = $this->handlePhoneSearchRequest($phone, $payload);\n }\n\n return $this->hasResults($response) ? $response : [];\n }\n\n private function handlePhoneSearchRequest(string $phone, array $payload): array\n {\n try {\n return $this->client->search('contacts', $payload);\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Phone match failed', [\n 'phone' => $phone,\n 'reason' => $exception->getMessage(),\n ]);\n\n return [];\n }\n }\n\n private function normalizePhoneNumber(string $phone): string\n {\n return ltrim(phone_e164(null, $phone), '+0');\n }\n\n /**\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n public function matchByName(string $name, ?int $userId = null): ?array\n {\n // Don't waste time searching for single character strings.\n if (\\strlen($name) <= 1) {\n return null;\n }\n\n $cacheKey = $this->getCacheKey($name, $userId);\n\n $result = Cache::remember($cacheKey, 60, function () use ($name, $userId) {\n $payload = $this->payloadBuilder->generateSearchContactsByNamePayload(\n $name,\n $this->getContactFields()\n );\n\n $hsContacts = $this->client->getPaginatedData($payload, 'contact');\n if (empty($hsContacts['results'])) {\n return false;\n }\n\n $contact = $this->importContact($hsContacts['results'][0]);\n if ($contact === null) {\n return false;\n }\n\n $account = $contact->account;\n $countryCode = $contact->country_code ?? $account->country_code ?? null;\n\n try {\n $hsOpportunities = $this->findOpportunities(\n $account ? $account->crm_provider_id : null,\n $contact->crm_provider_id,\n $userId\n );\n } catch (Exception $e) {\n $hsOpportunities = [];\n }\n\n $opportunity = null;\n $stage = null;\n if (! empty($hsOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n });\n\n return is_array($result) ? $result : null;\n }\n\n\n private function convertActivityAssociations(Activity $activity): array\n {\n return [\n 'contactIds' => $this->getParticipantsIds($activity),\n 'companyIds' => $activity->hasAccount() ? [$activity->account->crm_provider_id] : [],\n 'dealIds' => $activity->hasOpportunity() ? [$activity->opportunity->crm_provider_id] : [],\n 'ownerIds' => [],\n ];\n }\n\n private function getParticipantsIds(Activity $activity): array\n {\n $attendees = [];\n\n $participantRepository = app(ParticipantRepository::class);\n $participants = $participantRepository->getParticipantsWhoEnteredMeeting($activity);\n foreach ($participants as $participant) {\n if ($participant->user_id || $participant->isCoach()) {\n continue;\n }\n\n $contact = $participant->contact()->first();\n if ($contact && $contact->crm_provider_id) {\n $attendees[] = $contact->crm_provider_id;\n } else {\n if (! empty($participant->name)) {\n $attendeeData = $this->fetchMissingAttendeeInfo($participant);\n }\n if (! empty($attendeeData['id'])) {\n $attendees[] = $attendeeData['id'];\n }\n }\n }\n\n if ($activity->hasContact()) {\n $attendees[] = $activity->contact->crm_provider_id;\n }\n\n return array_unique($attendees);\n }\n\n private function fetchMissingAttendeeInfo(Participant $participant): array\n {\n // Check if we need to look inside an account context.\n $activity = $participant->getActivity();\n $companyId = $activity->hasAccount() ? $activity->getAccount()->crm_provider_id : null;\n\n // First check the local data.\n /** @var Contact[] $contacts */\n $contacts = $this->team->contacts()\n ->with('account')\n ->where('name', $participant->name)\n ->whereNotNull('email')\n ->get();\n\n foreach ($contacts as $contact) {\n // If we have a company in scope, check the contact is associated to it.\n if (\n $companyId !== null\n && ($contact->account_id === null || $companyId !== $contact->account->crm_provider_id)\n ) {\n continue;\n }\n\n return [\n 'id' => $contact->crm_provider_id,\n 'email' => $contact->email,\n ];\n }\n\n $payload = $this->generateNameSearchPayload($participant->name, 0, 20);\n\n try {\n $response = $this->client->getNewInstance()->crm()->contacts()->searchApi()->doSearch($payload);\n\n // TODO add some logic to choose the most suitable contact if multiple\n foreach ($response['results'] as $object) {\n $properties = $object['properties'];\n if (empty($object['properties']) === false) {\n // Check the company matches the contact.\n // Todo: Move this check inside the API search.\n if ($companyId !== null && $companyId !== $properties['associatedcompanyid']) {\n continue;\n }\n\n return [\n 'id' => $object['id'],\n 'email' => $properties['email'],\n ];\n }\n }\n } catch (Exception $e) {\n $this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [\n 'teamId' => $this->team->id_string,\n 'request' => $payload,\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return [];\n }\n\n /**\n * Store transcripts as note engagement.\n *\n * @throws Exception\n */\n public function createTranscriptNotes(Activity $activity): void\n {\n // For HS no need to check if Crm profile - Log Notes field is enabled\n // We only check if store_transcript toggle is enabled on crm profile.\n $engagement = [\n 'active' => true,\n 'ownerId' => $this->profile->crm_provider_id,\n 'timestamp' => $activity->created_at->tz($activity->user->timezone)->getTimestamp() * 1000,\n 'type' => 'NOTE',\n ];\n\n // Generate activity transcription.\n $transcriptionData = $this->generateTranscription($activity);\n\n // Truncate Notes with max notes length because transcription text could be very long.\n $transcripts = mb_strimwidth($transcriptionData, 0, static::ENGAGEMENT_BODY_MAX_LENGTH);\n\n $metadata = [\n 'body' => $transcripts,\n ];\n\n $associations = $this->convertActivityAssociations($activity);\n\n try {\n $hsEngagement = $this->client\n ->getInstance()\n ->engagements()\n ->create($engagement, $associations, $metadata);\n\n $this->logCrmEngagementManipulation(self::ACTION_CREATE, $engagement, $metadata, $associations);\n\n $noteId = $hsEngagement->data->engagement->id;\n\n // Store crm logged id in transcription.\n $transcription = $activity->getTranscription();\n $transcription->crm_activity_id = $noteId;\n $transcription->save();\n } catch (Exception $e) {\n Sentry::captureException($e);\n }\n }\n\n /*\n * @inheritdoc\n */\n public function updateRecord(string $objectType, string $objectId, array $data, array $headers = []): void\n {\n $payload = [\n 'properties' => $data,\n ];\n\n try {\n switch ($objectType) {\n case FieldData::OBJECT_OPPORTUNITY:\n $this->client->getNewInstance()->crm()->deals()->basicApi()->update($objectId, $payload);\n\n break;\n case FieldData::OBJECT_CONTACT:\n $this->client->getNewInstance()->crm()->contacts()->basicApi()->update($objectId, $payload);\n\n break;\n\n case FieldData::OBJECT_ACCOUNT:\n $this->client->getNewInstance()->crm()->companies()->basicApi()->update($objectId, $payload);\n\n break;\n\n case FieldData::OBJECT_TASK:\n // Endpoint for Engagements not ready\n $engagements = [\n 'type' => 'TASK',\n ];\n $metadata = $data;\n $this->client->getInstance()->engagements()->update($objectId, $engagements, $metadata);\n\n $this->logCrmEngagementManipulation(\n self::ACTION_UPDATE,\n ['crmId' => $objectId],\n $metadata,\n );\n\n break;\n\n default:\n throw new UnexpectedValueException('Unsupported object type \"' . $objectType . '\"');\n }\n } catch (\\HubSpot\\Client\\Crm\\Deals\\ApiException $apiException) {\n $errorMessage = $apiException->getMessage();\n if ($apiException->getResponseBody()) {\n $responseBody = json_decode($apiException->getResponseBody(), true, 512, JSON_THROW_ON_ERROR);\n $errorMessage = $responseBody['message'] ?? $apiException->getMessage();\n }\n\n $this->logger->error(\n '[HubSpot] Update record failed',\n [\n 'objectType' => $objectType,\n 'objectId' => $objectId,\n 'payload' => $payload,\n 'reason' => $errorMessage,\n 'team' => $this->team->getUuid(),\n ]\n );\n\n throw new CrmException($errorMessage);\n }\n }\n\n /*\n * @inheritdoc\n */\n public function getRecord(string $objectType, string $objectId, array $fields = []): array\n {\n switch ($objectType) {\n case FieldData::OBJECT_OPPORTUNITY:\n return $this->client->getInstance()->deals()->getById($objectId)->toArray();\n\n case FieldData::OBJECT_CONTACT:\n return $this->client->getInstance()->contacts()->getById($objectId)->toArray();\n\n case FieldData::OBJECT_ACCOUNT:\n return $this->client->getInstance()->companies()->getById($objectId)->toArray();\n\n case FieldData::OBJECT_TASK:\n return $this->client->getInstance()->engagements()->get($objectId)->toArray();\n\n default:\n throw new UnexpectedValueException('Unsupported object type \"' . $objectType . '\"');\n }\n }\n\n /**\n * @throws BadRequest\n * @throws CrmException\n */\n public function updateStage($crmObject, Stage $stage): void\n {\n $payload = [\n 'properties' => [\n [\n 'name' => 'dealstage',\n 'value' => $stage->crm_provider_id,\n ],\n ],\n ];\n\n try {\n $this->client->getInstance()->deals()->update($crmObject->crm_provider_id, $payload);\n } catch (BadRequest $badRequest) {\n if ($badRequest->getCode() === 403) {\n throw new CrmException(\n \"Sorry, you don't have permission to update this stage.\",\n $badRequest->getCode(),\n $badRequest,\n );\n }\n\n $this->logger->warning('[HubSpot] Stage update failed', [\n 'dealId' => $crmObject->crm_provider_id,\n 'payload' => $payload,\n 'message' => $badRequest->getMessage(),\n ]);\n\n throw $badRequest;\n }\n }\n\n public function generateProviderUrl(string $providerId, string $objectType): ?string\n {\n $url = null;\n $baseUrl = 'https://app.hubspot.com/contacts/' . $this->config->crm_provider_id . '/';\n\n switch ($objectType) {\n case 'account':\n $url = $baseUrl . 'company/' . $providerId;\n\n break;\n\n case 'contact':\n $url = $baseUrl . 'contact/' . $providerId;\n\n break;\n\n case 'opportunity':\n $url = $baseUrl . 'deal/' . $providerId;\n\n break;\n\n case 'task':\n case 'activity':\n return null;\n\n // This should not be deep-linked as per JMNY-3934.\n //$url = $baseUrl.'tasks/list/view/all/?taskId='.$providerId;\n break;\n }\n\n return $url;\n }\n\n public function searchCalls(Carbon $from, Carbon $to, string $activityProvider): array\n {\n $this->logger->info('[HubSpot] Search calls', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n ]);\n\n $calls = [];\n $page = 1;\n\n do {\n try {\n $payload = $this->payloadBuilder->generateGetCallsPayload($from, $to, $activityProvider, $page);\n $response = $this->client->search('calls', $payload);\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Search calls failed', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n 'reason' => $exception->getMessage(),\n ]);\n $response = null;\n }\n\n $responseResults = empty($response['results']) ? [] : $response['results'];\n $calls = array_merge($calls, $responseResults);\n $page++;\n } while (! empty($responseResults));\n\n return $calls;\n }\n\n public function searchCallsForPeriodByPage(Carbon $from, Carbon $to, int $page, bool $retry = true)\n {\n try {\n $payload = $this->payloadBuilder->generateSearchCallsByPeriodPayload($from, $to, $page);\n\n return $this->client->search('calls', $payload);\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Search calls for period failed', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n 'reason' => $exception->getMessage(),\n 'retry' => $retry,\n ]);\n\n if ($retry) {\n sleep(1);\n\n return $this->searchCallsForPeriodByPage($from, $to, $page, false);\n }\n\n return null;\n }\n }\n\n public function searchCallsForPeriod(Carbon $from, Carbon $to): Generator\n {\n $this->logger->info('[HubSpot] Search calls for period', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n ]);\n\n $page = 1;\n\n do {\n $response = $this->searchCallsForPeriodByPage($from, $to, $page);\n\n $responseResults = empty($response['results']) ? [] : $response['results'];\n\n $associationContacts = $this->getAssociationDataForCollection($responseResults, 'calls', 'contacts');\n $associationCompanies = $this->getAssociationDataForCollection($responseResults, 'calls', 'companies');\n $associationDeals = $this->getAssociationDataForCollection($responseResults, 'calls', 'deals');\n\n foreach ($responseResults as $call) {\n $call['associations'] = [\n 'contacts' => $this->importAssociationData($call, $associationContacts),\n 'companies' => $this->importAssociationData($call, $associationCompanies),\n 'deals' => $this->importAssociationData($call, $associationDeals),\n ];\n\n yield $call;\n }\n $page++;\n } while (! empty($responseResults));\n }\n\n public function getCall(string $callId): array\n {\n $this->logger->info('[HubSpot] Get call', [\n 'call_id' => $callId,\n ]);\n\n $searchAttributes = $this->payloadBuilder->getSearchCallAttributes();\n $endpoint = sprintf(\n 'https://api.hubapi.com/crm/v3/objects/calls/%s',\n $callId,\n );\n\n try {\n $response = $this->client->getInstance()->getClient()->request(\n 'GET',\n $endpoint,\n [],\n sprintf(\n 'properties=%s&associations=contacts,companies,deals',\n implode(',', $searchAttributes),\n ),\n );\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Get call failed', [\n 'call_id' => $callId,\n 'reason' => $exception->getMessage(),\n ]);\n $response = null;\n }\n\n return empty($response) ? [] : $response->toArray();\n }\n\n public function bulkAddPlaybackURLToDescription(array $crmUpdateData): array\n {\n $crmUpdateBatches = array_chunk($crmUpdateData, self::BATCH_UPDATE_LIMIT);\n\n $updatedCrmIds = [];\n\n foreach ($crmUpdateBatches as $crmBatch) {\n $payload = $this->payloadBuilder->generatePlaybackAddUrlBatchPayload($crmBatch);\n $updateSuccess = $this->bulkAddPlaybackURLToDescriptionRequest($payload);\n if ($updateSuccess) {\n $updatedCrmIds = array_merge($updatedCrmIds, array_column($crmBatch, 'crm_id'));\n }\n }\n\n return $updatedCrmIds;\n }\n\n private function bulkAddPlaybackURLToDescriptionRequest(array $payload, bool $retry = true): bool\n {\n try {\n $this->client->getNewInstance()->crm()->objects()->batchApi()->update('calls', $payload);\n\n return true;\n } catch (\\HubSpot\\Client\\Crm\\Objects\\ApiException $e) {\n $response = json_decode($e->getResponseBody(), true);\n $retryAfter =\n isset($response['policyName'])\n && $response['policyName'] == self::TEN_SECONDLY_ROLLING_POLICY\n ? self::TEN_SECONDLY_ROLLING_LIMIT\n : 1;\n } catch (Exception $e) {\n $retryAfter = 1;\n }\n\n $this->logger->warning('[HubSpot] Bulk add playback url to CRM failed', [\n 'reason' => $e->getMessage(),\n 'retry' => $retry,\n ]);\n\n if ($retry) {\n sleep($retryAfter);\n\n return $this->bulkAddPlaybackURLToDescriptionRequest($payload, false);\n }\n\n return false;\n }\n\n /**\n * Sometimes we have secondly rate limit error, then retry request after 1 second\n */\n public function searchCallByRecordingURLToken(string $playbackURLToken, bool $retry = true): array\n {\n $payload = $this->payloadBuilder->generateSearchCallByTokenPayload($playbackURLToken);\n\n $this->logger->info('[HubSpot] CRM Search by playback URL token requested', [\n 'request' => $payload,\n ]);\n\n try {\n $response = $this->client->search('calls', $payload);\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Search by playback URL token failed', [\n 'playbackURLToken' => $playbackURLToken,\n 'reason' => $exception->getMessage(),\n 'retry' => $retry,\n ]);\n\n if ($retry) {\n sleep(1);\n\n return $this->searchCallByRecordingURLToken($playbackURLToken, false);\n }\n\n return [];\n }\n\n return empty($response['results']) ? [] : $response['results'][0];\n }\n\n /**\n * Generate transcription for the activity.\n */\n private function generateTranscription(Activity $activity): string\n {\n if (! $this->config->store_transcript) {\n // If sending transcription to activity toggle is disabled\n return '';\n }\n\n $transcriptionSegments = $this->transcriptionService->findTranscriptionByActivity($activity);\n\n if ($transcriptionSegments->isEmpty()) {\n return '';\n }\n\n $transcription = sprintf(\n '<p><strong>Transcript for %s</strong></p><p></p>',\n $activity->title ?? $activity->activity_title,\n );\n\n $roomOwnerParticipant = $activity->findParticipantRoomOwner();\n $roomOwnerParticipantId = $roomOwnerParticipant !== null\n ? $roomOwnerParticipant->getId()\n : null;\n\n\n $transcription .= $transcriptionSegments\n ->map(static function (array $transcriptionSegment) use ($roomOwnerParticipantId): string {\n $isOrganiser = $roomOwnerParticipantId === $transcriptionSegment['participantId']\n && $roomOwnerParticipantId !== null;\n $transcriptColor = $isOrganiser ? '#000000' : '#f0415a';\n\n return sprintf(\n '<span style=\"color: %s;\">%s | </span>%s',\n $transcriptColor,\n $transcriptionSegment['formattedStartsAt'],\n $transcriptionSegment['transcript'],\n );\n })\n ->implode('<br />');\n\n return $transcription;\n }\n\n /**\n * @param array<array{\n * id: string,\n * label: string,\n * value?: string,\n * }> $options\n *\n * @return FieldData[]\n */\n private function importOptions(Field $field, array $options): array\n {\n $fieldValues = [];\n $values = [];\n $sequence = 0;\n\n foreach ($options as $option) {\n $values[] = [\n 'value' => $option['value'] ?? $option['id'],\n 'label' => substr($option['label'], 0, 255),\n 'sequence' => $sequence++,\n ];\n }\n\n $fieldsToPurge = $field->values()->get()->pluck('value')->toArray();\n\n foreach ($values as $value) {\n $value['value'] = substr($value['value'], 0, 255);\n $fieldValues[] = $field->values()->updateOrCreate([\n 'value' => $value['value'],\n ], $value);\n\n // Remove this value from the ones we are going to purge.\n if (($key = array_search($value['value'], $fieldsToPurge, false)) !== false) {\n unset($fieldsToPurge[$key]);\n }\n }\n\n // Delete the old values that are no longer used.\n $field->values()->whereIn('value', $fieldsToPurge)->delete();\n\n return $fieldValues;\n }\n\n public function saveTranscriptionSummaryAsNote(\n ActivityContract $activity,\n string $title,\n string $body,\n ?string $objectId,\n ?NoteObject $noteObject = null,\n ): ?string {\n if ($noteObject === null || $objectId === null) {\n return null;\n }\n\n /** @var User $user */\n $user = $activity->getUser();\n\n $profile = $this->assignCrmOwner($user, $activity);\n if (! $profile instanceof Profile) {\n return null;\n }\n\n $timestamp = Carbon::now($user->getTimezone())->getTimestamp() * 1000;\n $engagement = [\n 'active' => true,\n 'ownerId' => $profile->getAttribute('crm_provider_id'),\n 'timestamp' => $timestamp,\n 'type' => 'NOTE',\n ];\n\n // Truncate Notes with max notes length because transcription text could be very long.\n $body = mb_strimwidth($body, 0, self::ENGAGEMENT_BODY_MAX_LENGTH);\n $metadata = [\n 'body' => $body,\n ];\n\n $associations = $this->convertActivityAssociations($activity);\n\n try {\n $hsActivityId = $this->client->createNote(\n body: $body,\n ownerId: $profile->getCrmProviderId(),\n timestamp: $timestamp,\n objectId: $objectId,\n noteObject: $noteObject,\n );\n\n $this->logCrmEngagementManipulation(self::ACTION_CREATE, $engagement, $metadata, $associations);\n\n $this->logger->info('[HubSpot] Saving Transcription Summary as Note', [\n 'activity' => $activity->getUuid(),\n 'crmActivity' => $hsActivityId,\n ]);\n\n return $hsActivityId;\n } catch (Exception $e) {\n Sentry::captureException($e);\n }\n\n return null;\n }\n\n public function attachSummaryToActivity(ActivityContract $activity, string $summaryTitle, string $summaryContents): bool\n {\n $this->logger->info('[HubSpot] Attaching summary to activity', [\n 'activity' => $activity->getUuid(),\n 'summary_content' => $summaryContents,\n ]);\n\n if (! $activity instanceof Activity) {\n throw new InvalidArgumentException('Expected instance of Activity');\n }\n\n $summary = '<p><strong>' . $summaryTitle . '</strong></p>';\n $summary .= '<p>' . $summaryContents . '</p>';\n $metadata = $this->buildMetadataForSummaryUpdate($activity, $summary);\n\n try {\n $type = $this->matchActivityEngagementType($activity);\n $engagement = ['type' => $type];\n\n $this->client->updateEngagement($activity->getCrmProviderId(), $engagement, $metadata);\n } catch (Exception $e) {\n $this->logger->warning('[HubSpot] Update summary failed', [\n 'activity' => $activity->getUuid(),\n 'reason' => $e->getMessage(),\n ]);\n\n return false;\n }\n\n $this->logCrmEngagementManipulation(\n self::ACTION_UPDATE,\n ['crmId' => $activity->getCrmProviderId()],\n $metadata,\n );\n\n return true;\n }\n\n private function buildMetadataForSummaryUpdate(Activity $activity, string $summary): array\n {\n $descriptionField = $activity->getType() === Activity::TYPE_CONFERENCE ? 'internalMeetingNotes' : 'body';\n $engagement = $this->client->getEngagementData($activity->getCrmProviderId());\n // Meeting without internalMeetingNotes might mean it just does not have any notes;\n $description = $engagement['metadata'][$descriptionField] ?? null;\n\n if (empty($description)) {\n $data = $summary;\n } else {\n // avoid playbook url link to Jiminny being sent twice in the activity description\n $targetUrl = PlaybackUrlBuilder::build($activity);\n\n if (str_contains($description, $targetUrl)) {\n $jiminnyUrl = '<p><a href=\"' . $targetUrl . '\" title=\"Play at Jiminny\">Play at Jiminny</a></p>';\n $summary = str_replace($jiminnyUrl, '', $summary);\n\n $this->logger->info('[HubSpot] Summary modified', [\n 'activity' => $activity->getUuid(),\n 'target_url' => $jiminnyUrl,\n 'modified_summary_content' => $summary,\n ]);\n }\n\n $data = $description . '<p></p>' . $summary;\n }\n\n return [\n $descriptionField => $data,\n ];\n }\n\n public function fetchAndAssociateRelatedActivity(Activity $activity): ?Activity\n {\n return $this->syncRelatedActivityManager->fetchAndAssociateRelatedActivity($activity);\n }\n\n public function fetchRelatedActivity(Activity $activity): array\n {\n return [];\n }\n\n public function getDealsInBulk(array $dealIds): array\n {\n $payload = $this->payloadBuilder->getDealsInBulkPayload($dealIds);\n\n return $this->client->getPaginatedData($payload, 'deals');\n }\n\n /**\n * Extract deal IDs from HubSpot search response.\n *\n * @param array $hubspotResponse The raw HubSpot search API response.\n * @param bool $includeArchived Whether to include archived deals (default: false).\n *\n * @return string[] Array of deal IDs as strings.\n */\n public function extractDealIds(array $hubspotResponse, bool $includeArchived = false): array\n {\n if (empty($hubspotResponse['results'])) {\n return [];\n }\n\n return array_values(\n array_map(\n fn ($deal) => $deal['id'],\n array_filter(\n $hubspotResponse['results'],\n fn ($deal) => $includeArchived || empty($deal['archived'])\n )\n )\n );\n }\n\n public function matchActivityEngagementType(Activity $activity): string\n {\n return match ($activity->getType()) {\n Activity::TYPE_CONFERENCE => self::TYPE_MEETING,\n Activity::TYPE_SOFTPHONE, Activity::TYPE_SOFTPHONE_INBOUND => self::TYPE_CALL,\n default => self::TYPE_NOTE,\n };\n }\n\n private function assignCrmOwner(User $user, ActivityContract $activity): ?Profile\n {\n $profile = $user->getProfile();\n if ($profile instanceof Profile) {\n return $profile;\n }\n\n $this->logger->info('[HubSpot] Unable to save summary. No profile', [\n 'activity' => $activity->getUuid(),\n ]);\n\n return null;\n }\n\n private static function getDealsPipelinesEndpoint(): string\n {\n return self::API_URL . self::ENDPOINT_PIPELINES . self::PIPELINE_OBJECT_TYPE_DEALS;\n }\n\n public function verifyTaskExists(Activity $activity): bool\n {\n $crmProviderId = $activity->getCrmProviderId();\n $cacheKey = \"crm_task_exists:{$this->config->getId()}:$crmProviderId\";\n\n return Cache::remember($cacheKey, self::TASK_VERIFICATION_CACHE_TTL, function () use ($crmProviderId) {\n try {\n $engagement = $this->client->getEngagementData($crmProviderId);\n\n return ! empty($engagement);\n } catch (HttpNotFoundException|BadRequest) {\n // Engagement not found in CRM - this is expected and permanent\n $this->logger->info('[Hubspot] Engagement not found during verification', [\n 'engagement_id' => $crmProviderId,\n 'config_id' => $this->config->getId(),\n ]);\n\n return false;\n }\n // Let other exceptions (network errors, rate limits, etc.) bubble up for retry\n });\n }\n}","depth":4,"on_screen":true,"value":"<?php\n\nnamespace Jiminny\\Services\\Crm\\Hubspot;\n\nuse Carbon\\Carbon;\nuse Exception;\nuse Generator;\nuse GuzzleHttp\\Exception\\RequestException;\nuse Illuminate\\Support\\Facades\\Cache;\nuse InvalidArgumentException;\nuse Jiminny\\Contracts\\Repositories\\TeamRepository;\nuse Jiminny\\Contracts\\Services\\Crm\\ClientInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\FetchRelatedActivityInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\LayoutManagementInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\MatchCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\Provider\\HubspotInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityLookupInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityManipulationInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SavePlaybackLinkToCrmInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SendSummaryToCrmInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SettingsInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmMetadataInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\VerifyTaskExistsInterface;\nuse Jiminny\\Exceptions\\CrmException;\nuse Jiminny\\Exceptions\\HttpNotFoundException;\nuse Jiminny\\Jobs\\Crm\\NoteObject;\nuse Jiminny\\Models\\Account;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Contact;\nuse Jiminny\\Models\\Contracts\\ActivityContract;\nuse Jiminny\\Models\\Crm\\BusinessProcess;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Models\\Crm\\FieldData;\nuse Jiminny\\Models\\Crm\\Layout;\nuse Jiminny\\Models\\Crm\\Profile;\nuse Jiminny\\Models\\Lead;\nuse Jiminny\\Models\\Opportunity;\nuse Jiminny\\Models\\Participant;\nuse Jiminny\\Models\\Playbook;\nuse Jiminny\\Models\\SocialAccount;\nuse Jiminny\\Models\\Stage;\nuse Jiminny\\Models\\User;\nuse Jiminny\\Repositories\\Crm\\CrmEntityRepository;\nuse Jiminny\\Repositories\\Crm\\FieldRepository;\nuse Jiminny\\Repositories\\Crm\\ProfileRepository;\nuse Jiminny\\Repositories\\ParticipantRepository;\nuse Jiminny\\Services\\Avatar\\ProspectPhotoPathService;\nuse Jiminny\\Services\\Crm\\BaseService;\nuse Jiminny\\Services\\Crm\\Hubspot\\Actions\\SyncArchivedProfilesAction;\nuse Jiminny\\Services\\Crm\\Hubspot\\Fields\\ValueNormalizer;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\OpportunitySyncTrait;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\SyncCrmEntitiesTrait;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\SyncFieldsTrait;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\WriteCrmTrait;\nuse Jiminny\\Services\\Crm\\MatchDomainByEmailInterface;\nuse Jiminny\\Services\\Crm\\OpportunitySyncStrategyResolver;\nuse Jiminny\\Services\\Crm\\ResolveCompanyNameByEmailTrait;\nuse Jiminny\\Utils\\PlaybackUrlBuilder;\nuse Sentry;\nuse SevenShores\\Hubspot\\Exceptions\\BadRequest;\nuse Throwable;\nuse UnexpectedValueException;\n\n/**\n * @phpstan-type CrmFieldDefinition array{\n * name: string,\n * label: string,\n * description: string,\n * type: string,\n * fieldType: string,\n * hidden: bool,\n * showCurrencySymbol: bool,\n * options: array<array{\n * id: string,\n * label: string,\n * value?: string,\n * }\n */\nclass Service extends BaseService implements\n HubspotInterface,\n SyncCrmEntitiesInterface,\n SyncCrmMetadataInterface,\n SendSummaryToCrmInterface,\n MatchDomainByEmailInterface,\n SavePlaybackLinkToCrmInterface,\n RemoteEntityManipulationInterface,\n FetchRelatedActivityInterface,\n LayoutManagementInterface,\n SettingsInterface,\n MatchCrmEntitiesInterface,\n RemoteEntityLookupInterface,\n VerifyTaskExistsInterface\n{\n use ResolveCompanyNameByEmailTrait;\n use SyncCrmEntitiesTrait;\n use WriteCrmTrait;\n use SyncFieldsTrait;\n use OpportunitySyncTrait;\n\n private const int ENGAGEMENT_BODY_MAX_LENGTH = 65536;\n\n private const string LOG_DATE_FORMAT = 'Y-m-d H:i:s';\n private const int BATCH_UPDATE_LIMIT = 100;\n private const string TEN_SECONDLY_ROLLING_POLICY = 'TEN_SECONDLY_ROLLING';\n private const int TEN_SECONDLY_ROLLING_LIMIT = 10;\n\n private const string TYPE_NOTE = 'NOTE';\n\n private const string TYPE_MEETING = 'MEETING';\n\n private const string TYPE_CALL = 'CALL';\n\n private const string API_URL = 'https://api.hubapi.com';\n\n // NB: v1 is legacy - v3 is the newest\n private const string ENDPOINT_PIPELINES = '/crm-pipelines/v1/pipelines/';\n private const string PIPELINE_OBJECT_TYPE_DEALS = 'deals';\n\n private const int TASK_VERIFICATION_CACHE_TTL = 86400; // 1 day\n\n /**\n * @var ClientInterface|Client\n */\n protected $client;\n protected OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;\n protected CrmEntityRepository $crmEntityRepository;\n protected ProspectPhotoPathService $prospectPhotoPathService;\n\n private SyncFieldAction $syncFieldAction;\n private PayloadBuilder $payloadBuilder;\n private SyncRelatedActivityManager $syncRelatedActivityManager;\n private SyncArchivedProfilesAction $syncArchivedProfilesAction;\n private WebhookSyncBatchProcessor $batchProcessor;\n\n public function __construct(\n Client $client,\n SyncFieldAction $syncFieldAction,\n PayloadBuilder $payloadBuilder,\n ProspectPhotoPathService $prospectPhotoPathService,\n SyncArchivedProfilesAction $syncArchivedProfilesAction,\n WebhookSyncBatchProcessor $batchProcessor,\n ) {\n parent::__construct();\n\n $this->client = $client;\n $this->syncFieldAction = $syncFieldAction;\n $this->prospectPhotoPathService = $prospectPhotoPathService;\n $this->payloadBuilder = $payloadBuilder;\n $this->syncArchivedProfilesAction = $syncArchivedProfilesAction;\n $this->batchProcessor = $batchProcessor;\n $this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [\n 'client' => $this->client,\n ]);\n $this->syncRelatedActivityManager = app(SyncRelatedActivityManager::class, [\n 'client' => $this->client,\n 'payloadBuilder' => $this->payloadBuilder,\n 'logger' => $this->logger,\n ]);\n $this->crmEntityRepository = app(CrmEntityRepository::class);\n $this->dealFieldsService = app(DealFieldsService::class);\n }\n\n public function getDisplayName(): string\n {\n return 'HubSpot';\n }\n\n protected function getOAuthAccount(User $user): ?SocialAccount\n {\n // In this case, the Account Owner is always the connection for any API operations.\n $owner = $user->team->owner;\n\n return $owner->getSocialAccount(SocialAccount::PROVIDER_HUBSPOT);\n }\n\n public function getClient(): Client\n {\n /** @var Client */\n return $this->client;\n }\n\n /**\n * Convert raw field data into a format compatible with CRM APIs.\n *\n * @param bool $internal Direction of the conversion.\n * True is pulling from CRM, false normalize before sending to CRM.\n */\n public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string\n {\n return ValueNormalizer::normalize(\n fieldType: $fieldType,\n fieldValue: $fieldValue,\n isInbound: $internal,\n );\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultFields(string $activityType): array\n {\n $fields = [];\n\n if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {\n $defaultFields = FieldDefinitions::defaultTaskFields();\n\n // This lazy creates these fields if not already setup.\n foreach ($defaultFields as $defaultField) {\n $fields[] = $this->config->fields()->firstOrCreate($defaultField);\n }\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultActivityField(string $activityType): Field\n {\n /** @var Field $activityField */\n $activityField = $this->config->fields()->where([\n 'crm_provider_id' => 'activityType',\n 'object_type' => $activityType,\n ])->first();\n\n return $activityField;\n }\n\n /**\n * @inheritdoc\n */\n public function getSupportedPlaybookTypes(): array\n {\n return [Playbook::ACTIVITY_TYPE_TASK];\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultActivityLayoutFields(string $activityType, string $layoutType): array\n {\n $fields = [];\n\n if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {\n // Outcome should always be provided calls/meetings.\n $fieldData = [\n [\n 'crm_provider_id' => $layoutType === Layout::TYPE_SOFTPHONE_SUMMARY ? 'disposition' : 'meetingOutcome',\n 'object_type' => Field::OBJECT_TASK,\n ],\n ];\n\n foreach ($fieldData as $data) {\n $field = $this->config->fields()->where($data)->first();\n\n // Only add the field if it is created, which it should be.\n if ($field) {\n $fields[] = $field;\n }\n }\n }\n\n return $fields;\n }\n\n public function getDealInsightsFields(): array\n {\n return FieldDefinitions::dealInsightsFields();\n }\n\n protected function getDefaultFollowupLayoutFields(string $activityType): array\n {\n $fields = [];\n $fieldRepo = app(FieldRepository::class);\n $fieldData = FieldDefinitions::followupFieldsFilter();\n\n foreach ($fieldData as $data) {\n $field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $data);\n\n // Only add the field if it is created, which it should be.\n if ($field) {\n $fields[] = $field;\n }\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function syncField(Field $field): void\n {\n switch ($field->object_type) {\n case Field::OBJECT_ACCOUNT:\n $crmField = $this->client->getInstance()->companyProperties()->get($field->crm_provider_id);\n\n break;\n case Field::OBJECT_CONTACT:\n $crmField = $this->client->getInstance()->contactProperties()->get($field->crm_provider_id);\n\n break;\n case Field::OBJECT_OPPORTUNITY:\n $crmField = $this->client->getInstance()->dealProperties()->get($field->crm_provider_id);\n\n break;\n case Field::OBJECT_TASK:\n $this->syncSingleTaskField($field);\n\n return;\n default:\n return;\n }\n\n $this->syncFieldAction->execute($field, $crmField->toArray());\n }\n\n /**\n * @param array<array{\n * id:string,\n * label:string,\n * value?:string\n * }> $options\n *\n * @throws CrmException\n *\n * @return FieldData[]\n *\n */\n public function importPicklistValues(\n Field $field,\n array $options = [['id' => '', 'label' => '', 'value' => '']],\n ): array {\n if (! empty($options[0]['id']) || ! empty($options[0]['value'])) {\n // We already have the options, no need to fetch them again\n return $this->importOptions($field, $options);\n }\n\n $options = [];\n\n switch ($field->getObjectType()) {\n case Field::OBJECT_ACCOUNT:\n $options = $this->getClient()->fetchPropertyOptions('company', $field->getCrmProviderId());\n\n break;\n\n case Field::OBJECT_CONTACT:\n $options = $this->getClient()->fetchPropertyOptions('contact', $field->getCrmProviderId());\n\n break;\n\n case Field::OBJECT_OPPORTUNITY:\n // Hubspot has different endpoint for stages\n $options = $this->getClient()->fetchOpportunityFieldOptions($field);\n\n break;\n\n case Field::OBJECT_TASK:\n if ($field->getCrmProviderId() === 'disposition') {\n $options = $this->getClient()->fetchDispositionFieldOptions();\n } elseif (in_array($field->getCrmProviderId(), ['meetingOutcome', 'activityType'])) {\n $options = $this->getClient()->fetchMeetingOutcomeFieldOptions($field);\n }\n\n break;\n\n default:\n $this->logger->warning('Invalid object type', [\n 'object_type' => $field->getObjectType(),\n 'field_id' => $field->getId(),\n ]);\n\n throw new CrmException('Invalid object type');\n }\n\n return $this->importOptions($field, $options);\n }\n\n /**\n * @inheritdoc\n */\n public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage\n {\n $missingStage = null;\n\n try {\n // Use the HubSpot API client instead of the SDK crmPipelines() method\n $endpoint = self::getDealsPipelinesEndpoint();\n $pipelinesResponse = $this->client->getInstance()->getClient()->request('GET', $endpoint);\n $pipelines = $pipelinesResponse->data->results;\n } catch (RequestException|BadRequest $exception) {\n throw $exception;\n }\n\n foreach ($pipelines as $pipeline) {\n $stages = [];\n\n // We create a business process to contain the pipeline, and store all stages against it.\n $p = ResponseNormalize::normalizePipeline($pipeline);\n\n // Create/update business process for this pipeline\n $businessProcess = $this->config->businessProcesses()->updateOrCreate([\n 'crm_provider_id' => $p['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($p['label'], 0, 150),\n 'type' => BusinessProcess::TYPE_OPPORTUNITY,\n 'is_selectable' => $p['active'],\n ]);\n\n // A record type is really a clone of the business process, used to store which record uses which pipeline.\n // Create/update record type clone\n $this->config->recordTypes()->updateOrCreate([\n 'crm_provider_id' => $p['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($p['label'], 0, 150),\n 'is_selectable' => $p['active'],\n 'business_process_id' => $businessProcess->id ?? null,\n ]);\n\n // Stages - fetch all existing stages upfront to avoid N+1 queries\n $existingStages = $this->config->stages()\n ->withTrashed()\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->get()\n ->keyBy('crm_provider_id');\n\n foreach ($p['stages'] as $dealStage) {\n $s = ResponseNormalize::normalizeDealStage($dealStage);\n\n /** @var ?Stage $existingStage */\n $existingStage = $existingStages->get($s['id']);\n\n // Restore soft-deleted stages that are now active in HubSpot\n if ($existingStage?->trashed() && $s['active']) {\n $existingStage->restore();\n }\n\n // Upsert stage (updates soft-deleted records without restoring them)\n $stage = $this->config->stages()->withTrashed()->updateOrCreate([\n 'crm_provider_id' => $s['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($s['label'], 0, 50),\n 'label' => mb_strimwidth($s['label'], 0, 191),\n 'type' => Stage::TYPE_OPPORTUNITY,\n 'sequence' => $s['displayOrder'],\n 'is_selectable' => $s['active'],\n 'probability' => $s['probability'] * 100,\n ]);\n\n if ($missingStageName === $s['id']) {\n $missingStage = $stage;\n }\n\n $stages[] = $stage->id;\n }\n\n $businessProcess->stages()->sync($stages);\n }\n\n return $missingStage;\n }\n\n /**\n * @inheritdoc\n */\n public function syncOrganization(): void\n {\n try {\n $endpoint = 'https://api.hubapi.com/integrations/v1/me';\n $response = $this->client->getInstance()->getClient()->request('get', $endpoint);\n\n $accountData = $response->data;\n $this->config->update(['default_currency' => $accountData->currency]);\n } catch (BadRequest $e) {\n throw new CrmException('Could not sync the organization.', $e->getCode(), $e);\n }\n }\n\n /**\n * @inheritdoc\n *\n * @throws CrmException\n */\n public function syncProfiles(?User $userToSearch = null): ?Profile\n {\n $this->syncArchivedProfilesAction->execute($this->team, $this->client, $this->config);\n\n try {\n $owners = $this->client->getOwners();\n } catch (\\HubSpot\\Client\\Crm\\Owners\\ApiException $e) {\n $this->logger->error('[HubSpot] Could not sync the profiles.', [\n 'team_id' => $this->team->getId(),\n 'reason' => $e->getMessage(),\n ]);\n\n throw new CrmException('Could not sync the profiles.', $e->getCode(), $e);\n }\n\n $profileRepository = app(ProfileRepository::class);\n $teamRepository = app(TeamRepository::class);\n\n foreach ($owners as $owner) {\n if ($owner->getArchived()) {\n // not supposed to fetch archived, but log anyway\n $this->logger->warning('[HubSpot] Found archived owner', [\n 'crm_provider_id' => $owner->getId(),\n 'email' => $owner->getEmail(),\n ]);\n\n continue;\n }\n\n $email = $owner->getEmail();\n if ($email === null) {\n continue;\n }\n\n $user = $teamRepository->findActiveTeamMemberByEmail($this->team, $email);\n\n if (! $user instanceof User) {\n continue;\n }\n\n $profile = $profileRepository->updateOrCreateProfile($user, [\n 'crm_configuration_id' => $this->config->getId(),\n 'crm_provider_id' => $owner->getId(),\n ]);\n\n if ($userToSearch && $userToSearch->getId() === $user->getId()) {\n return $profile;\n }\n }\n\n return null;\n }\n\n private function generateNameSearchPayload(string $name, int $offset, int $limit): array\n {\n $payload = [\n 'query' => $name,\n 'sorts' => [\n [\n 'propertyName' => 'modifieddate',\n 'direction' => 'DESCENDING',\n ],\n ],\n 'properties' => [\n 'email',\n 'firstname',\n 'lastname',\n 'country',\n 'phone',\n 'mobilephone',\n 'jobtitle',\n 'hubspot_owner_id',\n 'associatedcompanyid',\n 'photo',\n 'industry',\n 'name',\n 'company',\n ],\n 'limit' => $limit,\n 'after' => $offset,\n ];\n\n $this->logger->debug('[HubSpot] CRM Search requested', [\n 'request' => $payload,\n ]);\n\n return $payload;\n }\n\n /**\n * @inheritdoc\n */\n public function find(string $name, array $scopes): array\n {\n $count = $this->limit ?? 20;\n $offset = $this->offset ?? 0;\n\n /** @var array<int, array<string, mixed>> */\n return Cache::remember(\n key: $this->team->getId() . $name . $count . $offset,\n ttl: 300,\n callback: function () use ($name, $offset, $count): array {\n $data = [];\n\n // Use the new V3 API to find contacts based on additional fields.\n foreach (['companies', 'contacts'] as $objectType) {\n $payload = $this->generateNameSearchPayload($name, $offset, $count);\n $type = $objectType === 'companies' ? 'account' : 'contact';\n\n try {\n $response = $this->client->search($objectType, $payload);\n\n // Build mapped list.\n foreach ($response['results'] as $object) {\n $properties = $object['properties'];\n\n $objectName = $this->buildContactName($properties);\n\n $record = [\n 'crmId' => $object['id'],\n // Pass crmUrl to the FE, needed for success message in the extension when you log activity.\n 'crmUrl' => $this->generateProviderUrl($object['id'], $type),\n 'name' => $objectName,\n 'prospectType' => $type,\n 'phoneNumbers' => [],\n ];\n\n if ($type === 'account') {\n $record['industry'] = $properties['industry'] ?? null;\n } else {\n $record['title'] = $properties['jobtitle'] ?? null;\n $record['organization'] = $properties['company'] ?? null;\n }\n\n $countryCode = $this->buildContactCountry($properties);\n $parsedNumber = $this->buildContactPhone($countryCode, $properties);\n\n // Add phone number to record.\n if (! empty($parsedNumber['phone'])) {\n $record['phoneNumbers'][] = [\n 'number' => $parsedNumber['phone'],\n 'nationalFormat' => phone_national($countryCode, $parsedNumber['phone']),\n 'type' => 'phone',\n ];\n }\n\n // Add mobile phone number to record.\n if (! empty($properties['mobilephone'])) {\n $mobileNumber = phone_e164($countryCode, $properties['mobilephone']);\n if ($mobileNumber !== null) {\n $record['phoneNumbers'][] = [\n 'number' => $mobileNumber,\n 'nationalFormat' => phone_national($countryCode, $mobileNumber),\n 'type' => 'mobile',\n ];\n }\n }\n\n $data[] = $record;\n }\n } catch (BadRequest $e) {\n $this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [\n 'teamId' => $this->team->getUuid(),\n 'request' => $payload,\n 'reason' => $e->getMessage(),\n ]);\n\n throw $e;\n }\n }\n\n return $data;\n },\n );\n }\n\n\n /**\n * @inheritdoc\n */\n public function findOpportunities(?string $crmAccountId, ?string $crmContactId, ?int $userId = null): array\n {\n $data = [];\n $ownerData = [];\n $ownerId = null;\n\n if ($crmAccountId === null) {\n return $data;\n }\n\n if ($userId) {\n $profileRepository = app(ProfileRepository::class);\n $profile = $profileRepository->findProfileByUserId($this->config, $userId);\n\n $ownerId = $profile instanceof Profile ? $profile->getCrmProviderId() : null;\n }\n\n $closedStages = $this->getClosedDealStages();\n $payload = $this->payloadBuilder->generateOpportunitiesSearchPayload(\n $this->config,\n $crmAccountId,\n $closedStages,\n );\n\n $results = $this->client->getPaginatedData($payload, 'deals');\n\n foreach ($results['results'] as $object) {\n $properties = $object['properties'];\n\n $amount = null;\n if (empty($properties['amount']) === false) {\n $currency = $properties['deal_currency_code'] ?? $this->config->default_currency;\n\n // Values can contain commas and any junk so strip them.\n $value = (float) preg_replace('/[^\\d.]/', '', $properties['amount']);\n $amount = formatCurrency($value, $currency);\n }\n\n $businessProcess = $this->config\n ->businessProcesses()\n ->where('crm_provider_id', $properties['pipeline'])\n ->first();\n\n if ($businessProcess === null) {\n // Import it.\n $stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $properties['dealstage']);\n $businessProcess = $this->config\n ->businessProcesses()\n ->where('crm_provider_id', $properties['pipeline'])\n ->first();\n } else {\n $stage = $businessProcess\n ->stages()\n ->where('crm_provider_id', $properties['dealstage'])\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->first();\n\n if ($stage === null) {\n // Import it.\n $stage = $this->importStages(null, $properties['dealstage']);\n }\n }\n\n $recordType = null;\n if ($businessProcess) {\n $recordType = $businessProcess->recordTypes()->first();\n }\n\n $isWon = in_array($properties['dealstage'], $closedStages['won']);\n $isLost = in_array($properties['dealstage'], $closedStages['lost']);\n\n $record = [\n 'crmId' => $object['id'],\n 'name' => $properties['dealname'] ?? 'Unknown Deal',\n 'value' => $amount,\n 'won' => $isWon,\n 'closed' => $isWon || $isLost,\n 'stage' => [\n 'id' => $stage?->getUuid() ?? '',\n 'name' => $stage?->getName() ?? '',\n ],\n ];\n\n if ($recordType) {\n $record += [\n 'recordType' => [\n 'id' => $recordType->id_string,\n 'name' => $recordType->name,\n ],\n ];\n }\n\n if ($ownerId && isset($properties['hubspot_owner_id']) && $properties['hubspot_owner_id'] === $ownerId) {\n $ownerData[] = $record;\n }\n\n $data[] = $record;\n }\n\n if (! empty($ownerData)) {\n return $ownerData;\n }\n\n return $data;\n }\n\n /**\n * @inheritdoc\n */\n public function getTasks(?string $objectType, string $objectId, ?string $opportunityId): array\n {\n $data = [];\n switch ($objectType) {\n case 'contact':\n $hsObject = 'contact';\n\n break;\n case 'account':\n $hsObject = 'company';\n\n break;\n default:\n // This is a hack to prioritise and override a contact/company with a deal.\n if ($opportunityId) {\n $hsObject = 'deal';\n $objectId = $opportunityId;\n } else {\n throw new InvalidArgumentException('Object type not supported.');\n }\n }\n\n $engagementTypes = ['meetings', 'tasks'];\n\n foreach ($engagementTypes as $engagementType) {\n $payload = $this->payloadBuilder->getLinkToTaskPayload($hsObject, $objectId, $engagementType);\n\n $this->logger->info('[HubSpot] CRM Search requested', [\n 'request' => $payload,\n ]);\n\n $engagements = $this->client->getPaginatedData($payload, $engagementType);\n\n foreach ($engagements['results'] as $engagement) {\n if ($engagementType == 'meetings') {\n $title = $engagement['properties']['hs_meeting_title'] ?? 'Scheduled meeting';\n } elseif ($engagementType == 'tasks') {\n $title = $engagement['properties']['hs_task_subject'];\n } else {\n $title = 'Scheduled meeting';\n }\n\n $data[] = [\n 'crmId' => $engagement['id'],\n 'subject' => $title,\n 'due' => $engagement['properties']['hs_timestamp'],\n 'type' => $engagement['properties']['hs_activity_type'] ?? null,\n ];\n }\n }\n\n usort($data, function ($item1, $item2) {\n return $item2['due'] <=> $item1['due'];\n });\n\n return $data;\n }\n\n /**\n * Try to find CRM Objects using email address\n *\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n public function matchExactlyByEmail(string $email, ?int $userId = null): ?array\n {\n $contactProperties = [\n 'email',\n 'firstname',\n 'lastname',\n 'country',\n 'phone',\n 'mobilephone',\n 'jobtitle',\n 'hubspot_owner_id',\n 'associatedcompanyid',\n 'photo',\n ];\n $contact = null;\n $account = null;\n\n try {\n $hsContact = $this->getClient()->getContactByEmail($email, $contactProperties);\n\n if ($hsContact) {\n $contact = $this->importContact($hsContact);\n $account = $contact->account;\n }\n\n $data = $this->convertCrmData($contact, $account, $userId);\n\n return ! empty(array_filter($data)) ? $data : null;\n } catch (BadRequest $e) {\n $this->logger->warning('[HubSpot] Search failed', [\n 'team_id' => $this->team->getId(),\n 'search_identifier' => $email,\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return null;\n }\n\n public function getDomain(string $email): ?string\n {\n return $this->getDomainFromEmail($email);\n }\n\n /**\n * Try to find CRM objects using domain name of the email address\n *\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n public function matchByDomain(string $domain, ?int $userId = null): ?array\n {\n $companyName = $domain;\n\n // Try to find a company matching their email domain.\n $companyProperties = [\n 'country',\n 'phone',\n 'name',\n 'hs_avatar_filemanager_key',\n 'industry',\n 'hubspot_owner_id',\n 'domain',\n ];\n\n try {\n $hsAccounts = $this->client\n ->getInstance()\n ->companies()\n ->searchByDomain($companyName, $companyProperties);\n } catch (Throwable $e) {\n $this->logger->info('[HubSpot] Search failed', [\n 'error' => $e->getMessage(),\n 'domain' => $domain,\n ]);\n\n return null;\n }\n\n $account = null;\n // If there are multiple accounts, don't guess, we'll ask later.\n if (\\count($hsAccounts->data->results) === 1) {\n // Persist this remote object.\n $account = $this->syncAccount($hsAccounts->data->results[0]->companyId);\n }\n\n $data = $this->convertCrmData(null, $account, $userId);\n\n return ! empty(array_filter($data)) ? $data : null;\n }\n\n /**\n * @return array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n protected function convertCrmData(?Contact $contact, ?Account $account, ?int $userId = null): array\n {\n $countryCode = null;\n if ($contact && $contact->country_code) {\n $countryCode = $contact->country_code;\n } elseif ($account && $account->country_code) {\n $countryCode = $account->country_code;\n }\n\n try {\n $hsOpportunities = $this->findOpportunities(\n $account ? $account->crm_provider_id : null,\n $contact ? $contact->crm_provider_id : null,\n $userId\n );\n } catch (Exception $e) {\n $hsOpportunities = [];\n }\n\n // If there are multiple opportunities, don't guess, we'll ask later.\n $opportunity = null;\n $stage = null;\n if (! empty($hsOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n }\n\n protected function getCacheKey(string $object, ?int $userId = null): ?string\n {\n $key = $this->team->getId() . $object;\n $keySuffix = $this->getOwnerKeySuffix($userId);\n\n return $key . $keySuffix;\n }\n\n private function getOwnerKeySuffix(?int $userId = null): string\n {\n return $userId === null ? '' : (string) $userId;\n }\n\n /**\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n *}\n */\n public function matchByPhone(string $phone, ?string $rawPhoneNumber = null, ?int $userId = null): ?array\n {\n if (str_contains($phone, '**')) {\n return null;\n }\n\n // trim all whitespaces if present so the lookup doesn't fail\n $phone = str_replace(' ', '', $phone);\n\n // Check if the user is internal.\n if ($this->isPhoneNumberOfTeamMember($phone)) {\n return null;\n }\n\n $response = $this->searchForPhoneNumber($phone);\n if (empty($response)) {\n return null;\n }\n\n // This would ideally importContact instead but the response type differs.\n $contact = $this->findAndSyncContact($response['results'][0]['id']);\n if (! $contact instanceof Contact) {\n return null;\n }\n\n $account = $contact->account;\n $countryCode = $contact->country_code ?? $account->country_code ?? null;\n\n try {\n $hsOpportunities = $this->findOpportunities(\n $account?->crm_provider_id,\n $contact->crm_provider_id,\n $userId\n );\n } catch (Exception $e) {\n $hsOpportunities = [];\n }\n\n $opportunity = null;\n $stage = null;\n\n try {\n if (! empty($hsOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n } catch (Exception $e) {\n $this->logger->debug('[HubSpot] Opportunity failed to sync.', [\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n }\n\n private function isPhoneNumberOfTeamMember(string $phone): bool\n {\n $teamRepository = app(TeamRepository::class);\n $user = $teamRepository->findTeamMemberByPhone($this->team, $phone);\n\n if ($user instanceof User) {\n return true;\n }\n\n return false;\n }\n\n private function findAndSyncContact(string $crmId): ?Contact\n {\n try {\n return $this->syncContact($crmId);\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Phone match failed', [\n 'reason' => $exception->getMessage(),\n ]);\n\n return null;\n }\n }\n\n private function hasResults(array $response): bool\n {\n return isset($response['total']) && is_numeric($response['total']) && $response['total'] > 0;\n }\n\n private function searchForPhoneNumber(string $phone): array\n {\n // Normalizes the provided phone number for the API search.\n $normalizedPhone = $this->normalizePhoneNumber($phone);\n $payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone);\n\n $this->logger->info('[HubSpot] Phone match search triggered', [\n 'phone' => $phone,\n 'normalizedPhone' => $normalizedPhone,\n 'payload' => $payload,\n ]);\n\n $response = $this->handlePhoneSearchRequest($normalizedPhone, $payload);\n\n if (! $this->hasResults($response)) {\n $nationalPhone = preg_replace('/\\D/', '', phone_national(null, $phone));\n $payload = $this->payloadBuilder->generatePhoneSearchPayload($nationalPhone);\n\n $this->logger->info('[HubSpot] Phone match national number search triggered', [\n 'phone' => $phone,\n 'nationalPhone' => $nationalPhone,\n 'payload' => $payload,\n ]);\n\n $response = $this->handlePhoneSearchRequest($phone, $payload);\n }\n\n if (! $this->hasResults($response)) {\n $payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone, true);\n\n $this->logger->info('[HubSpot] Phone match alternative search triggered', [\n 'phone' => $phone,\n 'normalizedPhone' => $normalizedPhone,\n 'payload' => $payload,\n ]);\n\n $response = $this->handlePhoneSearchRequest($phone, $payload);\n }\n\n return $this->hasResults($response) ? $response : [];\n }\n\n private function handlePhoneSearchRequest(string $phone, array $payload): array\n {\n try {\n return $this->client->search('contacts', $payload);\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Phone match failed', [\n 'phone' => $phone,\n 'reason' => $exception->getMessage(),\n ]);\n\n return [];\n }\n }\n\n private function normalizePhoneNumber(string $phone): string\n {\n return ltrim(phone_e164(null, $phone), '+0');\n }\n\n /**\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n public function matchByName(string $name, ?int $userId = null): ?array\n {\n // Don't waste time searching for single character strings.\n if (\\strlen($name) <= 1) {\n return null;\n }\n\n $cacheKey = $this->getCacheKey($name, $userId);\n\n $result = Cache::remember($cacheKey, 60, function () use ($name, $userId) {\n $payload = $this->payloadBuilder->generateSearchContactsByNamePayload(\n $name,\n $this->getContactFields()\n );\n\n $hsContacts = $this->client->getPaginatedData($payload, 'contact');\n if (empty($hsContacts['results'])) {\n return false;\n }\n\n $contact = $this->importContact($hsContacts['results'][0]);\n if ($contact === null) {\n return false;\n }\n\n $account = $contact->account;\n $countryCode = $contact->country_code ?? $account->country_code ?? null;\n\n try {\n $hsOpportunities = $this->findOpportunities(\n $account ? $account->crm_provider_id : null,\n $contact->crm_provider_id,\n $userId\n );\n } catch (Exception $e) {\n $hsOpportunities = [];\n }\n\n $opportunity = null;\n $stage = null;\n if (! empty($hsOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n });\n\n return is_array($result) ? $result : null;\n }\n\n\n private function convertActivityAssociations(Activity $activity): array\n {\n return [\n 'contactIds' => $this->getParticipantsIds($activity),\n 'companyIds' => $activity->hasAccount() ? [$activity->account->crm_provider_id] : [],\n 'dealIds' => $activity->hasOpportunity() ? [$activity->opportunity->crm_provider_id] : [],\n 'ownerIds' => [],\n ];\n }\n\n private function getParticipantsIds(Activity $activity): array\n {\n $attendees = [];\n\n $participantRepository = app(ParticipantRepository::class);\n $participants = $participantRepository->getParticipantsWhoEnteredMeeting($activity);\n foreach ($participants as $participant) {\n if ($participant->user_id || $participant->isCoach()) {\n continue;\n }\n\n $contact = $participant->contact()->first();\n if ($contact && $contact->crm_provider_id) {\n $attendees[] = $contact->crm_provider_id;\n } else {\n if (! empty($participant->name)) {\n $attendeeData = $this->fetchMissingAttendeeInfo($participant);\n }\n if (! empty($attendeeData['id'])) {\n $attendees[] = $attendeeData['id'];\n }\n }\n }\n\n if ($activity->hasContact()) {\n $attendees[] = $activity->contact->crm_provider_id;\n }\n\n return array_unique($attendees);\n }\n\n private function fetchMissingAttendeeInfo(Participant $participant): array\n {\n // Check if we need to look inside an account context.\n $activity = $participant->getActivity();\n $companyId = $activity->hasAccount() ? $activity->getAccount()->crm_provider_id : null;\n\n // First check the local data.\n /** @var Contact[] $contacts */\n $contacts = $this->team->contacts()\n ->with('account')\n ->where('name', $participant->name)\n ->whereNotNull('email')\n ->get();\n\n foreach ($contacts as $contact) {\n // If we have a company in scope, check the contact is associated to it.\n if (\n $companyId !== null\n && ($contact->account_id === null || $companyId !== $contact->account->crm_provider_id)\n ) {\n continue;\n }\n\n return [\n 'id' => $contact->crm_provider_id,\n 'email' => $contact->email,\n ];\n }\n\n $payload = $this->generateNameSearchPayload($participant->name, 0, 20);\n\n try {\n $response = $this->client->getNewInstance()->crm()->contacts()->searchApi()->doSearch($payload);\n\n // TODO add some logic to choose the most suitable contact if multiple\n foreach ($response['results'] as $object) {\n $properties = $object['properties'];\n if (empty($object['properties']) === false) {\n // Check the company matches the contact.\n // Todo: Move this check inside the API search.\n if ($companyId !== null && $companyId !== $properties['associatedcompanyid']) {\n continue;\n }\n\n return [\n 'id' => $object['id'],\n 'email' => $properties['email'],\n ];\n }\n }\n } catch (Exception $e) {\n $this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [\n 'teamId' => $this->team->id_string,\n 'request' => $payload,\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return [];\n }\n\n /**\n * Store transcripts as note engagement.\n *\n * @throws Exception\n */\n public function createTranscriptNotes(Activity $activity): void\n {\n // For HS no need to check if Crm profile - Log Notes field is enabled\n // We only check if store_transcript toggle is enabled on crm profile.\n $engagement = [\n 'active' => true,\n 'ownerId' => $this->profile->crm_provider_id,\n 'timestamp' => $activity->created_at->tz($activity->user->timezone)->getTimestamp() * 1000,\n 'type' => 'NOTE',\n ];\n\n // Generate activity transcription.\n $transcriptionData = $this->generateTranscription($activity);\n\n // Truncate Notes with max notes length because transcription text could be very long.\n $transcripts = mb_strimwidth($transcriptionData, 0, static::ENGAGEMENT_BODY_MAX_LENGTH);\n\n $metadata = [\n 'body' => $transcripts,\n ];\n\n $associations = $this->convertActivityAssociations($activity);\n\n try {\n $hsEngagement = $this->client\n ->getInstance()\n ->engagements()\n ->create($engagement, $associations, $metadata);\n\n $this->logCrmEngagementManipulation(self::ACTION_CREATE, $engagement, $metadata, $associations);\n\n $noteId = $hsEngagement->data->engagement->id;\n\n // Store crm logged id in transcription.\n $transcription = $activity->getTranscription();\n $transcription->crm_activity_id = $noteId;\n $transcription->save();\n } catch (Exception $e) {\n Sentry::captureException($e);\n }\n }\n\n /*\n * @inheritdoc\n */\n public function updateRecord(string $objectType, string $objectId, array $data, array $headers = []): void\n {\n $payload = [\n 'properties' => $data,\n ];\n\n try {\n switch ($objectType) {\n case FieldData::OBJECT_OPPORTUNITY:\n $this->client->getNewInstance()->crm()->deals()->basicApi()->update($objectId, $payload);\n\n break;\n case FieldData::OBJECT_CONTACT:\n $this->client->getNewInstance()->crm()->contacts()->basicApi()->update($objectId, $payload);\n\n break;\n\n case FieldData::OBJECT_ACCOUNT:\n $this->client->getNewInstance()->crm()->companies()->basicApi()->update($objectId, $payload);\n\n break;\n\n case FieldData::OBJECT_TASK:\n // Endpoint for Engagements not ready\n $engagements = [\n 'type' => 'TASK',\n ];\n $metadata = $data;\n $this->client->getInstance()->engagements()->update($objectId, $engagements, $metadata);\n\n $this->logCrmEngagementManipulation(\n self::ACTION_UPDATE,\n ['crmId' => $objectId],\n $metadata,\n );\n\n break;\n\n default:\n throw new UnexpectedValueException('Unsupported object type \"' . $objectType . '\"');\n }\n } catch (\\HubSpot\\Client\\Crm\\Deals\\ApiException $apiException) {\n $errorMessage = $apiException->getMessage();\n if ($apiException->getResponseBody()) {\n $responseBody = json_decode($apiException->getResponseBody(), true, 512, JSON_THROW_ON_ERROR);\n $errorMessage = $responseBody['message'] ?? $apiException->getMessage();\n }\n\n $this->logger->error(\n '[HubSpot] Update record failed',\n [\n 'objectType' => $objectType,\n 'objectId' => $objectId,\n 'payload' => $payload,\n 'reason' => $errorMessage,\n 'team' => $this->team->getUuid(),\n ]\n );\n\n throw new CrmException($errorMessage);\n }\n }\n\n /*\n * @inheritdoc\n */\n public function getRecord(string $objectType, string $objectId, array $fields = []): array\n {\n switch ($objectType) {\n case FieldData::OBJECT_OPPORTUNITY:\n return $this->client->getInstance()->deals()->getById($objectId)->toArray();\n\n case FieldData::OBJECT_CONTACT:\n return $this->client->getInstance()->contacts()->getById($objectId)->toArray();\n\n case FieldData::OBJECT_ACCOUNT:\n return $this->client->getInstance()->companies()->getById($objectId)->toArray();\n\n case FieldData::OBJECT_TASK:\n return $this->client->getInstance()->engagements()->get($objectId)->toArray();\n\n default:\n throw new UnexpectedValueException('Unsupported object type \"' . $objectType . '\"');\n }\n }\n\n /**\n * @throws BadRequest\n * @throws CrmException\n */\n public function updateStage($crmObject, Stage $stage): void\n {\n $payload = [\n 'properties' => [\n [\n 'name' => 'dealstage',\n 'value' => $stage->crm_provider_id,\n ],\n ],\n ];\n\n try {\n $this->client->getInstance()->deals()->update($crmObject->crm_provider_id, $payload);\n } catch (BadRequest $badRequest) {\n if ($badRequest->getCode() === 403) {\n throw new CrmException(\n \"Sorry, you don't have permission to update this stage.\",\n $badRequest->getCode(),\n $badRequest,\n );\n }\n\n $this->logger->warning('[HubSpot] Stage update failed', [\n 'dealId' => $crmObject->crm_provider_id,\n 'payload' => $payload,\n 'message' => $badRequest->getMessage(),\n ]);\n\n throw $badRequest;\n }\n }\n\n public function generateProviderUrl(string $providerId, string $objectType): ?string\n {\n $url = null;\n $baseUrl = 'https://app.hubspot.com/contacts/' . $this->config->crm_provider_id . '/';\n\n switch ($objectType) {\n case 'account':\n $url = $baseUrl . 'company/' . $providerId;\n\n break;\n\n case 'contact':\n $url = $baseUrl . 'contact/' . $providerId;\n\n break;\n\n case 'opportunity':\n $url = $baseUrl . 'deal/' . $providerId;\n\n break;\n\n case 'task':\n case 'activity':\n return null;\n\n // This should not be deep-linked as per JMNY-3934.\n //$url = $baseUrl.'tasks/list/view/all/?taskId='.$providerId;\n break;\n }\n\n return $url;\n }\n\n public function searchCalls(Carbon $from, Carbon $to, string $activityProvider): array\n {\n $this->logger->info('[HubSpot] Search calls', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n ]);\n\n $calls = [];\n $page = 1;\n\n do {\n try {\n $payload = $this->payloadBuilder->generateGetCallsPayload($from, $to, $activityProvider, $page);\n $response = $this->client->search('calls', $payload);\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Search calls failed', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n 'reason' => $exception->getMessage(),\n ]);\n $response = null;\n }\n\n $responseResults = empty($response['results']) ? [] : $response['results'];\n $calls = array_merge($calls, $responseResults);\n $page++;\n } while (! empty($responseResults));\n\n return $calls;\n }\n\n public function searchCallsForPeriodByPage(Carbon $from, Carbon $to, int $page, bool $retry = true)\n {\n try {\n $payload = $this->payloadBuilder->generateSearchCallsByPeriodPayload($from, $to, $page);\n\n return $this->client->search('calls', $payload);\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Search calls for period failed', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n 'reason' => $exception->getMessage(),\n 'retry' => $retry,\n ]);\n\n if ($retry) {\n sleep(1);\n\n return $this->searchCallsForPeriodByPage($from, $to, $page, false);\n }\n\n return null;\n }\n }\n\n public function searchCallsForPeriod(Carbon $from, Carbon $to): Generator\n {\n $this->logger->info('[HubSpot] Search calls for period', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n ]);\n\n $page = 1;\n\n do {\n $response = $this->searchCallsForPeriodByPage($from, $to, $page);\n\n $responseResults = empty($response['results']) ? [] : $response['results'];\n\n $associationContacts = $this->getAssociationDataForCollection($responseResults, 'calls', 'contacts');\n $associationCompanies = $this->getAssociationDataForCollection($responseResults, 'calls', 'companies');\n $associationDeals = $this->getAssociationDataForCollection($responseResults, 'calls', 'deals');\n\n foreach ($responseResults as $call) {\n $call['associations'] = [\n 'contacts' => $this->importAssociationData($call, $associationContacts),\n 'companies' => $this->importAssociationData($call, $associationCompanies),\n 'deals' => $this->importAssociationData($call, $associationDeals),\n ];\n\n yield $call;\n }\n $page++;\n } while (! empty($responseResults));\n }\n\n public function getCall(string $callId): array\n {\n $this->logger->info('[HubSpot] Get call', [\n 'call_id' => $callId,\n ]);\n\n $searchAttributes = $this->payloadBuilder->getSearchCallAttributes();\n $endpoint = sprintf(\n 'https://api.hubapi.com/crm/v3/objects/calls/%s',\n $callId,\n );\n\n try {\n $response = $this->client->getInstance()->getClient()->request(\n 'GET',\n $endpoint,\n [],\n sprintf(\n 'properties=%s&associations=contacts,companies,deals',\n implode(',', $searchAttributes),\n ),\n );\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Get call failed', [\n 'call_id' => $callId,\n 'reason' => $exception->getMessage(),\n ]);\n $response = null;\n }\n\n return empty($response) ? [] : $response->toArray();\n }\n\n public function bulkAddPlaybackURLToDescription(array $crmUpdateData): array\n {\n $crmUpdateBatches = array_chunk($crmUpdateData, self::BATCH_UPDATE_LIMIT);\n\n $updatedCrmIds = [];\n\n foreach ($crmUpdateBatches as $crmBatch) {\n $payload = $this->payloadBuilder->generatePlaybackAddUrlBatchPayload($crmBatch);\n $updateSuccess = $this->bulkAddPlaybackURLToDescriptionRequest($payload);\n if ($updateSuccess) {\n $updatedCrmIds = array_merge($updatedCrmIds, array_column($crmBatch, 'crm_id'));\n }\n }\n\n return $updatedCrmIds;\n }\n\n private function bulkAddPlaybackURLToDescriptionRequest(array $payload, bool $retry = true): bool\n {\n try {\n $this->client->getNewInstance()->crm()->objects()->batchApi()->update('calls', $payload);\n\n return true;\n } catch (\\HubSpot\\Client\\Crm\\Objects\\ApiException $e) {\n $response = json_decode($e->getResponseBody(), true);\n $retryAfter =\n isset($response['policyName'])\n && $response['policyName'] == self::TEN_SECONDLY_ROLLING_POLICY\n ? self::TEN_SECONDLY_ROLLING_LIMIT\n : 1;\n } catch (Exception $e) {\n $retryAfter = 1;\n }\n\n $this->logger->warning('[HubSpot] Bulk add playback url to CRM failed', [\n 'reason' => $e->getMessage(),\n 'retry' => $retry,\n ]);\n\n if ($retry) {\n sleep($retryAfter);\n\n return $this->bulkAddPlaybackURLToDescriptionRequest($payload, false);\n }\n\n return false;\n }\n\n /**\n * Sometimes we have secondly rate limit error, then retry request after 1 second\n */\n public function searchCallByRecordingURLToken(string $playbackURLToken, bool $retry = true): array\n {\n $payload = $this->payloadBuilder->generateSearchCallByTokenPayload($playbackURLToken);\n\n $this->logger->info('[HubSpot] CRM Search by playback URL token requested', [\n 'request' => $payload,\n ]);\n\n try {\n $response = $this->client->search('calls', $payload);\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Search by playback URL token failed', [\n 'playbackURLToken' => $playbackURLToken,\n 'reason' => $exception->getMessage(),\n 'retry' => $retry,\n ]);\n\n if ($retry) {\n sleep(1);\n\n return $this->searchCallByRecordingURLToken($playbackURLToken, false);\n }\n\n return [];\n }\n\n return empty($response['results']) ? [] : $response['results'][0];\n }\n\n /**\n * Generate transcription for the activity.\n */\n private function generateTranscription(Activity $activity): string\n {\n if (! $this->config->store_transcript) {\n // If sending transcription to activity toggle is disabled\n return '';\n }\n\n $transcriptionSegments = $this->transcriptionService->findTranscriptionByActivity($activity);\n\n if ($transcriptionSegments->isEmpty()) {\n return '';\n }\n\n $transcription = sprintf(\n '<p><strong>Transcript for %s</strong></p><p></p>',\n $activity->title ?? $activity->activity_title,\n );\n\n $roomOwnerParticipant = $activity->findParticipantRoomOwner();\n $roomOwnerParticipantId = $roomOwnerParticipant !== null\n ? $roomOwnerParticipant->getId()\n : null;\n\n\n $transcription .= $transcriptionSegments\n ->map(static function (array $transcriptionSegment) use ($roomOwnerParticipantId): string {\n $isOrganiser = $roomOwnerParticipantId === $transcriptionSegment['participantId']\n && $roomOwnerParticipantId !== null;\n $transcriptColor = $isOrganiser ? '#000000' : '#f0415a';\n\n return sprintf(\n '<span style=\"color: %s;\">%s | </span>%s',\n $transcriptColor,\n $transcriptionSegment['formattedStartsAt'],\n $transcriptionSegment['transcript'],\n );\n })\n ->implode('<br />');\n\n return $transcription;\n }\n\n /**\n * @param array<array{\n * id: string,\n * label: string,\n * value?: string,\n * }> $options\n *\n * @return FieldData[]\n */\n private function importOptions(Field $field, array $options): array\n {\n $fieldValues = [];\n $values = [];\n $sequence = 0;\n\n foreach ($options as $option) {\n $values[] = [\n 'value' => $option['value'] ?? $option['id'],\n 'label' => substr($option['label'], 0, 255),\n 'sequence' => $sequence++,\n ];\n }\n\n $fieldsToPurge = $field->values()->get()->pluck('value')->toArray();\n\n foreach ($values as $value) {\n $value['value'] = substr($value['value'], 0, 255);\n $fieldValues[] = $field->values()->updateOrCreate([\n 'value' => $value['value'],\n ], $value);\n\n // Remove this value from the ones we are going to purge.\n if (($key = array_search($value['value'], $fieldsToPurge, false)) !== false) {\n unset($fieldsToPurge[$key]);\n }\n }\n\n // Delete the old values that are no longer used.\n $field->values()->whereIn('value', $fieldsToPurge)->delete();\n\n return $fieldValues;\n }\n\n public function saveTranscriptionSummaryAsNote(\n ActivityContract $activity,\n string $title,\n string $body,\n ?string $objectId,\n ?NoteObject $noteObject = null,\n ): ?string {\n if ($noteObject === null || $objectId === null) {\n return null;\n }\n\n /** @var User $user */\n $user = $activity->getUser();\n\n $profile = $this->assignCrmOwner($user, $activity);\n if (! $profile instanceof Profile) {\n return null;\n }\n\n $timestamp = Carbon::now($user->getTimezone())->getTimestamp() * 1000;\n $engagement = [\n 'active' => true,\n 'ownerId' => $profile->getAttribute('crm_provider_id'),\n 'timestamp' => $timestamp,\n 'type' => 'NOTE',\n ];\n\n // Truncate Notes with max notes length because transcription text could be very long.\n $body = mb_strimwidth($body, 0, self::ENGAGEMENT_BODY_MAX_LENGTH);\n $metadata = [\n 'body' => $body,\n ];\n\n $associations = $this->convertActivityAssociations($activity);\n\n try {\n $hsActivityId = $this->client->createNote(\n body: $body,\n ownerId: $profile->getCrmProviderId(),\n timestamp: $timestamp,\n objectId: $objectId,\n noteObject: $noteObject,\n );\n\n $this->logCrmEngagementManipulation(self::ACTION_CREATE, $engagement, $metadata, $associations);\n\n $this->logger->info('[HubSpot] Saving Transcription Summary as Note', [\n 'activity' => $activity->getUuid(),\n 'crmActivity' => $hsActivityId,\n ]);\n\n return $hsActivityId;\n } catch (Exception $e) {\n Sentry::captureException($e);\n }\n\n return null;\n }\n\n public function attachSummaryToActivity(ActivityContract $activity, string $summaryTitle, string $summaryContents): bool\n {\n $this->logger->info('[HubSpot] Attaching summary to activity', [\n 'activity' => $activity->getUuid(),\n 'summary_content' => $summaryContents,\n ]);\n\n if (! $activity instanceof Activity) {\n throw new InvalidArgumentException('Expected instance of Activity');\n }\n\n $summary = '<p><strong>' . $summaryTitle . '</strong></p>';\n $summary .= '<p>' . $summaryContents . '</p>';\n $metadata = $this->buildMetadataForSummaryUpdate($activity, $summary);\n\n try {\n $type = $this->matchActivityEngagementType($activity);\n $engagement = ['type' => $type];\n\n $this->client->updateEngagement($activity->getCrmProviderId(), $engagement, $metadata);\n } catch (Exception $e) {\n $this->logger->warning('[HubSpot] Update summary failed', [\n 'activity' => $activity->getUuid(),\n 'reason' => $e->getMessage(),\n ]);\n\n return false;\n }\n\n $this->logCrmEngagementManipulation(\n self::ACTION_UPDATE,\n ['crmId' => $activity->getCrmProviderId()],\n $metadata,\n );\n\n return true;\n }\n\n private function buildMetadataForSummaryUpdate(Activity $activity, string $summary): array\n {\n $descriptionField = $activity->getType() === Activity::TYPE_CONFERENCE ? 'internalMeetingNotes' : 'body';\n $engagement = $this->client->getEngagementData($activity->getCrmProviderId());\n // Meeting without internalMeetingNotes might mean it just does not have any notes;\n $description = $engagement['metadata'][$descriptionField] ?? null;\n\n if (empty($description)) {\n $data = $summary;\n } else {\n // avoid playbook url link to Jiminny being sent twice in the activity description\n $targetUrl = PlaybackUrlBuilder::build($activity);\n\n if (str_contains($description, $targetUrl)) {\n $jiminnyUrl = '<p><a href=\"' . $targetUrl . '\" title=\"Play at Jiminny\">Play at Jiminny</a></p>';\n $summary = str_replace($jiminnyUrl, '', $summary);\n\n $this->logger->info('[HubSpot] Summary modified', [\n 'activity' => $activity->getUuid(),\n 'target_url' => $jiminnyUrl,\n 'modified_summary_content' => $summary,\n ]);\n }\n\n $data = $description . '<p></p>' . $summary;\n }\n\n return [\n $descriptionField => $data,\n ];\n }\n\n public function fetchAndAssociateRelatedActivity(Activity $activity): ?Activity\n {\n return $this->syncRelatedActivityManager->fetchAndAssociateRelatedActivity($activity);\n }\n\n public function fetchRelatedActivity(Activity $activity): array\n {\n return [];\n }\n\n public function getDealsInBulk(array $dealIds): array\n {\n $payload = $this->payloadBuilder->getDealsInBulkPayload($dealIds);\n\n return $this->client->getPaginatedData($payload, 'deals');\n }\n\n /**\n * Extract deal IDs from HubSpot search response.\n *\n * @param array $hubspotResponse The raw HubSpot search API response.\n * @param bool $includeArchived Whether to include archived deals (default: false).\n *\n * @return string[] Array of deal IDs as strings.\n */\n public function extractDealIds(array $hubspotResponse, bool $includeArchived = false): array\n {\n if (empty($hubspotResponse['results'])) {\n return [];\n }\n\n return array_values(\n array_map(\n fn ($deal) => $deal['id'],\n array_filter(\n $hubspotResponse['results'],\n fn ($deal) => $includeArchived || empty($deal['archived'])\n )\n )\n );\n }\n\n public function matchActivityEngagementType(Activity $activity): string\n {\n return match ($activity->getType()) {\n Activity::TYPE_CONFERENCE => self::TYPE_MEETING,\n Activity::TYPE_SOFTPHONE, Activity::TYPE_SOFTPHONE_INBOUND => self::TYPE_CALL,\n default => self::TYPE_NOTE,\n };\n }\n\n private function assignCrmOwner(User $user, ActivityContract $activity): ?Profile\n {\n $profile = $user->getProfile();\n if ($profile instanceof Profile) {\n return $profile;\n }\n\n $this->logger->info('[HubSpot] Unable to save summary. No profile', [\n 'activity' => $activity->getUuid(),\n ]);\n\n return null;\n }\n\n private static function getDealsPipelinesEndpoint(): string\n {\n return self::API_URL . self::ENDPOINT_PIPELINES . self::PIPELINE_OBJECT_TYPE_DEALS;\n }\n\n public function verifyTaskExists(Activity $activity): bool\n {\n $crmProviderId = $activity->getCrmProviderId();\n $cacheKey = \"crm_task_exists:{$this->config->getId()}:$crmProviderId\";\n\n return Cache::remember($cacheKey, self::TASK_VERIFICATION_CACHE_TTL, function () use ($crmProviderId) {\n try {\n $engagement = $this->client->getEngagementData($crmProviderId);\n\n return ! empty($engagement);\n } catch (HttpNotFoundException|BadRequest) {\n // Engagement not found in CRM - this is expected and permanent\n $this->logger->info('[Hubspot] Engagement not found during verification', [\n 'engagement_id' => $crmProviderId,\n 'config_id' => $this->config->getId(),\n ]);\n\n return false;\n }\n // Let other exceptions (network errors, rate limits, etc.) bubble up for retry\n });\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Project","depth":3,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Project","depth":3,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"New File or Directory…","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Expand Selected","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Collapse All","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Options","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
-3175715562603377888
|
-465054261620631449
|
click
|
accessibility
|
NULL
|
Project: faVsco.js, menu
JY-20725-handle-HS-search Project: faVsco.js, menu
JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
19
Previous Highlighted Error
Next Highlighted Error
[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {
"headers":{
"Date":["Thu,07 May 2026 14:21:15 GMT"],
"Content-Type":["application/json;charset=utf-8"],
"Transfer-Encoding":["chunked"],
"Connection":["keep-alive"],
"CF-Ray":["9f80deb8db60dc3a-SOF"],
"CF-Cache-Status":["DYNAMIC"],
"Strict-Transport-Security":["max-age=31536000; includeSubDomains; preload"],
"Vary":["origin,
accept-encoding"],
"access-control-allow-credentials":["false"],
"server-timing":["hcid;desc=\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\",
cfr;desc=\"9f80deb8e7c6dc3a-IAD\""],
"x-content-type-options":["nosniff"],
"x-hubspot-correlation-id":["019e02d0-6fd8-7812-bdba-885b7ccb3ee3"],
"Set-Cookie":["__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-[IP_ADDRESS]-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,
07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None"],
"Report-To":["{
\"endpoints\":[{
\"url\":\"https:\\/\\/a.nel.cloudflare.com\\/report\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\"}],
\"group\":\"cf-nel\",
\"max_age\":604800}"],
"NEL":["{
\"success_fraction\":0.01,
\"report_to\":\"cf-nel\",
\"max_age\":604800}"],
"Server":["cloudflare"]}} {
"correlation_id":"95236535-ec98-4541-b92a-adfa73b69eab",
"trace_id":"c7ab8365-903f-46d4-9403-0e5b551e3545"}
Code changed:
Hide
Sync Changes
Hide This Notification
7
48
1
33
1
Previous Highlighted Error
Next Highlighted Error
<?php
namespace Jiminny\Services\Crm\Hubspot;
use Carbon\Carbon;
use Exception;
use Generator;
use GuzzleHttp\Exception\RequestException;
use Illuminate\Support\Facades\Cache;
use InvalidArgumentException;
use Jiminny\Contracts\Repositories\TeamRepository;
use Jiminny\Contracts\Services\Crm\ClientInterface;
use Jiminny\Contracts\Services\Crm\FetchRelatedActivityInterface;
use Jiminny\Contracts\Services\Crm\LayoutManagementInterface;
use Jiminny\Contracts\Services\Crm\MatchCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\Provider\HubspotInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityLookupInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityManipulationInterface;
use Jiminny\Contracts\Services\Crm\SavePlaybackLinkToCrmInterface;
use Jiminny\Contracts\Services\Crm\SendSummaryToCrmInterface;
use Jiminny\Contracts\Services\Crm\SettingsInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmMetadataInterface;
use Jiminny\Contracts\Services\Crm\VerifyTaskExistsInterface;
use Jiminny\Exceptions\CrmException;
use Jiminny\Exceptions\HttpNotFoundException;
use Jiminny\Jobs\Crm\NoteObject;
use Jiminny\Models\Account;
use Jiminny\Models\Activity;
use Jiminny\Models\Contact;
use Jiminny\Models\Contracts\ActivityContract;
use Jiminny\Models\Crm\BusinessProcess;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\FieldData;
use Jiminny\Models\Crm\Layout;
use Jiminny\Models\Crm\Profile;
use Jiminny\Models\Lead;
use Jiminny\Models\Opportunity;
use Jiminny\Models\Participant;
use Jiminny\Models\Playbook;
use Jiminny\Models\SocialAccount;
use Jiminny\Models\Stage;
use Jiminny\Models\User;
use Jiminny\Repositories\Crm\CrmEntityRepository;
use Jiminny\Repositories\Crm\FieldRepository;
use Jiminny\Repositories\Crm\ProfileRepository;
use Jiminny\Repositories\ParticipantRepository;
use Jiminny\Services\Avatar\ProspectPhotoPathService;
use Jiminny\Services\Crm\BaseService;
use Jiminny\Services\Crm\Hubspot\Actions\SyncArchivedProfilesAction;
use Jiminny\Services\Crm\Hubspot\Fields\ValueNormalizer;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\OpportunitySyncTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\SyncCrmEntitiesTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\SyncFieldsTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\WriteCrmTrait;
use Jiminny\Services\Crm\MatchDomainByEmailInterface;
use Jiminny\Services\Crm\OpportunitySyncStrategyResolver;
use Jiminny\Services\Crm\ResolveCompanyNameByEmailTrait;
use Jiminny\Utils\PlaybackUrlBuilder;
use Sentry;
use SevenShores\Hubspot\Exceptions\BadRequest;
use Throwable;
use UnexpectedValueException;
/**
* @phpstan-type CrmFieldDefinition array{
* name: string,
* label: string,
* description: string,
* type: string,
* fieldType: string,
* hidden: bool,
* showCurrencySymbol: bool,
* options: array<array{
* id: string,
* label: string,
* value?: string,
* }
*/
class Service extends BaseService implements
HubspotInterface,
SyncCrmEntitiesInterface,
SyncCrmMetadataInterface,
SendSummaryToCrmInterface,
MatchDomainByEmailInterface,
SavePlaybackLinkToCrmInterface,
RemoteEntityManipulationInterface,
FetchRelatedActivityInterface,
LayoutManagementInterface,
SettingsInterface,
MatchCrmEntitiesInterface,
RemoteEntityLookupInterface,
VerifyTaskExistsInterface
{
use ResolveCompanyNameByEmailTrait;
use SyncCrmEntitiesTrait;
use WriteCrmTrait;
use SyncFieldsTrait;
use OpportunitySyncTrait;
private const int ENGAGEMENT_BODY_MAX_LENGTH = 65536;
private const string LOG_DATE_FORMAT = 'Y-m-d H:i:s';
private const int BATCH_UPDATE_LIMIT = 100;
private const string TEN_SECONDLY_ROLLING_POLICY = 'TEN_SECONDLY_ROLLING';
private const int TEN_SECONDLY_ROLLING_LIMIT = 10;
private const string TYPE_NOTE = 'NOTE';
private const string TYPE_MEETING = 'MEETING';
private const string TYPE_CALL = 'CALL';
private const string API_URL = '[URL_WITH_CREDENTIALS] ClientInterface|Client
*/
protected $client;
protected OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;
protected CrmEntityRepository $crmEntityRepository;
protected ProspectPhotoPathService $prospectPhotoPathService;
private SyncFieldAction $syncFieldAction;
private PayloadBuilder $payloadBuilder;
private SyncRelatedActivityManager $syncRelatedActivityManager;
private SyncArchivedProfilesAction $syncArchivedProfilesAction;
private WebhookSyncBatchProcessor $batchProcessor;
public function __construct(
Client $client,
SyncFieldAction $syncFieldAction,
PayloadBuilder $payloadBuilder,
ProspectPhotoPathService $prospectPhotoPathService,
SyncArchivedProfilesAction $syncArchivedProfilesAction,
WebhookSyncBatchProcessor $batchProcessor,
) {
parent::__construct();
$this->client = $client;
$this->syncFieldAction = $syncFieldAction;
$this->prospectPhotoPathService = $prospectPhotoPathService;
$this->payloadBuilder = $payloadBuilder;
$this->syncArchivedProfilesAction = $syncArchivedProfilesAction;
$this->batchProcessor = $batchProcessor;
$this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [
'client' => $this->client,
]);
$this->syncRelatedActivityManager = app(SyncRelatedActivityManager::class, [
'client' => $this->client,
'payloadBuilder' => $this->payloadBuilder,
'logger' => $this->logger,
]);
$this->crmEntityRepository = app(CrmEntityRepository::class);
$this->dealFieldsService = app(DealFieldsService::class);
}
public function getDisplayName(): string
{
return 'HubSpot';
}
protected function getOAuthAccount(User $user): ?SocialAccount
{
// In this case, the Account Owner is always the connection for any API operations.
$owner = $user->team->owner;
return $owner->getSocialAccount(SocialAccount::PROVIDER_HUBSPOT);
}
public function getClient(): Client
{
/** @var Client */
return $this->client;
}
/**
* Convert raw field data into a format compatible with CRM APIs.
*
* @param bool $internal Direction of the conversion.
* True is pulling from CRM, false normalize before sending to CRM.
*/
public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string
{
return ValueNormalizer::normalize(
fieldType: $fieldType,
fieldValue: $fieldValue,
isInbound: $internal,
);
}
/**
* @inheritdoc
*/
public function getDefaultFields(string $activityType): array
{
$fields = [];
if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {
$defaultFields = FieldDefinitions::defaultTaskFields();
// This lazy creates these fields if not already setup.
foreach ($defaultFields as $defaultField) {
$fields[] = $this->config->fields()->firstOrCreate($defaultField);
}
}
return $fields;
}
/**
* @inheritdoc
*/
public function getDefaultActivityField(string $activityType): Field
{
/** @var Field $activityField */
$activityField = $this->config->fields()->where([
'crm_provider_id' => 'activityType',
'object_type' => $activityType,
])->first();
return $activityField;
}
/**
* @inheritdoc
*/
public function getSupportedPlaybookTypes(): array
{
return [Playbook::ACTIVITY_TYPE_TASK];
}
/**
* @inheritdoc
*/
public function getDefaultActivityLayoutFields(string $activityType, string $layoutType): array
{
$fields = [];
if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {
// Outcome should always be provided calls/meetings.
$fieldData = [
[
'crm_provider_id' => $layoutType === Layout::TYPE_SOFTPHONE_SUMMARY ? 'disposition' : 'meetingOutcome',
'object_type' => Field::OBJECT_TASK,
],
];
foreach ($fieldData as $data) {
$field = $this->config->fields()->where($data)->first();
// Only add the field if it is created, which it should be.
if ($field) {
$fields[] = $field;
}
}
}
return $fields;
}
public function getDealInsightsFields(): array
{
return FieldDefinitions::dealInsightsFields();
}
protected function getDefaultFollowupLayoutFields(string $activityType): array
{
$fields = [];
$fieldRepo = app(FieldRepository::class);
$fieldData = FieldDefinitions::followupFieldsFilter();
foreach ($fieldData as $data) {
$field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $data);
// Only add the field if it is created, which it should be.
if ($field) {
$fields[] = $field;
}
}
return $fields;
}
/**
* @inheritdoc
*/
public function syncField(Field $field): void
{
switch ($field->object_type) {
case Field::OBJECT_ACCOUNT:
$crmField = $this->client->getInstance()->companyProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_CONTACT:
$crmField = $this->client->getInstance()->contactProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_OPPORTUNITY:
$crmField = $this->client->getInstance()->dealProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_TASK:
$this->syncSingleTaskField($field);
return;
default:
return;
}
$this->syncFieldAction->execute($field, $crmField->toArray());
}
/**
* @param array<array{
* id:string,
* label:string,
* value?:string
* }> $options
*
* @throws CrmException
*
* @return FieldData[]
*
*/
public function importPicklistValues(
Field $field,
array $options = [['id' => '', 'label' => '', 'value' => '']],
): array {
if (! empty($options[0]['id']) || ! empty($options[0]['value'])) {
// We already have the options, no need to fetch them again
return $this->importOptions($field, $options);
}
$options = [];
switch ($field->getObjectType()) {
case Field::OBJECT_ACCOUNT:
$options = $this->getClient()->fetchPropertyOptions('company', $field->getCrmProviderId());
break;
case Field::OBJECT_CONTACT:
$options = $this->getClient()->fetchPropertyOptions('contact', $field->getCrmProviderId());
break;
case Field::OBJECT_OPPORTUNITY:
// Hubspot has different endpoint for stages
$options = $this->getClient()->fetchOpportunityFieldOptions($field);
break;
case Field::OBJECT_TASK:
if ($field->getCrmProviderId() === 'disposition') {
$options = $this->getClient()->fetchDispositionFieldOptions();
} elseif (in_array($field->getCrmProviderId(), ['meetingOutcome', 'activityType'])) {
$options = $this->getClient()->fetchMeetingOutcomeFieldOptions($field);
}
break;
default:
$this->logger->warning('Invalid object type', [
'object_type' => $field->getObjectType(),
'field_id' => $field->getId(),
]);
throw new CrmException('Invalid object type');
}
return $this->importOptions($field, $options);
}
/**
* @inheritdoc
*/
public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage
{
$missingStage = null;
try {
// Use the HubSpot API client instead of the SDK crmPipelines() method
$endpoint = self::getDealsPipelinesEndpoint();
$pipelinesResponse = $this->client->getInstance()->getClient()->request('GET', $endpoint);
$pipelines = $pipelinesResponse->data->results;
} catch (RequestException|BadRequest $exception) {
throw $exception;
}
foreach ($pipelines as $pipeline) {
$stages = [];
// We create a business process to contain the pipeline, and store all stages against it.
$p = ResponseNormalize::normalizePipeline($pipeline);
// Create/update business process for this pipeline
$businessProcess = $this->config->businessProcesses()->updateOrCreate([
'crm_provider_id' => $p['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($p['label'], 0, 150),
'type' => BusinessProcess::TYPE_OPPORTUNITY,
'is_selectable' => $p['active'],
]);
// A record type is really a clone of the business process, used to store which record uses which pipeline.
// Create/update record type clone
$this->config->recordTypes()->updateOrCreate([
'crm_provider_id' => $p['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($p['label'], 0, 150),
'is_selectable' => $p['active'],
'business_process_id' => $businessProcess->id ?? null,
]);
// Stages - fetch all existing stages upfront to avoid N+1 queries
$existingStages = $this->config->stages()
->withTrashed()
->where('type', Stage::TYPE_OPPORTUNITY)
->get()
->keyBy('crm_provider_id');
foreach ($p['stages'] as $dealStage) {
$s = ResponseNormalize::normalizeDealStage($dealStage);
/** @var ?Stage $existingStage */
$existingStage = $existingStages->get($s['id']);
// Restore soft-deleted stages that are now active in HubSpot
if ($existingStage?->trashed() && $s['active']) {
$existingStage->restore();
}
// Upsert stage (updates soft-deleted records without restoring them)
$stage = $this->config->stages()->withTrashed()->updateOrCreate([
'crm_provider_id' => $s['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($s['label'], 0, 50),
'label' => mb_strimwidth($s['label'], 0, 191),
'type' => Stage::TYPE_OPPORTUNITY,
'sequence' => $s['displayOrder'],
'is_selectable' => $s['active'],
'probability' => $s['probability'] * 100,
]);
if ($missingStageName === $s['id']) {
$missingStage = $stage;
}
$stages[] = $stage->id;
}
$businessProcess->stages()->sync($stages);
}
return $missingStage;
}
/**
* @inheritdoc
*/
public function syncOrganization(): void
{
try {
$endpoint = '[URL_WITH_CREDENTIALS]
*/
public function find(string $name, array $scopes): array
{
$count = $this->limit ?? 20;
$offset = $this->offset ?? 0;
/** @var array<int, array<string, mixed>> */
return Cache::remember(
key: $this->team->getId() . $name . $count . $offset,
ttl: 300,
callback: function () use ($name, $offset, $count): array {
$data = [];
// Use the new V3 API to find contacts based on additional fields.
foreach (['companies', 'contacts'] as $objectType) {
$payload = $this->generateNameSearchPayload($name, $offset, $count);
$type = $objectType === 'companies' ? 'account' : 'contact';
try {
$response = $this->client->search($objectType, $payload);
// Build mapped list.
foreach ($response['results'] as $object) {
$properties = $object['properties'];
$objectName = $this->buildContactName($properties);
$record = [
'crmId' => $object['id'],
// Pass crmUrl to the FE, needed for success message in the extension when you log activity.
'crmUrl' => $this->generateProviderUrl($object['id'], $type),
'name' => $objectName,
'prospectType' => $type,
'phoneNumbers' => [],
];
if ($type === 'account') {
$record['industry'] = $properties['industry'] ?? null;
} else {
$record['title'] = $properties['jobtitle'] ?? null;
$record['organization'] = $properties['company'] ?? null;
}
$countryCode = $this->buildContactCountry($properties);
$parsedNumber = $this->buildContactPhone($countryCode, $properties);
// Add phone number to record.
if (! empty($parsedNumber['phone'])) {
$record['phoneNumbers'][] = [
'number' => $parsedNumber['phone'],
'nationalFormat' => phone_national($countryCode, $parsedNumber['phone']),
'type' => 'phone',
];
}
// Add mobile phone number to record.
if (! empty($properties['mobilephone'])) {
$mobileNumber = phone_e164($countryCode, $properties['mobilephone']);
if ($mobileNumber !== null) {
$record['phoneNumbers'][] = [
'number' => $mobileNumber,
'nationalFormat' => phone_national($countryCode, $mobileNumber),
'type' => 'mobile',
];
}
}
$data[] = $record;
}
} catch (BadRequest $e) {
$this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [
'teamId' => $this->team->getUuid(),
'request' => $payload,
'reason' => $e->getMessage(),
]);
throw $e;
}
}
return $data;
},
);
}
/**
* @inheritdoc
*/
public function findOpportunities(?string $crmAccountId, ?string $crmContactId, ?int $userId = null): array
{
$data = [];
$ownerData = [];
$ownerId = null;
if ($crmAccountId === null) {
return $data;
}
if ($userId) {
$profileRepository = app(ProfileRepository::class);
$profile = $profileRepository->findProfileByUserId($this->config, $userId);
$ownerId = $profile instanceof Profile ? $profile->getCrmProviderId() : null;
}
$closedStages = $this->getClosedDealStages();
$payload = $this->payloadBuilder->generateOpportunitiesSearchPayload(
$this->config,
$crmAccountId,
$closedStages,
);
$results = $this->client->getPaginatedData($payload, 'deals');
foreach ($results['results'] as $object) {
$properties = $object['properties'];
$amount = null;
if (empty($properties['amount']) === false) {
$currency = $properties['deal_currency_code'] ?? $this->config->default_currency;
// Values can contain commas and any junk so strip them.
$value = (float) preg_replace('/[^\d.]/', '', $properties['amount']);
$amount = formatCurrency($value, $currency);
}
$businessProcess = $this->config
->businessProcesses()
->where('crm_provider_id', $properties['pipeline'])
->first();
if ($businessProcess === null) {
// Import it.
$stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $properties['dealstage']);
$businessProcess = $this->config
->businessProcesses()
->where('crm_provider_id', $properties['pipeline'])
->first();
} else {
$stage = $businessProcess
->stages()
->where('crm_provider_id', $properties['dealstage'])
->where('type', Stage::TYPE_OPPORTUNITY)
->first();
if ($stage === null) {
// Import it.
$stage = $this->importStages(null, $properties['dealstage']);
}
}
$recordType = null;
if ($businessProcess) {
$recordType = $businessProcess->recordTypes()->first();
}
$isWon = in_array($properties['dealstage'], $closedStages['won']);
$isLost = in_array($properties['dealstage'], $closedStages['lost']);
$record = [
'crmId' => $object['id'],
'name' => $properties['dealname'] ?? 'Unknown Deal',
'value' => $amount,
'won' => $isWon,
'closed' => $isWon || $isLost,
'stage' => [
'id' => $stage?->getUuid() ?? '',
'name' => $stage?->getName() ?? '',
],
];
if ($recordType) {
$record += [
'recordType' => [
'id' => $recordType->id_string,
'name' => $recordType->name,
],
];
}
if ($ownerId && isset($properties['hubspot_owner_id']) && $properties['hubspot_owner_id'] === $ownerId) {
$ownerData[] = $record;
}
$data[] = $record;
}
if (! empty($ownerData)) {
return $ownerData;
}
return $data;
}
/**
* @inheritdoc
*/
public function getTasks(?string $objectType, string $objectId, ?string $opportunityId): array
{
$data = [];
switch ($objectType) {
case 'contact':
$hsObject = 'contact';
break;
case 'account':
$hsObject = 'company';
break;
default:
// This is a hack to prioritise and override a contact/company with a deal.
if ($opportunityId) {
$hsObject = 'deal';
$objectId = $opportunityId;
} else {
throw new InvalidArgumentException('Object type not supported.');
}
}
$engagementTypes = ['meetings', 'tasks'];
foreach ($engagementTypes as $engagementType) {
$payload = $this->payloadBuilder->getLinkToTaskPayload($hsObject, $objectId, $engagementType);
$this->logger->info('[HubSpot] CRM Search requested', [
'request' => $payload,
]);
$engagements = $this->client->getPaginatedData($payload, $engagementType);
foreach ($engagements['results'] as $engagement) {
if ($engagementType == 'meetings') {
$title = $engagement['properties']['hs_meeting_title'] ?? 'Scheduled meeting';
} elseif ($engagementType == 'tasks') {
$title = $engagement['properties']['hs_task_subject'];
} else {
$title = 'Scheduled meeting';
}
$data[] = [
'crmId' => $engagement['id'],
'subject' => $title,
'due' => $engagement['properties']['hs_timestamp'],
'type' => $engagement['properties']['hs_activity_type'] ?? null,
];
}
}
usort($data, function ($item1, $item2) {
return $item2['due'] <=> $item1['due'];
});
return $data;
}
/**
* Try to find CRM Objects using email address
*
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchExactlyByEmail(string $email, ?int $userId = null): ?array
{
$contactProperties = [
'email',
'firstname',
'lastname',
'country',
'phone',
'mobilephone',
'jobtitle',
'hubspot_owner_id',
'associatedcompanyid',
'photo',
];
$contact = null;
$account = null;
try {
$hsContact = $this->getClient()->getContactByEmail($email, $contactProperties);
if ($hsContact) {
$contact = $this->importContact($hsContact);
$account = $contact->account;
}
$data = $this->convertCrmData($contact, $account, $userId);
return ! empty(array_filter($data)) ? $data : null;
} catch (BadRequest $e) {
$this->logger->warning('[HubSpot] Search failed', [
'team_id' => $this->team->getId(),
'search_identifier' => $email,
'reason' => $e->getMessage(),
]);
}
return null;
}
public function getDomain(string $email): ?string
{
return $this->getDomainFromEmail($email);
}
/**
* Try to find CRM objects using domain name of the email address
*
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchByDomain(string $domain, ?int $userId = null): ?array
{
$companyName = $domain;
// Try to find a company matching their email domain.
$companyProperties = [
'country',
'phone',
'name',
'hs_avatar_filemanager_key',
'industry',
'hubspot_owner_id',
'domain',
];
try {
$hsAccounts = $this->client
->getInstance()
->companies()
->searchByDomain($companyName, $companyProperties);
} catch (Throwable $e) {
$this->logger->info('[HubSpot] Search failed', [
'error' => $e->getMessage(),
'domain' => $domain,
]);
return null;
}
$account = null;
// If there are multiple accounts, don't guess, we'll ask later.
if (\count($hsAccounts->data->results) === 1) {
// Persist this remote object.
$account = $this->syncAccount($hsAccounts->data->results[0]->companyId);
}
$data = $this->convertCrmData(null, $account, $userId);
return ! empty(array_filter($data)) ? $data : null;
}
/**
* @return array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
protected function convertCrmData(?Contact $contact, ?Account $account, ?int $userId = null): array
{
$countryCode = null;
if ($contact && $contact->country_code) {
$countryCode = $contact->country_code;
} elseif ($account && $account->country_code) {
$countryCode = $account->country_code;
}
try {
$hsOpportunities = $this->findOpportunities(
$account ? $account->crm_provider_id : null,
$contact ? $contact->crm_provider_id : null,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
// If there are multiple opportunities, don't guess, we'll ask later.
$opportunity = null;
$stage = null;
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
protected function getCacheKey(string $object, ?int $userId = null): ?string
{
$key = $this->team->getId() . $object;
$keySuffix = $this->getOwnerKeySuffix($userId);
return $key . $keySuffix;
}
private function getOwnerKeySuffix(?int $userId = null): string
{
return $userId === null ? '' : (string) $userId;
}
/**
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
*}
*/
public function matchByPhone(string $phone, ?string $rawPhoneNumber = null, ?int $userId = null): ?array
{
if (str_contains($phone, '**')) {
return null;
}
// trim all whitespaces if present so the lookup doesn't fail
$phone = str_replace(' ', '', $phone);
// Check if the user is internal.
if ($this->isPhoneNumberOfTeamMember($phone)) {
return null;
}
$response = $this->searchForPhoneNumber($phone);
if (empty($response)) {
return null;
}
// This would ideally importContact instead but the response type differs.
$contact = $this->findAndSyncContact($response['results'][0]['id']);
if (! $contact instanceof Contact) {
return null;
}
$account = $contact->account;
$countryCode = $contact->country_code ?? $account->country_code ?? null;
try {
$hsOpportunities = $this->findOpportunities(
$account?->crm_provider_id,
$contact->crm_provider_id,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
$opportunity = null;
$stage = null;
try {
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
} catch (Exception $e) {
$this->logger->debug('[HubSpot] Opportunity failed to sync.', [
'reason' => $e->getMessage(),
]);
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
private function isPhoneNumberOfTeamMember(string $phone): bool
{
$teamRepository = app(TeamRepository::class);
$user = $teamRepository->findTeamMemberByPhone($this->team, $phone);
if ($user instanceof User) {
return true;
}
return false;
}
private function findAndSyncContact(string $crmId): ?Contact
{
try {
return $this->syncContact($crmId);
} catch (Exception $exception) {
$this->logger->info('[HubSpot] Phone match failed', [
'reason' => $exception->getMessage(),
]);
return null;
}
}
private function hasResults(array $response): bool
{
return isset($response['total']) && is_numeric($response['total']) && $response['total'] > 0;
}
private function searchForPhoneNumber(string $phone): array
{
// Normalizes the provided phone number for the API search.
$normalizedPhone = $this->normalizePhoneNumber($phone);
$payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone);
$this->logger->info('[HubSpot] Phone match search triggered', [
'phone' => $phone,
'normalizedPhone' => $normalizedPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($normalizedPhone, $payload);
if (! $this->hasResults($response)) {
$nationalPhone = preg_replace('/\D/', '', phone_national(null, $phone));
$payload = $this->payloadBuilder->generatePhoneSearchPayload($nationalPhone);
$this->logger->info('[HubSpot] Phone match national number search triggered', [
'phone' => $phone,
'nationalPhone' => $nationalPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($phone, $payload);
}
if (! $this->hasResults($response)) {
$payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone, true);
$this->logger->info('[HubSpot] Phone match alternative search triggered', [
'phone' => $phone,
'normalizedPhone' => $normalizedPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($phone, $payload);
}
return $this->hasResults($response) ? $response : [];
}
private function handlePhoneSearchRequest(string $phone, array $payload): array
{
try {
return $this->client->search('contacts', $payload);
} catch (Exception $exception) {
$this->logger->info('[HubSpot] Phone match failed', [
'phone' => $phone,
'reason' => $exception->getMessage(),
]);
return [];
}
}
private function normalizePhoneNumber(string $phone): string
{
return ltrim(phone_e164(null, $phone), '+0');
}
/**
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchByName(string $name, ?int $userId = null): ?array
{
// Don't waste time searching for single character strings.
if (\strlen($name) <= 1) {
return null;
}
$cacheKey = $this->getCacheKey($name, $userId);
$result = Cache::remember($cacheKey, 60, function () use ($name, $userId) {
$payload = $this->payloadBuilder->generateSearchContactsByNamePayload(
$name,
$this->getContactFields()
);
$hsContacts = $this->client->getPaginatedData($payload, 'contact');
if (empty($hsContacts['results'])) {
return false;
}
$contact = $this->importContact($hsContacts['results'][0]);
if ($contact === null) {
return false;
}
$account = $contact->account;
$countryCode = $contact->country_code ?? $account->country_code ?? null;
try {
$hsOpportunities = $this->findOpportunities(
$account ? $account->crm_provider_id : null,
$contact->crm_provider_id,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
$opportunity = null;
$stage = null;
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
});
return is_array($result) ? $result : null;
}
private function convertActivityAssociations(Activity $activity): array
{
return [
'contactIds' => $this->getParticipantsIds($activity),
'companyIds' => $activity->hasAccount() ? [$activity->account->crm_provider_id] : [],
'dealIds' => $activity->hasOpportunity() ? [$activity->opportunity->crm_provider_id] : [],
'ownerIds' => [],
];
}
private function getParticipantsIds(Activity $activity): array
{
$attendees = [];
$participantRepository = app(ParticipantRepository::class);
$participants = $participantRepository->getParticipantsWhoEnteredMeeting($activity);
foreach ($participants as $participant) {
if ($participant->user_id || $participant->isCoach()) {
continue;
}
$contact = $participant->contact()->first();
if ($contact && $contact->crm_provider_id) {
$attendees[] = $contact->crm_provider_id;
} else {
if (! empty($participant->name)) {
$attendeeData = $this->fetchMissingAttendeeInfo($participant);
}
if (! empty($attendeeData['id'])) {
$attendees[] = $attendeeData['id'];
}
}
}
if ($activity->hasContact()) {
$attendees[] = $activity->contact->crm_provider_id;
}
return array_unique($attendees);
}
private function fetchMissingAttendeeInfo(Participant $participant): array
{
// Check if we need to look inside an account context.
$activity = $participant->getActivity();
$companyId = $activity->hasAccount() ? $activity->getAccount()->crm_provider_id : null;
// First check the local data.
/** @var Contact[] $contacts */
$contacts = $this->team->contacts()
->with('account')
->where('name', $participant->name)
->whereNotNull('email')
->get();
foreach ($contacts as $contact) {
// If we have a company in scope, check the contact is associated to it.
if (
$companyId !== null
&& ($contact->account_id === null || $companyId !== $contact->account->crm_provider_id)
) {
continue;
}
return [
'id' => $contact->crm_provider_id,
'email' => $contact->email,
];
}
$payload = $this->generateNameSearchPayload($participant->name, 0, 20);
try {
$response = $this->client->getNewInstance()->crm()->contacts()->searchApi()->doSearch($payload);
// TODO add some logic to choose the most suitable contact if multiple
foreach ($response['results'] as $object) {
$properties = $object['properties'];
if (empty($object['properties']) === false) {
// Check the company matches the contact.
// Todo: Move this check inside the API search.
if ($companyId !== null && $companyId !== $properties['associatedcompanyid']) {
continue;
}
return [
'id' => $object['id'],
'email' => $properties['email'],
];
}
}
} catch (Exception $e) {
$this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [
'teamId' => $this->team->id_string,
'request' => $payload,
'reason' => $e->getMessage(),
]);
}
return [];
}
/**
* Store transcripts as note engagement.
*
* @throws Exception
*/
public function createTranscriptNotes(Activity $activity): void
{
// For HS no need to check if Crm profile - Log Notes field is enabled
// We only check if store_transcript toggle is enabled on crm profile.
$engagement = [
'active' => true,
'ownerId' => $this->profile->crm_provider_id,
'timestamp' => $activity->created_at->tz($activity->user->timezone)->getTimestamp() * 1000,
'type' => 'NOTE',
];
// Generate activity transcription.
$transcriptionData = $this->generateTranscription($activity);
// Truncate Notes with max notes length because transcription text could be very long.
$transcripts = mb_strimwidth($transcriptionData, 0, static::ENGAGEMENT_BODY_MAX_LENGTH);
$metadata = [
'body' => $transcripts,
];
$associations = $this->convertActivityAssociations($activity);
try {
$hsEngagement = $this->client
->getInstance()
->engagements()
->create($engagement, $associations, $metadata);
$this->logCrmEngagementManipulation(self::ACTION_CREATE, $engagement, $metadata, $associations);
$noteId = $hsEngagement->data->engagement->id;
// Store crm logged id in transcription.
$transcription = $activity->getTranscription();
$transcription->crm_activity_id = $noteId;
$transcription->save();
} catch (Exception $e) {
Sentry::captureException($e);
}
}
/*
* @inheritdoc
*/
public function updateRecord(string $objectType, string $objectId, array $data, array $headers = []): void
{
$payload = [
'properties' => $data,
];
try {
switch ($objectType) {
case FieldData::OBJECT_OPPORTUNITY:
$this->client->getNewInstance()->crm()->deals()->basicApi()->update($objectId, $payload);
break;
case FieldData::OBJECT_CONTACT:
$this->client->getNewInstance()->crm()->contacts()->basicApi()->update($objectId, $payload);
break;
case FieldData::OBJECT_ACCOUNT:
$this->client->getNewInstance()->crm()->companies()->basicApi()->update($objectId, $payload);
break;
case FieldData::OBJECT_TASK:
// Endpoint for Engagements not ready
$engagements = [
'type' => 'TASK',
];
$metadata = $data;
$this->client->getInstance()->engagements()->update($objectId, $engagements, $metadata);
$this->logCrmEngagementManipulation(
self::ACTION_UPDATE,
['crmId...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
9807
|
444
|
9
|
2026-05-08T13:34:16.225399+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-08/1778 /Users/lukas/.screenpipe/data/data/2026-05-08/1778247256225_m2.jpg...
|
PhpStorm
|
faVsco.js – Service.php
|
1
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
JY-20725-handle-HS-search Project: faVsco.js, menu
JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
19
Previous Highlighted Error
Next Highlighted Error
[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {
"headers":{
"Date":["Thu,07 May 2026 14:21:15 GMT"],
"Content-Type":["application/json;charset=utf-8"],
"Transfer-Encoding":["chunked"],
"Connection":["keep-alive"],
"CF-Ray":["9f80deb8db60dc3a-SOF"],
"CF-Cache-Status":["DYNAMIC"],
"Strict-Transport-Security":["max-age=31536000; includeSubDomains; preload"],
"Vary":["origin,
accept-encoding"],
"access-control-allow-credentials":["false"],
"server-timing":["hcid;desc=\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\",
cfr;desc=\"9f80deb8e7c6dc3a-IAD\""],
"x-content-type-options":["nosniff"],
"x-hubspot-correlation-id":["019e02d0-6fd8-7812-bdba-885b7ccb3ee3"],
"Set-Cookie":["__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-[IP_ADDRESS]-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,
07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None"],
"Report-To":["{
\"endpoints\":[{
\"url\":\"https:\\/\\/a.nel.cloudflare.com\\/report\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\"}],
\"group\":\"cf-nel\",
\"max_age\":604800}"],
"NEL":["{
\"success_fraction\":0.01,
\"report_to\":\"cf-nel\",
\"max_age\":604800}"],
"Server":["cloudflare"]}} {
"correlation_id":"95236535-ec98-4541-b92a-adfa73b69eab",
"trace_id":"c7ab8365-903f-46d4-9403-0e5b551e3545"}
Code changed:
Hide
Sync Changes
Hide This Notification
7
48
1
33
1
Previous Highlighted Error
Next Highlighted Error
<?php
namespace Jiminny\Services\Crm\Hubspot;
use Carbon\Carbon;
use Exception;
use Generator;
use GuzzleHttp\Exception\RequestException;
use Illuminate\Support\Facades\Cache;
use InvalidArgumentException;
use Jiminny\Contracts\Repositories\TeamRepository;
use Jiminny\Contracts\Services\Crm\ClientInterface;
use Jiminny\Contracts\Services\Crm\FetchRelatedActivityInterface;
use Jiminny\Contracts\Services\Crm\LayoutManagementInterface;
use Jiminny\Contracts\Services\Crm\MatchCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\Provider\HubspotInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityLookupInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityManipulationInterface;
use Jiminny\Contracts\Services\Crm\SavePlaybackLinkToCrmInterface;
use Jiminny\Contracts\Services\Crm\SendSummaryToCrmInterface;
use Jiminny\Contracts\Services\Crm\SettingsInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmMetadataInterface;
use Jiminny\Contracts\Services\Crm\VerifyTaskExistsInterface;
use Jiminny\Exceptions\CrmException;
use Jiminny\Exceptions\HttpNotFoundException;
use Jiminny\Jobs\Crm\NoteObject;
use Jiminny\Models\Account;
use Jiminny\Models\Activity;
use Jiminny\Models\Contact;
use Jiminny\Models\Contracts\ActivityContract;
use Jiminny\Models\Crm\BusinessProcess;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\FieldData;
use Jiminny\Models\Crm\Layout;
use Jiminny\Models\Crm\Profile;
use Jiminny\Models\Lead;
use Jiminny\Models\Opportunity;
use Jiminny\Models\Participant;
use Jiminny\Models\Playbook;
use Jiminny\Models\SocialAccount;
use Jiminny\Models\Stage;
use Jiminny\Models\User;
use Jiminny\Repositories\Crm\CrmEntityRepository;
use Jiminny\Repositories\Crm\FieldRepository;
use Jiminny\Repositories\Crm\ProfileRepository;
use Jiminny\Repositories\ParticipantRepository;
use Jiminny\Services\Avatar\ProspectPhotoPathService;
use Jiminny\Services\Crm\BaseService;
use Jiminny\Services\Crm\Hubspot\Actions\SyncArchivedProfilesAction;
use Jiminny\Services\Crm\Hubspot\Fields\ValueNormalizer;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\OpportunitySyncTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\SyncCrmEntitiesTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\SyncFieldsTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\WriteCrmTrait;
use Jiminny\Services\Crm\MatchDomainByEmailInterface;
use Jiminny\Services\Crm\OpportunitySyncStrategyResolver;
use Jiminny\Services\Crm\ResolveCompanyNameByEmailTrait;
use Jiminny\Utils\PlaybackUrlBuilder;
use Sentry;
use SevenShores\Hubspot\Exceptions\BadRequest;
use Throwable;
use UnexpectedValueException;
/**
* @phpstan-type CrmFieldDefinition array{
* name: string,
* label: string,
* description: string,
* type: string,
* fieldType: string,
* hidden: bool,
* showCurrencySymbol: bool,
* options: array<array{
* id: string,
* label: string,
* value?: string,
* }
*/
class Service extends BaseService implements
HubspotInterface,
SyncCrmEntitiesInterface,
SyncCrmMetadataInterface,
SendSummaryToCrmInterface,
MatchDomainByEmailInterface,
SavePlaybackLinkToCrmInterface,
RemoteEntityManipulationInterface,
FetchRelatedActivityInterface,
LayoutManagementInterface,
SettingsInterface,
MatchCrmEntitiesInterface,
RemoteEntityLookupInterface,
VerifyTaskExistsInterface
{
use ResolveCompanyNameByEmailTrait;
use SyncCrmEntitiesTrait;
use WriteCrmTrait;
use SyncFieldsTrait;
use OpportunitySyncTrait;
private const int ENGAGEMENT_BODY_MAX_LENGTH = 65536;
private const string LOG_DATE_FORMAT = 'Y-m-d H:i:s';
private const int BATCH_UPDATE_LIMIT = 100;
private const string TEN_SECONDLY_ROLLING_POLICY = 'TEN_SECONDLY_ROLLING';
private const int TEN_SECONDLY_ROLLING_LIMIT = 10;
private const string TYPE_NOTE = 'NOTE';
private const string TYPE_MEETING = 'MEETING';
private const string TYPE_CALL = 'CALL';
private const string API_URL = '[URL_WITH_CREDENTIALS] ClientInterface|Client
*/
protected $client;
protected OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;
protected CrmEntityRepository $crmEntityRepository;
protected ProspectPhotoPathService $prospectPhotoPathService;
private SyncFieldAction $syncFieldAction;
private PayloadBuilder $payloadBuilder;
private SyncRelatedActivityManager $syncRelatedActivityManager;
private SyncArchivedProfilesAction $syncArchivedProfilesAction;
private WebhookSyncBatchProcessor $batchProcessor;
public function __construct(
Client $client,
SyncFieldAction $syncFieldAction,
PayloadBuilder $payloadBuilder,
ProspectPhotoPathService $prospectPhotoPathService,
SyncArchivedProfilesAction $syncArchivedProfilesAction,
WebhookSyncBatchProcessor $batchProcessor,
) {
parent::__construct();
$this->client = $client;
$this->syncFieldAction = $syncFieldAction;
$this->prospectPhotoPathService = $prospectPhotoPathService;
$this->payloadBuilder = $payloadBuilder;
$this->syncArchivedProfilesAction = $syncArchivedProfilesAction;
$this->batchProcessor = $batchProcessor;
$this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [
'client' => $this->client,
]);
$this->syncRelatedActivityManager = app(SyncRelatedActivityManager::class, [
'client' => $this->client,
'payloadBuilder' => $this->payloadBuilder,
'logger' => $this->logger,
]);
$this->crmEntityRepository = app(CrmEntityRepository::class);
$this->dealFieldsService = app(DealFieldsService::class);
}
public function getDisplayName(): string
{
return 'HubSpot';
}
protected function getOAuthAccount(User $user): ?SocialAccount
{
// In this case, the Account Owner is always the connection for any API operations.
$owner = $user->team->owner;
return $owner->getSocialAccount(SocialAccount::PROVIDER_HUBSPOT);
}
public function getClient(): Client
{
/** @var Client */
return $this->client;
}
/**
* Convert raw field data into a format compatible with CRM APIs.
*
* @param bool $internal Direction of the conversion.
* True is pulling from CRM, false normalize before sending to CRM.
*/
public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string
{
return ValueNormalizer::normalize(
fieldType: $fieldType,
fieldValue: $fieldValue,
isInbound: $internal,
);
}
/**
* @inheritdoc
*/
public function getDefaultFields(string $activityType): array
{
$fields = [];
if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {
$defaultFields = FieldDefinitions::defaultTaskFields();
// This lazy creates these fields if not already setup.
foreach ($defaultFields as $defaultField) {
$fields[] = $this->config->fields()->firstOrCreate($defaultField);
}
}
return $fields;
}
/**
* @inheritdoc
*/
public function getDefaultActivityField(string $activityType): Field
{
/** @var Field $activityField */
$activityField = $this->config->fields()->where([
'crm_provider_id' => 'activityType',
'object_type' => $activityType,
])->first();
return $activityField;
}
/**
* @inheritdoc
*/
public function getSupportedPlaybookTypes(): array
{
return [Playbook::ACTIVITY_TYPE_TASK];
}
/**
* @inheritdoc
*/
public function getDefaultActivityLayoutFields(string $activityType, string $layoutType): array
{
$fields = [];
if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {
// Outcome should always be provided calls/meetings.
$fieldData = [
[
'crm_provider_id' => $layoutType === Layout::TYPE_SOFTPHONE_SUMMARY ? 'disposition' : 'meetingOutcome',
'object_type' => Field::OBJECT_TASK,
],
];
foreach ($fieldData as $data) {
$field = $this->config->fields()->where($data)->first();
// Only add the field if it is created, which it should be.
if ($field) {
$fields[] = $field;
}
}
}
return $fields;
}
public function getDealInsightsFields(): array
{
return FieldDefinitions::dealInsightsFields();
}
protected function getDefaultFollowupLayoutFields(string $activityType): array
{
$fields = [];
$fieldRepo = app(FieldRepository::class);
$fieldData = FieldDefinitions::followupFieldsFilter();
foreach ($fieldData as $data) {
$field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $data);
// Only add the field if it is created, which it should be.
if ($field) {
$fields[] = $field;
}
}
return $fields;
}
/**
* @inheritdoc
*/
public function syncField(Field $field): void
{
switch ($field->object_type) {
case Field::OBJECT_ACCOUNT:
$crmField = $this->client->getInstance()->companyProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_CONTACT:
$crmField = $this->client->getInstance()->contactProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_OPPORTUNITY:
$crmField = $this->client->getInstance()->dealProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_TASK:
$this->syncSingleTaskField($field);
return;
default:
return;
}
$this->syncFieldAction->execute($field, $crmField->toArray());
}
/**
* @param array<array{
* id:string,
* label:string,
* value?:string
* }> $options
*
* @throws CrmException
*
* @return FieldData[]
*
*/
public function importPicklistValues(
Field $field,
array $options = [['id' => '', 'label' => '', 'value' => '']],
): array {
if (! empty($options[0]['id']) || ! empty($options[0]['value'])) {
// We already have the options, no need to fetch them again
return $this->importOptions($field, $options);
}
$options = [];
switch ($field->getObjectType()) {
case Field::OBJECT_ACCOUNT:
$options = $this->getClient()->fetchPropertyOptions('company', $field->getCrmProviderId());
break;
case Field::OBJECT_CONTACT:
$options = $this->getClient()->fetchPropertyOptions('contact', $field->getCrmProviderId());
break;
case Field::OBJECT_OPPORTUNITY:
// Hubspot has different endpoint for stages
$options = $this->getClient()->fetchOpportunityFieldOptions($field);
break;
case Field::OBJECT_TASK:
if ($field->getCrmProviderId() === 'disposition') {
$options = $this->getClient()->fetchDispositionFieldOptions();
} elseif (in_array($field->getCrmProviderId(), ['meetingOutcome', 'activityType'])) {
$options = $this->getClient()->fetchMeetingOutcomeFieldOptions($field);
}
break;
default:
$this->logger->warning('Invalid object type', [
'object_type' => $field->getObjectType(),
'field_id' => $field->getId(),
]);
throw new CrmException('Invalid object type');
}
return $this->importOptions($field, $options);
}
/**
* @inheritdoc
*/
public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage
{
$missingStage = null;
try {
// Use the HubSpot API client instead of the SDK crmPipelines() method
$endpoint = self::getDealsPipelinesEndpoint();
$pipelinesResponse = $this->client->getInstance()->getClient()->request('GET', $endpoint);
$pipelines = $pipelinesResponse->data->results;
} catch (RequestException|BadRequest $exception) {
throw $exception;
}
foreach ($pipelines as $pipeline) {
$stages = [];
// We create a business process to contain the pipeline, and store all stages against it.
$p = ResponseNormalize::normalizePipeline($pipeline);
// Create/update business process for this pipeline
$businessProcess = $this->config->businessProcesses()->updateOrCreate([
'crm_provider_id' => $p['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($p['label'], 0, 150),
'type' => BusinessProcess::TYPE_OPPORTUNITY,
'is_selectable' => $p['active'],
]);
// A record type is really a clone of the business process, used to store which record uses which pipeline.
// Create/update record type clone
$this->config->recordTypes()->updateOrCreate([
'crm_provider_id' => $p['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($p['label'], 0, 150),
'is_selectable' => $p['active'],
'business_process_id' => $businessProcess->id ?? null,
]);
// Stages - fetch all existing stages upfront to avoid N+1 queries
$existingStages = $this->config->stages()
->withTrashed()
->where('type', Stage::TYPE_OPPORTUNITY)
->get()
->keyBy('crm_provider_id');
foreach ($p['stages'] as $dealStage) {
$s = ResponseNormalize::normalizeDealStage($dealStage);
/** @var ?Stage $existingStage */
$existingStage = $existingStages->get($s['id']);
// Restore soft-deleted stages that are now active in HubSpot
if ($existingStage?->trashed() && $s['active']) {
$existingStage->restore();
}
// Upsert stage (updates soft-deleted records without restoring them)
$stage = $this->config->stages()->withTrashed()->updateOrCreate([
'crm_provider_id' => $s['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($s['label'], 0, 50),
'label' => mb_strimwidth($s['label'], 0, 191),
'type' => Stage::TYPE_OPPORTUNITY,
'sequence' => $s['displayOrder'],
'is_selectable' => $s['active'],
'probability' => $s['probability'] * 100,
]);
if ($missingStageName === $s['id']) {
$missingStage = $stage;
}
$stages[] = $stage->id;
}
$businessProcess->stages()->sync($stages);
}
return $missingStage;
}
/**
* @inheritdoc
*/
public function syncOrganization(): void
{
try {
$endpoint = '[URL_WITH_CREDENTIALS]
*/
public function find(string $name, array $scopes): array
{
$count = $this->limit ?? 20;
$offset = $this->offset ?? 0;
/** @var array<int, array<string, mixed>> */
return Cache::remember(
key: $this->team->getId() . $name . $count . $offset,
ttl: 300,
callback: function () use ($name, $offset, $count): array {
$data = [];
// Use the new V3 API to find contacts based on additional fields.
foreach (['companies', 'contacts'] as $objectType) {
$payload = $this->generateNameSearchPayload($name, $offset, $count);
$type = $objectType === 'companies' ? 'account' : 'contact';
try {
$response = $this->client->search($objectType, $payload);
// Build mapped list.
foreach ($response['results'] as $object) {
$properties = $object['properties'];
$objectName = $this->buildContactName($properties);
$record = [
'crmId' => $object['id'],
// Pass crmUrl to the FE, needed for success message in the extension when you log activity.
'crmUrl' => $this->generateProviderUrl($object['id'], $type),
'name' => $objectName,
'prospectType' => $type,
'phoneNumbers' => [],
];
if ($type === 'account') {
$record['industry'] = $properties['industry'] ?? null;
} else {
$record['title'] = $properties['jobtitle'] ?? null;
$record['organization'] = $properties['company'] ?? null;
}
$countryCode = $this->buildContactCountry($properties);
$parsedNumber = $this->buildContactPhone($countryCode, $properties);
// Add phone number to record.
if (! empty($parsedNumber['phone'])) {
$record['phoneNumbers'][] = [
'number' => $parsedNumber['phone'],
'nationalFormat' => phone_national($countryCode, $parsedNumber['phone']),
'type' => 'phone',
];
}
// Add mobile phone number to record.
if (! empty($properties['mobilephone'])) {
$mobileNumber = phone_e164($countryCode, $properties['mobilephone']);
if ($mobileNumber !== null) {
$record['phoneNumbers'][] = [
'number' => $mobileNumber,
'nationalFormat' => phone_national($countryCode, $mobileNumber),
'type' => 'mobile',
];
}
}
$data[] = $record;
}
} catch (BadRequest $e) {
$this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [
'teamId' => $this->team->getUuid(),
'request' => $payload,
'reason' => $e->getMessage(),
]);
throw $e;
}
}
return $data;
},
);
}
/**
* @inheritdoc
*/
public function findOpportunities(?string $crmAccountId, ?string $crmContactId, ?int $userId = null): array
{
$data = [];
$ownerData = [];
$ownerId = null;
if ($crmAccountId === null) {
return $data;
}
if ($userId) {
$profileRepository = app(ProfileRepository::class);
$profile = $profileRepository->findProfileByUserId($this->config, $userId);
$ownerId = $profile instanceof Profile ? $profile->getCrmProviderId() : null;
}
$closedStages = $this->getClosedDealStages();
$payload = $this->payloadBuilder->generateOpportunitiesSearchPayload(
$this->config,
$crmAccountId,
$closedStages,
);
$results = $this->client->getPaginatedData($payload, 'deals');
foreach ($results['results'] as $object) {
$properties = $object['properties'];
$amount = null;
if (empty($properties['amount']) === false) {
$currency = $properties['deal_currency_code'] ?? $this->config->default_currency;
// Values can contain commas and any junk so strip them.
$value = (float) preg_replace('/[^\d.]/', '', $properties['amount']);
$amount = formatCurrency($value, $currency);
}
$businessProcess = $this->config
->businessProcesses()
->where('crm_provider_id', $properties['pipeline'])
->first();
if ($businessProcess === null) {
// Import it.
$stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $properties['dealstage']);
$businessProcess = $this->config
->businessProcesses()
->where('crm_provider_id', $properties['pipeline'])
->first();
} else {
$stage = $businessProcess
->stages()
->where('crm_provider_id', $properties['dealstage'])
->where('type', Stage::TYPE_OPPORTUNITY)
->first();
if ($stage === null) {
// Import it.
$stage = $this->importStages(null, $properties['dealstage']);
}
}
$recordType = null;
if ($businessProcess) {
$recordType = $businessProcess->recordTypes()->first();
}
$isWon = in_array($properties['dealstage'], $closedStages['won']);
$isLost = in_array($properties['dealstage'], $closedStages['lost']);
$record = [
'crmId' => $object['id'],
'name' => $properties['dealname'] ?? 'Unknown Deal',
'value' => $amount,
'won' => $isWon,
'closed' => $isWon || $isLost,
'stage' => [
'id' => $stage?->getUuid() ?? '',
'name' => $stage?->getName() ?? '',
],
];
if ($recordType) {
$record += [
'recordType' => [
'id' => $recordType->id_string,
'name' => $recordType->name,
],
];
}
if ($ownerId && isset($properties['hubspot_owner_id']) && $properties['hubspot_owner_id'] === $ownerId) {
$ownerData[] = $record;
}
$data[] = $record;
}
if (! empty($ownerData)) {
return $ownerData;
}
return $data;
}
/**
* @inheritdoc
*/
public function getTasks(?string $objectType, string $objectId, ?string $opportunityId): array
{
$data = [];
switch ($objectType) {
case 'contact':
$hsObject = 'contact';
break;
case 'account':
$hsObject = 'company';
break;
default:
// This is a hack to prioritise and override a contact/company with a deal.
if ($opportunityId) {
$hsObject = 'deal';
$objectId = $opportunityId;
} else {
throw new InvalidArgumentException('Object type not supported.');
}
}
$engagementTypes = ['meetings', 'tasks'];
foreach ($engagementTypes as $engagementType) {
$payload = $this->payloadBuilder->getLinkToTaskPayload($hsObject, $objectId, $engagementType);
$this->logger->info('[HubSpot] CRM Search requested', [
'request' => $payload,
]);
$engagements = $this->client->getPaginatedData($payload, $engagementType);
foreach ($engagements['results'] as $engagement) {
if ($engagementType == 'meetings') {
$title = $engagement['properties']['hs_meeting_title'] ?? 'Scheduled meeting';
} elseif ($engagementType == 'tasks') {
$title = $engagement['properties']['hs_task_subject'];
} else {
$title = 'Scheduled meeting';
}
$data[] = [
'crmId' => $engagement['id'],
'subject' => $title,
'due' => $engagement['properties']['hs_timestamp'],
'type' => $engagement['properties']['hs_activity_type'] ?? null,
];
}
}
usort($data, function ($item1, $item2) {
return $item2['due'] <=> $item1['due'];
});
return $data;
}
/**
* Try to find CRM Objects using email address
*
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchExactlyByEmail(string $email, ?int $userId = null): ?array
{
$contactProperties = [
'email',
'firstname',
'lastname',
'country',
'phone',
'mobilephone',
'jobtitle',
'hubspot_owner_id',
'associatedcompanyid',
'photo',
];
$contact = null;
$account = null;
try {
$hsContact = $this->getClient()->getContactByEmail($email, $contactProperties);
if ($hsContact) {
$contact = $this->importContact($hsContact);
$account = $contact->account;
}
$data = $this->convertCrmData($contact, $account, $userId);
return ! empty(array_filter($data)) ? $data : null;
} catch (BadRequest $e) {
$this->logger->warning('[HubSpot] Search failed', [
'team_id' => $this->team->getId(),
'search_identifier' => $email,
'reason' => $e->getMessage(),
]);
}
return null;
}
public function getDomain(string $email): ?string
{
return $this->getDomainFromEmail($email);
}
/**
* Try to find CRM objects using domain name of the email address
*
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchByDomain(string $domain, ?int $userId = null): ?array
{
$companyName = $domain;
// Try to find a company matching their email domain.
$companyProperties = [
'country',
'phone',
'name',
'hs_avatar_filemanager_key',
'industry',
'hubspot_owner_id',
'domain',
];
try {
$hsAccounts = $this->client
->getInstance()
->companies()
->searchByDomain($companyName, $companyProperties);
} catch (Throwable $e) {
$this->logger->info('[HubSpot] Search failed', [
'error' => $e->getMessage(),
'domain' => $domain,
]);
return null;
}
$account = null;
// If there are multiple accounts, don't guess, we'll ask later.
if (\count($hsAccounts->data->results) === 1) {
// Persist this remote object.
$account = $this->syncAccount($hsAccounts->data->results[0]->companyId);
}
$data = $this->convertCrmData(null, $account, $userId);
return ! empty(array_filter($data)) ? $data : null;
}
/**
* @return array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
protected function convertCrmData(?Contact $contact, ?Account $account, ?int $userId = null): array
{
$countryCode = null;
if ($contact && $contact->country_code) {
$countryCode = $contact->country_code;
} elseif ($account && $account->country_code) {
$countryCode = $account->country_code;
}
try {
$hsOpportunities = $this->findOpportunities(
$account ? $account->crm_provider_id : null,
$contact ? $contact->crm_provider_id : null,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
// If there are multiple opportunities, don't guess, we'll ask later.
$opportunity = null;
$stage = null;
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
protected function getCacheKey(string $object, ?int $userId = null): ?string
{
$key = $this->team->getId() . $object;
$keySuffix = $this->getOwnerKeySuffix($userId);
return $key . $keySuffix;
}
private function getOwnerKeySuffix(?int $userId = null): string
{
return $userId === null ? '' : (string) $userId;
}
/**
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
*}
*/
public function matchByPhone(string $phone, ?string $rawPhoneNumber = null, ?int $userId = null): ?array
{
if (str_contains($phone, '**')) {
return null;
}
// trim all whitespaces if present so the lookup doesn't fail
$phone = str_replace(' ', '', $phone);
// Check if the user is internal.
if ($this->isPhoneNumberOfTeamMember($phone)) {
return null;
}
$response = $this->searchForPhoneNumber($phone);
if (empty($response)) {
return null;
}
// This would ideally importContact instead but the response type differs.
$contact = $this->findAndSyncContact($response['results'][0]['id']);
if (! $contact instanceof Contact) {
return null;
}
$account = $contact->account;
$countryCode = $contact->country_code ?? $account->country_code ?? null;
try {
$hsOpportunities = $this->findOpportunities(
$account?->crm_provider_id,
$contact->crm_provider_id,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
$opportunity = null;
$stage = null;
try {
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
} catch (Exception $e) {
$this->logger->debug('[HubSpot] Opportunity failed to sync.', [
'reason' => $e->getMessage(),
]);
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
private function isPhoneNumberOfTeamMember(string $phone): bool
{
$teamRepository = app(TeamRepository::class);
$user = $teamRepository->findTeamMemberByPhone($this->team, $phone);
if ($user instanceof User) {
return true;
}
return false;
}
private function findAndSyncContact(string $crmId): ?Contact
{
try {
return $this->syncContact($crmId);
} catch (Exception $exception) {
$this->logger->info('[HubSpot] Phone match failed', [
'reason' => $exception->getMessage(),
]);
return null;
}
}
private function hasResults(array $response): bool
{
return isset($response['total']) && is_numeric($response['total']) && $response['total'] > 0;
}
private function searchForPhoneNumber(string $phone): array
{
// Normalizes the provided phone number for the API search.
$normalizedPhone = $this->normalizePhoneNumber($phone);
$payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone);
$this->logger->info('[HubSpot] Phone match search triggered', [
'phone' => $phone,
'normalizedPhone' => $normalizedPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($normalizedPhone, $payload);
if (! $this->hasResults($response)) {
$nationalPhone = preg_replace('/\D/', '', phone_national(null, $phone));
$payload = $this->payloadBuilder->generatePhoneSearchPayload($nationalPhone);
$this->logger->info('[HubSpot] Phone match national number search triggered', [
'phone' => $phone,
'nationalPhone' => $nationalPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($phone, $payload);
}
if (! $this->hasResults($response)) {
$payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone, true);
$this->logger->info('[HubSpot] Phone match alternative search triggered', [
'phone' => $phone,
'normalizedPhone' => $normalizedPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($phone, $payload);
}
return $this->hasResults($response) ? $response : [];
}
private function handlePhoneSearchRequest(string $phone, array $payload): array
{
try {
return $this->client->search('contacts', $payload);
} catch (Exception $exception) {
$this->logger->info('[HubSpot] Phone match failed', [
'phone' => $phone,
'reason' => $exception->getMessage(),
]);
return [];
}
}
private function normalizePhoneNumber(string $phone): string
{
return ltrim(phone_e164(null, $phone), '+0');
}
/**
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchByName(string $name, ?int $userId = null): ?array
{
// Don't waste time searching for single character strings.
if (\strlen($name) <= 1) {
return null;
}
$cacheKey = $this->getCacheKey($name, $userId);
$result = Cache::remember($cacheKey, 60, function () use ($name, $userId) {
$payload = $this->payloadBuilder->generateSearchContactsByNamePayload(
$name,
$this->getContactFields()
);
$hsContacts = $this->client->getPaginatedData($payload, 'contact');
if (empty($hsContacts['results'])) {
return false;
}
$contact = $this->importContact($hsContacts['results'][0]);
if ($contact === null) {
return false;
}
$account = $contact->account;
$countryCode = $contact->country_code ?? $account->country_code ?? null;
try {
$hsOpportunities = $this->findOpportunities(
$account ? $account->crm_provider_id : null,
$contact->crm_provider_id,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
$opportunity = null;
$stage = null;
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
});
return is_array($result) ? $result : null;
}
private function convertActivityAssociations(Activity $activity): array
{
return [
'contactIds' => $this->getParticipantsIds($activity),
'companyIds' => $activity->hasAccount() ? [$activity->account->crm_provider_id] : [],
'dealIds' => $activity->hasOpportunity() ? [$activity->opportunity->crm_provider_id] : [],
'ownerIds' => [],
];
}
private function getParticipantsIds(Activity $activity): array
{
$attendees = [];
$participantRepository = app(ParticipantRepository::class);
$participants = $participantRepository->getParticipantsWhoEnteredMeeting($activity);
foreach ($participants as $participant) {
if ($participant->user_id || $participant->isCoach()) {
continue;
}
$contact = $participant->contact()->first();
if ($contact && $contact->crm_provider_id) {
$attendees[] = $contact->crm_provider_id;
} else {
if (! empty($participant->name)) {
$attendeeData = $this->fetchMissingAttendeeInfo($participant);
}
if (! empty($attendeeData['id'])) {
$attendees[] = $attendeeData['id'];
}
}
}
if ($activity->hasContact()) {
$attendees[] = $activity->contact->crm_provider_id;
}
return array_unique($attendees);
}
private function fetchMissingAttendeeInfo(Participant $participant): array
{
// Check if we need to look inside an account context.
$activity = $participant->getActivity();
$companyId = $activity->hasAccount() ? $activity->getAccount()->crm_provider_id : null;
// First check the local data.
/** @var Contact[] $contacts */
$contacts = $this->team->contacts()
->with('account')
->where('name', $participant->name)
->whereNotNull('email')
->get();
foreach ($contacts as $contact) {
// If we have a company in scope, check the contact is associated to it.
if (
$companyId !== null
&& ($contact->account_id === null || $companyId !== $contact->account->crm_provider_id)
) {
continue;
}
return [
'id' => $contact->crm_provider_id,
'email' => $contact->email,
];
}
$payload = $this->generateNameSearchPayload($participant->name, 0, 20);
try {
$response = $this->client->getNewInstance()->crm()->contacts()->searchApi()->doSearch($payload);
// TODO add some logic to choose the most suitable contact if multiple
foreach ($response['results'] as $object) {
$properties = $object['properties'];
if (empty($object['properties']) === false) {
// Check the company matches the contact.
// Todo: Move this check inside the API search.
if ($companyId !== null && $companyId !== $properties['associatedcompanyid']) {
continue;
}
return [
'id' => $object['id'],
'email' => $properties['email'],
];
}
}
} catch (Exception $e) {
$this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [
'teamId' => $this->team->id_string,
'request' => $payload,
'reason' => $e->getMessage(),
]);
}
return [];
}
/**
* Store transcripts as note engagement.
*
* @throws Exception
*/
public function createTranscriptNotes(Activity $activity): void
{
// For HS no need to check if Crm profile - Log Notes field is enabled
// We only check if store_transcript toggle is enabled on crm profile.
$engagement = [
'active' => true,
'ownerId' => $this->profile->crm_provider_id,
'timestamp' => $activity->created_at->tz($activity->user->timezone)->getTimestamp() * 1000,
'type' => 'NOTE',
];
// Generate activity transcription.
$transcriptionData = $this->generateTranscription($activity);
// Truncate Notes with max notes length because transcription text could be very long.
$transcripts = mb_strimwidth($transcriptionData, 0, static::ENGAGEMENT_BODY_MAX_LENGTH);
$metadata = [
'body' => $transcripts,
];
$associations = $this->convertActivityAssociations($activity);
try {
$hsEngagement = $this->client
->getInstance()
->engagements()
->create($engagement, $associations, $metadata);
$this->logCrmEngagementManipulation(self::ACTION_CREATE, $engagement, $metadata, $associations);
$noteId = $hsEngagement->data->engagement->id;
// Store crm logged id in transcription.
$transcription = $activity->getTranscription();
$transcription->crm_activity_id = $noteId;
$transcription->save();
} catch (Exception $e) {
Sentry::captureException($e);
}
}
/*
* @inheritdoc
*/
public function updateRecord(string $objectType, string $objectId, array $data, array $headers = []): void
{
$payload = [
'properties' => $data,
];
try {
switch ($objectType) {
case FieldData::OBJECT_OPPORTUNITY:
$this->client->getNewInstance()->crm()->deals()->basicApi()->update($objectId, $payload);
break;
case FieldData::OBJECT_CONTACT:
$this->client->getNewInstance()->crm()->contacts()->basicApi()->update($objectId, $payload);
break;
case FieldData::OBJECT_ACCOUNT:
$this->client->getNewInstance()->crm()->companies()->basicApi()->update($objectId, $payload);
break;
case FieldData::OBJECT_TASK:
// Endpoint for Engagements not ready
$engagements = [
'type' => 'TASK',
];
$metadata = $data;
$this->client->getInstance()->engagements()->update($objectId, $engagements, $metadata);
$this->logCrmEngagementManipulation(
self::ACTION_UPDATE,
['crmId...
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"bounds":{"left":0.025930852,"top":0.019952115,"width":0.03856383,"height":0.025538707},"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JY-20725-handle-HS-search-rate-limit, menu","depth":5,"bounds":{"left":0.064494684,"top":0.019952115,"width":0.09541223,"height":0.025538707},"on_screen":true,"help_text":"Git Branch: JY-20725-handle-HS-search-rate-limit","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"bounds":{"left":0.8081782,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"AskJiminnyReportActivityServiceTest","depth":6,"bounds":{"left":0.8234708,"top":0.019952115,"width":0.09208777,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'AskJiminnyReportActivityServiceTest'","depth":6,"bounds":{"left":0.9155585,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'AskJiminnyReportActivityServiceTest'","depth":6,"bounds":{"left":0.9268617,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"bounds":{"left":0.9381649,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"bounds":{"left":0.96609044,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"bounds":{"left":0.9773936,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"IDE and Project Settings","depth":5,"bounds":{"left":0.9886968,"top":0.019952115,"width":0.011303186,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.042220745,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"19","depth":4,"bounds":{"left":0.6615692,"top":0.10055866,"width":0.009640957,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"bounds":{"left":0.67287236,"top":0.09896249,"width":0.00731383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"bounds":{"left":0.68018615,"top":0.09896249,"width":0.006981383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {\n\"headers\":{\n\"Date\":[\"Thu,07 May 2026 14:21:15 GMT\"],\n \"Content-Type\":[\"application/json;charset=utf-8\"],\n \"Transfer-Encoding\":[\"chunked\"],\n \"Connection\":[\"keep-alive\"],\n \"CF-Ray\":[\"9f80deb8db60dc3a-SOF\"],\n \"CF-Cache-Status\":[\"DYNAMIC\"],\n \"Strict-Transport-Security\":[\"max-age=31536000; includeSubDomains; preload\"],\n \"Vary\":[\"origin,\n accept-encoding\"],\n \"access-control-allow-credentials\":[\"false\"],\n \"server-timing\":[\"hcid;desc=\\\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\\\",\n cfr;desc=\\\"9f80deb8e7c6dc3a-IAD\\\"\"],\n \"x-content-type-options\":[\"nosniff\"],\n \"x-hubspot-correlation-id\":[\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\"],\n \"Set-Cookie\":[\"__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-1.0.1.1-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,\n 07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None\"],\n \"Report-To\":[\"{\n\\\"endpoints\\\":[{\n\\\"url\\\":\\\"https:\\\\/\\\\/a.nel.cloudflare.com\\\\/report\\\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\\\"}],\n\\\"group\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"NEL\":[\"{\n\\\"success_fraction\\\":0.01,\n\\\"report_to\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"Server\":[\"cloudflare\"]}} {\n\"correlation_id\":\"95236535-ec98-4541-b92a-adfa73b69eab\",\n\"trace_id\":\"c7ab8365-903f-46d4-9403-0e5b551e3545\"}","depth":4,"bounds":{"left":0.37632978,"top":0.0,"width":0.5728058,"height":0.632083},"on_screen":true,"lines":[{"char_start":624,"char_count":72,"bounds":{"left":0.37632978,"top":0.0,"width":0.18384309,"height":0.014365523}},{"char_start":696,"char_count":219,"bounds":{"left":0.37632978,"top":0.0,"width":0.56515956,"height":0.014365523}},{"char_start":915,"char_count":83,"bounds":{"left":0.37632978,"top":0.0,"width":0.21243352,"height":0.014365523}},{"char_start":998,"char_count":20,"bounds":{"left":0.37632978,"top":0.0,"width":0.04920213,"height":0.014365523}},{"char_start":1018,"char_count":17,"bounds":{"left":0.37632978,"top":0.0,"width":0.041223403,"height":0.014365523}},{"char_start":1035,"char_count":203,"bounds":{"left":0.37632978,"top":0.0,"width":0.52360374,"height":0.014365523}},{"char_start":1238,"char_count":22,"bounds":{"left":0.37632978,"top":0.007980846,"width":0.05418883,"height":0.014365523}},{"char_start":1260,"char_count":23,"bounds":{"left":0.37632978,"top":0.025538707,"width":0.056848403,"height":0.014365523}},{"char_start":1283,"char_count":10,"bounds":{"left":0.37632978,"top":0.04309657,"width":0.023271276,"height":0.014365523}},{"char_start":1293,"char_count":27,"bounds":{"left":0.37632978,"top":0.060654428,"width":0.06715426,"height":0.014365523}},{"char_start":1320,"char_count":26,"bounds":{"left":0.37632978,"top":0.07821229,"width":0.06482713,"height":0.014365523}},{"char_start":1346,"char_count":23,"bounds":{"left":0.37632978,"top":0.09577015,"width":0.056848403,"height":0.014365523}},{"char_start":1369,"char_count":28,"bounds":{"left":0.37632978,"top":0.11332801,"width":0.06981383,"height":0.014365523}},{"char_start":1397,"char_count":57,"bounds":{"left":0.37632978,"top":0.13088587,"width":0.14494681,"height":0.014365523}}],"value":"[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {\n\"headers\":{\n\"Date\":[\"Thu,07 May 2026 14:21:15 GMT\"],\n \"Content-Type\":[\"application/json;charset=utf-8\"],\n \"Transfer-Encoding\":[\"chunked\"],\n \"Connection\":[\"keep-alive\"],\n \"CF-Ray\":[\"9f80deb8db60dc3a-SOF\"],\n \"CF-Cache-Status\":[\"DYNAMIC\"],\n \"Strict-Transport-Security\":[\"max-age=31536000; includeSubDomains; preload\"],\n \"Vary\":[\"origin,\n accept-encoding\"],\n \"access-control-allow-credentials\":[\"false\"],\n \"server-timing\":[\"hcid;desc=\\\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\\\",\n cfr;desc=\\\"9f80deb8e7c6dc3a-IAD\\\"\"],\n \"x-content-type-options\":[\"nosniff\"],\n \"x-hubspot-correlation-id\":[\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\"],\n \"Set-Cookie\":[\"__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-1.0.1.1-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,\n 07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None\"],\n \"Report-To\":[\"{\n\\\"endpoints\\\":[{\n\\\"url\\\":\\\"https:\\\\/\\\\/a.nel.cloudflare.com\\\\/report\\\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\\\"}],\n\\\"group\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"NEL\":[\"{\n\\\"success_fraction\\\":0.01,\n\\\"report_to\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"Server\":[\"cloudflare\"]}} {\n\"correlation_id\":\"95236535-ec98-4541-b92a-adfa73b69eab\",\n\"trace_id\":\"c7ab8365-903f-46d4-9403-0e5b551e3545\"}","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.042220745,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"7","depth":4,"bounds":{"left":0.29022607,"top":0.17478053,"width":0.0076462766,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"48","depth":4,"bounds":{"left":0.29986703,"top":0.17478053,"width":0.010305851,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"1","depth":4,"bounds":{"left":0.31216756,"top":0.17478053,"width":0.00731383,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"33","depth":4,"bounds":{"left":0.32147607,"top":0.17478053,"width":0.010305851,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"1","depth":4,"bounds":{"left":0.3337766,"top":0.17478053,"width":0.00731383,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"bounds":{"left":0.34275267,"top":0.17318435,"width":0.00731383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"bounds":{"left":0.35006648,"top":0.17318435,"width":0.006981383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\nnamespace Jiminny\\Services\\Crm\\Hubspot;\n\nuse Carbon\\Carbon;\nuse Exception;\nuse Generator;\nuse GuzzleHttp\\Exception\\RequestException;\nuse Illuminate\\Support\\Facades\\Cache;\nuse InvalidArgumentException;\nuse Jiminny\\Contracts\\Repositories\\TeamRepository;\nuse Jiminny\\Contracts\\Services\\Crm\\ClientInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\FetchRelatedActivityInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\LayoutManagementInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\MatchCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\Provider\\HubspotInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityLookupInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityManipulationInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SavePlaybackLinkToCrmInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SendSummaryToCrmInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SettingsInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmMetadataInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\VerifyTaskExistsInterface;\nuse Jiminny\\Exceptions\\CrmException;\nuse Jiminny\\Exceptions\\HttpNotFoundException;\nuse Jiminny\\Jobs\\Crm\\NoteObject;\nuse Jiminny\\Models\\Account;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Contact;\nuse Jiminny\\Models\\Contracts\\ActivityContract;\nuse Jiminny\\Models\\Crm\\BusinessProcess;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Models\\Crm\\FieldData;\nuse Jiminny\\Models\\Crm\\Layout;\nuse Jiminny\\Models\\Crm\\Profile;\nuse Jiminny\\Models\\Lead;\nuse Jiminny\\Models\\Opportunity;\nuse Jiminny\\Models\\Participant;\nuse Jiminny\\Models\\Playbook;\nuse Jiminny\\Models\\SocialAccount;\nuse Jiminny\\Models\\Stage;\nuse Jiminny\\Models\\User;\nuse Jiminny\\Repositories\\Crm\\CrmEntityRepository;\nuse Jiminny\\Repositories\\Crm\\FieldRepository;\nuse Jiminny\\Repositories\\Crm\\ProfileRepository;\nuse Jiminny\\Repositories\\ParticipantRepository;\nuse Jiminny\\Services\\Avatar\\ProspectPhotoPathService;\nuse Jiminny\\Services\\Crm\\BaseService;\nuse Jiminny\\Services\\Crm\\Hubspot\\Actions\\SyncArchivedProfilesAction;\nuse Jiminny\\Services\\Crm\\Hubspot\\Fields\\ValueNormalizer;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\OpportunitySyncTrait;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\SyncCrmEntitiesTrait;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\SyncFieldsTrait;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\WriteCrmTrait;\nuse Jiminny\\Services\\Crm\\MatchDomainByEmailInterface;\nuse Jiminny\\Services\\Crm\\OpportunitySyncStrategyResolver;\nuse Jiminny\\Services\\Crm\\ResolveCompanyNameByEmailTrait;\nuse Jiminny\\Utils\\PlaybackUrlBuilder;\nuse Sentry;\nuse SevenShores\\Hubspot\\Exceptions\\BadRequest;\nuse Throwable;\nuse UnexpectedValueException;\n\n/**\n * @phpstan-type CrmFieldDefinition array{\n * name: string,\n * label: string,\n * description: string,\n * type: string,\n * fieldType: string,\n * hidden: bool,\n * showCurrencySymbol: bool,\n * options: array<array{\n * id: string,\n * label: string,\n * value?: string,\n * }\n */\nclass Service extends BaseService implements\n HubspotInterface,\n SyncCrmEntitiesInterface,\n SyncCrmMetadataInterface,\n SendSummaryToCrmInterface,\n MatchDomainByEmailInterface,\n SavePlaybackLinkToCrmInterface,\n RemoteEntityManipulationInterface,\n FetchRelatedActivityInterface,\n LayoutManagementInterface,\n SettingsInterface,\n MatchCrmEntitiesInterface,\n RemoteEntityLookupInterface,\n VerifyTaskExistsInterface\n{\n use ResolveCompanyNameByEmailTrait;\n use SyncCrmEntitiesTrait;\n use WriteCrmTrait;\n use SyncFieldsTrait;\n use OpportunitySyncTrait;\n\n private const int ENGAGEMENT_BODY_MAX_LENGTH = 65536;\n\n private const string LOG_DATE_FORMAT = 'Y-m-d H:i:s';\n private const int BATCH_UPDATE_LIMIT = 100;\n private const string TEN_SECONDLY_ROLLING_POLICY = 'TEN_SECONDLY_ROLLING';\n private const int TEN_SECONDLY_ROLLING_LIMIT = 10;\n\n private const string TYPE_NOTE = 'NOTE';\n\n private const string TYPE_MEETING = 'MEETING';\n\n private const string TYPE_CALL = 'CALL';\n\n private const string API_URL = 'https://api.hubapi.com';\n\n // NB: v1 is legacy - v3 is the newest\n private const string ENDPOINT_PIPELINES = '/crm-pipelines/v1/pipelines/';\n private const string PIPELINE_OBJECT_TYPE_DEALS = 'deals';\n\n private const int TASK_VERIFICATION_CACHE_TTL = 86400; // 1 day\n\n /**\n * @var ClientInterface|Client\n */\n protected $client;\n protected OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;\n protected CrmEntityRepository $crmEntityRepository;\n protected ProspectPhotoPathService $prospectPhotoPathService;\n\n private SyncFieldAction $syncFieldAction;\n private PayloadBuilder $payloadBuilder;\n private SyncRelatedActivityManager $syncRelatedActivityManager;\n private SyncArchivedProfilesAction $syncArchivedProfilesAction;\n private WebhookSyncBatchProcessor $batchProcessor;\n\n public function __construct(\n Client $client,\n SyncFieldAction $syncFieldAction,\n PayloadBuilder $payloadBuilder,\n ProspectPhotoPathService $prospectPhotoPathService,\n SyncArchivedProfilesAction $syncArchivedProfilesAction,\n WebhookSyncBatchProcessor $batchProcessor,\n ) {\n parent::__construct();\n\n $this->client = $client;\n $this->syncFieldAction = $syncFieldAction;\n $this->prospectPhotoPathService = $prospectPhotoPathService;\n $this->payloadBuilder = $payloadBuilder;\n $this->syncArchivedProfilesAction = $syncArchivedProfilesAction;\n $this->batchProcessor = $batchProcessor;\n $this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [\n 'client' => $this->client,\n ]);\n $this->syncRelatedActivityManager = app(SyncRelatedActivityManager::class, [\n 'client' => $this->client,\n 'payloadBuilder' => $this->payloadBuilder,\n 'logger' => $this->logger,\n ]);\n $this->crmEntityRepository = app(CrmEntityRepository::class);\n $this->dealFieldsService = app(DealFieldsService::class);\n }\n\n public function getDisplayName(): string\n {\n return 'HubSpot';\n }\n\n protected function getOAuthAccount(User $user): ?SocialAccount\n {\n // In this case, the Account Owner is always the connection for any API operations.\n $owner = $user->team->owner;\n\n return $owner->getSocialAccount(SocialAccount::PROVIDER_HUBSPOT);\n }\n\n public function getClient(): Client\n {\n /** @var Client */\n return $this->client;\n }\n\n /**\n * Convert raw field data into a format compatible with CRM APIs.\n *\n * @param bool $internal Direction of the conversion.\n * True is pulling from CRM, false normalize before sending to CRM.\n */\n public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string\n {\n return ValueNormalizer::normalize(\n fieldType: $fieldType,\n fieldValue: $fieldValue,\n isInbound: $internal,\n );\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultFields(string $activityType): array\n {\n $fields = [];\n\n if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {\n $defaultFields = FieldDefinitions::defaultTaskFields();\n\n // This lazy creates these fields if not already setup.\n foreach ($defaultFields as $defaultField) {\n $fields[] = $this->config->fields()->firstOrCreate($defaultField);\n }\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultActivityField(string $activityType): Field\n {\n /** @var Field $activityField */\n $activityField = $this->config->fields()->where([\n 'crm_provider_id' => 'activityType',\n 'object_type' => $activityType,\n ])->first();\n\n return $activityField;\n }\n\n /**\n * @inheritdoc\n */\n public function getSupportedPlaybookTypes(): array\n {\n return [Playbook::ACTIVITY_TYPE_TASK];\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultActivityLayoutFields(string $activityType, string $layoutType): array\n {\n $fields = [];\n\n if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {\n // Outcome should always be provided calls/meetings.\n $fieldData = [\n [\n 'crm_provider_id' => $layoutType === Layout::TYPE_SOFTPHONE_SUMMARY ? 'disposition' : 'meetingOutcome',\n 'object_type' => Field::OBJECT_TASK,\n ],\n ];\n\n foreach ($fieldData as $data) {\n $field = $this->config->fields()->where($data)->first();\n\n // Only add the field if it is created, which it should be.\n if ($field) {\n $fields[] = $field;\n }\n }\n }\n\n return $fields;\n }\n\n public function getDealInsightsFields(): array\n {\n return FieldDefinitions::dealInsightsFields();\n }\n\n protected function getDefaultFollowupLayoutFields(string $activityType): array\n {\n $fields = [];\n $fieldRepo = app(FieldRepository::class);\n $fieldData = FieldDefinitions::followupFieldsFilter();\n\n foreach ($fieldData as $data) {\n $field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $data);\n\n // Only add the field if it is created, which it should be.\n if ($field) {\n $fields[] = $field;\n }\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function syncField(Field $field): void\n {\n switch ($field->object_type) {\n case Field::OBJECT_ACCOUNT:\n $crmField = $this->client->getInstance()->companyProperties()->get($field->crm_provider_id);\n\n break;\n case Field::OBJECT_CONTACT:\n $crmField = $this->client->getInstance()->contactProperties()->get($field->crm_provider_id);\n\n break;\n case Field::OBJECT_OPPORTUNITY:\n $crmField = $this->client->getInstance()->dealProperties()->get($field->crm_provider_id);\n\n break;\n case Field::OBJECT_TASK:\n $this->syncSingleTaskField($field);\n\n return;\n default:\n return;\n }\n\n $this->syncFieldAction->execute($field, $crmField->toArray());\n }\n\n /**\n * @param array<array{\n * id:string,\n * label:string,\n * value?:string\n * }> $options\n *\n * @throws CrmException\n *\n * @return FieldData[]\n *\n */\n public function importPicklistValues(\n Field $field,\n array $options = [['id' => '', 'label' => '', 'value' => '']],\n ): array {\n if (! empty($options[0]['id']) || ! empty($options[0]['value'])) {\n // We already have the options, no need to fetch them again\n return $this->importOptions($field, $options);\n }\n\n $options = [];\n\n switch ($field->getObjectType()) {\n case Field::OBJECT_ACCOUNT:\n $options = $this->getClient()->fetchPropertyOptions('company', $field->getCrmProviderId());\n\n break;\n\n case Field::OBJECT_CONTACT:\n $options = $this->getClient()->fetchPropertyOptions('contact', $field->getCrmProviderId());\n\n break;\n\n case Field::OBJECT_OPPORTUNITY:\n // Hubspot has different endpoint for stages\n $options = $this->getClient()->fetchOpportunityFieldOptions($field);\n\n break;\n\n case Field::OBJECT_TASK:\n if ($field->getCrmProviderId() === 'disposition') {\n $options = $this->getClient()->fetchDispositionFieldOptions();\n } elseif (in_array($field->getCrmProviderId(), ['meetingOutcome', 'activityType'])) {\n $options = $this->getClient()->fetchMeetingOutcomeFieldOptions($field);\n }\n\n break;\n\n default:\n $this->logger->warning('Invalid object type', [\n 'object_type' => $field->getObjectType(),\n 'field_id' => $field->getId(),\n ]);\n\n throw new CrmException('Invalid object type');\n }\n\n return $this->importOptions($field, $options);\n }\n\n /**\n * @inheritdoc\n */\n public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage\n {\n $missingStage = null;\n\n try {\n // Use the HubSpot API client instead of the SDK crmPipelines() method\n $endpoint = self::getDealsPipelinesEndpoint();\n $pipelinesResponse = $this->client->getInstance()->getClient()->request('GET', $endpoint);\n $pipelines = $pipelinesResponse->data->results;\n } catch (RequestException|BadRequest $exception) {\n throw $exception;\n }\n\n foreach ($pipelines as $pipeline) {\n $stages = [];\n\n // We create a business process to contain the pipeline, and store all stages against it.\n $p = ResponseNormalize::normalizePipeline($pipeline);\n\n // Create/update business process for this pipeline\n $businessProcess = $this->config->businessProcesses()->updateOrCreate([\n 'crm_provider_id' => $p['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($p['label'], 0, 150),\n 'type' => BusinessProcess::TYPE_OPPORTUNITY,\n 'is_selectable' => $p['active'],\n ]);\n\n // A record type is really a clone of the business process, used to store which record uses which pipeline.\n // Create/update record type clone\n $this->config->recordTypes()->updateOrCreate([\n 'crm_provider_id' => $p['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($p['label'], 0, 150),\n 'is_selectable' => $p['active'],\n 'business_process_id' => $businessProcess->id ?? null,\n ]);\n\n // Stages - fetch all existing stages upfront to avoid N+1 queries\n $existingStages = $this->config->stages()\n ->withTrashed()\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->get()\n ->keyBy('crm_provider_id');\n\n foreach ($p['stages'] as $dealStage) {\n $s = ResponseNormalize::normalizeDealStage($dealStage);\n\n /** @var ?Stage $existingStage */\n $existingStage = $existingStages->get($s['id']);\n\n // Restore soft-deleted stages that are now active in HubSpot\n if ($existingStage?->trashed() && $s['active']) {\n $existingStage->restore();\n }\n\n // Upsert stage (updates soft-deleted records without restoring them)\n $stage = $this->config->stages()->withTrashed()->updateOrCreate([\n 'crm_provider_id' => $s['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($s['label'], 0, 50),\n 'label' => mb_strimwidth($s['label'], 0, 191),\n 'type' => Stage::TYPE_OPPORTUNITY,\n 'sequence' => $s['displayOrder'],\n 'is_selectable' => $s['active'],\n 'probability' => $s['probability'] * 100,\n ]);\n\n if ($missingStageName === $s['id']) {\n $missingStage = $stage;\n }\n\n $stages[] = $stage->id;\n }\n\n $businessProcess->stages()->sync($stages);\n }\n\n return $missingStage;\n }\n\n /**\n * @inheritdoc\n */\n public function syncOrganization(): void\n {\n try {\n $endpoint = 'https://api.hubapi.com/integrations/v1/me';\n $response = $this->client->getInstance()->getClient()->request('get', $endpoint);\n\n $accountData = $response->data;\n $this->config->update(['default_currency' => $accountData->currency]);\n } catch (BadRequest $e) {\n throw new CrmException('Could not sync the organization.', $e->getCode(), $e);\n }\n }\n\n /**\n * @inheritdoc\n *\n * @throws CrmException\n */\n public function syncProfiles(?User $userToSearch = null): ?Profile\n {\n $this->syncArchivedProfilesAction->execute($this->team, $this->client, $this->config);\n\n try {\n $owners = $this->client->getOwners();\n } catch (\\HubSpot\\Client\\Crm\\Owners\\ApiException $e) {\n $this->logger->error('[HubSpot] Could not sync the profiles.', [\n 'team_id' => $this->team->getId(),\n 'reason' => $e->getMessage(),\n ]);\n\n throw new CrmException('Could not sync the profiles.', $e->getCode(), $e);\n }\n\n $profileRepository = app(ProfileRepository::class);\n $teamRepository = app(TeamRepository::class);\n\n foreach ($owners as $owner) {\n if ($owner->getArchived()) {\n // not supposed to fetch archived, but log anyway\n $this->logger->warning('[HubSpot] Found archived owner', [\n 'crm_provider_id' => $owner->getId(),\n 'email' => $owner->getEmail(),\n ]);\n\n continue;\n }\n\n $email = $owner->getEmail();\n if ($email === null) {\n continue;\n }\n\n $user = $teamRepository->findActiveTeamMemberByEmail($this->team, $email);\n\n if (! $user instanceof User) {\n continue;\n }\n\n $profile = $profileRepository->updateOrCreateProfile($user, [\n 'crm_configuration_id' => $this->config->getId(),\n 'crm_provider_id' => $owner->getId(),\n ]);\n\n if ($userToSearch && $userToSearch->getId() === $user->getId()) {\n return $profile;\n }\n }\n\n return null;\n }\n\n private function generateNameSearchPayload(string $name, int $offset, int $limit): array\n {\n $payload = [\n 'query' => $name,\n 'sorts' => [\n [\n 'propertyName' => 'modifieddate',\n 'direction' => 'DESCENDING',\n ],\n ],\n 'properties' => [\n 'email',\n 'firstname',\n 'lastname',\n 'country',\n 'phone',\n 'mobilephone',\n 'jobtitle',\n 'hubspot_owner_id',\n 'associatedcompanyid',\n 'photo',\n 'industry',\n 'name',\n 'company',\n ],\n 'limit' => $limit,\n 'after' => $offset,\n ];\n\n $this->logger->debug('[HubSpot] CRM Search requested', [\n 'request' => $payload,\n ]);\n\n return $payload;\n }\n\n /**\n * @inheritdoc\n */\n public function find(string $name, array $scopes): array\n {\n $count = $this->limit ?? 20;\n $offset = $this->offset ?? 0;\n\n /** @var array<int, array<string, mixed>> */\n return Cache::remember(\n key: $this->team->getId() . $name . $count . $offset,\n ttl: 300,\n callback: function () use ($name, $offset, $count): array {\n $data = [];\n\n // Use the new V3 API to find contacts based on additional fields.\n foreach (['companies', 'contacts'] as $objectType) {\n $payload = $this->generateNameSearchPayload($name, $offset, $count);\n $type = $objectType === 'companies' ? 'account' : 'contact';\n\n try {\n $response = $this->client->search($objectType, $payload);\n\n // Build mapped list.\n foreach ($response['results'] as $object) {\n $properties = $object['properties'];\n\n $objectName = $this->buildContactName($properties);\n\n $record = [\n 'crmId' => $object['id'],\n // Pass crmUrl to the FE, needed for success message in the extension when you log activity.\n 'crmUrl' => $this->generateProviderUrl($object['id'], $type),\n 'name' => $objectName,\n 'prospectType' => $type,\n 'phoneNumbers' => [],\n ];\n\n if ($type === 'account') {\n $record['industry'] = $properties['industry'] ?? null;\n } else {\n $record['title'] = $properties['jobtitle'] ?? null;\n $record['organization'] = $properties['company'] ?? null;\n }\n\n $countryCode = $this->buildContactCountry($properties);\n $parsedNumber = $this->buildContactPhone($countryCode, $properties);\n\n // Add phone number to record.\n if (! empty($parsedNumber['phone'])) {\n $record['phoneNumbers'][] = [\n 'number' => $parsedNumber['phone'],\n 'nationalFormat' => phone_national($countryCode, $parsedNumber['phone']),\n 'type' => 'phone',\n ];\n }\n\n // Add mobile phone number to record.\n if (! empty($properties['mobilephone'])) {\n $mobileNumber = phone_e164($countryCode, $properties['mobilephone']);\n if ($mobileNumber !== null) {\n $record['phoneNumbers'][] = [\n 'number' => $mobileNumber,\n 'nationalFormat' => phone_national($countryCode, $mobileNumber),\n 'type' => 'mobile',\n ];\n }\n }\n\n $data[] = $record;\n }\n } catch (BadRequest $e) {\n $this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [\n 'teamId' => $this->team->getUuid(),\n 'request' => $payload,\n 'reason' => $e->getMessage(),\n ]);\n\n throw $e;\n }\n }\n\n return $data;\n },\n );\n }\n\n\n /**\n * @inheritdoc\n */\n public function findOpportunities(?string $crmAccountId, ?string $crmContactId, ?int $userId = null): array\n {\n $data = [];\n $ownerData = [];\n $ownerId = null;\n\n if ($crmAccountId === null) {\n return $data;\n }\n\n if ($userId) {\n $profileRepository = app(ProfileRepository::class);\n $profile = $profileRepository->findProfileByUserId($this->config, $userId);\n\n $ownerId = $profile instanceof Profile ? $profile->getCrmProviderId() : null;\n }\n\n $closedStages = $this->getClosedDealStages();\n $payload = $this->payloadBuilder->generateOpportunitiesSearchPayload(\n $this->config,\n $crmAccountId,\n $closedStages,\n );\n\n $results = $this->client->getPaginatedData($payload, 'deals');\n\n foreach ($results['results'] as $object) {\n $properties = $object['properties'];\n\n $amount = null;\n if (empty($properties['amount']) === false) {\n $currency = $properties['deal_currency_code'] ?? $this->config->default_currency;\n\n // Values can contain commas and any junk so strip them.\n $value = (float) preg_replace('/[^\\d.]/', '', $properties['amount']);\n $amount = formatCurrency($value, $currency);\n }\n\n $businessProcess = $this->config\n ->businessProcesses()\n ->where('crm_provider_id', $properties['pipeline'])\n ->first();\n\n if ($businessProcess === null) {\n // Import it.\n $stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $properties['dealstage']);\n $businessProcess = $this->config\n ->businessProcesses()\n ->where('crm_provider_id', $properties['pipeline'])\n ->first();\n } else {\n $stage = $businessProcess\n ->stages()\n ->where('crm_provider_id', $properties['dealstage'])\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->first();\n\n if ($stage === null) {\n // Import it.\n $stage = $this->importStages(null, $properties['dealstage']);\n }\n }\n\n $recordType = null;\n if ($businessProcess) {\n $recordType = $businessProcess->recordTypes()->first();\n }\n\n $isWon = in_array($properties['dealstage'], $closedStages['won']);\n $isLost = in_array($properties['dealstage'], $closedStages['lost']);\n\n $record = [\n 'crmId' => $object['id'],\n 'name' => $properties['dealname'] ?? 'Unknown Deal',\n 'value' => $amount,\n 'won' => $isWon,\n 'closed' => $isWon || $isLost,\n 'stage' => [\n 'id' => $stage?->getUuid() ?? '',\n 'name' => $stage?->getName() ?? '',\n ],\n ];\n\n if ($recordType) {\n $record += [\n 'recordType' => [\n 'id' => $recordType->id_string,\n 'name' => $recordType->name,\n ],\n ];\n }\n\n if ($ownerId && isset($properties['hubspot_owner_id']) && $properties['hubspot_owner_id'] === $ownerId) {\n $ownerData[] = $record;\n }\n\n $data[] = $record;\n }\n\n if (! empty($ownerData)) {\n return $ownerData;\n }\n\n return $data;\n }\n\n /**\n * @inheritdoc\n */\n public function getTasks(?string $objectType, string $objectId, ?string $opportunityId): array\n {\n $data = [];\n switch ($objectType) {\n case 'contact':\n $hsObject = 'contact';\n\n break;\n case 'account':\n $hsObject = 'company';\n\n break;\n default:\n // This is a hack to prioritise and override a contact/company with a deal.\n if ($opportunityId) {\n $hsObject = 'deal';\n $objectId = $opportunityId;\n } else {\n throw new InvalidArgumentException('Object type not supported.');\n }\n }\n\n $engagementTypes = ['meetings', 'tasks'];\n\n foreach ($engagementTypes as $engagementType) {\n $payload = $this->payloadBuilder->getLinkToTaskPayload($hsObject, $objectId, $engagementType);\n\n $this->logger->info('[HubSpot] CRM Search requested', [\n 'request' => $payload,\n ]);\n\n $engagements = $this->client->getPaginatedData($payload, $engagementType);\n\n foreach ($engagements['results'] as $engagement) {\n if ($engagementType == 'meetings') {\n $title = $engagement['properties']['hs_meeting_title'] ?? 'Scheduled meeting';\n } elseif ($engagementType == 'tasks') {\n $title = $engagement['properties']['hs_task_subject'];\n } else {\n $title = 'Scheduled meeting';\n }\n\n $data[] = [\n 'crmId' => $engagement['id'],\n 'subject' => $title,\n 'due' => $engagement['properties']['hs_timestamp'],\n 'type' => $engagement['properties']['hs_activity_type'] ?? null,\n ];\n }\n }\n\n usort($data, function ($item1, $item2) {\n return $item2['due'] <=> $item1['due'];\n });\n\n return $data;\n }\n\n /**\n * Try to find CRM Objects using email address\n *\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n public function matchExactlyByEmail(string $email, ?int $userId = null): ?array\n {\n $contactProperties = [\n 'email',\n 'firstname',\n 'lastname',\n 'country',\n 'phone',\n 'mobilephone',\n 'jobtitle',\n 'hubspot_owner_id',\n 'associatedcompanyid',\n 'photo',\n ];\n $contact = null;\n $account = null;\n\n try {\n $hsContact = $this->getClient()->getContactByEmail($email, $contactProperties);\n\n if ($hsContact) {\n $contact = $this->importContact($hsContact);\n $account = $contact->account;\n }\n\n $data = $this->convertCrmData($contact, $account, $userId);\n\n return ! empty(array_filter($data)) ? $data : null;\n } catch (BadRequest $e) {\n $this->logger->warning('[HubSpot] Search failed', [\n 'team_id' => $this->team->getId(),\n 'search_identifier' => $email,\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return null;\n }\n\n public function getDomain(string $email): ?string\n {\n return $this->getDomainFromEmail($email);\n }\n\n /**\n * Try to find CRM objects using domain name of the email address\n *\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n public function matchByDomain(string $domain, ?int $userId = null): ?array\n {\n $companyName = $domain;\n\n // Try to find a company matching their email domain.\n $companyProperties = [\n 'country',\n 'phone',\n 'name',\n 'hs_avatar_filemanager_key',\n 'industry',\n 'hubspot_owner_id',\n 'domain',\n ];\n\n try {\n $hsAccounts = $this->client\n ->getInstance()\n ->companies()\n ->searchByDomain($companyName, $companyProperties);\n } catch (Throwable $e) {\n $this->logger->info('[HubSpot] Search failed', [\n 'error' => $e->getMessage(),\n 'domain' => $domain,\n ]);\n\n return null;\n }\n\n $account = null;\n // If there are multiple accounts, don't guess, we'll ask later.\n if (\\count($hsAccounts->data->results) === 1) {\n // Persist this remote object.\n $account = $this->syncAccount($hsAccounts->data->results[0]->companyId);\n }\n\n $data = $this->convertCrmData(null, $account, $userId);\n\n return ! empty(array_filter($data)) ? $data : null;\n }\n\n /**\n * @return array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n protected function convertCrmData(?Contact $contact, ?Account $account, ?int $userId = null): array\n {\n $countryCode = null;\n if ($contact && $contact->country_code) {\n $countryCode = $contact->country_code;\n } elseif ($account && $account->country_code) {\n $countryCode = $account->country_code;\n }\n\n try {\n $hsOpportunities = $this->findOpportunities(\n $account ? $account->crm_provider_id : null,\n $contact ? $contact->crm_provider_id : null,\n $userId\n );\n } catch (Exception $e) {\n $hsOpportunities = [];\n }\n\n // If there are multiple opportunities, don't guess, we'll ask later.\n $opportunity = null;\n $stage = null;\n if (! empty($hsOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n }\n\n protected function getCacheKey(string $object, ?int $userId = null): ?string\n {\n $key = $this->team->getId() . $object;\n $keySuffix = $this->getOwnerKeySuffix($userId);\n\n return $key . $keySuffix;\n }\n\n private function getOwnerKeySuffix(?int $userId = null): string\n {\n return $userId === null ? '' : (string) $userId;\n }\n\n /**\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n *}\n */\n public function matchByPhone(string $phone, ?string $rawPhoneNumber = null, ?int $userId = null): ?array\n {\n if (str_contains($phone, '**')) {\n return null;\n }\n\n // trim all whitespaces if present so the lookup doesn't fail\n $phone = str_replace(' ', '', $phone);\n\n // Check if the user is internal.\n if ($this->isPhoneNumberOfTeamMember($phone)) {\n return null;\n }\n\n $response = $this->searchForPhoneNumber($phone);\n if (empty($response)) {\n return null;\n }\n\n // This would ideally importContact instead but the response type differs.\n $contact = $this->findAndSyncContact($response['results'][0]['id']);\n if (! $contact instanceof Contact) {\n return null;\n }\n\n $account = $contact->account;\n $countryCode = $contact->country_code ?? $account->country_code ?? null;\n\n try {\n $hsOpportunities = $this->findOpportunities(\n $account?->crm_provider_id,\n $contact->crm_provider_id,\n $userId\n );\n } catch (Exception $e) {\n $hsOpportunities = [];\n }\n\n $opportunity = null;\n $stage = null;\n\n try {\n if (! empty($hsOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n } catch (Exception $e) {\n $this->logger->debug('[HubSpot] Opportunity failed to sync.', [\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n }\n\n private function isPhoneNumberOfTeamMember(string $phone): bool\n {\n $teamRepository = app(TeamRepository::class);\n $user = $teamRepository->findTeamMemberByPhone($this->team, $phone);\n\n if ($user instanceof User) {\n return true;\n }\n\n return false;\n }\n\n private function findAndSyncContact(string $crmId): ?Contact\n {\n try {\n return $this->syncContact($crmId);\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Phone match failed', [\n 'reason' => $exception->getMessage(),\n ]);\n\n return null;\n }\n }\n\n private function hasResults(array $response): bool\n {\n return isset($response['total']) && is_numeric($response['total']) && $response['total'] > 0;\n }\n\n private function searchForPhoneNumber(string $phone): array\n {\n // Normalizes the provided phone number for the API search.\n $normalizedPhone = $this->normalizePhoneNumber($phone);\n $payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone);\n\n $this->logger->info('[HubSpot] Phone match search triggered', [\n 'phone' => $phone,\n 'normalizedPhone' => $normalizedPhone,\n 'payload' => $payload,\n ]);\n\n $response = $this->handlePhoneSearchRequest($normalizedPhone, $payload);\n\n if (! $this->hasResults($response)) {\n $nationalPhone = preg_replace('/\\D/', '', phone_national(null, $phone));\n $payload = $this->payloadBuilder->generatePhoneSearchPayload($nationalPhone);\n\n $this->logger->info('[HubSpot] Phone match national number search triggered', [\n 'phone' => $phone,\n 'nationalPhone' => $nationalPhone,\n 'payload' => $payload,\n ]);\n\n $response = $this->handlePhoneSearchRequest($phone, $payload);\n }\n\n if (! $this->hasResults($response)) {\n $payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone, true);\n\n $this->logger->info('[HubSpot] Phone match alternative search triggered', [\n 'phone' => $phone,\n 'normalizedPhone' => $normalizedPhone,\n 'payload' => $payload,\n ]);\n\n $response = $this->handlePhoneSearchRequest($phone, $payload);\n }\n\n return $this->hasResults($response) ? $response : [];\n }\n\n private function handlePhoneSearchRequest(string $phone, array $payload): array\n {\n try {\n return $this->client->search('contacts', $payload);\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Phone match failed', [\n 'phone' => $phone,\n 'reason' => $exception->getMessage(),\n ]);\n\n return [];\n }\n }\n\n private function normalizePhoneNumber(string $phone): string\n {\n return ltrim(phone_e164(null, $phone), '+0');\n }\n\n /**\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n public function matchByName(string $name, ?int $userId = null): ?array\n {\n // Don't waste time searching for single character strings.\n if (\\strlen($name) <= 1) {\n return null;\n }\n\n $cacheKey = $this->getCacheKey($name, $userId);\n\n $result = Cache::remember($cacheKey, 60, function () use ($name, $userId) {\n $payload = $this->payloadBuilder->generateSearchContactsByNamePayload(\n $name,\n $this->getContactFields()\n );\n\n $hsContacts = $this->client->getPaginatedData($payload, 'contact');\n if (empty($hsContacts['results'])) {\n return false;\n }\n\n $contact = $this->importContact($hsContacts['results'][0]);\n if ($contact === null) {\n return false;\n }\n\n $account = $contact->account;\n $countryCode = $contact->country_code ?? $account->country_code ?? null;\n\n try {\n $hsOpportunities = $this->findOpportunities(\n $account ? $account->crm_provider_id : null,\n $contact->crm_provider_id,\n $userId\n );\n } catch (Exception $e) {\n $hsOpportunities = [];\n }\n\n $opportunity = null;\n $stage = null;\n if (! empty($hsOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n });\n\n return is_array($result) ? $result : null;\n }\n\n\n private function convertActivityAssociations(Activity $activity): array\n {\n return [\n 'contactIds' => $this->getParticipantsIds($activity),\n 'companyIds' => $activity->hasAccount() ? [$activity->account->crm_provider_id] : [],\n 'dealIds' => $activity->hasOpportunity() ? [$activity->opportunity->crm_provider_id] : [],\n 'ownerIds' => [],\n ];\n }\n\n private function getParticipantsIds(Activity $activity): array\n {\n $attendees = [];\n\n $participantRepository = app(ParticipantRepository::class);\n $participants = $participantRepository->getParticipantsWhoEnteredMeeting($activity);\n foreach ($participants as $participant) {\n if ($participant->user_id || $participant->isCoach()) {\n continue;\n }\n\n $contact = $participant->contact()->first();\n if ($contact && $contact->crm_provider_id) {\n $attendees[] = $contact->crm_provider_id;\n } else {\n if (! empty($participant->name)) {\n $attendeeData = $this->fetchMissingAttendeeInfo($participant);\n }\n if (! empty($attendeeData['id'])) {\n $attendees[] = $attendeeData['id'];\n }\n }\n }\n\n if ($activity->hasContact()) {\n $attendees[] = $activity->contact->crm_provider_id;\n }\n\n return array_unique($attendees);\n }\n\n private function fetchMissingAttendeeInfo(Participant $participant): array\n {\n // Check if we need to look inside an account context.\n $activity = $participant->getActivity();\n $companyId = $activity->hasAccount() ? $activity->getAccount()->crm_provider_id : null;\n\n // First check the local data.\n /** @var Contact[] $contacts */\n $contacts = $this->team->contacts()\n ->with('account')\n ->where('name', $participant->name)\n ->whereNotNull('email')\n ->get();\n\n foreach ($contacts as $contact) {\n // If we have a company in scope, check the contact is associated to it.\n if (\n $companyId !== null\n && ($contact->account_id === null || $companyId !== $contact->account->crm_provider_id)\n ) {\n continue;\n }\n\n return [\n 'id' => $contact->crm_provider_id,\n 'email' => $contact->email,\n ];\n }\n\n $payload = $this->generateNameSearchPayload($participant->name, 0, 20);\n\n try {\n $response = $this->client->getNewInstance()->crm()->contacts()->searchApi()->doSearch($payload);\n\n // TODO add some logic to choose the most suitable contact if multiple\n foreach ($response['results'] as $object) {\n $properties = $object['properties'];\n if (empty($object['properties']) === false) {\n // Check the company matches the contact.\n // Todo: Move this check inside the API search.\n if ($companyId !== null && $companyId !== $properties['associatedcompanyid']) {\n continue;\n }\n\n return [\n 'id' => $object['id'],\n 'email' => $properties['email'],\n ];\n }\n }\n } catch (Exception $e) {\n $this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [\n 'teamId' => $this->team->id_string,\n 'request' => $payload,\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return [];\n }\n\n /**\n * Store transcripts as note engagement.\n *\n * @throws Exception\n */\n public function createTranscriptNotes(Activity $activity): void\n {\n // For HS no need to check if Crm profile - Log Notes field is enabled\n // We only check if store_transcript toggle is enabled on crm profile.\n $engagement = [\n 'active' => true,\n 'ownerId' => $this->profile->crm_provider_id,\n 'timestamp' => $activity->created_at->tz($activity->user->timezone)->getTimestamp() * 1000,\n 'type' => 'NOTE',\n ];\n\n // Generate activity transcription.\n $transcriptionData = $this->generateTranscription($activity);\n\n // Truncate Notes with max notes length because transcription text could be very long.\n $transcripts = mb_strimwidth($transcriptionData, 0, static::ENGAGEMENT_BODY_MAX_LENGTH);\n\n $metadata = [\n 'body' => $transcripts,\n ];\n\n $associations = $this->convertActivityAssociations($activity);\n\n try {\n $hsEngagement = $this->client\n ->getInstance()\n ->engagements()\n ->create($engagement, $associations, $metadata);\n\n $this->logCrmEngagementManipulation(self::ACTION_CREATE, $engagement, $metadata, $associations);\n\n $noteId = $hsEngagement->data->engagement->id;\n\n // Store crm logged id in transcription.\n $transcription = $activity->getTranscription();\n $transcription->crm_activity_id = $noteId;\n $transcription->save();\n } catch (Exception $e) {\n Sentry::captureException($e);\n }\n }\n\n /*\n * @inheritdoc\n */\n public function updateRecord(string $objectType, string $objectId, array $data, array $headers = []): void\n {\n $payload = [\n 'properties' => $data,\n ];\n\n try {\n switch ($objectType) {\n case FieldData::OBJECT_OPPORTUNITY:\n $this->client->getNewInstance()->crm()->deals()->basicApi()->update($objectId, $payload);\n\n break;\n case FieldData::OBJECT_CONTACT:\n $this->client->getNewInstance()->crm()->contacts()->basicApi()->update($objectId, $payload);\n\n break;\n\n case FieldData::OBJECT_ACCOUNT:\n $this->client->getNewInstance()->crm()->companies()->basicApi()->update($objectId, $payload);\n\n break;\n\n case FieldData::OBJECT_TASK:\n // Endpoint for Engagements not ready\n $engagements = [\n 'type' => 'TASK',\n ];\n $metadata = $data;\n $this->client->getInstance()->engagements()->update($objectId, $engagements, $metadata);\n\n $this->logCrmEngagementManipulation(\n self::ACTION_UPDATE,\n ['crmId' => $objectId],\n $metadata,\n );\n\n break;\n\n default:\n throw new UnexpectedValueException('Unsupported object type \"' . $objectType . '\"');\n }\n } catch (\\HubSpot\\Client\\Crm\\Deals\\ApiException $apiException) {\n $errorMessage = $apiException->getMessage();\n if ($apiException->getResponseBody()) {\n $responseBody = json_decode($apiException->getResponseBody(), true, 512, JSON_THROW_ON_ERROR);\n $errorMessage = $responseBody['message'] ?? $apiException->getMessage();\n }\n\n $this->logger->error(\n '[HubSpot] Update record failed',\n [\n 'objectType' => $objectType,\n 'objectId' => $objectId,\n 'payload' => $payload,\n 'reason' => $errorMessage,\n 'team' => $this->team->getUuid(),\n ]\n );\n\n throw new CrmException($errorMessage);\n }\n }\n\n /*\n * @inheritdoc\n */\n public function getRecord(string $objectType, string $objectId, array $fields = []): array\n {\n switch ($objectType) {\n case FieldData::OBJECT_OPPORTUNITY:\n return $this->client->getInstance()->deals()->getById($objectId)->toArray();\n\n case FieldData::OBJECT_CONTACT:\n return $this->client->getInstance()->contacts()->getById($objectId)->toArray();\n\n case FieldData::OBJECT_ACCOUNT:\n return $this->client->getInstance()->companies()->getById($objectId)->toArray();\n\n case FieldData::OBJECT_TASK:\n return $this->client->getInstance()->engagements()->get($objectId)->toArray();\n\n default:\n throw new UnexpectedValueException('Unsupported object type \"' . $objectType . '\"');\n }\n }\n\n /**\n * @throws BadRequest\n * @throws CrmException\n */\n public function updateStage($crmObject, Stage $stage): void\n {\n $payload = [\n 'properties' => [\n [\n 'name' => 'dealstage',\n 'value' => $stage->crm_provider_id,\n ],\n ],\n ];\n\n try {\n $this->client->getInstance()->deals()->update($crmObject->crm_provider_id, $payload);\n } catch (BadRequest $badRequest) {\n if ($badRequest->getCode() === 403) {\n throw new CrmException(\n \"Sorry, you don't have permission to update this stage.\",\n $badRequest->getCode(),\n $badRequest,\n );\n }\n\n $this->logger->warning('[HubSpot] Stage update failed', [\n 'dealId' => $crmObject->crm_provider_id,\n 'payload' => $payload,\n 'message' => $badRequest->getMessage(),\n ]);\n\n throw $badRequest;\n }\n }\n\n public function generateProviderUrl(string $providerId, string $objectType): ?string\n {\n $url = null;\n $baseUrl = 'https://app.hubspot.com/contacts/' . $this->config->crm_provider_id . '/';\n\n switch ($objectType) {\n case 'account':\n $url = $baseUrl . 'company/' . $providerId;\n\n break;\n\n case 'contact':\n $url = $baseUrl . 'contact/' . $providerId;\n\n break;\n\n case 'opportunity':\n $url = $baseUrl . 'deal/' . $providerId;\n\n break;\n\n case 'task':\n case 'activity':\n return null;\n\n // This should not be deep-linked as per JMNY-3934.\n //$url = $baseUrl.'tasks/list/view/all/?taskId='.$providerId;\n break;\n }\n\n return $url;\n }\n\n public function searchCalls(Carbon $from, Carbon $to, string $activityProvider): array\n {\n $this->logger->info('[HubSpot] Search calls', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n ]);\n\n $calls = [];\n $page = 1;\n\n do {\n try {\n $payload = $this->payloadBuilder->generateGetCallsPayload($from, $to, $activityProvider, $page);\n $response = $this->client->search('calls', $payload);\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Search calls failed', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n 'reason' => $exception->getMessage(),\n ]);\n $response = null;\n }\n\n $responseResults = empty($response['results']) ? [] : $response['results'];\n $calls = array_merge($calls, $responseResults);\n $page++;\n } while (! empty($responseResults));\n\n return $calls;\n }\n\n public function searchCallsForPeriodByPage(Carbon $from, Carbon $to, int $page, bool $retry = true)\n {\n try {\n $payload = $this->payloadBuilder->generateSearchCallsByPeriodPayload($from, $to, $page);\n\n return $this->client->search('calls', $payload);\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Search calls for period failed', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n 'reason' => $exception->getMessage(),\n 'retry' => $retry,\n ]);\n\n if ($retry) {\n sleep(1);\n\n return $this->searchCallsForPeriodByPage($from, $to, $page, false);\n }\n\n return null;\n }\n }\n\n public function searchCallsForPeriod(Carbon $from, Carbon $to): Generator\n {\n $this->logger->info('[HubSpot] Search calls for period', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n ]);\n\n $page = 1;\n\n do {\n $response = $this->searchCallsForPeriodByPage($from, $to, $page);\n\n $responseResults = empty($response['results']) ? [] : $response['results'];\n\n $associationContacts = $this->getAssociationDataForCollection($responseResults, 'calls', 'contacts');\n $associationCompanies = $this->getAssociationDataForCollection($responseResults, 'calls', 'companies');\n $associationDeals = $this->getAssociationDataForCollection($responseResults, 'calls', 'deals');\n\n foreach ($responseResults as $call) {\n $call['associations'] = [\n 'contacts' => $this->importAssociationData($call, $associationContacts),\n 'companies' => $this->importAssociationData($call, $associationCompanies),\n 'deals' => $this->importAssociationData($call, $associationDeals),\n ];\n\n yield $call;\n }\n $page++;\n } while (! empty($responseResults));\n }\n\n public function getCall(string $callId): array\n {\n $this->logger->info('[HubSpot] Get call', [\n 'call_id' => $callId,\n ]);\n\n $searchAttributes = $this->payloadBuilder->getSearchCallAttributes();\n $endpoint = sprintf(\n 'https://api.hubapi.com/crm/v3/objects/calls/%s',\n $callId,\n );\n\n try {\n $response = $this->client->getInstance()->getClient()->request(\n 'GET',\n $endpoint,\n [],\n sprintf(\n 'properties=%s&associations=contacts,companies,deals',\n implode(',', $searchAttributes),\n ),\n );\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Get call failed', [\n 'call_id' => $callId,\n 'reason' => $exception->getMessage(),\n ]);\n $response = null;\n }\n\n return empty($response) ? [] : $response->toArray();\n }\n\n public function bulkAddPlaybackURLToDescription(array $crmUpdateData): array\n {\n $crmUpdateBatches = array_chunk($crmUpdateData, self::BATCH_UPDATE_LIMIT);\n\n $updatedCrmIds = [];\n\n foreach ($crmUpdateBatches as $crmBatch) {\n $payload = $this->payloadBuilder->generatePlaybackAddUrlBatchPayload($crmBatch);\n $updateSuccess = $this->bulkAddPlaybackURLToDescriptionRequest($payload);\n if ($updateSuccess) {\n $updatedCrmIds = array_merge($updatedCrmIds, array_column($crmBatch, 'crm_id'));\n }\n }\n\n return $updatedCrmIds;\n }\n\n private function bulkAddPlaybackURLToDescriptionRequest(array $payload, bool $retry = true): bool\n {\n try {\n $this->client->getNewInstance()->crm()->objects()->batchApi()->update('calls', $payload);\n\n return true;\n } catch (\\HubSpot\\Client\\Crm\\Objects\\ApiException $e) {\n $response = json_decode($e->getResponseBody(), true);\n $retryAfter =\n isset($response['policyName'])\n && $response['policyName'] == self::TEN_SECONDLY_ROLLING_POLICY\n ? self::TEN_SECONDLY_ROLLING_LIMIT\n : 1;\n } catch (Exception $e) {\n $retryAfter = 1;\n }\n\n $this->logger->warning('[HubSpot] Bulk add playback url to CRM failed', [\n 'reason' => $e->getMessage(),\n 'retry' => $retry,\n ]);\n\n if ($retry) {\n sleep($retryAfter);\n\n return $this->bulkAddPlaybackURLToDescriptionRequest($payload, false);\n }\n\n return false;\n }\n\n /**\n * Sometimes we have secondly rate limit error, then retry request after 1 second\n */\n public function searchCallByRecordingURLToken(string $playbackURLToken, bool $retry = true): array\n {\n $payload = $this->payloadBuilder->generateSearchCallByTokenPayload($playbackURLToken);\n\n $this->logger->info('[HubSpot] CRM Search by playback URL token requested', [\n 'request' => $payload,\n ]);\n\n try {\n $response = $this->client->search('calls', $payload);\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Search by playback URL token failed', [\n 'playbackURLToken' => $playbackURLToken,\n 'reason' => $exception->getMessage(),\n 'retry' => $retry,\n ]);\n\n if ($retry) {\n sleep(1);\n\n return $this->searchCallByRecordingURLToken($playbackURLToken, false);\n }\n\n return [];\n }\n\n return empty($response['results']) ? [] : $response['results'][0];\n }\n\n /**\n * Generate transcription for the activity.\n */\n private function generateTranscription(Activity $activity): string\n {\n if (! $this->config->store_transcript) {\n // If sending transcription to activity toggle is disabled\n return '';\n }\n\n $transcriptionSegments = $this->transcriptionService->findTranscriptionByActivity($activity);\n\n if ($transcriptionSegments->isEmpty()) {\n return '';\n }\n\n $transcription = sprintf(\n '<p><strong>Transcript for %s</strong></p><p></p>',\n $activity->title ?? $activity->activity_title,\n );\n\n $roomOwnerParticipant = $activity->findParticipantRoomOwner();\n $roomOwnerParticipantId = $roomOwnerParticipant !== null\n ? $roomOwnerParticipant->getId()\n : null;\n\n\n $transcription .= $transcriptionSegments\n ->map(static function (array $transcriptionSegment) use ($roomOwnerParticipantId): string {\n $isOrganiser = $roomOwnerParticipantId === $transcriptionSegment['participantId']\n && $roomOwnerParticipantId !== null;\n $transcriptColor = $isOrganiser ? '#000000' : '#f0415a';\n\n return sprintf(\n '<span style=\"color: %s;\">%s | </span>%s',\n $transcriptColor,\n $transcriptionSegment['formattedStartsAt'],\n $transcriptionSegment['transcript'],\n );\n })\n ->implode('<br />');\n\n return $transcription;\n }\n\n /**\n * @param array<array{\n * id: string,\n * label: string,\n * value?: string,\n * }> $options\n *\n * @return FieldData[]\n */\n private function importOptions(Field $field, array $options): array\n {\n $fieldValues = [];\n $values = [];\n $sequence = 0;\n\n foreach ($options as $option) {\n $values[] = [\n 'value' => $option['value'] ?? $option['id'],\n 'label' => substr($option['label'], 0, 255),\n 'sequence' => $sequence++,\n ];\n }\n\n $fieldsToPurge = $field->values()->get()->pluck('value')->toArray();\n\n foreach ($values as $value) {\n $value['value'] = substr($value['value'], 0, 255);\n $fieldValues[] = $field->values()->updateOrCreate([\n 'value' => $value['value'],\n ], $value);\n\n // Remove this value from the ones we are going to purge.\n if (($key = array_search($value['value'], $fieldsToPurge, false)) !== false) {\n unset($fieldsToPurge[$key]);\n }\n }\n\n // Delete the old values that are no longer used.\n $field->values()->whereIn('value', $fieldsToPurge)->delete();\n\n return $fieldValues;\n }\n\n public function saveTranscriptionSummaryAsNote(\n ActivityContract $activity,\n string $title,\n string $body,\n ?string $objectId,\n ?NoteObject $noteObject = null,\n ): ?string {\n if ($noteObject === null || $objectId === null) {\n return null;\n }\n\n /** @var User $user */\n $user = $activity->getUser();\n\n $profile = $this->assignCrmOwner($user, $activity);\n if (! $profile instanceof Profile) {\n return null;\n }\n\n $timestamp = Carbon::now($user->getTimezone())->getTimestamp() * 1000;\n $engagement = [\n 'active' => true,\n 'ownerId' => $profile->getAttribute('crm_provider_id'),\n 'timestamp' => $timestamp,\n 'type' => 'NOTE',\n ];\n\n // Truncate Notes with max notes length because transcription text could be very long.\n $body = mb_strimwidth($body, 0, self::ENGAGEMENT_BODY_MAX_LENGTH);\n $metadata = [\n 'body' => $body,\n ];\n\n $associations = $this->convertActivityAssociations($activity);\n\n try {\n $hsActivityId = $this->client->createNote(\n body: $body,\n ownerId: $profile->getCrmProviderId(),\n timestamp: $timestamp,\n objectId: $objectId,\n noteObject: $noteObject,\n );\n\n $this->logCrmEngagementManipulation(self::ACTION_CREATE, $engagement, $metadata, $associations);\n\n $this->logger->info('[HubSpot] Saving Transcription Summary as Note', [\n 'activity' => $activity->getUuid(),\n 'crmActivity' => $hsActivityId,\n ]);\n\n return $hsActivityId;\n } catch (Exception $e) {\n Sentry::captureException($e);\n }\n\n return null;\n }\n\n public function attachSummaryToActivity(ActivityContract $activity, string $summaryTitle, string $summaryContents): bool\n {\n $this->logger->info('[HubSpot] Attaching summary to activity', [\n 'activity' => $activity->getUuid(),\n 'summary_content' => $summaryContents,\n ]);\n\n if (! $activity instanceof Activity) {\n throw new InvalidArgumentException('Expected instance of Activity');\n }\n\n $summary = '<p><strong>' . $summaryTitle . '</strong></p>';\n $summary .= '<p>' . $summaryContents . '</p>';\n $metadata = $this->buildMetadataForSummaryUpdate($activity, $summary);\n\n try {\n $type = $this->matchActivityEngagementType($activity);\n $engagement = ['type' => $type];\n\n $this->client->updateEngagement($activity->getCrmProviderId(), $engagement, $metadata);\n } catch (Exception $e) {\n $this->logger->warning('[HubSpot] Update summary failed', [\n 'activity' => $activity->getUuid(),\n 'reason' => $e->getMessage(),\n ]);\n\n return false;\n }\n\n $this->logCrmEngagementManipulation(\n self::ACTION_UPDATE,\n ['crmId' => $activity->getCrmProviderId()],\n $metadata,\n );\n\n return true;\n }\n\n private function buildMetadataForSummaryUpdate(Activity $activity, string $summary): array\n {\n $descriptionField = $activity->getType() === Activity::TYPE_CONFERENCE ? 'internalMeetingNotes' : 'body';\n $engagement = $this->client->getEngagementData($activity->getCrmProviderId());\n // Meeting without internalMeetingNotes might mean it just does not have any notes;\n $description = $engagement['metadata'][$descriptionField] ?? null;\n\n if (empty($description)) {\n $data = $summary;\n } else {\n // avoid playbook url link to Jiminny being sent twice in the activity description\n $targetUrl = PlaybackUrlBuilder::build($activity);\n\n if (str_contains($description, $targetUrl)) {\n $jiminnyUrl = '<p><a href=\"' . $targetUrl . '\" title=\"Play at Jiminny\">Play at Jiminny</a></p>';\n $summary = str_replace($jiminnyUrl, '', $summary);\n\n $this->logger->info('[HubSpot] Summary modified', [\n 'activity' => $activity->getUuid(),\n 'target_url' => $jiminnyUrl,\n 'modified_summary_content' => $summary,\n ]);\n }\n\n $data = $description . '<p></p>' . $summary;\n }\n\n return [\n $descriptionField => $data,\n ];\n }\n\n public function fetchAndAssociateRelatedActivity(Activity $activity): ?Activity\n {\n return $this->syncRelatedActivityManager->fetchAndAssociateRelatedActivity($activity);\n }\n\n public function fetchRelatedActivity(Activity $activity): array\n {\n return [];\n }\n\n public function getDealsInBulk(array $dealIds): array\n {\n $payload = $this->payloadBuilder->getDealsInBulkPayload($dealIds);\n\n return $this->client->getPaginatedData($payload, 'deals');\n }\n\n /**\n * Extract deal IDs from HubSpot search response.\n *\n * @param array $hubspotResponse The raw HubSpot search API response.\n * @param bool $includeArchived Whether to include archived deals (default: false).\n *\n * @return string[] Array of deal IDs as strings.\n */\n public function extractDealIds(array $hubspotResponse, bool $includeArchived = false): array\n {\n if (empty($hubspotResponse['results'])) {\n return [];\n }\n\n return array_values(\n array_map(\n fn ($deal) => $deal['id'],\n array_filter(\n $hubspotResponse['results'],\n fn ($deal) => $includeArchived || empty($deal['archived'])\n )\n )\n );\n }\n\n public function matchActivityEngagementType(Activity $activity): string\n {\n return match ($activity->getType()) {\n Activity::TYPE_CONFERENCE => self::TYPE_MEETING,\n Activity::TYPE_SOFTPHONE, Activity::TYPE_SOFTPHONE_INBOUND => self::TYPE_CALL,\n default => self::TYPE_NOTE,\n };\n }\n\n private function assignCrmOwner(User $user, ActivityContract $activity): ?Profile\n {\n $profile = $user->getProfile();\n if ($profile instanceof Profile) {\n return $profile;\n }\n\n $this->logger->info('[HubSpot] Unable to save summary. No profile', [\n 'activity' => $activity->getUuid(),\n ]);\n\n return null;\n }\n\n private static function getDealsPipelinesEndpoint(): string\n {\n return self::API_URL . self::ENDPOINT_PIPELINES . self::PIPELINE_OBJECT_TYPE_DEALS;\n }\n\n public function verifyTaskExists(Activity $activity): bool\n {\n $crmProviderId = $activity->getCrmProviderId();\n $cacheKey = \"crm_task_exists:{$this->config->getId()}:$crmProviderId\";\n\n return Cache::remember($cacheKey, self::TASK_VERIFICATION_CACHE_TTL, function () use ($crmProviderId) {\n try {\n $engagement = $this->client->getEngagementData($crmProviderId);\n\n return ! empty($engagement);\n } catch (HttpNotFoundException|BadRequest) {\n // Engagement not found in CRM - this is expected and permanent\n $this->logger->info('[Hubspot] Engagement not found during verification', [\n 'engagement_id' => $crmProviderId,\n 'config_id' => $this->config->getId(),\n ]);\n\n return false;\n }\n // Let other exceptions (network errors, rate limits, etc.) bubble up for retry\n });\n }\n}","depth":4,"on_screen":true,"value":"<?php\n\nnamespace Jiminny\\Services\\Crm\\Hubspot;\n\nuse Carbon\\Carbon;\nuse Exception;\nuse Generator;\nuse GuzzleHttp\\Exception\\RequestException;\nuse Illuminate\\Support\\Facades\\Cache;\nuse InvalidArgumentException;\nuse Jiminny\\Contracts\\Repositories\\TeamRepository;\nuse Jiminny\\Contracts\\Services\\Crm\\ClientInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\FetchRelatedActivityInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\LayoutManagementInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\MatchCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\Provider\\HubspotInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityLookupInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityManipulationInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SavePlaybackLinkToCrmInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SendSummaryToCrmInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SettingsInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmMetadataInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\VerifyTaskExistsInterface;\nuse Jiminny\\Exceptions\\CrmException;\nuse Jiminny\\Exceptions\\HttpNotFoundException;\nuse Jiminny\\Jobs\\Crm\\NoteObject;\nuse Jiminny\\Models\\Account;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Contact;\nuse Jiminny\\Models\\Contracts\\ActivityContract;\nuse Jiminny\\Models\\Crm\\BusinessProcess;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Models\\Crm\\FieldData;\nuse Jiminny\\Models\\Crm\\Layout;\nuse Jiminny\\Models\\Crm\\Profile;\nuse Jiminny\\Models\\Lead;\nuse Jiminny\\Models\\Opportunity;\nuse Jiminny\\Models\\Participant;\nuse Jiminny\\Models\\Playbook;\nuse Jiminny\\Models\\SocialAccount;\nuse Jiminny\\Models\\Stage;\nuse Jiminny\\Models\\User;\nuse Jiminny\\Repositories\\Crm\\CrmEntityRepository;\nuse Jiminny\\Repositories\\Crm\\FieldRepository;\nuse Jiminny\\Repositories\\Crm\\ProfileRepository;\nuse Jiminny\\Repositories\\ParticipantRepository;\nuse Jiminny\\Services\\Avatar\\ProspectPhotoPathService;\nuse Jiminny\\Services\\Crm\\BaseService;\nuse Jiminny\\Services\\Crm\\Hubspot\\Actions\\SyncArchivedProfilesAction;\nuse Jiminny\\Services\\Crm\\Hubspot\\Fields\\ValueNormalizer;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\OpportunitySyncTrait;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\SyncCrmEntitiesTrait;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\SyncFieldsTrait;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\WriteCrmTrait;\nuse Jiminny\\Services\\Crm\\MatchDomainByEmailInterface;\nuse Jiminny\\Services\\Crm\\OpportunitySyncStrategyResolver;\nuse Jiminny\\Services\\Crm\\ResolveCompanyNameByEmailTrait;\nuse Jiminny\\Utils\\PlaybackUrlBuilder;\nuse Sentry;\nuse SevenShores\\Hubspot\\Exceptions\\BadRequest;\nuse Throwable;\nuse UnexpectedValueException;\n\n/**\n * @phpstan-type CrmFieldDefinition array{\n * name: string,\n * label: string,\n * description: string,\n * type: string,\n * fieldType: string,\n * hidden: bool,\n * showCurrencySymbol: bool,\n * options: array<array{\n * id: string,\n * label: string,\n * value?: string,\n * }\n */\nclass Service extends BaseService implements\n HubspotInterface,\n SyncCrmEntitiesInterface,\n SyncCrmMetadataInterface,\n SendSummaryToCrmInterface,\n MatchDomainByEmailInterface,\n SavePlaybackLinkToCrmInterface,\n RemoteEntityManipulationInterface,\n FetchRelatedActivityInterface,\n LayoutManagementInterface,\n SettingsInterface,\n MatchCrmEntitiesInterface,\n RemoteEntityLookupInterface,\n VerifyTaskExistsInterface\n{\n use ResolveCompanyNameByEmailTrait;\n use SyncCrmEntitiesTrait;\n use WriteCrmTrait;\n use SyncFieldsTrait;\n use OpportunitySyncTrait;\n\n private const int ENGAGEMENT_BODY_MAX_LENGTH = 65536;\n\n private const string LOG_DATE_FORMAT = 'Y-m-d H:i:s';\n private const int BATCH_UPDATE_LIMIT = 100;\n private const string TEN_SECONDLY_ROLLING_POLICY = 'TEN_SECONDLY_ROLLING';\n private const int TEN_SECONDLY_ROLLING_LIMIT = 10;\n\n private const string TYPE_NOTE = 'NOTE';\n\n private const string TYPE_MEETING = 'MEETING';\n\n private const string TYPE_CALL = 'CALL';\n\n private const string API_URL = 'https://api.hubapi.com';\n\n // NB: v1 is legacy - v3 is the newest\n private const string ENDPOINT_PIPELINES = '/crm-pipelines/v1/pipelines/';\n private const string PIPELINE_OBJECT_TYPE_DEALS = 'deals';\n\n private const int TASK_VERIFICATION_CACHE_TTL = 86400; // 1 day\n\n /**\n * @var ClientInterface|Client\n */\n protected $client;\n protected OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;\n protected CrmEntityRepository $crmEntityRepository;\n protected ProspectPhotoPathService $prospectPhotoPathService;\n\n private SyncFieldAction $syncFieldAction;\n private PayloadBuilder $payloadBuilder;\n private SyncRelatedActivityManager $syncRelatedActivityManager;\n private SyncArchivedProfilesAction $syncArchivedProfilesAction;\n private WebhookSyncBatchProcessor $batchProcessor;\n\n public function __construct(\n Client $client,\n SyncFieldAction $syncFieldAction,\n PayloadBuilder $payloadBuilder,\n ProspectPhotoPathService $prospectPhotoPathService,\n SyncArchivedProfilesAction $syncArchivedProfilesAction,\n WebhookSyncBatchProcessor $batchProcessor,\n ) {\n parent::__construct();\n\n $this->client = $client;\n $this->syncFieldAction = $syncFieldAction;\n $this->prospectPhotoPathService = $prospectPhotoPathService;\n $this->payloadBuilder = $payloadBuilder;\n $this->syncArchivedProfilesAction = $syncArchivedProfilesAction;\n $this->batchProcessor = $batchProcessor;\n $this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [\n 'client' => $this->client,\n ]);\n $this->syncRelatedActivityManager = app(SyncRelatedActivityManager::class, [\n 'client' => $this->client,\n 'payloadBuilder' => $this->payloadBuilder,\n 'logger' => $this->logger,\n ]);\n $this->crmEntityRepository = app(CrmEntityRepository::class);\n $this->dealFieldsService = app(DealFieldsService::class);\n }\n\n public function getDisplayName(): string\n {\n return 'HubSpot';\n }\n\n protected function getOAuthAccount(User $user): ?SocialAccount\n {\n // In this case, the Account Owner is always the connection for any API operations.\n $owner = $user->team->owner;\n\n return $owner->getSocialAccount(SocialAccount::PROVIDER_HUBSPOT);\n }\n\n public function getClient(): Client\n {\n /** @var Client */\n return $this->client;\n }\n\n /**\n * Convert raw field data into a format compatible with CRM APIs.\n *\n * @param bool $internal Direction of the conversion.\n * True is pulling from CRM, false normalize before sending to CRM.\n */\n public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string\n {\n return ValueNormalizer::normalize(\n fieldType: $fieldType,\n fieldValue: $fieldValue,\n isInbound: $internal,\n );\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultFields(string $activityType): array\n {\n $fields = [];\n\n if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {\n $defaultFields = FieldDefinitions::defaultTaskFields();\n\n // This lazy creates these fields if not already setup.\n foreach ($defaultFields as $defaultField) {\n $fields[] = $this->config->fields()->firstOrCreate($defaultField);\n }\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultActivityField(string $activityType): Field\n {\n /** @var Field $activityField */\n $activityField = $this->config->fields()->where([\n 'crm_provider_id' => 'activityType',\n 'object_type' => $activityType,\n ])->first();\n\n return $activityField;\n }\n\n /**\n * @inheritdoc\n */\n public function getSupportedPlaybookTypes(): array\n {\n return [Playbook::ACTIVITY_TYPE_TASK];\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultActivityLayoutFields(string $activityType, string $layoutType): array\n {\n $fields = [];\n\n if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {\n // Outcome should always be provided calls/meetings.\n $fieldData = [\n [\n 'crm_provider_id' => $layoutType === Layout::TYPE_SOFTPHONE_SUMMARY ? 'disposition' : 'meetingOutcome',\n 'object_type' => Field::OBJECT_TASK,\n ],\n ];\n\n foreach ($fieldData as $data) {\n $field = $this->config->fields()->where($data)->first();\n\n // Only add the field if it is created, which it should be.\n if ($field) {\n $fields[] = $field;\n }\n }\n }\n\n return $fields;\n }\n\n public function getDealInsightsFields(): array\n {\n return FieldDefinitions::dealInsightsFields();\n }\n\n protected function getDefaultFollowupLayoutFields(string $activityType): array\n {\n $fields = [];\n $fieldRepo = app(FieldRepository::class);\n $fieldData = FieldDefinitions::followupFieldsFilter();\n\n foreach ($fieldData as $data) {\n $field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $data);\n\n // Only add the field if it is created, which it should be.\n if ($field) {\n $fields[] = $field;\n }\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function syncField(Field $field): void\n {\n switch ($field->object_type) {\n case Field::OBJECT_ACCOUNT:\n $crmField = $this->client->getInstance()->companyProperties()->get($field->crm_provider_id);\n\n break;\n case Field::OBJECT_CONTACT:\n $crmField = $this->client->getInstance()->contactProperties()->get($field->crm_provider_id);\n\n break;\n case Field::OBJECT_OPPORTUNITY:\n $crmField = $this->client->getInstance()->dealProperties()->get($field->crm_provider_id);\n\n break;\n case Field::OBJECT_TASK:\n $this->syncSingleTaskField($field);\n\n return;\n default:\n return;\n }\n\n $this->syncFieldAction->execute($field, $crmField->toArray());\n }\n\n /**\n * @param array<array{\n * id:string,\n * label:string,\n * value?:string\n * }> $options\n *\n * @throws CrmException\n *\n * @return FieldData[]\n *\n */\n public function importPicklistValues(\n Field $field,\n array $options = [['id' => '', 'label' => '', 'value' => '']],\n ): array {\n if (! empty($options[0]['id']) || ! empty($options[0]['value'])) {\n // We already have the options, no need to fetch them again\n return $this->importOptions($field, $options);\n }\n\n $options = [];\n\n switch ($field->getObjectType()) {\n case Field::OBJECT_ACCOUNT:\n $options = $this->getClient()->fetchPropertyOptions('company', $field->getCrmProviderId());\n\n break;\n\n case Field::OBJECT_CONTACT:\n $options = $this->getClient()->fetchPropertyOptions('contact', $field->getCrmProviderId());\n\n break;\n\n case Field::OBJECT_OPPORTUNITY:\n // Hubspot has different endpoint for stages\n $options = $this->getClient()->fetchOpportunityFieldOptions($field);\n\n break;\n\n case Field::OBJECT_TASK:\n if ($field->getCrmProviderId() === 'disposition') {\n $options = $this->getClient()->fetchDispositionFieldOptions();\n } elseif (in_array($field->getCrmProviderId(), ['meetingOutcome', 'activityType'])) {\n $options = $this->getClient()->fetchMeetingOutcomeFieldOptions($field);\n }\n\n break;\n\n default:\n $this->logger->warning('Invalid object type', [\n 'object_type' => $field->getObjectType(),\n 'field_id' => $field->getId(),\n ]);\n\n throw new CrmException('Invalid object type');\n }\n\n return $this->importOptions($field, $options);\n }\n\n /**\n * @inheritdoc\n */\n public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage\n {\n $missingStage = null;\n\n try {\n // Use the HubSpot API client instead of the SDK crmPipelines() method\n $endpoint = self::getDealsPipelinesEndpoint();\n $pipelinesResponse = $this->client->getInstance()->getClient()->request('GET', $endpoint);\n $pipelines = $pipelinesResponse->data->results;\n } catch (RequestException|BadRequest $exception) {\n throw $exception;\n }\n\n foreach ($pipelines as $pipeline) {\n $stages = [];\n\n // We create a business process to contain the pipeline, and store all stages against it.\n $p = ResponseNormalize::normalizePipeline($pipeline);\n\n // Create/update business process for this pipeline\n $businessProcess = $this->config->businessProcesses()->updateOrCreate([\n 'crm_provider_id' => $p['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($p['label'], 0, 150),\n 'type' => BusinessProcess::TYPE_OPPORTUNITY,\n 'is_selectable' => $p['active'],\n ]);\n\n // A record type is really a clone of the business process, used to store which record uses which pipeline.\n // Create/update record type clone\n $this->config->recordTypes()->updateOrCreate([\n 'crm_provider_id' => $p['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($p['label'], 0, 150),\n 'is_selectable' => $p['active'],\n 'business_process_id' => $businessProcess->id ?? null,\n ]);\n\n // Stages - fetch all existing stages upfront to avoid N+1 queries\n $existingStages = $this->config->stages()\n ->withTrashed()\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->get()\n ->keyBy('crm_provider_id');\n\n foreach ($p['stages'] as $dealStage) {\n $s = ResponseNormalize::normalizeDealStage($dealStage);\n\n /** @var ?Stage $existingStage */\n $existingStage = $existingStages->get($s['id']);\n\n // Restore soft-deleted stages that are now active in HubSpot\n if ($existingStage?->trashed() && $s['active']) {\n $existingStage->restore();\n }\n\n // Upsert stage (updates soft-deleted records without restoring them)\n $stage = $this->config->stages()->withTrashed()->updateOrCreate([\n 'crm_provider_id' => $s['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($s['label'], 0, 50),\n 'label' => mb_strimwidth($s['label'], 0, 191),\n 'type' => Stage::TYPE_OPPORTUNITY,\n 'sequence' => $s['displayOrder'],\n 'is_selectable' => $s['active'],\n 'probability' => $s['probability'] * 100,\n ]);\n\n if ($missingStageName === $s['id']) {\n $missingStage = $stage;\n }\n\n $stages[] = $stage->id;\n }\n\n $businessProcess->stages()->sync($stages);\n }\n\n return $missingStage;\n }\n\n /**\n * @inheritdoc\n */\n public function syncOrganization(): void\n {\n try {\n $endpoint = 'https://api.hubapi.com/integrations/v1/me';\n $response = $this->client->getInstance()->getClient()->request('get', $endpoint);\n\n $accountData = $response->data;\n $this->config->update(['default_currency' => $accountData->currency]);\n } catch (BadRequest $e) {\n throw new CrmException('Could not sync the organization.', $e->getCode(), $e);\n }\n }\n\n /**\n * @inheritdoc\n *\n * @throws CrmException\n */\n public function syncProfiles(?User $userToSearch = null): ?Profile\n {\n $this->syncArchivedProfilesAction->execute($this->team, $this->client, $this->config);\n\n try {\n $owners = $this->client->getOwners();\n } catch (\\HubSpot\\Client\\Crm\\Owners\\ApiException $e) {\n $this->logger->error('[HubSpot] Could not sync the profiles.', [\n 'team_id' => $this->team->getId(),\n 'reason' => $e->getMessage(),\n ]);\n\n throw new CrmException('Could not sync the profiles.', $e->getCode(), $e);\n }\n\n $profileRepository = app(ProfileRepository::class);\n $teamRepository = app(TeamRepository::class);\n\n foreach ($owners as $owner) {\n if ($owner->getArchived()) {\n // not supposed to fetch archived, but log anyway\n $this->logger->warning('[HubSpot] Found archived owner', [\n 'crm_provider_id' => $owner->getId(),\n 'email' => $owner->getEmail(),\n ]);\n\n continue;\n }\n\n $email = $owner->getEmail();\n if ($email === null) {\n continue;\n }\n\n $user = $teamRepository->findActiveTeamMemberByEmail($this->team, $email);\n\n if (! $user instanceof User) {\n continue;\n }\n\n $profile = $profileRepository->updateOrCreateProfile($user, [\n 'crm_configuration_id' => $this->config->getId(),\n 'crm_provider_id' => $owner->getId(),\n ]);\n\n if ($userToSearch && $userToSearch->getId() === $user->getId()) {\n return $profile;\n }\n }\n\n return null;\n }\n\n private function generateNameSearchPayload(string $name, int $offset, int $limit): array\n {\n $payload = [\n 'query' => $name,\n 'sorts' => [\n [\n 'propertyName' => 'modifieddate',\n 'direction' => 'DESCENDING',\n ],\n ],\n 'properties' => [\n 'email',\n 'firstname',\n 'lastname',\n 'country',\n 'phone',\n 'mobilephone',\n 'jobtitle',\n 'hubspot_owner_id',\n 'associatedcompanyid',\n 'photo',\n 'industry',\n 'name',\n 'company',\n ],\n 'limit' => $limit,\n 'after' => $offset,\n ];\n\n $this->logger->debug('[HubSpot] CRM Search requested', [\n 'request' => $payload,\n ]);\n\n return $payload;\n }\n\n /**\n * @inheritdoc\n */\n public function find(string $name, array $scopes): array\n {\n $count = $this->limit ?? 20;\n $offset = $this->offset ?? 0;\n\n /** @var array<int, array<string, mixed>> */\n return Cache::remember(\n key: $this->team->getId() . $name . $count . $offset,\n ttl: 300,\n callback: function () use ($name, $offset, $count): array {\n $data = [];\n\n // Use the new V3 API to find contacts based on additional fields.\n foreach (['companies', 'contacts'] as $objectType) {\n $payload = $this->generateNameSearchPayload($name, $offset, $count);\n $type = $objectType === 'companies' ? 'account' : 'contact';\n\n try {\n $response = $this->client->search($objectType, $payload);\n\n // Build mapped list.\n foreach ($response['results'] as $object) {\n $properties = $object['properties'];\n\n $objectName = $this->buildContactName($properties);\n\n $record = [\n 'crmId' => $object['id'],\n // Pass crmUrl to the FE, needed for success message in the extension when you log activity.\n 'crmUrl' => $this->generateProviderUrl($object['id'], $type),\n 'name' => $objectName,\n 'prospectType' => $type,\n 'phoneNumbers' => [],\n ];\n\n if ($type === 'account') {\n $record['industry'] = $properties['industry'] ?? null;\n } else {\n $record['title'] = $properties['jobtitle'] ?? null;\n $record['organization'] = $properties['company'] ?? null;\n }\n\n $countryCode = $this->buildContactCountry($properties);\n $parsedNumber = $this->buildContactPhone($countryCode, $properties);\n\n // Add phone number to record.\n if (! empty($parsedNumber['phone'])) {\n $record['phoneNumbers'][] = [\n 'number' => $parsedNumber['phone'],\n 'nationalFormat' => phone_national($countryCode, $parsedNumber['phone']),\n 'type' => 'phone',\n ];\n }\n\n // Add mobile phone number to record.\n if (! empty($properties['mobilephone'])) {\n $mobileNumber = phone_e164($countryCode, $properties['mobilephone']);\n if ($mobileNumber !== null) {\n $record['phoneNumbers'][] = [\n 'number' => $mobileNumber,\n 'nationalFormat' => phone_national($countryCode, $mobileNumber),\n 'type' => 'mobile',\n ];\n }\n }\n\n $data[] = $record;\n }\n } catch (BadRequest $e) {\n $this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [\n 'teamId' => $this->team->getUuid(),\n 'request' => $payload,\n 'reason' => $e->getMessage(),\n ]);\n\n throw $e;\n }\n }\n\n return $data;\n },\n );\n }\n\n\n /**\n * @inheritdoc\n */\n public function findOpportunities(?string $crmAccountId, ?string $crmContactId, ?int $userId = null): array\n {\n $data = [];\n $ownerData = [];\n $ownerId = null;\n\n if ($crmAccountId === null) {\n return $data;\n }\n\n if ($userId) {\n $profileRepository = app(ProfileRepository::class);\n $profile = $profileRepository->findProfileByUserId($this->config, $userId);\n\n $ownerId = $profile instanceof Profile ? $profile->getCrmProviderId() : null;\n }\n\n $closedStages = $this->getClosedDealStages();\n $payload = $this->payloadBuilder->generateOpportunitiesSearchPayload(\n $this->config,\n $crmAccountId,\n $closedStages,\n );\n\n $results = $this->client->getPaginatedData($payload, 'deals');\n\n foreach ($results['results'] as $object) {\n $properties = $object['properties'];\n\n $amount = null;\n if (empty($properties['amount']) === false) {\n $currency = $properties['deal_currency_code'] ?? $this->config->default_currency;\n\n // Values can contain commas and any junk so strip them.\n $value = (float) preg_replace('/[^\\d.]/', '', $properties['amount']);\n $amount = formatCurrency($value, $currency);\n }\n\n $businessProcess = $this->config\n ->businessProcesses()\n ->where('crm_provider_id', $properties['pipeline'])\n ->first();\n\n if ($businessProcess === null) {\n // Import it.\n $stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $properties['dealstage']);\n $businessProcess = $this->config\n ->businessProcesses()\n ->where('crm_provider_id', $properties['pipeline'])\n ->first();\n } else {\n $stage = $businessProcess\n ->stages()\n ->where('crm_provider_id', $properties['dealstage'])\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->first();\n\n if ($stage === null) {\n // Import it.\n $stage = $this->importStages(null, $properties['dealstage']);\n }\n }\n\n $recordType = null;\n if ($businessProcess) {\n $recordType = $businessProcess->recordTypes()->first();\n }\n\n $isWon = in_array($properties['dealstage'], $closedStages['won']);\n $isLost = in_array($properties['dealstage'], $closedStages['lost']);\n\n $record = [\n 'crmId' => $object['id'],\n 'name' => $properties['dealname'] ?? 'Unknown Deal',\n 'value' => $amount,\n 'won' => $isWon,\n 'closed' => $isWon || $isLost,\n 'stage' => [\n 'id' => $stage?->getUuid() ?? '',\n 'name' => $stage?->getName() ?? '',\n ],\n ];\n\n if ($recordType) {\n $record += [\n 'recordType' => [\n 'id' => $recordType->id_string,\n 'name' => $recordType->name,\n ],\n ];\n }\n\n if ($ownerId && isset($properties['hubspot_owner_id']) && $properties['hubspot_owner_id'] === $ownerId) {\n $ownerData[] = $record;\n }\n\n $data[] = $record;\n }\n\n if (! empty($ownerData)) {\n return $ownerData;\n }\n\n return $data;\n }\n\n /**\n * @inheritdoc\n */\n public function getTasks(?string $objectType, string $objectId, ?string $opportunityId): array\n {\n $data = [];\n switch ($objectType) {\n case 'contact':\n $hsObject = 'contact';\n\n break;\n case 'account':\n $hsObject = 'company';\n\n break;\n default:\n // This is a hack to prioritise and override a contact/company with a deal.\n if ($opportunityId) {\n $hsObject = 'deal';\n $objectId = $opportunityId;\n } else {\n throw new InvalidArgumentException('Object type not supported.');\n }\n }\n\n $engagementTypes = ['meetings', 'tasks'];\n\n foreach ($engagementTypes as $engagementType) {\n $payload = $this->payloadBuilder->getLinkToTaskPayload($hsObject, $objectId, $engagementType);\n\n $this->logger->info('[HubSpot] CRM Search requested', [\n 'request' => $payload,\n ]);\n\n $engagements = $this->client->getPaginatedData($payload, $engagementType);\n\n foreach ($engagements['results'] as $engagement) {\n if ($engagementType == 'meetings') {\n $title = $engagement['properties']['hs_meeting_title'] ?? 'Scheduled meeting';\n } elseif ($engagementType == 'tasks') {\n $title = $engagement['properties']['hs_task_subject'];\n } else {\n $title = 'Scheduled meeting';\n }\n\n $data[] = [\n 'crmId' => $engagement['id'],\n 'subject' => $title,\n 'due' => $engagement['properties']['hs_timestamp'],\n 'type' => $engagement['properties']['hs_activity_type'] ?? null,\n ];\n }\n }\n\n usort($data, function ($item1, $item2) {\n return $item2['due'] <=> $item1['due'];\n });\n\n return $data;\n }\n\n /**\n * Try to find CRM Objects using email address\n *\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n public function matchExactlyByEmail(string $email, ?int $userId = null): ?array\n {\n $contactProperties = [\n 'email',\n 'firstname',\n 'lastname',\n 'country',\n 'phone',\n 'mobilephone',\n 'jobtitle',\n 'hubspot_owner_id',\n 'associatedcompanyid',\n 'photo',\n ];\n $contact = null;\n $account = null;\n\n try {\n $hsContact = $this->getClient()->getContactByEmail($email, $contactProperties);\n\n if ($hsContact) {\n $contact = $this->importContact($hsContact);\n $account = $contact->account;\n }\n\n $data = $this->convertCrmData($contact, $account, $userId);\n\n return ! empty(array_filter($data)) ? $data : null;\n } catch (BadRequest $e) {\n $this->logger->warning('[HubSpot] Search failed', [\n 'team_id' => $this->team->getId(),\n 'search_identifier' => $email,\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return null;\n }\n\n public function getDomain(string $email): ?string\n {\n return $this->getDomainFromEmail($email);\n }\n\n /**\n * Try to find CRM objects using domain name of the email address\n *\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n public function matchByDomain(string $domain, ?int $userId = null): ?array\n {\n $companyName = $domain;\n\n // Try to find a company matching their email domain.\n $companyProperties = [\n 'country',\n 'phone',\n 'name',\n 'hs_avatar_filemanager_key',\n 'industry',\n 'hubspot_owner_id',\n 'domain',\n ];\n\n try {\n $hsAccounts = $this->client\n ->getInstance()\n ->companies()\n ->searchByDomain($companyName, $companyProperties);\n } catch (Throwable $e) {\n $this->logger->info('[HubSpot] Search failed', [\n 'error' => $e->getMessage(),\n 'domain' => $domain,\n ]);\n\n return null;\n }\n\n $account = null;\n // If there are multiple accounts, don't guess, we'll ask later.\n if (\\count($hsAccounts->data->results) === 1) {\n // Persist this remote object.\n $account = $this->syncAccount($hsAccounts->data->results[0]->companyId);\n }\n\n $data = $this->convertCrmData(null, $account, $userId);\n\n return ! empty(array_filter($data)) ? $data : null;\n }\n\n /**\n * @return array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n protected function convertCrmData(?Contact $contact, ?Account $account, ?int $userId = null): array\n {\n $countryCode = null;\n if ($contact && $contact->country_code) {\n $countryCode = $contact->country_code;\n } elseif ($account && $account->country_code) {\n $countryCode = $account->country_code;\n }\n\n try {\n $hsOpportunities = $this->findOpportunities(\n $account ? $account->crm_provider_id : null,\n $contact ? $contact->crm_provider_id : null,\n $userId\n );\n } catch (Exception $e) {\n $hsOpportunities = [];\n }\n\n // If there are multiple opportunities, don't guess, we'll ask later.\n $opportunity = null;\n $stage = null;\n if (! empty($hsOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n }\n\n protected function getCacheKey(string $object, ?int $userId = null): ?string\n {\n $key = $this->team->getId() . $object;\n $keySuffix = $this->getOwnerKeySuffix($userId);\n\n return $key . $keySuffix;\n }\n\n private function getOwnerKeySuffix(?int $userId = null): string\n {\n return $userId === null ? '' : (string) $userId;\n }\n\n /**\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n *}\n */\n public function matchByPhone(string $phone, ?string $rawPhoneNumber = null, ?int $userId = null): ?array\n {\n if (str_contains($phone, '**')) {\n return null;\n }\n\n // trim all whitespaces if present so the lookup doesn't fail\n $phone = str_replace(' ', '', $phone);\n\n // Check if the user is internal.\n if ($this->isPhoneNumberOfTeamMember($phone)) {\n return null;\n }\n\n $response = $this->searchForPhoneNumber($phone);\n if (empty($response)) {\n return null;\n }\n\n // This would ideally importContact instead but the response type differs.\n $contact = $this->findAndSyncContact($response['results'][0]['id']);\n if (! $contact instanceof Contact) {\n return null;\n }\n\n $account = $contact->account;\n $countryCode = $contact->country_code ?? $account->country_code ?? null;\n\n try {\n $hsOpportunities = $this->findOpportunities(\n $account?->crm_provider_id,\n $contact->crm_provider_id,\n $userId\n );\n } catch (Exception $e) {\n $hsOpportunities = [];\n }\n\n $opportunity = null;\n $stage = null;\n\n try {\n if (! empty($hsOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n } catch (Exception $e) {\n $this->logger->debug('[HubSpot] Opportunity failed to sync.', [\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n }\n\n private function isPhoneNumberOfTeamMember(string $phone): bool\n {\n $teamRepository = app(TeamRepository::class);\n $user = $teamRepository->findTeamMemberByPhone($this->team, $phone);\n\n if ($user instanceof User) {\n return true;\n }\n\n return false;\n }\n\n private function findAndSyncContact(string $crmId): ?Contact\n {\n try {\n return $this->syncContact($crmId);\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Phone match failed', [\n 'reason' => $exception->getMessage(),\n ]);\n\n return null;\n }\n }\n\n private function hasResults(array $response): bool\n {\n return isset($response['total']) && is_numeric($response['total']) && $response['total'] > 0;\n }\n\n private function searchForPhoneNumber(string $phone): array\n {\n // Normalizes the provided phone number for the API search.\n $normalizedPhone = $this->normalizePhoneNumber($phone);\n $payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone);\n\n $this->logger->info('[HubSpot] Phone match search triggered', [\n 'phone' => $phone,\n 'normalizedPhone' => $normalizedPhone,\n 'payload' => $payload,\n ]);\n\n $response = $this->handlePhoneSearchRequest($normalizedPhone, $payload);\n\n if (! $this->hasResults($response)) {\n $nationalPhone = preg_replace('/\\D/', '', phone_national(null, $phone));\n $payload = $this->payloadBuilder->generatePhoneSearchPayload($nationalPhone);\n\n $this->logger->info('[HubSpot] Phone match national number search triggered', [\n 'phone' => $phone,\n 'nationalPhone' => $nationalPhone,\n 'payload' => $payload,\n ]);\n\n $response = $this->handlePhoneSearchRequest($phone, $payload);\n }\n\n if (! $this->hasResults($response)) {\n $payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone, true);\n\n $this->logger->info('[HubSpot] Phone match alternative search triggered', [\n 'phone' => $phone,\n 'normalizedPhone' => $normalizedPhone,\n 'payload' => $payload,\n ]);\n\n $response = $this->handlePhoneSearchRequest($phone, $payload);\n }\n\n return $this->hasResults($response) ? $response : [];\n }\n\n private function handlePhoneSearchRequest(string $phone, array $payload): array\n {\n try {\n return $this->client->search('contacts', $payload);\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Phone match failed', [\n 'phone' => $phone,\n 'reason' => $exception->getMessage(),\n ]);\n\n return [];\n }\n }\n\n private function normalizePhoneNumber(string $phone): string\n {\n return ltrim(phone_e164(null, $phone), '+0');\n }\n\n /**\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n public function matchByName(string $name, ?int $userId = null): ?array\n {\n // Don't waste time searching for single character strings.\n if (\\strlen($name) <= 1) {\n return null;\n }\n\n $cacheKey = $this->getCacheKey($name, $userId);\n\n $result = Cache::remember($cacheKey, 60, function () use ($name, $userId) {\n $payload = $this->payloadBuilder->generateSearchContactsByNamePayload(\n $name,\n $this->getContactFields()\n );\n\n $hsContacts = $this->client->getPaginatedData($payload, 'contact');\n if (empty($hsContacts['results'])) {\n return false;\n }\n\n $contact = $this->importContact($hsContacts['results'][0]);\n if ($contact === null) {\n return false;\n }\n\n $account = $contact->account;\n $countryCode = $contact->country_code ?? $account->country_code ?? null;\n\n try {\n $hsOpportunities = $this->findOpportunities(\n $account ? $account->crm_provider_id : null,\n $contact->crm_provider_id,\n $userId\n );\n } catch (Exception $e) {\n $hsOpportunities = [];\n }\n\n $opportunity = null;\n $stage = null;\n if (! empty($hsOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n });\n\n return is_array($result) ? $result : null;\n }\n\n\n private function convertActivityAssociations(Activity $activity): array\n {\n return [\n 'contactIds' => $this->getParticipantsIds($activity),\n 'companyIds' => $activity->hasAccount() ? [$activity->account->crm_provider_id] : [],\n 'dealIds' => $activity->hasOpportunity() ? [$activity->opportunity->crm_provider_id] : [],\n 'ownerIds' => [],\n ];\n }\n\n private function getParticipantsIds(Activity $activity): array\n {\n $attendees = [];\n\n $participantRepository = app(ParticipantRepository::class);\n $participants = $participantRepository->getParticipantsWhoEnteredMeeting($activity);\n foreach ($participants as $participant) {\n if ($participant->user_id || $participant->isCoach()) {\n continue;\n }\n\n $contact = $participant->contact()->first();\n if ($contact && $contact->crm_provider_id) {\n $attendees[] = $contact->crm_provider_id;\n } else {\n if (! empty($participant->name)) {\n $attendeeData = $this->fetchMissingAttendeeInfo($participant);\n }\n if (! empty($attendeeData['id'])) {\n $attendees[] = $attendeeData['id'];\n }\n }\n }\n\n if ($activity->hasContact()) {\n $attendees[] = $activity->contact->crm_provider_id;\n }\n\n return array_unique($attendees);\n }\n\n private function fetchMissingAttendeeInfo(Participant $participant): array\n {\n // Check if we need to look inside an account context.\n $activity = $participant->getActivity();\n $companyId = $activity->hasAccount() ? $activity->getAccount()->crm_provider_id : null;\n\n // First check the local data.\n /** @var Contact[] $contacts */\n $contacts = $this->team->contacts()\n ->with('account')\n ->where('name', $participant->name)\n ->whereNotNull('email')\n ->get();\n\n foreach ($contacts as $contact) {\n // If we have a company in scope, check the contact is associated to it.\n if (\n $companyId !== null\n && ($contact->account_id === null || $companyId !== $contact->account->crm_provider_id)\n ) {\n continue;\n }\n\n return [\n 'id' => $contact->crm_provider_id,\n 'email' => $contact->email,\n ];\n }\n\n $payload = $this->generateNameSearchPayload($participant->name, 0, 20);\n\n try {\n $response = $this->client->getNewInstance()->crm()->contacts()->searchApi()->doSearch($payload);\n\n // TODO add some logic to choose the most suitable contact if multiple\n foreach ($response['results'] as $object) {\n $properties = $object['properties'];\n if (empty($object['properties']) === false) {\n // Check the company matches the contact.\n // Todo: Move this check inside the API search.\n if ($companyId !== null && $companyId !== $properties['associatedcompanyid']) {\n continue;\n }\n\n return [\n 'id' => $object['id'],\n 'email' => $properties['email'],\n ];\n }\n }\n } catch (Exception $e) {\n $this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [\n 'teamId' => $this->team->id_string,\n 'request' => $payload,\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return [];\n }\n\n /**\n * Store transcripts as note engagement.\n *\n * @throws Exception\n */\n public function createTranscriptNotes(Activity $activity): void\n {\n // For HS no need to check if Crm profile - Log Notes field is enabled\n // We only check if store_transcript toggle is enabled on crm profile.\n $engagement = [\n 'active' => true,\n 'ownerId' => $this->profile->crm_provider_id,\n 'timestamp' => $activity->created_at->tz($activity->user->timezone)->getTimestamp() * 1000,\n 'type' => 'NOTE',\n ];\n\n // Generate activity transcription.\n $transcriptionData = $this->generateTranscription($activity);\n\n // Truncate Notes with max notes length because transcription text could be very long.\n $transcripts = mb_strimwidth($transcriptionData, 0, static::ENGAGEMENT_BODY_MAX_LENGTH);\n\n $metadata = [\n 'body' => $transcripts,\n ];\n\n $associations = $this->convertActivityAssociations($activity);\n\n try {\n $hsEngagement = $this->client\n ->getInstance()\n ->engagements()\n ->create($engagement, $associations, $metadata);\n\n $this->logCrmEngagementManipulation(self::ACTION_CREATE, $engagement, $metadata, $associations);\n\n $noteId = $hsEngagement->data->engagement->id;\n\n // Store crm logged id in transcription.\n $transcription = $activity->getTranscription();\n $transcription->crm_activity_id = $noteId;\n $transcription->save();\n } catch (Exception $e) {\n Sentry::captureException($e);\n }\n }\n\n /*\n * @inheritdoc\n */\n public function updateRecord(string $objectType, string $objectId, array $data, array $headers = []): void\n {\n $payload = [\n 'properties' => $data,\n ];\n\n try {\n switch ($objectType) {\n case FieldData::OBJECT_OPPORTUNITY:\n $this->client->getNewInstance()->crm()->deals()->basicApi()->update($objectId, $payload);\n\n break;\n case FieldData::OBJECT_CONTACT:\n $this->client->getNewInstance()->crm()->contacts()->basicApi()->update($objectId, $payload);\n\n break;\n\n case FieldData::OBJECT_ACCOUNT:\n $this->client->getNewInstance()->crm()->companies()->basicApi()->update($objectId, $payload);\n\n break;\n\n case FieldData::OBJECT_TASK:\n // Endpoint for Engagements not ready\n $engagements = [\n 'type' => 'TASK',\n ];\n $metadata = $data;\n $this->client->getInstance()->engagements()->update($objectId, $engagements, $metadata);\n\n $this->logCrmEngagementManipulation(\n self::ACTION_UPDATE,\n ['crmId' => $objectId],\n $metadata,\n );\n\n break;\n\n default:\n throw new UnexpectedValueException('Unsupported object type \"' . $objectType . '\"');\n }\n } catch (\\HubSpot\\Client\\Crm\\Deals\\ApiException $apiException) {\n $errorMessage = $apiException->getMessage();\n if ($apiException->getResponseBody()) {\n $responseBody = json_decode($apiException->getResponseBody(), true, 512, JSON_THROW_ON_ERROR);\n $errorMessage = $responseBody['message'] ?? $apiException->getMessage();\n }\n\n $this->logger->error(\n '[HubSpot] Update record failed',\n [\n 'objectType' => $objectType,\n 'objectId' => $objectId,\n 'payload' => $payload,\n 'reason' => $errorMessage,\n 'team' => $this->team->getUuid(),\n ]\n );\n\n throw new CrmException($errorMessage);\n }\n }\n\n /*\n * @inheritdoc\n */\n public function getRecord(string $objectType, string $objectId, array $fields = []): array\n {\n switch ($objectType) {\n case FieldData::OBJECT_OPPORTUNITY:\n return $this->client->getInstance()->deals()->getById($objectId)->toArray();\n\n case FieldData::OBJECT_CONTACT:\n return $this->client->getInstance()->contacts()->getById($objectId)->toArray();\n\n case FieldData::OBJECT_ACCOUNT:\n return $this->client->getInstance()->companies()->getById($objectId)->toArray();\n\n case FieldData::OBJECT_TASK:\n return $this->client->getInstance()->engagements()->get($objectId)->toArray();\n\n default:\n throw new UnexpectedValueException('Unsupported object type \"' . $objectType . '\"');\n }\n }\n\n /**\n * @throws BadRequest\n * @throws CrmException\n */\n public function updateStage($crmObject, Stage $stage): void\n {\n $payload = [\n 'properties' => [\n [\n 'name' => 'dealstage',\n 'value' => $stage->crm_provider_id,\n ],\n ],\n ];\n\n try {\n $this->client->getInstance()->deals()->update($crmObject->crm_provider_id, $payload);\n } catch (BadRequest $badRequest) {\n if ($badRequest->getCode() === 403) {\n throw new CrmException(\n \"Sorry, you don't have permission to update this stage.\",\n $badRequest->getCode(),\n $badRequest,\n );\n }\n\n $this->logger->warning('[HubSpot] Stage update failed', [\n 'dealId' => $crmObject->crm_provider_id,\n 'payload' => $payload,\n 'message' => $badRequest->getMessage(),\n ]);\n\n throw $badRequest;\n }\n }\n\n public function generateProviderUrl(string $providerId, string $objectType): ?string\n {\n $url = null;\n $baseUrl = 'https://app.hubspot.com/contacts/' . $this->config->crm_provider_id . '/';\n\n switch ($objectType) {\n case 'account':\n $url = $baseUrl . 'company/' . $providerId;\n\n break;\n\n case 'contact':\n $url = $baseUrl . 'contact/' . $providerId;\n\n break;\n\n case 'opportunity':\n $url = $baseUrl . 'deal/' . $providerId;\n\n break;\n\n case 'task':\n case 'activity':\n return null;\n\n // This should not be deep-linked as per JMNY-3934.\n //$url = $baseUrl.'tasks/list/view/all/?taskId='.$providerId;\n break;\n }\n\n return $url;\n }\n\n public function searchCalls(Carbon $from, Carbon $to, string $activityProvider): array\n {\n $this->logger->info('[HubSpot] Search calls', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n ]);\n\n $calls = [];\n $page = 1;\n\n do {\n try {\n $payload = $this->payloadBuilder->generateGetCallsPayload($from, $to, $activityProvider, $page);\n $response = $this->client->search('calls', $payload);\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Search calls failed', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n 'reason' => $exception->getMessage(),\n ]);\n $response = null;\n }\n\n $responseResults = empty($response['results']) ? [] : $response['results'];\n $calls = array_merge($calls, $responseResults);\n $page++;\n } while (! empty($responseResults));\n\n return $calls;\n }\n\n public function searchCallsForPeriodByPage(Carbon $from, Carbon $to, int $page, bool $retry = true)\n {\n try {\n $payload = $this->payloadBuilder->generateSearchCallsByPeriodPayload($from, $to, $page);\n\n return $this->client->search('calls', $payload);\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Search calls for period failed', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n 'reason' => $exception->getMessage(),\n 'retry' => $retry,\n ]);\n\n if ($retry) {\n sleep(1);\n\n return $this->searchCallsForPeriodByPage($from, $to, $page, false);\n }\n\n return null;\n }\n }\n\n public function searchCallsForPeriod(Carbon $from, Carbon $to): Generator\n {\n $this->logger->info('[HubSpot] Search calls for period', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n ]);\n\n $page = 1;\n\n do {\n $response = $this->searchCallsForPeriodByPage($from, $to, $page);\n\n $responseResults = empty($response['results']) ? [] : $response['results'];\n\n $associationContacts = $this->getAssociationDataForCollection($responseResults, 'calls', 'contacts');\n $associationCompanies = $this->getAssociationDataForCollection($responseResults, 'calls', 'companies');\n $associationDeals = $this->getAssociationDataForCollection($responseResults, 'calls', 'deals');\n\n foreach ($responseResults as $call) {\n $call['associations'] = [\n 'contacts' => $this->importAssociationData($call, $associationContacts),\n 'companies' => $this->importAssociationData($call, $associationCompanies),\n 'deals' => $this->importAssociationData($call, $associationDeals),\n ];\n\n yield $call;\n }\n $page++;\n } while (! empty($responseResults));\n }\n\n public function getCall(string $callId): array\n {\n $this->logger->info('[HubSpot] Get call', [\n 'call_id' => $callId,\n ]);\n\n $searchAttributes = $this->payloadBuilder->getSearchCallAttributes();\n $endpoint = sprintf(\n 'https://api.hubapi.com/crm/v3/objects/calls/%s',\n $callId,\n );\n\n try {\n $response = $this->client->getInstance()->getClient()->request(\n 'GET',\n $endpoint,\n [],\n sprintf(\n 'properties=%s&associations=contacts,companies,deals',\n implode(',', $searchAttributes),\n ),\n );\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Get call failed', [\n 'call_id' => $callId,\n 'reason' => $exception->getMessage(),\n ]);\n $response = null;\n }\n\n return empty($response) ? [] : $response->toArray();\n }\n\n public function bulkAddPlaybackURLToDescription(array $crmUpdateData): array\n {\n $crmUpdateBatches = array_chunk($crmUpdateData, self::BATCH_UPDATE_LIMIT);\n\n $updatedCrmIds = [];\n\n foreach ($crmUpdateBatches as $crmBatch) {\n $payload = $this->payloadBuilder->generatePlaybackAddUrlBatchPayload($crmBatch);\n $updateSuccess = $this->bulkAddPlaybackURLToDescriptionRequest($payload);\n if ($updateSuccess) {\n $updatedCrmIds = array_merge($updatedCrmIds, array_column($crmBatch, 'crm_id'));\n }\n }\n\n return $updatedCrmIds;\n }\n\n private function bulkAddPlaybackURLToDescriptionRequest(array $payload, bool $retry = true): bool\n {\n try {\n $this->client->getNewInstance()->crm()->objects()->batchApi()->update('calls', $payload);\n\n return true;\n } catch (\\HubSpot\\Client\\Crm\\Objects\\ApiException $e) {\n $response = json_decode($e->getResponseBody(), true);\n $retryAfter =\n isset($response['policyName'])\n && $response['policyName'] == self::TEN_SECONDLY_ROLLING_POLICY\n ? self::TEN_SECONDLY_ROLLING_LIMIT\n : 1;\n } catch (Exception $e) {\n $retryAfter = 1;\n }\n\n $this->logger->warning('[HubSpot] Bulk add playback url to CRM failed', [\n 'reason' => $e->getMessage(),\n 'retry' => $retry,\n ]);\n\n if ($retry) {\n sleep($retryAfter);\n\n return $this->bulkAddPlaybackURLToDescriptionRequest($payload, false);\n }\n\n return false;\n }\n\n /**\n * Sometimes we have secondly rate limit error, then retry request after 1 second\n */\n public function searchCallByRecordingURLToken(string $playbackURLToken, bool $retry = true): array\n {\n $payload = $this->payloadBuilder->generateSearchCallByTokenPayload($playbackURLToken);\n\n $this->logger->info('[HubSpot] CRM Search by playback URL token requested', [\n 'request' => $payload,\n ]);\n\n try {\n $response = $this->client->search('calls', $payload);\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Search by playback URL token failed', [\n 'playbackURLToken' => $playbackURLToken,\n 'reason' => $exception->getMessage(),\n 'retry' => $retry,\n ]);\n\n if ($retry) {\n sleep(1);\n\n return $this->searchCallByRecordingURLToken($playbackURLToken, false);\n }\n\n return [];\n }\n\n return empty($response['results']) ? [] : $response['results'][0];\n }\n\n /**\n * Generate transcription for the activity.\n */\n private function generateTranscription(Activity $activity): string\n {\n if (! $this->config->store_transcript) {\n // If sending transcription to activity toggle is disabled\n return '';\n }\n\n $transcriptionSegments = $this->transcriptionService->findTranscriptionByActivity($activity);\n\n if ($transcriptionSegments->isEmpty()) {\n return '';\n }\n\n $transcription = sprintf(\n '<p><strong>Transcript for %s</strong></p><p></p>',\n $activity->title ?? $activity->activity_title,\n );\n\n $roomOwnerParticipant = $activity->findParticipantRoomOwner();\n $roomOwnerParticipantId = $roomOwnerParticipant !== null\n ? $roomOwnerParticipant->getId()\n : null;\n\n\n $transcription .= $transcriptionSegments\n ->map(static function (array $transcriptionSegment) use ($roomOwnerParticipantId): string {\n $isOrganiser = $roomOwnerParticipantId === $transcriptionSegment['participantId']\n && $roomOwnerParticipantId !== null;\n $transcriptColor = $isOrganiser ? '#000000' : '#f0415a';\n\n return sprintf(\n '<span style=\"color: %s;\">%s | </span>%s',\n $transcriptColor,\n $transcriptionSegment['formattedStartsAt'],\n $transcriptionSegment['transcript'],\n );\n })\n ->implode('<br />');\n\n return $transcription;\n }\n\n /**\n * @param array<array{\n * id: string,\n * label: string,\n * value?: string,\n * }> $options\n *\n * @return FieldData[]\n */\n private function importOptions(Field $field, array $options): array\n {\n $fieldValues = [];\n $values = [];\n $sequence = 0;\n\n foreach ($options as $option) {\n $values[] = [\n 'value' => $option['value'] ?? $option['id'],\n 'label' => substr($option['label'], 0, 255),\n 'sequence' => $sequence++,\n ];\n }\n\n $fieldsToPurge = $field->values()->get()->pluck('value')->toArray();\n\n foreach ($values as $value) {\n $value['value'] = substr($value['value'], 0, 255);\n $fieldValues[] = $field->values()->updateOrCreate([\n 'value' => $value['value'],\n ], $value);\n\n // Remove this value from the ones we are going to purge.\n if (($key = array_search($value['value'], $fieldsToPurge, false)) !== false) {\n unset($fieldsToPurge[$key]);\n }\n }\n\n // Delete the old values that are no longer used.\n $field->values()->whereIn('value', $fieldsToPurge)->delete();\n\n return $fieldValues;\n }\n\n public function saveTranscriptionSummaryAsNote(\n ActivityContract $activity,\n string $title,\n string $body,\n ?string $objectId,\n ?NoteObject $noteObject = null,\n ): ?string {\n if ($noteObject === null || $objectId === null) {\n return null;\n }\n\n /** @var User $user */\n $user = $activity->getUser();\n\n $profile = $this->assignCrmOwner($user, $activity);\n if (! $profile instanceof Profile) {\n return null;\n }\n\n $timestamp = Carbon::now($user->getTimezone())->getTimestamp() * 1000;\n $engagement = [\n 'active' => true,\n 'ownerId' => $profile->getAttribute('crm_provider_id'),\n 'timestamp' => $timestamp,\n 'type' => 'NOTE',\n ];\n\n // Truncate Notes with max notes length because transcription text could be very long.\n $body = mb_strimwidth($body, 0, self::ENGAGEMENT_BODY_MAX_LENGTH);\n $metadata = [\n 'body' => $body,\n ];\n\n $associations = $this->convertActivityAssociations($activity);\n\n try {\n $hsActivityId = $this->client->createNote(\n body: $body,\n ownerId: $profile->getCrmProviderId(),\n timestamp: $timestamp,\n objectId: $objectId,\n noteObject: $noteObject,\n );\n\n $this->logCrmEngagementManipulation(self::ACTION_CREATE, $engagement, $metadata, $associations);\n\n $this->logger->info('[HubSpot] Saving Transcription Summary as Note', [\n 'activity' => $activity->getUuid(),\n 'crmActivity' => $hsActivityId,\n ]);\n\n return $hsActivityId;\n } catch (Exception $e) {\n Sentry::captureException($e);\n }\n\n return null;\n }\n\n public function attachSummaryToActivity(ActivityContract $activity, string $summaryTitle, string $summaryContents): bool\n {\n $this->logger->info('[HubSpot] Attaching summary to activity', [\n 'activity' => $activity->getUuid(),\n 'summary_content' => $summaryContents,\n ]);\n\n if (! $activity instanceof Activity) {\n throw new InvalidArgumentException('Expected instance of Activity');\n }\n\n $summary = '<p><strong>' . $summaryTitle . '</strong></p>';\n $summary .= '<p>' . $summaryContents . '</p>';\n $metadata = $this->buildMetadataForSummaryUpdate($activity, $summary);\n\n try {\n $type = $this->matchActivityEngagementType($activity);\n $engagement = ['type' => $type];\n\n $this->client->updateEngagement($activity->getCrmProviderId(), $engagement, $metadata);\n } catch (Exception $e) {\n $this->logger->warning('[HubSpot] Update summary failed', [\n 'activity' => $activity->getUuid(),\n 'reason' => $e->getMessage(),\n ]);\n\n return false;\n }\n\n $this->logCrmEngagementManipulation(\n self::ACTION_UPDATE,\n ['crmId' => $activity->getCrmProviderId()],\n $metadata,\n );\n\n return true;\n }\n\n private function buildMetadataForSummaryUpdate(Activity $activity, string $summary): array\n {\n $descriptionField = $activity->getType() === Activity::TYPE_CONFERENCE ? 'internalMeetingNotes' : 'body';\n $engagement = $this->client->getEngagementData($activity->getCrmProviderId());\n // Meeting without internalMeetingNotes might mean it just does not have any notes;\n $description = $engagement['metadata'][$descriptionField] ?? null;\n\n if (empty($description)) {\n $data = $summary;\n } else {\n // avoid playbook url link to Jiminny being sent twice in the activity description\n $targetUrl = PlaybackUrlBuilder::build($activity);\n\n if (str_contains($description, $targetUrl)) {\n $jiminnyUrl = '<p><a href=\"' . $targetUrl . '\" title=\"Play at Jiminny\">Play at Jiminny</a></p>';\n $summary = str_replace($jiminnyUrl, '', $summary);\n\n $this->logger->info('[HubSpot] Summary modified', [\n 'activity' => $activity->getUuid(),\n 'target_url' => $jiminnyUrl,\n 'modified_summary_content' => $summary,\n ]);\n }\n\n $data = $description . '<p></p>' . $summary;\n }\n\n return [\n $descriptionField => $data,\n ];\n }\n\n public function fetchAndAssociateRelatedActivity(Activity $activity): ?Activity\n {\n return $this->syncRelatedActivityManager->fetchAndAssociateRelatedActivity($activity);\n }\n\n public function fetchRelatedActivity(Activity $activity): array\n {\n return [];\n }\n\n public function getDealsInBulk(array $dealIds): array\n {\n $payload = $this->payloadBuilder->getDealsInBulkPayload($dealIds);\n\n return $this->client->getPaginatedData($payload, 'deals');\n }\n\n /**\n * Extract deal IDs from HubSpot search response.\n *\n * @param array $hubspotResponse The raw HubSpot search API response.\n * @param bool $includeArchived Whether to include archived deals (default: false).\n *\n * @return string[] Array of deal IDs as strings.\n */\n public function extractDealIds(array $hubspotResponse, bool $includeArchived = false): array\n {\n if (empty($hubspotResponse['results'])) {\n return [];\n }\n\n return array_values(\n array_map(\n fn ($deal) => $deal['id'],\n array_filter(\n $hubspotResponse['results'],\n fn ($deal) => $includeArchived || empty($deal['archived'])\n )\n )\n );\n }\n\n public function matchActivityEngagementType(Activity $activity): string\n {\n return match ($activity->getType()) {\n Activity::TYPE_CONFERENCE => self::TYPE_MEETING,\n Activity::TYPE_SOFTPHONE, Activity::TYPE_SOFTPHONE_INBOUND => self::TYPE_CALL,\n default => self::TYPE_NOTE,\n };\n }\n\n private function assignCrmOwner(User $user, ActivityContract $activity): ?Profile\n {\n $profile = $user->getProfile();\n if ($profile instanceof Profile) {\n return $profile;\n }\n\n $this->logger->info('[HubSpot] Unable to save summary. No profile', [\n 'activity' => $activity->getUuid(),\n ]);\n\n return null;\n }\n\n private static function getDealsPipelinesEndpoint(): string\n {\n return self::API_URL . self::ENDPOINT_PIPELINES . self::PIPELINE_OBJECT_TYPE_DEALS;\n }\n\n public function verifyTaskExists(Activity $activity): bool\n {\n $crmProviderId = $activity->getCrmProviderId();\n $cacheKey = \"crm_task_exists:{$this->config->getId()}:$crmProviderId\";\n\n return Cache::remember($cacheKey, self::TASK_VERIFICATION_CACHE_TTL, function () use ($crmProviderId) {\n try {\n $engagement = $this->client->getEngagementData($crmProviderId);\n\n return ! empty($engagement);\n } catch (HttpNotFoundException|BadRequest) {\n // Engagement not found in CRM - this is expected and permanent\n $this->logger->info('[Hubspot] Engagement not found during verification', [\n 'engagement_id' => $crmProviderId,\n 'config_id' => $this->config->getId(),\n ]);\n\n return false;\n }\n // Let other exceptions (network errors, rate limits, etc.) bubble up for retry\n });\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Project","depth":3,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Project","depth":3,"bounds":{"left":0.011968086,"top":0.047885075,"width":0.024268618,"height":0.024740623},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"New File or Directory…","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Expand Selected","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Collapse All","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Options","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
-3175715562603377888
|
-465054261620631449
|
click
|
accessibility
|
NULL
|
Project: faVsco.js, menu
JY-20725-handle-HS-search Project: faVsco.js, menu
JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
19
Previous Highlighted Error
Next Highlighted Error
[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {
"headers":{
"Date":["Thu,07 May 2026 14:21:15 GMT"],
"Content-Type":["application/json;charset=utf-8"],
"Transfer-Encoding":["chunked"],
"Connection":["keep-alive"],
"CF-Ray":["9f80deb8db60dc3a-SOF"],
"CF-Cache-Status":["DYNAMIC"],
"Strict-Transport-Security":["max-age=31536000; includeSubDomains; preload"],
"Vary":["origin,
accept-encoding"],
"access-control-allow-credentials":["false"],
"server-timing":["hcid;desc=\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\",
cfr;desc=\"9f80deb8e7c6dc3a-IAD\""],
"x-content-type-options":["nosniff"],
"x-hubspot-correlation-id":["019e02d0-6fd8-7812-bdba-885b7ccb3ee3"],
"Set-Cookie":["__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-[IP_ADDRESS]-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,
07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None"],
"Report-To":["{
\"endpoints\":[{
\"url\":\"https:\\/\\/a.nel.cloudflare.com\\/report\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\"}],
\"group\":\"cf-nel\",
\"max_age\":604800}"],
"NEL":["{
\"success_fraction\":0.01,
\"report_to\":\"cf-nel\",
\"max_age\":604800}"],
"Server":["cloudflare"]}} {
"correlation_id":"95236535-ec98-4541-b92a-adfa73b69eab",
"trace_id":"c7ab8365-903f-46d4-9403-0e5b551e3545"}
Code changed:
Hide
Sync Changes
Hide This Notification
7
48
1
33
1
Previous Highlighted Error
Next Highlighted Error
<?php
namespace Jiminny\Services\Crm\Hubspot;
use Carbon\Carbon;
use Exception;
use Generator;
use GuzzleHttp\Exception\RequestException;
use Illuminate\Support\Facades\Cache;
use InvalidArgumentException;
use Jiminny\Contracts\Repositories\TeamRepository;
use Jiminny\Contracts\Services\Crm\ClientInterface;
use Jiminny\Contracts\Services\Crm\FetchRelatedActivityInterface;
use Jiminny\Contracts\Services\Crm\LayoutManagementInterface;
use Jiminny\Contracts\Services\Crm\MatchCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\Provider\HubspotInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityLookupInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityManipulationInterface;
use Jiminny\Contracts\Services\Crm\SavePlaybackLinkToCrmInterface;
use Jiminny\Contracts\Services\Crm\SendSummaryToCrmInterface;
use Jiminny\Contracts\Services\Crm\SettingsInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmMetadataInterface;
use Jiminny\Contracts\Services\Crm\VerifyTaskExistsInterface;
use Jiminny\Exceptions\CrmException;
use Jiminny\Exceptions\HttpNotFoundException;
use Jiminny\Jobs\Crm\NoteObject;
use Jiminny\Models\Account;
use Jiminny\Models\Activity;
use Jiminny\Models\Contact;
use Jiminny\Models\Contracts\ActivityContract;
use Jiminny\Models\Crm\BusinessProcess;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\FieldData;
use Jiminny\Models\Crm\Layout;
use Jiminny\Models\Crm\Profile;
use Jiminny\Models\Lead;
use Jiminny\Models\Opportunity;
use Jiminny\Models\Participant;
use Jiminny\Models\Playbook;
use Jiminny\Models\SocialAccount;
use Jiminny\Models\Stage;
use Jiminny\Models\User;
use Jiminny\Repositories\Crm\CrmEntityRepository;
use Jiminny\Repositories\Crm\FieldRepository;
use Jiminny\Repositories\Crm\ProfileRepository;
use Jiminny\Repositories\ParticipantRepository;
use Jiminny\Services\Avatar\ProspectPhotoPathService;
use Jiminny\Services\Crm\BaseService;
use Jiminny\Services\Crm\Hubspot\Actions\SyncArchivedProfilesAction;
use Jiminny\Services\Crm\Hubspot\Fields\ValueNormalizer;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\OpportunitySyncTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\SyncCrmEntitiesTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\SyncFieldsTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\WriteCrmTrait;
use Jiminny\Services\Crm\MatchDomainByEmailInterface;
use Jiminny\Services\Crm\OpportunitySyncStrategyResolver;
use Jiminny\Services\Crm\ResolveCompanyNameByEmailTrait;
use Jiminny\Utils\PlaybackUrlBuilder;
use Sentry;
use SevenShores\Hubspot\Exceptions\BadRequest;
use Throwable;
use UnexpectedValueException;
/**
* @phpstan-type CrmFieldDefinition array{
* name: string,
* label: string,
* description: string,
* type: string,
* fieldType: string,
* hidden: bool,
* showCurrencySymbol: bool,
* options: array<array{
* id: string,
* label: string,
* value?: string,
* }
*/
class Service extends BaseService implements
HubspotInterface,
SyncCrmEntitiesInterface,
SyncCrmMetadataInterface,
SendSummaryToCrmInterface,
MatchDomainByEmailInterface,
SavePlaybackLinkToCrmInterface,
RemoteEntityManipulationInterface,
FetchRelatedActivityInterface,
LayoutManagementInterface,
SettingsInterface,
MatchCrmEntitiesInterface,
RemoteEntityLookupInterface,
VerifyTaskExistsInterface
{
use ResolveCompanyNameByEmailTrait;
use SyncCrmEntitiesTrait;
use WriteCrmTrait;
use SyncFieldsTrait;
use OpportunitySyncTrait;
private const int ENGAGEMENT_BODY_MAX_LENGTH = 65536;
private const string LOG_DATE_FORMAT = 'Y-m-d H:i:s';
private const int BATCH_UPDATE_LIMIT = 100;
private const string TEN_SECONDLY_ROLLING_POLICY = 'TEN_SECONDLY_ROLLING';
private const int TEN_SECONDLY_ROLLING_LIMIT = 10;
private const string TYPE_NOTE = 'NOTE';
private const string TYPE_MEETING = 'MEETING';
private const string TYPE_CALL = 'CALL';
private const string API_URL = '[URL_WITH_CREDENTIALS] ClientInterface|Client
*/
protected $client;
protected OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;
protected CrmEntityRepository $crmEntityRepository;
protected ProspectPhotoPathService $prospectPhotoPathService;
private SyncFieldAction $syncFieldAction;
private PayloadBuilder $payloadBuilder;
private SyncRelatedActivityManager $syncRelatedActivityManager;
private SyncArchivedProfilesAction $syncArchivedProfilesAction;
private WebhookSyncBatchProcessor $batchProcessor;
public function __construct(
Client $client,
SyncFieldAction $syncFieldAction,
PayloadBuilder $payloadBuilder,
ProspectPhotoPathService $prospectPhotoPathService,
SyncArchivedProfilesAction $syncArchivedProfilesAction,
WebhookSyncBatchProcessor $batchProcessor,
) {
parent::__construct();
$this->client = $client;
$this->syncFieldAction = $syncFieldAction;
$this->prospectPhotoPathService = $prospectPhotoPathService;
$this->payloadBuilder = $payloadBuilder;
$this->syncArchivedProfilesAction = $syncArchivedProfilesAction;
$this->batchProcessor = $batchProcessor;
$this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [
'client' => $this->client,
]);
$this->syncRelatedActivityManager = app(SyncRelatedActivityManager::class, [
'client' => $this->client,
'payloadBuilder' => $this->payloadBuilder,
'logger' => $this->logger,
]);
$this->crmEntityRepository = app(CrmEntityRepository::class);
$this->dealFieldsService = app(DealFieldsService::class);
}
public function getDisplayName(): string
{
return 'HubSpot';
}
protected function getOAuthAccount(User $user): ?SocialAccount
{
// In this case, the Account Owner is always the connection for any API operations.
$owner = $user->team->owner;
return $owner->getSocialAccount(SocialAccount::PROVIDER_HUBSPOT);
}
public function getClient(): Client
{
/** @var Client */
return $this->client;
}
/**
* Convert raw field data into a format compatible with CRM APIs.
*
* @param bool $internal Direction of the conversion.
* True is pulling from CRM, false normalize before sending to CRM.
*/
public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string
{
return ValueNormalizer::normalize(
fieldType: $fieldType,
fieldValue: $fieldValue,
isInbound: $internal,
);
}
/**
* @inheritdoc
*/
public function getDefaultFields(string $activityType): array
{
$fields = [];
if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {
$defaultFields = FieldDefinitions::defaultTaskFields();
// This lazy creates these fields if not already setup.
foreach ($defaultFields as $defaultField) {
$fields[] = $this->config->fields()->firstOrCreate($defaultField);
}
}
return $fields;
}
/**
* @inheritdoc
*/
public function getDefaultActivityField(string $activityType): Field
{
/** @var Field $activityField */
$activityField = $this->config->fields()->where([
'crm_provider_id' => 'activityType',
'object_type' => $activityType,
])->first();
return $activityField;
}
/**
* @inheritdoc
*/
public function getSupportedPlaybookTypes(): array
{
return [Playbook::ACTIVITY_TYPE_TASK];
}
/**
* @inheritdoc
*/
public function getDefaultActivityLayoutFields(string $activityType, string $layoutType): array
{
$fields = [];
if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {
// Outcome should always be provided calls/meetings.
$fieldData = [
[
'crm_provider_id' => $layoutType === Layout::TYPE_SOFTPHONE_SUMMARY ? 'disposition' : 'meetingOutcome',
'object_type' => Field::OBJECT_TASK,
],
];
foreach ($fieldData as $data) {
$field = $this->config->fields()->where($data)->first();
// Only add the field if it is created, which it should be.
if ($field) {
$fields[] = $field;
}
}
}
return $fields;
}
public function getDealInsightsFields(): array
{
return FieldDefinitions::dealInsightsFields();
}
protected function getDefaultFollowupLayoutFields(string $activityType): array
{
$fields = [];
$fieldRepo = app(FieldRepository::class);
$fieldData = FieldDefinitions::followupFieldsFilter();
foreach ($fieldData as $data) {
$field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $data);
// Only add the field if it is created, which it should be.
if ($field) {
$fields[] = $field;
}
}
return $fields;
}
/**
* @inheritdoc
*/
public function syncField(Field $field): void
{
switch ($field->object_type) {
case Field::OBJECT_ACCOUNT:
$crmField = $this->client->getInstance()->companyProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_CONTACT:
$crmField = $this->client->getInstance()->contactProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_OPPORTUNITY:
$crmField = $this->client->getInstance()->dealProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_TASK:
$this->syncSingleTaskField($field);
return;
default:
return;
}
$this->syncFieldAction->execute($field, $crmField->toArray());
}
/**
* @param array<array{
* id:string,
* label:string,
* value?:string
* }> $options
*
* @throws CrmException
*
* @return FieldData[]
*
*/
public function importPicklistValues(
Field $field,
array $options = [['id' => '', 'label' => '', 'value' => '']],
): array {
if (! empty($options[0]['id']) || ! empty($options[0]['value'])) {
// We already have the options, no need to fetch them again
return $this->importOptions($field, $options);
}
$options = [];
switch ($field->getObjectType()) {
case Field::OBJECT_ACCOUNT:
$options = $this->getClient()->fetchPropertyOptions('company', $field->getCrmProviderId());
break;
case Field::OBJECT_CONTACT:
$options = $this->getClient()->fetchPropertyOptions('contact', $field->getCrmProviderId());
break;
case Field::OBJECT_OPPORTUNITY:
// Hubspot has different endpoint for stages
$options = $this->getClient()->fetchOpportunityFieldOptions($field);
break;
case Field::OBJECT_TASK:
if ($field->getCrmProviderId() === 'disposition') {
$options = $this->getClient()->fetchDispositionFieldOptions();
} elseif (in_array($field->getCrmProviderId(), ['meetingOutcome', 'activityType'])) {
$options = $this->getClient()->fetchMeetingOutcomeFieldOptions($field);
}
break;
default:
$this->logger->warning('Invalid object type', [
'object_type' => $field->getObjectType(),
'field_id' => $field->getId(),
]);
throw new CrmException('Invalid object type');
}
return $this->importOptions($field, $options);
}
/**
* @inheritdoc
*/
public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage
{
$missingStage = null;
try {
// Use the HubSpot API client instead of the SDK crmPipelines() method
$endpoint = self::getDealsPipelinesEndpoint();
$pipelinesResponse = $this->client->getInstance()->getClient()->request('GET', $endpoint);
$pipelines = $pipelinesResponse->data->results;
} catch (RequestException|BadRequest $exception) {
throw $exception;
}
foreach ($pipelines as $pipeline) {
$stages = [];
// We create a business process to contain the pipeline, and store all stages against it.
$p = ResponseNormalize::normalizePipeline($pipeline);
// Create/update business process for this pipeline
$businessProcess = $this->config->businessProcesses()->updateOrCreate([
'crm_provider_id' => $p['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($p['label'], 0, 150),
'type' => BusinessProcess::TYPE_OPPORTUNITY,
'is_selectable' => $p['active'],
]);
// A record type is really a clone of the business process, used to store which record uses which pipeline.
// Create/update record type clone
$this->config->recordTypes()->updateOrCreate([
'crm_provider_id' => $p['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($p['label'], 0, 150),
'is_selectable' => $p['active'],
'business_process_id' => $businessProcess->id ?? null,
]);
// Stages - fetch all existing stages upfront to avoid N+1 queries
$existingStages = $this->config->stages()
->withTrashed()
->where('type', Stage::TYPE_OPPORTUNITY)
->get()
->keyBy('crm_provider_id');
foreach ($p['stages'] as $dealStage) {
$s = ResponseNormalize::normalizeDealStage($dealStage);
/** @var ?Stage $existingStage */
$existingStage = $existingStages->get($s['id']);
// Restore soft-deleted stages that are now active in HubSpot
if ($existingStage?->trashed() && $s['active']) {
$existingStage->restore();
}
// Upsert stage (updates soft-deleted records without restoring them)
$stage = $this->config->stages()->withTrashed()->updateOrCreate([
'crm_provider_id' => $s['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($s['label'], 0, 50),
'label' => mb_strimwidth($s['label'], 0, 191),
'type' => Stage::TYPE_OPPORTUNITY,
'sequence' => $s['displayOrder'],
'is_selectable' => $s['active'],
'probability' => $s['probability'] * 100,
]);
if ($missingStageName === $s['id']) {
$missingStage = $stage;
}
$stages[] = $stage->id;
}
$businessProcess->stages()->sync($stages);
}
return $missingStage;
}
/**
* @inheritdoc
*/
public function syncOrganization(): void
{
try {
$endpoint = '[URL_WITH_CREDENTIALS]
*/
public function find(string $name, array $scopes): array
{
$count = $this->limit ?? 20;
$offset = $this->offset ?? 0;
/** @var array<int, array<string, mixed>> */
return Cache::remember(
key: $this->team->getId() . $name . $count . $offset,
ttl: 300,
callback: function () use ($name, $offset, $count): array {
$data = [];
// Use the new V3 API to find contacts based on additional fields.
foreach (['companies', 'contacts'] as $objectType) {
$payload = $this->generateNameSearchPayload($name, $offset, $count);
$type = $objectType === 'companies' ? 'account' : 'contact';
try {
$response = $this->client->search($objectType, $payload);
// Build mapped list.
foreach ($response['results'] as $object) {
$properties = $object['properties'];
$objectName = $this->buildContactName($properties);
$record = [
'crmId' => $object['id'],
// Pass crmUrl to the FE, needed for success message in the extension when you log activity.
'crmUrl' => $this->generateProviderUrl($object['id'], $type),
'name' => $objectName,
'prospectType' => $type,
'phoneNumbers' => [],
];
if ($type === 'account') {
$record['industry'] = $properties['industry'] ?? null;
} else {
$record['title'] = $properties['jobtitle'] ?? null;
$record['organization'] = $properties['company'] ?? null;
}
$countryCode = $this->buildContactCountry($properties);
$parsedNumber = $this->buildContactPhone($countryCode, $properties);
// Add phone number to record.
if (! empty($parsedNumber['phone'])) {
$record['phoneNumbers'][] = [
'number' => $parsedNumber['phone'],
'nationalFormat' => phone_national($countryCode, $parsedNumber['phone']),
'type' => 'phone',
];
}
// Add mobile phone number to record.
if (! empty($properties['mobilephone'])) {
$mobileNumber = phone_e164($countryCode, $properties['mobilephone']);
if ($mobileNumber !== null) {
$record['phoneNumbers'][] = [
'number' => $mobileNumber,
'nationalFormat' => phone_national($countryCode, $mobileNumber),
'type' => 'mobile',
];
}
}
$data[] = $record;
}
} catch (BadRequest $e) {
$this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [
'teamId' => $this->team->getUuid(),
'request' => $payload,
'reason' => $e->getMessage(),
]);
throw $e;
}
}
return $data;
},
);
}
/**
* @inheritdoc
*/
public function findOpportunities(?string $crmAccountId, ?string $crmContactId, ?int $userId = null): array
{
$data = [];
$ownerData = [];
$ownerId = null;
if ($crmAccountId === null) {
return $data;
}
if ($userId) {
$profileRepository = app(ProfileRepository::class);
$profile = $profileRepository->findProfileByUserId($this->config, $userId);
$ownerId = $profile instanceof Profile ? $profile->getCrmProviderId() : null;
}
$closedStages = $this->getClosedDealStages();
$payload = $this->payloadBuilder->generateOpportunitiesSearchPayload(
$this->config,
$crmAccountId,
$closedStages,
);
$results = $this->client->getPaginatedData($payload, 'deals');
foreach ($results['results'] as $object) {
$properties = $object['properties'];
$amount = null;
if (empty($properties['amount']) === false) {
$currency = $properties['deal_currency_code'] ?? $this->config->default_currency;
// Values can contain commas and any junk so strip them.
$value = (float) preg_replace('/[^\d.]/', '', $properties['amount']);
$amount = formatCurrency($value, $currency);
}
$businessProcess = $this->config
->businessProcesses()
->where('crm_provider_id', $properties['pipeline'])
->first();
if ($businessProcess === null) {
// Import it.
$stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $properties['dealstage']);
$businessProcess = $this->config
->businessProcesses()
->where('crm_provider_id', $properties['pipeline'])
->first();
} else {
$stage = $businessProcess
->stages()
->where('crm_provider_id', $properties['dealstage'])
->where('type', Stage::TYPE_OPPORTUNITY)
->first();
if ($stage === null) {
// Import it.
$stage = $this->importStages(null, $properties['dealstage']);
}
}
$recordType = null;
if ($businessProcess) {
$recordType = $businessProcess->recordTypes()->first();
}
$isWon = in_array($properties['dealstage'], $closedStages['won']);
$isLost = in_array($properties['dealstage'], $closedStages['lost']);
$record = [
'crmId' => $object['id'],
'name' => $properties['dealname'] ?? 'Unknown Deal',
'value' => $amount,
'won' => $isWon,
'closed' => $isWon || $isLost,
'stage' => [
'id' => $stage?->getUuid() ?? '',
'name' => $stage?->getName() ?? '',
],
];
if ($recordType) {
$record += [
'recordType' => [
'id' => $recordType->id_string,
'name' => $recordType->name,
],
];
}
if ($ownerId && isset($properties['hubspot_owner_id']) && $properties['hubspot_owner_id'] === $ownerId) {
$ownerData[] = $record;
}
$data[] = $record;
}
if (! empty($ownerData)) {
return $ownerData;
}
return $data;
}
/**
* @inheritdoc
*/
public function getTasks(?string $objectType, string $objectId, ?string $opportunityId): array
{
$data = [];
switch ($objectType) {
case 'contact':
$hsObject = 'contact';
break;
case 'account':
$hsObject = 'company';
break;
default:
// This is a hack to prioritise and override a contact/company with a deal.
if ($opportunityId) {
$hsObject = 'deal';
$objectId = $opportunityId;
} else {
throw new InvalidArgumentException('Object type not supported.');
}
}
$engagementTypes = ['meetings', 'tasks'];
foreach ($engagementTypes as $engagementType) {
$payload = $this->payloadBuilder->getLinkToTaskPayload($hsObject, $objectId, $engagementType);
$this->logger->info('[HubSpot] CRM Search requested', [
'request' => $payload,
]);
$engagements = $this->client->getPaginatedData($payload, $engagementType);
foreach ($engagements['results'] as $engagement) {
if ($engagementType == 'meetings') {
$title = $engagement['properties']['hs_meeting_title'] ?? 'Scheduled meeting';
} elseif ($engagementType == 'tasks') {
$title = $engagement['properties']['hs_task_subject'];
} else {
$title = 'Scheduled meeting';
}
$data[] = [
'crmId' => $engagement['id'],
'subject' => $title,
'due' => $engagement['properties']['hs_timestamp'],
'type' => $engagement['properties']['hs_activity_type'] ?? null,
];
}
}
usort($data, function ($item1, $item2) {
return $item2['due'] <=> $item1['due'];
});
return $data;
}
/**
* Try to find CRM Objects using email address
*
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchExactlyByEmail(string $email, ?int $userId = null): ?array
{
$contactProperties = [
'email',
'firstname',
'lastname',
'country',
'phone',
'mobilephone',
'jobtitle',
'hubspot_owner_id',
'associatedcompanyid',
'photo',
];
$contact = null;
$account = null;
try {
$hsContact = $this->getClient()->getContactByEmail($email, $contactProperties);
if ($hsContact) {
$contact = $this->importContact($hsContact);
$account = $contact->account;
}
$data = $this->convertCrmData($contact, $account, $userId);
return ! empty(array_filter($data)) ? $data : null;
} catch (BadRequest $e) {
$this->logger->warning('[HubSpot] Search failed', [
'team_id' => $this->team->getId(),
'search_identifier' => $email,
'reason' => $e->getMessage(),
]);
}
return null;
}
public function getDomain(string $email): ?string
{
return $this->getDomainFromEmail($email);
}
/**
* Try to find CRM objects using domain name of the email address
*
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchByDomain(string $domain, ?int $userId = null): ?array
{
$companyName = $domain;
// Try to find a company matching their email domain.
$companyProperties = [
'country',
'phone',
'name',
'hs_avatar_filemanager_key',
'industry',
'hubspot_owner_id',
'domain',
];
try {
$hsAccounts = $this->client
->getInstance()
->companies()
->searchByDomain($companyName, $companyProperties);
} catch (Throwable $e) {
$this->logger->info('[HubSpot] Search failed', [
'error' => $e->getMessage(),
'domain' => $domain,
]);
return null;
}
$account = null;
// If there are multiple accounts, don't guess, we'll ask later.
if (\count($hsAccounts->data->results) === 1) {
// Persist this remote object.
$account = $this->syncAccount($hsAccounts->data->results[0]->companyId);
}
$data = $this->convertCrmData(null, $account, $userId);
return ! empty(array_filter($data)) ? $data : null;
}
/**
* @return array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
protected function convertCrmData(?Contact $contact, ?Account $account, ?int $userId = null): array
{
$countryCode = null;
if ($contact && $contact->country_code) {
$countryCode = $contact->country_code;
} elseif ($account && $account->country_code) {
$countryCode = $account->country_code;
}
try {
$hsOpportunities = $this->findOpportunities(
$account ? $account->crm_provider_id : null,
$contact ? $contact->crm_provider_id : null,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
// If there are multiple opportunities, don't guess, we'll ask later.
$opportunity = null;
$stage = null;
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
protected function getCacheKey(string $object, ?int $userId = null): ?string
{
$key = $this->team->getId() . $object;
$keySuffix = $this->getOwnerKeySuffix($userId);
return $key . $keySuffix;
}
private function getOwnerKeySuffix(?int $userId = null): string
{
return $userId === null ? '' : (string) $userId;
}
/**
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
*}
*/
public function matchByPhone(string $phone, ?string $rawPhoneNumber = null, ?int $userId = null): ?array
{
if (str_contains($phone, '**')) {
return null;
}
// trim all whitespaces if present so the lookup doesn't fail
$phone = str_replace(' ', '', $phone);
// Check if the user is internal.
if ($this->isPhoneNumberOfTeamMember($phone)) {
return null;
}
$response = $this->searchForPhoneNumber($phone);
if (empty($response)) {
return null;
}
// This would ideally importContact instead but the response type differs.
$contact = $this->findAndSyncContact($response['results'][0]['id']);
if (! $contact instanceof Contact) {
return null;
}
$account = $contact->account;
$countryCode = $contact->country_code ?? $account->country_code ?? null;
try {
$hsOpportunities = $this->findOpportunities(
$account?->crm_provider_id,
$contact->crm_provider_id,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
$opportunity = null;
$stage = null;
try {
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
} catch (Exception $e) {
$this->logger->debug('[HubSpot] Opportunity failed to sync.', [
'reason' => $e->getMessage(),
]);
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
private function isPhoneNumberOfTeamMember(string $phone): bool
{
$teamRepository = app(TeamRepository::class);
$user = $teamRepository->findTeamMemberByPhone($this->team, $phone);
if ($user instanceof User) {
return true;
}
return false;
}
private function findAndSyncContact(string $crmId): ?Contact
{
try {
return $this->syncContact($crmId);
} catch (Exception $exception) {
$this->logger->info('[HubSpot] Phone match failed', [
'reason' => $exception->getMessage(),
]);
return null;
}
}
private function hasResults(array $response): bool
{
return isset($response['total']) && is_numeric($response['total']) && $response['total'] > 0;
}
private function searchForPhoneNumber(string $phone): array
{
// Normalizes the provided phone number for the API search.
$normalizedPhone = $this->normalizePhoneNumber($phone);
$payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone);
$this->logger->info('[HubSpot] Phone match search triggered', [
'phone' => $phone,
'normalizedPhone' => $normalizedPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($normalizedPhone, $payload);
if (! $this->hasResults($response)) {
$nationalPhone = preg_replace('/\D/', '', phone_national(null, $phone));
$payload = $this->payloadBuilder->generatePhoneSearchPayload($nationalPhone);
$this->logger->info('[HubSpot] Phone match national number search triggered', [
'phone' => $phone,
'nationalPhone' => $nationalPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($phone, $payload);
}
if (! $this->hasResults($response)) {
$payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone, true);
$this->logger->info('[HubSpot] Phone match alternative search triggered', [
'phone' => $phone,
'normalizedPhone' => $normalizedPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($phone, $payload);
}
return $this->hasResults($response) ? $response : [];
}
private function handlePhoneSearchRequest(string $phone, array $payload): array
{
try {
return $this->client->search('contacts', $payload);
} catch (Exception $exception) {
$this->logger->info('[HubSpot] Phone match failed', [
'phone' => $phone,
'reason' => $exception->getMessage(),
]);
return [];
}
}
private function normalizePhoneNumber(string $phone): string
{
return ltrim(phone_e164(null, $phone), '+0');
}
/**
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchByName(string $name, ?int $userId = null): ?array
{
// Don't waste time searching for single character strings.
if (\strlen($name) <= 1) {
return null;
}
$cacheKey = $this->getCacheKey($name, $userId);
$result = Cache::remember($cacheKey, 60, function () use ($name, $userId) {
$payload = $this->payloadBuilder->generateSearchContactsByNamePayload(
$name,
$this->getContactFields()
);
$hsContacts = $this->client->getPaginatedData($payload, 'contact');
if (empty($hsContacts['results'])) {
return false;
}
$contact = $this->importContact($hsContacts['results'][0]);
if ($contact === null) {
return false;
}
$account = $contact->account;
$countryCode = $contact->country_code ?? $account->country_code ?? null;
try {
$hsOpportunities = $this->findOpportunities(
$account ? $account->crm_provider_id : null,
$contact->crm_provider_id,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
$opportunity = null;
$stage = null;
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
});
return is_array($result) ? $result : null;
}
private function convertActivityAssociations(Activity $activity): array
{
return [
'contactIds' => $this->getParticipantsIds($activity),
'companyIds' => $activity->hasAccount() ? [$activity->account->crm_provider_id] : [],
'dealIds' => $activity->hasOpportunity() ? [$activity->opportunity->crm_provider_id] : [],
'ownerIds' => [],
];
}
private function getParticipantsIds(Activity $activity): array
{
$attendees = [];
$participantRepository = app(ParticipantRepository::class);
$participants = $participantRepository->getParticipantsWhoEnteredMeeting($activity);
foreach ($participants as $participant) {
if ($participant->user_id || $participant->isCoach()) {
continue;
}
$contact = $participant->contact()->first();
if ($contact && $contact->crm_provider_id) {
$attendees[] = $contact->crm_provider_id;
} else {
if (! empty($participant->name)) {
$attendeeData = $this->fetchMissingAttendeeInfo($participant);
}
if (! empty($attendeeData['id'])) {
$attendees[] = $attendeeData['id'];
}
}
}
if ($activity->hasContact()) {
$attendees[] = $activity->contact->crm_provider_id;
}
return array_unique($attendees);
}
private function fetchMissingAttendeeInfo(Participant $participant): array
{
// Check if we need to look inside an account context.
$activity = $participant->getActivity();
$companyId = $activity->hasAccount() ? $activity->getAccount()->crm_provider_id : null;
// First check the local data.
/** @var Contact[] $contacts */
$contacts = $this->team->contacts()
->with('account')
->where('name', $participant->name)
->whereNotNull('email')
->get();
foreach ($contacts as $contact) {
// If we have a company in scope, check the contact is associated to it.
if (
$companyId !== null
&& ($contact->account_id === null || $companyId !== $contact->account->crm_provider_id)
) {
continue;
}
return [
'id' => $contact->crm_provider_id,
'email' => $contact->email,
];
}
$payload = $this->generateNameSearchPayload($participant->name, 0, 20);
try {
$response = $this->client->getNewInstance()->crm()->contacts()->searchApi()->doSearch($payload);
// TODO add some logic to choose the most suitable contact if multiple
foreach ($response['results'] as $object) {
$properties = $object['properties'];
if (empty($object['properties']) === false) {
// Check the company matches the contact.
// Todo: Move this check inside the API search.
if ($companyId !== null && $companyId !== $properties['associatedcompanyid']) {
continue;
}
return [
'id' => $object['id'],
'email' => $properties['email'],
];
}
}
} catch (Exception $e) {
$this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [
'teamId' => $this->team->id_string,
'request' => $payload,
'reason' => $e->getMessage(),
]);
}
return [];
}
/**
* Store transcripts as note engagement.
*
* @throws Exception
*/
public function createTranscriptNotes(Activity $activity): void
{
// For HS no need to check if Crm profile - Log Notes field is enabled
// We only check if store_transcript toggle is enabled on crm profile.
$engagement = [
'active' => true,
'ownerId' => $this->profile->crm_provider_id,
'timestamp' => $activity->created_at->tz($activity->user->timezone)->getTimestamp() * 1000,
'type' => 'NOTE',
];
// Generate activity transcription.
$transcriptionData = $this->generateTranscription($activity);
// Truncate Notes with max notes length because transcription text could be very long.
$transcripts = mb_strimwidth($transcriptionData, 0, static::ENGAGEMENT_BODY_MAX_LENGTH);
$metadata = [
'body' => $transcripts,
];
$associations = $this->convertActivityAssociations($activity);
try {
$hsEngagement = $this->client
->getInstance()
->engagements()
->create($engagement, $associations, $metadata);
$this->logCrmEngagementManipulation(self::ACTION_CREATE, $engagement, $metadata, $associations);
$noteId = $hsEngagement->data->engagement->id;
// Store crm logged id in transcription.
$transcription = $activity->getTranscription();
$transcription->crm_activity_id = $noteId;
$transcription->save();
} catch (Exception $e) {
Sentry::captureException($e);
}
}
/*
* @inheritdoc
*/
public function updateRecord(string $objectType, string $objectId, array $data, array $headers = []): void
{
$payload = [
'properties' => $data,
];
try {
switch ($objectType) {
case FieldData::OBJECT_OPPORTUNITY:
$this->client->getNewInstance()->crm()->deals()->basicApi()->update($objectId, $payload);
break;
case FieldData::OBJECT_CONTACT:
$this->client->getNewInstance()->crm()->contacts()->basicApi()->update($objectId, $payload);
break;
case FieldData::OBJECT_ACCOUNT:
$this->client->getNewInstance()->crm()->companies()->basicApi()->update($objectId, $payload);
break;
case FieldData::OBJECT_TASK:
// Endpoint for Engagements not ready
$engagements = [
'type' => 'TASK',
];
$metadata = $data;
$this->client->getInstance()->engagements()->update($objectId, $engagements, $metadata);
$this->logCrmEngagementManipulation(
self::ACTION_UPDATE,
['crmId...
|
9806
|
NULL
|
NULL
|
NULL
|
|
9806
|
444
|
8
|
2026-05-08T13:34:12.176289+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-08/1778 /Users/lukas/.screenpipe/data/data/2026-05-08/1778247252176_m2.jpg...
|
PhpStorm
|
faVsco.js – Service.php
|
1
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
JY-20725-handle-HS-search Project: faVsco.js, menu
JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
19
Previous Highlighted Error
Next Highlighted Error
[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {
"headers":{
"Date":["Thu,07 May 2026 14:21:15 GMT"],
"Content-Type":["application/json;charset=utf-8"],
"Transfer-Encoding":["chunked"],
"Connection":["keep-alive"],
"CF-Ray":["9f80deb8db60dc3a-SOF"],
"CF-Cache-Status":["DYNAMIC"],
"Strict-Transport-Security":["max-age=31536000; includeSubDomains; preload"],
"Vary":["origin,
accept-encoding"],
"access-control-allow-credentials":["false"],
"server-timing":["hcid;desc=\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\",
cfr;desc=\"9f80deb8e7c6dc3a-IAD\""],
"x-content-type-options":["nosniff"],
"x-hubspot-correlation-id":["019e02d0-6fd8-7812-bdba-885b7ccb3ee3"],
"Set-Cookie":["__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-[IP_ADDRESS]-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,
07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None"],
"Report-To":["{
\"endpoints\":[{
\"url\":\"https:\\/\\/a.nel.cloudflare.com\\/report\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\"}],
\"group\":\"cf-nel\",
\"max_age\":604800}"],
"NEL":["{
\"success_fraction\":0.01,
\"report_to\":\"cf-nel\",
\"max_age\":604800}"],
"Server":["cloudflare"]}} {
"correlation_id":"95236535-ec98-4541-b92a-adfa73b69eab",
"trace_id":"c7ab8365-903f-46d4-9403-0e5b551e3545"}...
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"bounds":{"left":0.025930852,"top":0.019952115,"width":0.03856383,"height":0.025538707},"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JY-20725-handle-HS-search-rate-limit, menu","depth":5,"bounds":{"left":0.064494684,"top":0.019952115,"width":0.09541223,"height":0.025538707},"on_screen":true,"help_text":"Git Branch: JY-20725-handle-HS-search-rate-limit","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"bounds":{"left":0.8081782,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"AskJiminnyReportActivityServiceTest","depth":6,"bounds":{"left":0.8234708,"top":0.019952115,"width":0.09208777,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'AskJiminnyReportActivityServiceTest'","depth":6,"bounds":{"left":0.9155585,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'AskJiminnyReportActivityServiceTest'","depth":6,"bounds":{"left":0.9268617,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"bounds":{"left":0.9381649,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"bounds":{"left":0.96609044,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"bounds":{"left":0.9773936,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"IDE and Project Settings","depth":5,"bounds":{"left":0.9886968,"top":0.019952115,"width":0.011303186,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.042220745,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"19","depth":4,"bounds":{"left":0.6615692,"top":0.10055866,"width":0.009640957,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"bounds":{"left":0.67287236,"top":0.09896249,"width":0.00731383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"bounds":{"left":0.68018615,"top":0.09896249,"width":0.006981383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {\n\"headers\":{\n\"Date\":[\"Thu,07 May 2026 14:21:15 GMT\"],\n \"Content-Type\":[\"application/json;charset=utf-8\"],\n \"Transfer-Encoding\":[\"chunked\"],\n \"Connection\":[\"keep-alive\"],\n \"CF-Ray\":[\"9f80deb8db60dc3a-SOF\"],\n \"CF-Cache-Status\":[\"DYNAMIC\"],\n \"Strict-Transport-Security\":[\"max-age=31536000; includeSubDomains; preload\"],\n \"Vary\":[\"origin,\n accept-encoding\"],\n \"access-control-allow-credentials\":[\"false\"],\n \"server-timing\":[\"hcid;desc=\\\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\\\",\n cfr;desc=\\\"9f80deb8e7c6dc3a-IAD\\\"\"],\n \"x-content-type-options\":[\"nosniff\"],\n \"x-hubspot-correlation-id\":[\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\"],\n \"Set-Cookie\":[\"__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-1.0.1.1-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,\n 07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None\"],\n \"Report-To\":[\"{\n\\\"endpoints\\\":[{\n\\\"url\\\":\\\"https:\\\\/\\\\/a.nel.cloudflare.com\\\\/report\\\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\\\"}],\n\\\"group\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"NEL\":[\"{\n\\\"success_fraction\\\":0.01,\n\\\"report_to\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"Server\":[\"cloudflare\"]}} {\n\"correlation_id\":\"95236535-ec98-4541-b92a-adfa73b69eab\",\n\"trace_id\":\"c7ab8365-903f-46d4-9403-0e5b551e3545\"}","depth":4,"bounds":{"left":0.37632978,"top":0.0,"width":0.5728058,"height":0.632083},"on_screen":true,"value":"[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {\n\"headers\":{\n\"Date\":[\"Thu,07 May 2026 14:21:15 GMT\"],\n \"Content-Type\":[\"application/json;charset=utf-8\"],\n \"Transfer-Encoding\":[\"chunked\"],\n \"Connection\":[\"keep-alive\"],\n \"CF-Ray\":[\"9f80deb8db60dc3a-SOF\"],\n \"CF-Cache-Status\":[\"DYNAMIC\"],\n \"Strict-Transport-Security\":[\"max-age=31536000; includeSubDomains; preload\"],\n \"Vary\":[\"origin,\n accept-encoding\"],\n \"access-control-allow-credentials\":[\"false\"],\n \"server-timing\":[\"hcid;desc=\\\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\\\",\n cfr;desc=\\\"9f80deb8e7c6dc3a-IAD\\\"\"],\n \"x-content-type-options\":[\"nosniff\"],\n \"x-hubspot-correlation-id\":[\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\"],\n \"Set-Cookie\":[\"__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-1.0.1.1-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,\n 07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None\"],\n \"Report-To\":[\"{\n\\\"endpoints\\\":[{\n\\\"url\\\":\\\"https:\\\\/\\\\/a.nel.cloudflare.com\\\\/report\\\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\\\"}],\n\\\"group\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"NEL\":[\"{\n\\\"success_fraction\\\":0.01,\n\\\"report_to\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"Server\":[\"cloudflare\"]}} {\n\"correlation_id\":\"95236535-ec98-4541-b92a-adfa73b69eab\",\n\"trace_id\":\"c7ab8365-903f-46d4-9403-0e5b551e3545\"}","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
6337657672782761712
|
-3048904810292590134
|
visual_change
|
accessibility
|
NULL
|
Project: faVsco.js, menu
JY-20725-handle-HS-search Project: faVsco.js, menu
JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
19
Previous Highlighted Error
Next Highlighted Error
[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {
"headers":{
"Date":["Thu,07 May 2026 14:21:15 GMT"],
"Content-Type":["application/json;charset=utf-8"],
"Transfer-Encoding":["chunked"],
"Connection":["keep-alive"],
"CF-Ray":["9f80deb8db60dc3a-SOF"],
"CF-Cache-Status":["DYNAMIC"],
"Strict-Transport-Security":["max-age=31536000; includeSubDomains; preload"],
"Vary":["origin,
accept-encoding"],
"access-control-allow-credentials":["false"],
"server-timing":["hcid;desc=\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\",
cfr;desc=\"9f80deb8e7c6dc3a-IAD\""],
"x-content-type-options":["nosniff"],
"x-hubspot-correlation-id":["019e02d0-6fd8-7812-bdba-885b7ccb3ee3"],
"Set-Cookie":["__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-[IP_ADDRESS]-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,
07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None"],
"Report-To":["{
\"endpoints\":[{
\"url\":\"https:\\/\\/a.nel.cloudflare.com\\/report\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\"}],
\"group\":\"cf-nel\",
\"max_age\":604800}"],
"NEL":["{
\"success_fraction\":0.01,
\"report_to\":\"cf-nel\",
\"max_age\":604800}"],
"Server":["cloudflare"]}} {
"correlation_id":"95236535-ec98-4541-b92a-adfa73b69eab",
"trace_id":"c7ab8365-903f-46d4-9403-0e5b551e3545"}...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
9805
|
444
|
7
|
2026-05-08T13:34:05.546798+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-08/1778 /Users/lukas/.screenpipe/data/data/2026-05-08/1778247245546_m2.jpg...
|
PhpStorm
|
faVsco.js – Service.php
|
1
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
JY-20725-handle-HS-search Project: faVsco.js, menu
JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
19
Previous Highlighted Error
Next Highlighted Error
[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {
"headers":{
"Date":["Thu,07 May 2026 14:21:15 GMT"],
"Content-Type":["application/json;charset=utf-8"],
"Transfer-Encoding":["chunked"],
"Connection":["keep-alive"],
"CF-Ray":["9f80deb8db60dc3a-SOF"],
"CF-Cache-Status":["DYNAMIC"],
"Strict-Transport-Security":["max-age=31536000; includeSubDomains; preload"],
"Vary":["origin,
accept-encoding"],
"access-control-allow-credentials":["false"],
"server-timing":["hcid;desc=\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\",
cfr;desc=\"9f80deb8e7c6dc3a-IAD\""],
"x-content-type-options":["nosniff"],
"x-hubspot-correlation-id":["019e02d0-6fd8-7812-bdba-885b7ccb3ee3"],
"Set-Cookie":["__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-[IP_ADDRESS]-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,
07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None"],
"Report-To":["{
\"endpoints\":[{
\"url\":\"https:\\/\\/a.nel.cloudflare.com\\/report\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\"}],
\"group\":\"cf-nel\",
\"max_age\":604800}"],
"NEL":["{
\"success_fraction\":0.01,
\"report_to\":\"cf-nel\",
\"max_age\":604800}"],
"Server":["cloudflare"]}} {
"correlation_id":"95236535-ec98-4541-b92a-adfa73b69eab",
"trace_id":"c7ab8365-903f-46d4-9403-0e5b551e3545"}
Code changed:
Hide
Sync Changes
Hide This Notification
7
9
6
Previous Highlighted Error
Next Highlighted Error
<?php
namespace Jiminny\Services\Crm\Hubspot;
use Carbon\Carbon;
use Exception;
use Generator;
use GuzzleHttp\Exception\RequestException;
use Illuminate\Support\Facades\Cache;
use InvalidArgumentException;
use Jiminny\Contracts\Repositories\TeamRepository;
use Jiminny\Contracts\Services\Crm\ClientInterface;
use Jiminny\Contracts\Services\Crm\FetchRelatedActivityInterface;
use Jiminny\Contracts\Services\Crm\LayoutManagementInterface;
use Jiminny\Contracts\Services\Crm\MatchCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\Provider\HubspotInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityLookupInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityManipulationInterface;
use Jiminny\Contracts\Services\Crm\SavePlaybackLinkToCrmInterface;
use Jiminny\Contracts\Services\Crm\SendSummaryToCrmInterface;
use Jiminny\Contracts\Services\Crm\SettingsInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmMetadataInterface;
use Jiminny\Contracts\Services\Crm\VerifyTaskExistsInterface;
use Jiminny\Exceptions\CrmException;
use Jiminny\Exceptions\HttpNotFoundException;
use Jiminny\Jobs\Crm\NoteObject;
use Jiminny\Models\Account;
use Jiminny\Models\Activity;
use Jiminny\Models\Contact;
use Jiminny\Models\Contracts\ActivityContract;
use Jiminny\Models\Crm\BusinessProcess;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\FieldData;
use Jiminny\Models\Crm\Layout;
use Jiminny\Models\Crm\Profile;
use Jiminny\Models\Lead;
use Jiminny\Models\Opportunity;
use Jiminny\Models\Participant;
use Jiminny\Models\Playbook;
use Jiminny\Models\SocialAccount;
use Jiminny\Models\Stage;
use Jiminny\Models\User;
use Jiminny\Repositories\Crm\CrmEntityRepository;
use Jiminny\Repositories\Crm\FieldRepository;
use Jiminny\Repositories\Crm\ProfileRepository;
use Jiminny\Repositories\ParticipantRepository;
use Jiminny\Services\Avatar\ProspectPhotoPathService;
use Jiminny\Services\Crm\BaseService;
use Jiminny\Services\Crm\Hubspot\Actions\SyncArchivedProfilesAction;
use Jiminny\Services\Crm\Hubspot\Fields\ValueNormalizer;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\OpportunitySyncTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\SyncCrmEntitiesTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\SyncFieldsTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\WriteCrmTrait;
use Jiminny\Services\Crm\MatchDomainByEmailInterface;
use Jiminny\Services\Crm\OpportunitySyncStrategyResolver;
use Jiminny\Services\Crm\ResolveCompanyNameByEmailTrait;
use Jiminny\Utils\PlaybackUrlBuilder;
use Sentry;
use SevenShores\Hubspot\Exceptions\BadRequest;
use Throwable;
use UnexpectedValueException;
/**
* @phpstan-type CrmFieldDefinition array{
* name: string,
* label: string,
* description: string,
* type: string,
* fieldType: string,
* hidden: bool,
* showCurrencySymbol: bool,
* options: array<array{
* id: string,
* label: string,
* value?: string,
* }
*/
class Service extends BaseService implements
HubspotInterface,
SyncCrmEntitiesInterface,
SyncCrmMetadataInterface,
SendSummaryToCrmInterface,
MatchDomainByEmailInterface,
SavePlaybackLinkToCrmInterface,
RemoteEntityManipulationInterface,
FetchRelatedActivityInterface,
LayoutManagementInterface,
SettingsInterface,
MatchCrmEntitiesInterface,
RemoteEntityLookupInterface,
VerifyTaskExistsInterface
{
use ResolveCompanyNameByEmailTrait;
use SyncCrmEntitiesTrait;
use WriteCrmTrait;
use SyncFieldsTrait;
use OpportunitySyncTrait;
private const int ENGAGEMENT_BODY_MAX_LENGTH = 65536;
private const string LOG_DATE_FORMAT = 'Y-m-d H:i:s';
private const int BATCH_UPDATE_LIMIT = 100;
private const string TEN_SECONDLY_ROLLING_POLICY = 'TEN_SECONDLY_ROLLING';
private const int TEN_SECONDLY_ROLLING_LIMIT = 10;
private const string TYPE_NOTE = 'NOTE';
private const string TYPE_MEETING = 'MEETING';
private const string TYPE_CALL = 'CALL';
private const string API_URL = '[URL_WITH_CREDENTIALS] ClientInterface|Client
*/
protected $client;
protected OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;
protected CrmEntityRepository $crmEntityRepository;
protected ProspectPhotoPathService $prospectPhotoPathService;
private SyncFieldAction $syncFieldAction;
private PayloadBuilder $payloadBuilder;
private SyncRelatedActivityManager $syncRelatedActivityManager;
private SyncArchivedProfilesAction $syncArchivedProfilesAction;
private WebhookSyncBatchProcessor $batchProcessor;
public function __construct(
Client $client,
SyncFieldAction $syncFieldAction,
PayloadBuilder $payloadBuilder,
ProspectPhotoPathService $prospectPhotoPathService,
SyncArchivedProfilesAction $syncArchivedProfilesAction,
WebhookSyncBatchProcessor $batchProcessor,
) {
parent::__construct();
$this->client = $client;
$this->syncFieldAction = $syncFieldAction;
$this->prospectPhotoPathService = $prospectPhotoPathService;
$this->payloadBuilder = $payloadBuilder;
$this->syncArchivedProfilesAction = $syncArchivedProfilesAction;
$this->batchProcessor = $batchProcessor;
$this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [
'client' => $this->client,
]);
$this->syncRelatedActivityManager = app(SyncRelatedActivityManager::class, [
'client' => $this->client,
'payloadBuilder' => $this->payloadBuilder,
'logger' => $this->logger,
]);
$this->crmEntityRepository = app(CrmEntityRepository::class);
$this->dealFieldsService = app(DealFieldsService::class);
}
public function getDisplayName(): string
{
return 'HubSpot';
}
protected function getOAuthAccount(User $user): ?SocialAccount
{
// In this case, the Account Owner is always the connection for any API operations.
$owner = $user->team->owner;
return $owner->getSocialAccount(SocialAccount::PROVIDER_HUBSPOT);
}
public function getClient(): Client
{
/** @var Client */
return $this->client;
}
/**
* Convert raw field data into a format compatible with CRM APIs.
*
* @param bool $internal Direction of the conversion.
* True is pulling from CRM, false normalize before sending to CRM.
*/
public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string
{
return ValueNormalizer::normalize(
fieldType: $fieldType,
fieldValue: $fieldValue,
isInbound: $internal,
);
}
/**
* @inheritdoc
*/
public function getDefaultFields(string $activityType): array
{
$fields = [];
if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {
$defaultFields = FieldDefinitions::defaultTaskFields();
// This lazy creates these fields if not already setup.
foreach ($defaultFields as $defaultField) {
$fields[] = $this->config->fields()->firstOrCreate($defaultField);
}
}
return $fields;
}
/**
* @inheritdoc
*/
public function getDefaultActivityField(string $activityType): Field
{
/** @var Field $activityField */
$activityField = $this->config->fields()->where([
'crm_provider_id' => 'activityType',
'object_type' => $activityType,
])->first();
return $activityField;
}
/**
* @inheritdoc
*/
public function getSupportedPlaybookTypes(): array
{
return [Playbook::ACTIVITY_TYPE_TASK];
}
/**
* @inheritdoc
*/
public function getDefaultActivityLayoutFields(string $activityType, string $layoutType): array
{
$fields = [];
if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {
// Outcome should always be provided calls/meetings.
$fieldData = [
[
'crm_provider_id' => $layoutType === Layout::TYPE_SOFTPHONE_SUMMARY ? 'disposition' : 'meetingOutcome',
'object_type' => Field::OBJECT_TASK,
],
];
foreach ($fieldData as $data) {
$field = $this->config->fields()->where($data)->first();
// Only add the field if it is created, which it should be.
if ($field) {
$fields[] = $field;
}
}
}
return $fields;
}
public function getDealInsightsFields(): array
{
return FieldDefinitions::dealInsightsFields();
}
protected function getDefaultFollowupLayoutFields(string $activityType): array
{
$fields = [];
$fieldRepo = app(FieldRepository::class);
$fieldData = FieldDefinitions::followupFieldsFilter();
foreach ($fieldData as $data) {
$field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $data);
// Only add the field if it is created, which it should be.
if ($field) {
$fields[] = $field;
}
}
return $fields;
}
/**
* @inheritdoc
*/
public function syncField(Field $field): void
{
switch ($field->object_type) {
case Field::OBJECT_ACCOUNT:
$crmField = $this->client->getInstance()->companyProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_CONTACT:
$crmField = $this->client->getInstance()->contactProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_OPPORTUNITY:
$crmField = $this->client->getInstance()->dealProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_TASK:
$this->syncSingleTaskField($field);
return;
default:
return;
}
$this->syncFieldAction->execute($field, $crmField->toArray());
}
/**
* @param array<array{
* id:string,
* label:string,
* value?:string
* }> $options
*
* @throws CrmException
*
* @return FieldData[]
*
*/
public function importPicklistValues(
Field $field,
array $options = [['id' => '', 'label' => '', 'value' => '']],
): array {
if (! empty($options[0]['id']) || ! empty($options[0]['value'])) {
// We already have the options, no need to fetch them again
return $this->importOptions($field, $options);
}
$options = [];
switch ($field->getObjectType()) {
case Field::OBJECT_ACCOUNT:
$options = $this->getClient()->fetchPropertyOptions('company', $field->getCrmProviderId());
break;
case Field::OBJECT_CONTACT:
$options = $this->getClient()->fetchPropertyOptions('contact', $field->getCrmProviderId());
break;
case Field::OBJECT_OPPORTUNITY:
// Hubspot has different endpoint for stages
$options = $this->getClient()->fetchOpportunityFieldOptions($field);
break;
case Field::OBJECT_TASK:
if ($field->getCrmProviderId() === 'disposition') {
$options = $this->getClient()->fetchDispositionFieldOptions();
} elseif (in_array($field->getCrmProviderId(), ['meetingOutcome', 'activityType'])) {
$options = $this->getClient()->fetchMeetingOutcomeFieldOptions($field);
}
break;
default:
$this->logger->warning('Invalid object type', [
'object_type' => $field->getObjectType(),
'field_id' => $field->getId(),
]);
throw new CrmException('Invalid object type');
}
return $this->importOptions($field, $options);
}
/**
* @inheritdoc
*/
public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage
{
$missingStage = null;
try {
// Use the HubSpot API client instead of the SDK crmPipelines() method
$endpoint = self::getDealsPipelinesEndpoint();
$pipelinesResponse = $this->client->getInstance()->getClient()->request('GET', $endpoint);
$pipelines = $pipelinesResponse->data->results;
} catch (RequestException|BadRequest $exception) {
throw $exception;
}
foreach ($pipelines as $pipeline) {
$stages = [];
// We create a business process to contain the pipeline, and store all stages against it.
$p = ResponseNormalize::normalizePipeline($pipeline);
// Create/update business process for this pipeline
$businessProcess = $this->config->businessProcesses()->updateOrCreate([
'crm_provider_id' => $p['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($p['label'], 0, 150),
'type' => BusinessProcess::TYPE_OPPORTUNITY,
'is_selectable' => $p['active'],
]);
// A record type is really a clone of the business process, used to store which record uses which pipeline.
// Create/update record type clone
$this->config->recordTypes()->updateOrCreate([
'crm_provider_id' => $p['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($p['label'], 0, 150),
'is_selectable' => $p['active'],
'business_process_id' => $businessProcess->id ?? null,
]);
// Stages - fetch all existing stages upfront to avoid N+1 queries
$existingStages = $this->config->stages()
->withTrashed()
->where('type', Stage::TYPE_OPPORTUNITY)
->get()
->keyBy('crm_provider_id');
foreach ($p['stages'] as $dealStage) {
$s = ResponseNormalize::normalizeDealStage($dealStage);
/** @var ?Stage $existingStage */
$existingStage = $existingStages->get($s['id']);
// Restore soft-deleted stages that are now active in HubSpot
if ($existingStage?->trashed() && $s['active']) {
$existingStage->restore();
}
// Upsert stage (updates soft-deleted records without restoring them)
$stage = $this->config->stages()->withTrashed()->updateOrCreate([
'crm_provider_id' => $s['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($s['label'], 0, 50),
'label' => mb_strimwidth($s['label'], 0, 191),
'type' => Stage::TYPE_OPPORTUNITY,
'sequence' => $s['displayOrder'],
'is_selectable' => $s['active'],
'probability' => $s['probability'] * 100,
]);
if ($missingStageName === $s['id']) {
$missingStage = $stage;
}
$stages[] = $stage->id;
}
$businessProcess->stages()->sync($stages);
}
return $missingStage;
}
/**
* @inheritdoc
*/
public function syncOrganization(): void
{
try {
$endpoint = '[URL_WITH_CREDENTIALS]
*/
public function find(string $name, array $scopes): array
{
$count = $this->limit ?? 20;
$offset = $this->offset ?? 0;
/** @var array<int, array<string, mixed>> */
return Cache::remember(
key: $this->team->getId() . $name . $count . $offset,
ttl: 300,
callback: function () use ($name, $offset, $count): array {
$data = [];
// Use the new V3 API to find contacts based on additional fields.
foreach (['companies', 'contacts'] as $objectType) {
$payload = $this->generateNameSearchPayload($name, $offset, $count);
$type = $objectType === 'companies' ? 'account' : 'contact';
try {
$response = $this->client->search($objectType, $payload);
// Build mapped list.
foreach ($response['results'] as $object) {
$properties = $object['properties'];
$objectName = $this->buildContactName($properties);
$record = [
'crmId' => $object['id'],
// Pass crmUrl to the FE, needed for success message in the extension when you log activity.
'crmUrl' => $this->generateProviderUrl($object['id'], $type),
'name' => $objectName,
'prospectType' => $type,
'phoneNumbers' => [],
];
if ($type === 'account') {
$record['industry'] = $properties['industry'] ?? null;
} else {
$record['title'] = $properties['jobtitle'] ?? null;
$record['organization'] = $properties['company'] ?? null;
}
$countryCode = $this->buildContactCountry($properties);
$parsedNumber = $this->buildContactPhone($countryCode, $properties);
// Add phone number to record.
if (! empty($parsedNumber['phone'])) {
$record['phoneNumbers'][] = [
'number' => $parsedNumber['phone'],
'nationalFormat' => phone_national($countryCode, $parsedNumber['phone']),
'type' => 'phone',
];
}
// Add mobile phone number to record.
if (! empty($properties['mobilephone'])) {
$mobileNumber = phone_e164($countryCode, $properties['mobilephone']);
if ($mobileNumber !== null) {
$record['phoneNumbers'][] = [
'number' => $mobileNumber,
'nationalFormat' => phone_national($countryCode, $mobileNumber),
'type' => 'mobile',
];
}
}
$data[] = $record;
}
} catch (BadRequest $e) {
$this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [
'teamId' => $this->team->getUuid(),
'request' => $payload,
'reason' => $e->getMessage(),
]);
throw $e;
}
}
return $data;
},
);
}
/**
* @inheritdoc
*/
public function findOpportunities(?string $crmAccountId, ?string $crmContactId, ?int $userId = null): array
{
$data = [];
$ownerData = [];
$ownerId = null;
if ($crmAccountId === null) {
return $data;
}
if ($userId) {
$profileRepository = app(ProfileRepository::class);
$profile = $profileRepository->findProfileByUserId($this->config, $userId);
$ownerId = $profile instanceof Profile ? $profile->getCrmProviderId() : null;
}
$closedStages = $this->getClosedDealStages();
$payload = $this->payloadBuilder->generateOpportunitiesSearchPayload(
$this->config,
$crmAccountId,
$closedStages,
);
$results = $this->client->getPaginatedData($payload, 'deals');
foreach ($results['results'] as $object) {
$properties = $object['properties'];
$amount = null;
if (empty($properties['amount']) === false) {
$currency = $properties['deal_currency_code'] ?? $this->config->default_currency;
// Values can contain commas and any junk so strip them.
$value = (float) preg_replace('/[^\d.]/', '', $properties['amount']);
$amount = formatCurrency($value, $currency);
}
$businessProcess = $this->config
->businessProcesses()
->where('crm_provider_id', $properties['pipeline'])
->first();
if ($businessProcess === null) {
// Import it.
$stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $properties['dealstage']);
$businessProcess = $this->config
->businessProcesses()
->where('crm_provider_id', $properties['pipeline'])
->first();
} else {
$stage = $businessProcess
->stages()
->where('crm_provider_id', $properties['dealstage'])
->where('type', Stage::TYPE_OPPORTUNITY)
->first();
if ($stage === null) {
// Import it.
$stage = $this->importStages(null, $properties['dealstage']);
}
}
$recordType = null;
if ($businessProcess) {
$recordType = $businessProcess->recordTypes()->first();
}
$isWon = in_array($properties['dealstage'], $closedStages['won']);
$isLost = in_array($properties['dealstage'], $closedStages['lost']);
$record = [
'crmId' => $object['id'],
'name' => $properties['dealname'] ?? 'Unknown Deal',
'value' => $amount,
'won' => $isWon,
'closed' => $isWon || $isLost,
'stage' => [
'id' => $stage?->getUuid() ?? '',
'name' => $stage?->getName() ?? '',
],
];
if ($recordType) {
$record += [
'recordType' => [
'id' => $recordType->id_string,
'name' => $recordType->name,
],
];
}
if ($ownerId && isset($properties['hubspot_owner_id']) && $properties['hubspot_owner_id'] === $ownerId) {
$ownerData[] = $record;
}
$data[] = $record;
}
if (! empty($ownerData)) {
return $ownerData;
}
return $data;
}
/**
* @inheritdoc
*/
public function getTasks(?string $objectType, string $objectId, ?string $opportunityId): array
{
$data = [];
switch ($objectType) {
case 'contact':
$hsObject = 'contact';
break;
case 'account':
$hsObject = 'company';
break;
default:
// This is a hack to prioritise and override a contact/company with a deal.
if ($opportunityId) {
$hsObject = 'deal';
$objectId = $opportunityId;
} else {
throw new InvalidArgumentException('Object type not supported.');
}
}
$engagementTypes = ['meetings', 'tasks'];
foreach ($engagementTypes as $engagementType) {
$payload = $this->payloadBuilder->getLinkToTaskPayload($hsObject, $objectId, $engagementType);
$this->logger->info('[HubSpot] CRM Search requested', [
'request' => $payload,
]);
$engagements = $this->client->getPaginatedData($payload, $engagementType);
foreach ($engagements['results'] as $engagement) {
if ($engagementType == 'meetings') {
$title = $engagement['properties']['hs_meeting_title'] ?? 'Scheduled meeting';
} elseif ($engagementType == 'tasks') {
$title = $engagement['properties']['hs_task_subject'];
} else {
$title = 'Scheduled meeting';
}
$data[] = [
'crmId' => $engagement['id'],
'subject' => $title,
'due' => $engagement['properties']['hs_timestamp'],
'type' => $engagement['properties']['hs_activity_type'] ?? null,
];
}
}
usort($data, function ($item1, $item2) {
return $item2['due'] <=> $item1['due'];
});
return $data;
}
/**
* Try to find CRM Objects using email address
*
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchExactlyByEmail(string $email, ?int $userId = null): ?array
{
$contactProperties = [
'email',
'firstname',
'lastname',
'country',
'phone',
'mobilephone',
'jobtitle',
'hubspot_owner_id',
'associatedcompanyid',
'photo',
];
$contact = null;
$account = null;
try {
$hsContact = $this->getClient()->getContactByEmail($email, $contactProperties);
if ($hsContact) {
$contact = $this->importContact($hsContact);
$account = $contact->account;
}
$data = $this->convertCrmData($contact, $account, $userId);
return ! empty(array_filter($data)) ? $data : null;
} catch (BadRequest $e) {
$this->logger->warning('[HubSpot] Search failed', [
'team_id' => $this->team->getId(),
'search_identifier' => $email,
'reason' => $e->getMessage(),
]);
}
return null;
}
public function getDomain(string $email): ?string
{
return $this->getDomainFromEmail($email);
}
/**
* Try to find CRM objects using domain name of the email address
*
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchByDomain(string $domain, ?int $userId = null): ?array
{
$companyName = $domain;
// Try to find a company matching their email domain.
$companyProperties = [
'country',
'phone',
'name',
'hs_avatar_filemanager_key',
'industry',
'hubspot_owner_id',
'domain',
];
try {
$hsAccounts = $this->client
->getInstance()
->companies()
->searchByDomain($companyName, $companyProperties);
} catch (Throwable $e) {
$this->logger->info('[HubSpot] Search failed', [
'error' => $e->getMessage(),
'domain' => $domain,
]);
return null;
}
$account = null;
// If there are multiple accounts, don't guess, we'll ask later.
if (\count($hsAccounts->data->results) === 1) {
// Persist this remote object.
$account = $this->syncAccount($hsAccounts->data->results[0]->companyId);
}
$data = $this->convertCrmData(null, $account, $userId);
return ! empty(array_filter($data)) ? $data : null;
}
/**
* @return array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
protected function convertCrmData(?Contact $contact, ?Account $account, ?int $userId = null): array
{
$countryCode = null;
if ($contact && $contact->country_code) {
$countryCode = $contact->country_code;
} elseif ($account && $account->country_code) {
$countryCode = $account->country_code;
}
try {
$hsOpportunities = $this->findOpportunities(
$account ? $account->crm_provider_id : null,
$contact ? $contact->crm_provider_id : null,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
// If there are multiple opportunities, don't guess, we'll ask later.
$opportunity = null;
$stage = null;
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
protected function getCacheKey(string $object, ?int $userId = null): ?string
{
$key = $this->team->getId() . $object;
$keySuffix = $this->getOwnerKeySuffix($userId);
return $key . $keySuffix;
}
private function getOwnerKeySuffix(?int $userId = null): string
{
return $userId === null ? '' : (string) $userId;
}
/**
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
*}
*/
public function matchByPhone(string $phone, ?string $rawPhoneNumber = null, ?int $userId = null): ?array
{
if (str_contains($phone, '**')) {
return null;
}
// trim all whitespaces if present so the lookup doesn't fail
$phone = str_replace(' ', '', $phone);
// Check if the user is internal.
if ($this->isPhoneNumberOfTeamMember($phone)) {
return null;
}
$response = $this->searchForPhoneNumber($phone);
if (empty($response)) {
return null;
}
// This would ideally importContact instead but the response type differs.
$contact = $this->findAndSyncContact($response['results'][0]['id']);
if (! $contact instanceof Contact) {
return null;
}
$account = $contact->account;
$countryCode = $contact->country_code ?? $account->country_code ?? null;
try {
$hsOpportunities = $this->findOpportunities(
$account?->crm_provider_id,
$contact->crm_provider_id,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
$opportunity = null;
$stage = null;
try {
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
} catch (Exception $e) {
$this->logger->debug('[HubSpot] Opportunity failed to sync.', [
'reason' => $e->getMessage(),
]);
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
private function isPhoneNumberOfTeamMember(string $phone): bool
{
$teamRepository = app(TeamRepository::class);
$user = $teamRepository->findTeamMemberByPhone($this->team, $phone);
if ($user instanceof User) {
return true;
}
return false;
}
private function findAndSyncContact(string $crmId): ?Contact
{
try {
return $this->syncContact($crmId);
} catch (Exception $exception) {
$this->logger->info('[HubSpot] Phone match failed', [
'reason' => $exception->getMessage(),
]);
return null;
}
}
private function hasResults(array $response): bool
{
return isset($response['total']) && is_numeric($response['total']) && $response['total'] > 0;
}
private function searchForPhoneNumber(string $phone): array
{
// Normalizes the provided phone number for the API search.
$normalizedPhone = $this->normalizePhoneNumber($phone);
$payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone);
$this->logger->info('[HubSpot] Phone match search triggered', [
'phone' => $phone,
'normalizedPhone' => $normalizedPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($normalizedPhone, $payload);
if (! $this->hasResults($response)) {
$nationalPhone = preg_replace('/\D/', '', phone_national(null, $phone));
$payload = $this->payloadBuilder->generatePhoneSearchPayload($nationalPhone);
$this->logger->info('[HubSpot] Phone match national number search triggered', [
'phone' => $phone,
'nationalPhone' => $nationalPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($phone, $payload);
}
if (! $this->hasResults($response)) {
$payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone, true);
$this->logger->info('[HubSpot] Phone match alternative search triggered', [
'phone' => $phone,
'normalizedPhone' => $normalizedPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($phone, $payload);
}
return $this->hasResults($response) ? $response : [];
}
private function handlePhoneSearchRequest(string $phone, array $payload): array
{
try {
return $this->client->search('contacts', $payload);
} catch (Exception $exception) {
$this->logger->info('[HubSpot] Phone match failed', [
'phone' => $phone,
'reason' => $exception->getMessage(),
]);
return [];
}
}
private function normalizePhoneNumber(string $phone): string
{
return ltrim(phone_e164(null, $phone), '+0');
}
/**
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchByName(string $name, ?int $userId = null): ?array
{
// $payload = $this->payloadBuilder->generateSearchContactsByNamePayload(
// $name,
// $this->getContactFields()
// );
// $hsContacts = $this->client->getPaginatedData($payload, 'contact');
// return null;
// Don't waste time searching for single character strings.
if (\strlen($name) <= 1) {
return null;
}
$cacheKey = $this->getCacheKey($name, $userId);
$result = Cache::remember($cacheKey, 60, function () use ($name, $userId) {
$payload = $this->payloadBuilder->generateSearchContactsByNamePayload(
$name,
$this->getContactFields()
);
$hsContacts = $this->client->getPaginatedData($payload, 'contact');
if (empty($hsContacts['results'])) {
return false;
}
$contact = $this->importContact($hsContacts['results'][0]);
if ($contact === null) {
return false;
}
$account = $contact->account;
$countryCode = $contact->country_code ?? $account->country_code ?? null;
try {
$hsOpportunities = $this->findOpportunities(
$account ? $account->crm_provider_id : null,
$contact->crm_provider_id,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
$opportunity = null;
$stage = null;
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
});
return is_array($result) ? $result : null;
}
private function convertActivityAssociations(Activity $activity): array
{
return [
'contactIds' => $this->getParticipantsIds($activity),
'companyIds' => $activity->hasAccount() ? [$activity->account->crm_provider_id] : [],
'dealIds' => $activity->hasOpportunity() ? [$activity->opportunity->crm_provider_id] : [],
'ownerIds' => [],
];
}
private function getParticipantsIds(Activity $activity): array
{
$attendees = [];
$participantRepository = app(ParticipantRepository::class);
$participants = $participantRepository->getParticipantsWhoEnteredMeeting($activity);
foreach ($participants as $participant) {
if ($participant->user_id || $participant->isCoach()) {
continue;
}
$contact = $participant->contact()->first();
if ($contact && $contact->crm_provider_id) {
$attendees[] = $contact->crm_provider_id;
} else {
if (! empty($participant->name)) {
$attendeeData = $this->fetchMissingAttendeeInfo($participant);
}
if (! empty($attendeeData['id'])) {
$attendees[] = $attendeeData['id'];
}
}
}
if ($activity->hasContact()) {
$attendees[] = $activity->contact->crm_provider_id;
}
return array_unique($attendees);
}
private function fetchMissingAttendeeInfo(Participant $participant): array
{
// Check if we need to look inside an account context.
$activity = $participant->getActivity();
$companyId = $activity->hasAccount() ? $activity->getAccount()->crm_provider_id : null;
// First check the local data.
/** @var Contact[] $contacts */
$contacts = $this->team->contacts()
->with('account')
->where('name', $participant->name)
->whereNotNull('email')
->get();
foreach ($contacts as $contact) {
// If we have a company in scope, check the contact is associated to it.
if (
$companyId !== null
&& ($contact->account_id === null || $companyId !== $contact->account->crm_provider_id)
) {
continue;
}
return [
'id' => $contact->crm_provider_id,
'email' => $contact->email,
];
}
$payload = $this->generateNameSearchPayload($participant->name, 0, 20);
try {
$response = $this->client->getNewInstance()->crm()->contacts()->searchApi()->doSearch($payload);
// TODO add some logic to choose the most suitable contact if multiple
foreach ($response['results'] as $object) {
$properties = $object['properties'];
if (empty($object['properties']) === false) {
// Check the company matches the contact.
// Todo: Move this check inside the API search.
if ($companyId !== null && $companyId !== $properties['associatedcompanyid']) {
continue;
}
return [
'id' => $object['id'],
'email' => $properties['email'],
];
}
}
} catch (Exception $e) {
$this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [
'teamId' => $this->team->id_string,
'request' => $payload,
'reason' => $e->getMessage(),
]);
}
return [];
}
/**
* Store transcripts as note engagement.
*
* @throws Exception
*/
public function createTranscriptNotes(Activity $activity): void
{
// For HS no need to check if Crm profile - Log Notes field is enabled
// We only check if store_transcript toggle is enabled on crm profile.
$engagement = [
'active' => true,
'ownerId' => $this->profile->crm_provider_id,
'timestamp' => $activity->created_at->tz($activity->user->timezone)->getTimestamp() * 1000,
'type' => 'NOTE',
];
// Generate activity transcription.
$transcriptionData = $this->generateTranscription($activity);
// Truncate Notes with max notes length because transcription text could be very long.
$transcripts = mb_strimwidth($transcriptionData, 0, static::ENGAGEMENT_BODY_MAX_LENGTH);
$metadata = [
'body' => $transcripts,
];
$associations = $this->convertActivityAssociations($activity);
try {
$hsEngagement = $this->client
->getInstance()
->engagements()
->create($engagement, $associations, $metadata);
$this->logCrmEngagementManipulation(self::ACTION_CREATE, $engagement, $metadata, $associations);
$noteId = $hsEngagement->data->engagement->id;
// Store crm logged id in transcription.
$transcription = $activity->getTranscription();
$transcription->crm_activity_id = $noteId;
$transcription->save();
} catch (Exception $e) {
Sentry::captureException($e);
}
}
/*
* @inheritdoc
*/
public function updateRecord(string $objectType, string $objectId, array $data, array $headers = []): void
{
$payload = [
'properties' => $data,
];
try {
switch ($objectType) {
case FieldData::OBJECT_OPPORTUNITY:
$this->client->getNewInstance()->crm()->deals()->basicApi()->update($objectId, $payload);
break;
case FieldData::OBJECT_CONTACT:
$this->client->getNewInstance()->crm()->contacts()->basicApi()->update($objectId, $payload);
break;
case FieldData::OBJECT_ACCOUNT:
$this->client->getNewInstance()->crm()->companies()->basicApi()->update($objectId, $payload);
break;
case FieldData::OBJECT_TASK:
// Endpoint for Engagements not ready
$engagements = [
'type' => 'TASK',
];
$metadata =...
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"bounds":{"left":0.025930852,"top":0.019952115,"width":0.03856383,"height":0.025538707},"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JY-20725-handle-HS-search-rate-limit, menu","depth":5,"bounds":{"left":0.064494684,"top":0.019952115,"width":0.09541223,"height":0.025538707},"on_screen":true,"help_text":"Git Branch: JY-20725-handle-HS-search-rate-limit","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"bounds":{"left":0.8081782,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"AskJiminnyReportActivityServiceTest","depth":6,"bounds":{"left":0.8234708,"top":0.019952115,"width":0.09208777,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'AskJiminnyReportActivityServiceTest'","depth":6,"bounds":{"left":0.9155585,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'AskJiminnyReportActivityServiceTest'","depth":6,"bounds":{"left":0.9268617,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"bounds":{"left":0.9381649,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"bounds":{"left":0.96609044,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"bounds":{"left":0.9773936,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"IDE and Project Settings","depth":5,"bounds":{"left":0.9886968,"top":0.019952115,"width":0.011303186,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.042220745,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"19","depth":4,"bounds":{"left":0.6615692,"top":0.10055866,"width":0.009640957,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"bounds":{"left":0.67287236,"top":0.09896249,"width":0.00731383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"bounds":{"left":0.68018615,"top":0.09896249,"width":0.006981383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {\n\"headers\":{\n\"Date\":[\"Thu,07 May 2026 14:21:15 GMT\"],\n \"Content-Type\":[\"application/json;charset=utf-8\"],\n \"Transfer-Encoding\":[\"chunked\"],\n \"Connection\":[\"keep-alive\"],\n \"CF-Ray\":[\"9f80deb8db60dc3a-SOF\"],\n \"CF-Cache-Status\":[\"DYNAMIC\"],\n \"Strict-Transport-Security\":[\"max-age=31536000; includeSubDomains; preload\"],\n \"Vary\":[\"origin,\n accept-encoding\"],\n \"access-control-allow-credentials\":[\"false\"],\n \"server-timing\":[\"hcid;desc=\\\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\\\",\n cfr;desc=\\\"9f80deb8e7c6dc3a-IAD\\\"\"],\n \"x-content-type-options\":[\"nosniff\"],\n \"x-hubspot-correlation-id\":[\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\"],\n \"Set-Cookie\":[\"__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-1.0.1.1-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,\n 07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None\"],\n \"Report-To\":[\"{\n\\\"endpoints\\\":[{\n\\\"url\\\":\\\"https:\\\\/\\\\/a.nel.cloudflare.com\\\\/report\\\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\\\"}],\n\\\"group\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"NEL\":[\"{\n\\\"success_fraction\\\":0.01,\n\\\"report_to\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"Server\":[\"cloudflare\"]}} {\n\"correlation_id\":\"95236535-ec98-4541-b92a-adfa73b69eab\",\n\"trace_id\":\"c7ab8365-903f-46d4-9403-0e5b551e3545\"}","depth":4,"bounds":{"left":0.37632978,"top":0.0,"width":0.5728058,"height":0.632083},"on_screen":true,"lines":[{"char_start":624,"char_count":72,"bounds":{"left":0.37632978,"top":0.0,"width":0.18384309,"height":0.014365523}},{"char_start":696,"char_count":219,"bounds":{"left":0.37632978,"top":0.0,"width":0.56515956,"height":0.014365523}},{"char_start":915,"char_count":83,"bounds":{"left":0.37632978,"top":0.0,"width":0.21243352,"height":0.014365523}},{"char_start":998,"char_count":20,"bounds":{"left":0.37632978,"top":0.0,"width":0.04920213,"height":0.014365523}},{"char_start":1018,"char_count":17,"bounds":{"left":0.37632978,"top":0.0,"width":0.041223403,"height":0.014365523}},{"char_start":1035,"char_count":203,"bounds":{"left":0.37632978,"top":0.0,"width":0.52360374,"height":0.014365523}},{"char_start":1238,"char_count":22,"bounds":{"left":0.37632978,"top":0.007980846,"width":0.05418883,"height":0.014365523}},{"char_start":1260,"char_count":23,"bounds":{"left":0.37632978,"top":0.025538707,"width":0.056848403,"height":0.014365523}},{"char_start":1283,"char_count":10,"bounds":{"left":0.37632978,"top":0.04309657,"width":0.023271276,"height":0.014365523}},{"char_start":1293,"char_count":27,"bounds":{"left":0.37632978,"top":0.060654428,"width":0.06715426,"height":0.014365523}},{"char_start":1320,"char_count":26,"bounds":{"left":0.37632978,"top":0.07821229,"width":0.06482713,"height":0.014365523}},{"char_start":1346,"char_count":23,"bounds":{"left":0.37632978,"top":0.09577015,"width":0.056848403,"height":0.014365523}},{"char_start":1369,"char_count":28,"bounds":{"left":0.37632978,"top":0.11332801,"width":0.06981383,"height":0.014365523}},{"char_start":1397,"char_count":57,"bounds":{"left":0.37632978,"top":0.13088587,"width":0.14494681,"height":0.014365523}}],"value":"[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {\n\"headers\":{\n\"Date\":[\"Thu,07 May 2026 14:21:15 GMT\"],\n \"Content-Type\":[\"application/json;charset=utf-8\"],\n \"Transfer-Encoding\":[\"chunked\"],\n \"Connection\":[\"keep-alive\"],\n \"CF-Ray\":[\"9f80deb8db60dc3a-SOF\"],\n \"CF-Cache-Status\":[\"DYNAMIC\"],\n \"Strict-Transport-Security\":[\"max-age=31536000; includeSubDomains; preload\"],\n \"Vary\":[\"origin,\n accept-encoding\"],\n \"access-control-allow-credentials\":[\"false\"],\n \"server-timing\":[\"hcid;desc=\\\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\\\",\n cfr;desc=\\\"9f80deb8e7c6dc3a-IAD\\\"\"],\n \"x-content-type-options\":[\"nosniff\"],\n \"x-hubspot-correlation-id\":[\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\"],\n \"Set-Cookie\":[\"__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-1.0.1.1-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,\n 07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None\"],\n \"Report-To\":[\"{\n\\\"endpoints\\\":[{\n\\\"url\\\":\\\"https:\\\\/\\\\/a.nel.cloudflare.com\\\\/report\\\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\\\"}],\n\\\"group\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"NEL\":[\"{\n\\\"success_fraction\\\":0.01,\n\\\"report_to\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"Server\":[\"cloudflare\"]}} {\n\"correlation_id\":\"95236535-ec98-4541-b92a-adfa73b69eab\",\n\"trace_id\":\"c7ab8365-903f-46d4-9403-0e5b551e3545\"}","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.042220745,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"7","depth":4,"bounds":{"left":0.31349733,"top":0.17478053,"width":0.0076462766,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"9","depth":4,"bounds":{"left":0.3231383,"top":0.17478053,"width":0.007978723,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"6","depth":4,"bounds":{"left":0.3331117,"top":0.17478053,"width":0.007978723,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"bounds":{"left":0.34275267,"top":0.17318435,"width":0.00731383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"bounds":{"left":0.35006648,"top":0.17318435,"width":0.006981383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\nnamespace Jiminny\\Services\\Crm\\Hubspot;\n\nuse Carbon\\Carbon;\nuse Exception;\nuse Generator;\nuse GuzzleHttp\\Exception\\RequestException;\nuse Illuminate\\Support\\Facades\\Cache;\nuse InvalidArgumentException;\nuse Jiminny\\Contracts\\Repositories\\TeamRepository;\nuse Jiminny\\Contracts\\Services\\Crm\\ClientInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\FetchRelatedActivityInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\LayoutManagementInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\MatchCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\Provider\\HubspotInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityLookupInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityManipulationInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SavePlaybackLinkToCrmInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SendSummaryToCrmInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SettingsInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmMetadataInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\VerifyTaskExistsInterface;\nuse Jiminny\\Exceptions\\CrmException;\nuse Jiminny\\Exceptions\\HttpNotFoundException;\nuse Jiminny\\Jobs\\Crm\\NoteObject;\nuse Jiminny\\Models\\Account;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Contact;\nuse Jiminny\\Models\\Contracts\\ActivityContract;\nuse Jiminny\\Models\\Crm\\BusinessProcess;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Models\\Crm\\FieldData;\nuse Jiminny\\Models\\Crm\\Layout;\nuse Jiminny\\Models\\Crm\\Profile;\nuse Jiminny\\Models\\Lead;\nuse Jiminny\\Models\\Opportunity;\nuse Jiminny\\Models\\Participant;\nuse Jiminny\\Models\\Playbook;\nuse Jiminny\\Models\\SocialAccount;\nuse Jiminny\\Models\\Stage;\nuse Jiminny\\Models\\User;\nuse Jiminny\\Repositories\\Crm\\CrmEntityRepository;\nuse Jiminny\\Repositories\\Crm\\FieldRepository;\nuse Jiminny\\Repositories\\Crm\\ProfileRepository;\nuse Jiminny\\Repositories\\ParticipantRepository;\nuse Jiminny\\Services\\Avatar\\ProspectPhotoPathService;\nuse Jiminny\\Services\\Crm\\BaseService;\nuse Jiminny\\Services\\Crm\\Hubspot\\Actions\\SyncArchivedProfilesAction;\nuse Jiminny\\Services\\Crm\\Hubspot\\Fields\\ValueNormalizer;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\OpportunitySyncTrait;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\SyncCrmEntitiesTrait;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\SyncFieldsTrait;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\WriteCrmTrait;\nuse Jiminny\\Services\\Crm\\MatchDomainByEmailInterface;\nuse Jiminny\\Services\\Crm\\OpportunitySyncStrategyResolver;\nuse Jiminny\\Services\\Crm\\ResolveCompanyNameByEmailTrait;\nuse Jiminny\\Utils\\PlaybackUrlBuilder;\nuse Sentry;\nuse SevenShores\\Hubspot\\Exceptions\\BadRequest;\nuse Throwable;\nuse UnexpectedValueException;\n\n/**\n * @phpstan-type CrmFieldDefinition array{\n * name: string,\n * label: string,\n * description: string,\n * type: string,\n * fieldType: string,\n * hidden: bool,\n * showCurrencySymbol: bool,\n * options: array<array{\n * id: string,\n * label: string,\n * value?: string,\n * }\n */\nclass Service extends BaseService implements\n HubspotInterface,\n SyncCrmEntitiesInterface,\n SyncCrmMetadataInterface,\n SendSummaryToCrmInterface,\n MatchDomainByEmailInterface,\n SavePlaybackLinkToCrmInterface,\n RemoteEntityManipulationInterface,\n FetchRelatedActivityInterface,\n LayoutManagementInterface,\n SettingsInterface,\n MatchCrmEntitiesInterface,\n RemoteEntityLookupInterface,\n VerifyTaskExistsInterface\n{\n use ResolveCompanyNameByEmailTrait;\n use SyncCrmEntitiesTrait;\n use WriteCrmTrait;\n use SyncFieldsTrait;\n use OpportunitySyncTrait;\n\n private const int ENGAGEMENT_BODY_MAX_LENGTH = 65536;\n\n private const string LOG_DATE_FORMAT = 'Y-m-d H:i:s';\n private const int BATCH_UPDATE_LIMIT = 100;\n private const string TEN_SECONDLY_ROLLING_POLICY = 'TEN_SECONDLY_ROLLING';\n private const int TEN_SECONDLY_ROLLING_LIMIT = 10;\n\n private const string TYPE_NOTE = 'NOTE';\n\n private const string TYPE_MEETING = 'MEETING';\n\n private const string TYPE_CALL = 'CALL';\n\n private const string API_URL = 'https://api.hubapi.com';\n\n // NB: v1 is legacy - v3 is the newest\n private const string ENDPOINT_PIPELINES = '/crm-pipelines/v1/pipelines/';\n private const string PIPELINE_OBJECT_TYPE_DEALS = 'deals';\n\n private const int TASK_VERIFICATION_CACHE_TTL = 86400; // 1 day\n\n /**\n * @var ClientInterface|Client\n */\n protected $client;\n protected OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;\n protected CrmEntityRepository $crmEntityRepository;\n protected ProspectPhotoPathService $prospectPhotoPathService;\n\n private SyncFieldAction $syncFieldAction;\n private PayloadBuilder $payloadBuilder;\n private SyncRelatedActivityManager $syncRelatedActivityManager;\n private SyncArchivedProfilesAction $syncArchivedProfilesAction;\n private WebhookSyncBatchProcessor $batchProcessor;\n\n public function __construct(\n Client $client,\n SyncFieldAction $syncFieldAction,\n PayloadBuilder $payloadBuilder,\n ProspectPhotoPathService $prospectPhotoPathService,\n SyncArchivedProfilesAction $syncArchivedProfilesAction,\n WebhookSyncBatchProcessor $batchProcessor,\n ) {\n parent::__construct();\n\n $this->client = $client;\n $this->syncFieldAction = $syncFieldAction;\n $this->prospectPhotoPathService = $prospectPhotoPathService;\n $this->payloadBuilder = $payloadBuilder;\n $this->syncArchivedProfilesAction = $syncArchivedProfilesAction;\n $this->batchProcessor = $batchProcessor;\n $this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [\n 'client' => $this->client,\n ]);\n $this->syncRelatedActivityManager = app(SyncRelatedActivityManager::class, [\n 'client' => $this->client,\n 'payloadBuilder' => $this->payloadBuilder,\n 'logger' => $this->logger,\n ]);\n $this->crmEntityRepository = app(CrmEntityRepository::class);\n $this->dealFieldsService = app(DealFieldsService::class);\n }\n\n public function getDisplayName(): string\n {\n return 'HubSpot';\n }\n\n protected function getOAuthAccount(User $user): ?SocialAccount\n {\n // In this case, the Account Owner is always the connection for any API operations.\n $owner = $user->team->owner;\n\n return $owner->getSocialAccount(SocialAccount::PROVIDER_HUBSPOT);\n }\n\n public function getClient(): Client\n {\n /** @var Client */\n return $this->client;\n }\n\n /**\n * Convert raw field data into a format compatible with CRM APIs.\n *\n * @param bool $internal Direction of the conversion.\n * True is pulling from CRM, false normalize before sending to CRM.\n */\n public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string\n {\n return ValueNormalizer::normalize(\n fieldType: $fieldType,\n fieldValue: $fieldValue,\n isInbound: $internal,\n );\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultFields(string $activityType): array\n {\n $fields = [];\n\n if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {\n $defaultFields = FieldDefinitions::defaultTaskFields();\n\n // This lazy creates these fields if not already setup.\n foreach ($defaultFields as $defaultField) {\n $fields[] = $this->config->fields()->firstOrCreate($defaultField);\n }\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultActivityField(string $activityType): Field\n {\n /** @var Field $activityField */\n $activityField = $this->config->fields()->where([\n 'crm_provider_id' => 'activityType',\n 'object_type' => $activityType,\n ])->first();\n\n return $activityField;\n }\n\n /**\n * @inheritdoc\n */\n public function getSupportedPlaybookTypes(): array\n {\n return [Playbook::ACTIVITY_TYPE_TASK];\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultActivityLayoutFields(string $activityType, string $layoutType): array\n {\n $fields = [];\n\n if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {\n // Outcome should always be provided calls/meetings.\n $fieldData = [\n [\n 'crm_provider_id' => $layoutType === Layout::TYPE_SOFTPHONE_SUMMARY ? 'disposition' : 'meetingOutcome',\n 'object_type' => Field::OBJECT_TASK,\n ],\n ];\n\n foreach ($fieldData as $data) {\n $field = $this->config->fields()->where($data)->first();\n\n // Only add the field if it is created, which it should be.\n if ($field) {\n $fields[] = $field;\n }\n }\n }\n\n return $fields;\n }\n\n public function getDealInsightsFields(): array\n {\n return FieldDefinitions::dealInsightsFields();\n }\n\n protected function getDefaultFollowupLayoutFields(string $activityType): array\n {\n $fields = [];\n $fieldRepo = app(FieldRepository::class);\n $fieldData = FieldDefinitions::followupFieldsFilter();\n\n foreach ($fieldData as $data) {\n $field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $data);\n\n // Only add the field if it is created, which it should be.\n if ($field) {\n $fields[] = $field;\n }\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function syncField(Field $field): void\n {\n switch ($field->object_type) {\n case Field::OBJECT_ACCOUNT:\n $crmField = $this->client->getInstance()->companyProperties()->get($field->crm_provider_id);\n\n break;\n case Field::OBJECT_CONTACT:\n $crmField = $this->client->getInstance()->contactProperties()->get($field->crm_provider_id);\n\n break;\n case Field::OBJECT_OPPORTUNITY:\n $crmField = $this->client->getInstance()->dealProperties()->get($field->crm_provider_id);\n\n break;\n case Field::OBJECT_TASK:\n $this->syncSingleTaskField($field);\n\n return;\n default:\n return;\n }\n\n $this->syncFieldAction->execute($field, $crmField->toArray());\n }\n\n /**\n * @param array<array{\n * id:string,\n * label:string,\n * value?:string\n * }> $options\n *\n * @throws CrmException\n *\n * @return FieldData[]\n *\n */\n public function importPicklistValues(\n Field $field,\n array $options = [['id' => '', 'label' => '', 'value' => '']],\n ): array {\n if (! empty($options[0]['id']) || ! empty($options[0]['value'])) {\n // We already have the options, no need to fetch them again\n return $this->importOptions($field, $options);\n }\n\n $options = [];\n\n switch ($field->getObjectType()) {\n case Field::OBJECT_ACCOUNT:\n $options = $this->getClient()->fetchPropertyOptions('company', $field->getCrmProviderId());\n\n break;\n\n case Field::OBJECT_CONTACT:\n $options = $this->getClient()->fetchPropertyOptions('contact', $field->getCrmProviderId());\n\n break;\n\n case Field::OBJECT_OPPORTUNITY:\n // Hubspot has different endpoint for stages\n $options = $this->getClient()->fetchOpportunityFieldOptions($field);\n\n break;\n\n case Field::OBJECT_TASK:\n if ($field->getCrmProviderId() === 'disposition') {\n $options = $this->getClient()->fetchDispositionFieldOptions();\n } elseif (in_array($field->getCrmProviderId(), ['meetingOutcome', 'activityType'])) {\n $options = $this->getClient()->fetchMeetingOutcomeFieldOptions($field);\n }\n\n break;\n\n default:\n $this->logger->warning('Invalid object type', [\n 'object_type' => $field->getObjectType(),\n 'field_id' => $field->getId(),\n ]);\n\n throw new CrmException('Invalid object type');\n }\n\n return $this->importOptions($field, $options);\n }\n\n /**\n * @inheritdoc\n */\n public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage\n {\n $missingStage = null;\n\n try {\n // Use the HubSpot API client instead of the SDK crmPipelines() method\n $endpoint = self::getDealsPipelinesEndpoint();\n $pipelinesResponse = $this->client->getInstance()->getClient()->request('GET', $endpoint);\n $pipelines = $pipelinesResponse->data->results;\n } catch (RequestException|BadRequest $exception) {\n throw $exception;\n }\n\n foreach ($pipelines as $pipeline) {\n $stages = [];\n\n // We create a business process to contain the pipeline, and store all stages against it.\n $p = ResponseNormalize::normalizePipeline($pipeline);\n\n // Create/update business process for this pipeline\n $businessProcess = $this->config->businessProcesses()->updateOrCreate([\n 'crm_provider_id' => $p['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($p['label'], 0, 150),\n 'type' => BusinessProcess::TYPE_OPPORTUNITY,\n 'is_selectable' => $p['active'],\n ]);\n\n // A record type is really a clone of the business process, used to store which record uses which pipeline.\n // Create/update record type clone\n $this->config->recordTypes()->updateOrCreate([\n 'crm_provider_id' => $p['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($p['label'], 0, 150),\n 'is_selectable' => $p['active'],\n 'business_process_id' => $businessProcess->id ?? null,\n ]);\n\n // Stages - fetch all existing stages upfront to avoid N+1 queries\n $existingStages = $this->config->stages()\n ->withTrashed()\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->get()\n ->keyBy('crm_provider_id');\n\n foreach ($p['stages'] as $dealStage) {\n $s = ResponseNormalize::normalizeDealStage($dealStage);\n\n /** @var ?Stage $existingStage */\n $existingStage = $existingStages->get($s['id']);\n\n // Restore soft-deleted stages that are now active in HubSpot\n if ($existingStage?->trashed() && $s['active']) {\n $existingStage->restore();\n }\n\n // Upsert stage (updates soft-deleted records without restoring them)\n $stage = $this->config->stages()->withTrashed()->updateOrCreate([\n 'crm_provider_id' => $s['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($s['label'], 0, 50),\n 'label' => mb_strimwidth($s['label'], 0, 191),\n 'type' => Stage::TYPE_OPPORTUNITY,\n 'sequence' => $s['displayOrder'],\n 'is_selectable' => $s['active'],\n 'probability' => $s['probability'] * 100,\n ]);\n\n if ($missingStageName === $s['id']) {\n $missingStage = $stage;\n }\n\n $stages[] = $stage->id;\n }\n\n $businessProcess->stages()->sync($stages);\n }\n\n return $missingStage;\n }\n\n /**\n * @inheritdoc\n */\n public function syncOrganization(): void\n {\n try {\n $endpoint = 'https://api.hubapi.com/integrations/v1/me';\n $response = $this->client->getInstance()->getClient()->request('get', $endpoint);\n\n $accountData = $response->data;\n $this->config->update(['default_currency' => $accountData->currency]);\n } catch (BadRequest $e) {\n throw new CrmException('Could not sync the organization.', $e->getCode(), $e);\n }\n }\n\n /**\n * @inheritdoc\n *\n * @throws CrmException\n */\n public function syncProfiles(?User $userToSearch = null): ?Profile\n {\n $this->syncArchivedProfilesAction->execute($this->team, $this->client, $this->config);\n\n try {\n $owners = $this->client->getOwners();\n } catch (\\HubSpot\\Client\\Crm\\Owners\\ApiException $e) {\n $this->logger->error('[HubSpot] Could not sync the profiles.', [\n 'team_id' => $this->team->getId(),\n 'reason' => $e->getMessage(),\n ]);\n\n throw new CrmException('Could not sync the profiles.', $e->getCode(), $e);\n }\n\n $profileRepository = app(ProfileRepository::class);\n $teamRepository = app(TeamRepository::class);\n\n foreach ($owners as $owner) {\n if ($owner->getArchived()) {\n // not supposed to fetch archived, but log anyway\n $this->logger->warning('[HubSpot] Found archived owner', [\n 'crm_provider_id' => $owner->getId(),\n 'email' => $owner->getEmail(),\n ]);\n\n continue;\n }\n\n $email = $owner->getEmail();\n if ($email === null) {\n continue;\n }\n\n $user = $teamRepository->findActiveTeamMemberByEmail($this->team, $email);\n\n if (! $user instanceof User) {\n continue;\n }\n\n $profile = $profileRepository->updateOrCreateProfile($user, [\n 'crm_configuration_id' => $this->config->getId(),\n 'crm_provider_id' => $owner->getId(),\n ]);\n\n if ($userToSearch && $userToSearch->getId() === $user->getId()) {\n return $profile;\n }\n }\n\n return null;\n }\n\n private function generateNameSearchPayload(string $name, int $offset, int $limit): array\n {\n $payload = [\n 'query' => $name,\n 'sorts' => [\n [\n 'propertyName' => 'modifieddate',\n 'direction' => 'DESCENDING',\n ],\n ],\n 'properties' => [\n 'email',\n 'firstname',\n 'lastname',\n 'country',\n 'phone',\n 'mobilephone',\n 'jobtitle',\n 'hubspot_owner_id',\n 'associatedcompanyid',\n 'photo',\n 'industry',\n 'name',\n 'company',\n ],\n 'limit' => $limit,\n 'after' => $offset,\n ];\n\n $this->logger->debug('[HubSpot] CRM Search requested', [\n 'request' => $payload,\n ]);\n\n return $payload;\n }\n\n /**\n * @inheritdoc\n */\n public function find(string $name, array $scopes): array\n {\n $count = $this->limit ?? 20;\n $offset = $this->offset ?? 0;\n\n /** @var array<int, array<string, mixed>> */\n return Cache::remember(\n key: $this->team->getId() . $name . $count . $offset,\n ttl: 300,\n callback: function () use ($name, $offset, $count): array {\n $data = [];\n\n // Use the new V3 API to find contacts based on additional fields.\n foreach (['companies', 'contacts'] as $objectType) {\n $payload = $this->generateNameSearchPayload($name, $offset, $count);\n $type = $objectType === 'companies' ? 'account' : 'contact';\n\n try {\n $response = $this->client->search($objectType, $payload);\n\n // Build mapped list.\n foreach ($response['results'] as $object) {\n $properties = $object['properties'];\n\n $objectName = $this->buildContactName($properties);\n\n $record = [\n 'crmId' => $object['id'],\n // Pass crmUrl to the FE, needed for success message in the extension when you log activity.\n 'crmUrl' => $this->generateProviderUrl($object['id'], $type),\n 'name' => $objectName,\n 'prospectType' => $type,\n 'phoneNumbers' => [],\n ];\n\n if ($type === 'account') {\n $record['industry'] = $properties['industry'] ?? null;\n } else {\n $record['title'] = $properties['jobtitle'] ?? null;\n $record['organization'] = $properties['company'] ?? null;\n }\n\n $countryCode = $this->buildContactCountry($properties);\n $parsedNumber = $this->buildContactPhone($countryCode, $properties);\n\n // Add phone number to record.\n if (! empty($parsedNumber['phone'])) {\n $record['phoneNumbers'][] = [\n 'number' => $parsedNumber['phone'],\n 'nationalFormat' => phone_national($countryCode, $parsedNumber['phone']),\n 'type' => 'phone',\n ];\n }\n\n // Add mobile phone number to record.\n if (! empty($properties['mobilephone'])) {\n $mobileNumber = phone_e164($countryCode, $properties['mobilephone']);\n if ($mobileNumber !== null) {\n $record['phoneNumbers'][] = [\n 'number' => $mobileNumber,\n 'nationalFormat' => phone_national($countryCode, $mobileNumber),\n 'type' => 'mobile',\n ];\n }\n }\n\n $data[] = $record;\n }\n } catch (BadRequest $e) {\n $this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [\n 'teamId' => $this->team->getUuid(),\n 'request' => $payload,\n 'reason' => $e->getMessage(),\n ]);\n\n throw $e;\n }\n }\n\n return $data;\n },\n );\n }\n\n\n /**\n * @inheritdoc\n */\n public function findOpportunities(?string $crmAccountId, ?string $crmContactId, ?int $userId = null): array\n {\n $data = [];\n $ownerData = [];\n $ownerId = null;\n\n if ($crmAccountId === null) {\n return $data;\n }\n\n if ($userId) {\n $profileRepository = app(ProfileRepository::class);\n $profile = $profileRepository->findProfileByUserId($this->config, $userId);\n\n $ownerId = $profile instanceof Profile ? $profile->getCrmProviderId() : null;\n }\n\n $closedStages = $this->getClosedDealStages();\n $payload = $this->payloadBuilder->generateOpportunitiesSearchPayload(\n $this->config,\n $crmAccountId,\n $closedStages,\n );\n\n $results = $this->client->getPaginatedData($payload, 'deals');\n\n foreach ($results['results'] as $object) {\n $properties = $object['properties'];\n\n $amount = null;\n if (empty($properties['amount']) === false) {\n $currency = $properties['deal_currency_code'] ?? $this->config->default_currency;\n\n // Values can contain commas and any junk so strip them.\n $value = (float) preg_replace('/[^\\d.]/', '', $properties['amount']);\n $amount = formatCurrency($value, $currency);\n }\n\n $businessProcess = $this->config\n ->businessProcesses()\n ->where('crm_provider_id', $properties['pipeline'])\n ->first();\n\n if ($businessProcess === null) {\n // Import it.\n $stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $properties['dealstage']);\n $businessProcess = $this->config\n ->businessProcesses()\n ->where('crm_provider_id', $properties['pipeline'])\n ->first();\n } else {\n $stage = $businessProcess\n ->stages()\n ->where('crm_provider_id', $properties['dealstage'])\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->first();\n\n if ($stage === null) {\n // Import it.\n $stage = $this->importStages(null, $properties['dealstage']);\n }\n }\n\n $recordType = null;\n if ($businessProcess) {\n $recordType = $businessProcess->recordTypes()->first();\n }\n\n $isWon = in_array($properties['dealstage'], $closedStages['won']);\n $isLost = in_array($properties['dealstage'], $closedStages['lost']);\n\n $record = [\n 'crmId' => $object['id'],\n 'name' => $properties['dealname'] ?? 'Unknown Deal',\n 'value' => $amount,\n 'won' => $isWon,\n 'closed' => $isWon || $isLost,\n 'stage' => [\n 'id' => $stage?->getUuid() ?? '',\n 'name' => $stage?->getName() ?? '',\n ],\n ];\n\n if ($recordType) {\n $record += [\n 'recordType' => [\n 'id' => $recordType->id_string,\n 'name' => $recordType->name,\n ],\n ];\n }\n\n if ($ownerId && isset($properties['hubspot_owner_id']) && $properties['hubspot_owner_id'] === $ownerId) {\n $ownerData[] = $record;\n }\n\n $data[] = $record;\n }\n\n if (! empty($ownerData)) {\n return $ownerData;\n }\n\n return $data;\n }\n\n /**\n * @inheritdoc\n */\n public function getTasks(?string $objectType, string $objectId, ?string $opportunityId): array\n {\n $data = [];\n switch ($objectType) {\n case 'contact':\n $hsObject = 'contact';\n\n break;\n case 'account':\n $hsObject = 'company';\n\n break;\n default:\n // This is a hack to prioritise and override a contact/company with a deal.\n if ($opportunityId) {\n $hsObject = 'deal';\n $objectId = $opportunityId;\n } else {\n throw new InvalidArgumentException('Object type not supported.');\n }\n }\n\n $engagementTypes = ['meetings', 'tasks'];\n\n foreach ($engagementTypes as $engagementType) {\n $payload = $this->payloadBuilder->getLinkToTaskPayload($hsObject, $objectId, $engagementType);\n\n $this->logger->info('[HubSpot] CRM Search requested', [\n 'request' => $payload,\n ]);\n\n $engagements = $this->client->getPaginatedData($payload, $engagementType);\n\n foreach ($engagements['results'] as $engagement) {\n if ($engagementType == 'meetings') {\n $title = $engagement['properties']['hs_meeting_title'] ?? 'Scheduled meeting';\n } elseif ($engagementType == 'tasks') {\n $title = $engagement['properties']['hs_task_subject'];\n } else {\n $title = 'Scheduled meeting';\n }\n\n $data[] = [\n 'crmId' => $engagement['id'],\n 'subject' => $title,\n 'due' => $engagement['properties']['hs_timestamp'],\n 'type' => $engagement['properties']['hs_activity_type'] ?? null,\n ];\n }\n }\n\n usort($data, function ($item1, $item2) {\n return $item2['due'] <=> $item1['due'];\n });\n\n return $data;\n }\n\n /**\n * Try to find CRM Objects using email address\n *\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n public function matchExactlyByEmail(string $email, ?int $userId = null): ?array\n {\n $contactProperties = [\n 'email',\n 'firstname',\n 'lastname',\n 'country',\n 'phone',\n 'mobilephone',\n 'jobtitle',\n 'hubspot_owner_id',\n 'associatedcompanyid',\n 'photo',\n ];\n $contact = null;\n $account = null;\n\n try {\n $hsContact = $this->getClient()->getContactByEmail($email, $contactProperties);\n\n if ($hsContact) {\n $contact = $this->importContact($hsContact);\n $account = $contact->account;\n }\n\n $data = $this->convertCrmData($contact, $account, $userId);\n\n return ! empty(array_filter($data)) ? $data : null;\n } catch (BadRequest $e) {\n $this->logger->warning('[HubSpot] Search failed', [\n 'team_id' => $this->team->getId(),\n 'search_identifier' => $email,\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return null;\n }\n\n public function getDomain(string $email): ?string\n {\n return $this->getDomainFromEmail($email);\n }\n\n /**\n * Try to find CRM objects using domain name of the email address\n *\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n public function matchByDomain(string $domain, ?int $userId = null): ?array\n {\n $companyName = $domain;\n\n // Try to find a company matching their email domain.\n $companyProperties = [\n 'country',\n 'phone',\n 'name',\n 'hs_avatar_filemanager_key',\n 'industry',\n 'hubspot_owner_id',\n 'domain',\n ];\n\n try {\n $hsAccounts = $this->client\n ->getInstance()\n ->companies()\n ->searchByDomain($companyName, $companyProperties);\n } catch (Throwable $e) {\n $this->logger->info('[HubSpot] Search failed', [\n 'error' => $e->getMessage(),\n 'domain' => $domain,\n ]);\n\n return null;\n }\n\n $account = null;\n // If there are multiple accounts, don't guess, we'll ask later.\n if (\\count($hsAccounts->data->results) === 1) {\n // Persist this remote object.\n $account = $this->syncAccount($hsAccounts->data->results[0]->companyId);\n }\n\n $data = $this->convertCrmData(null, $account, $userId);\n\n return ! empty(array_filter($data)) ? $data : null;\n }\n\n /**\n * @return array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n protected function convertCrmData(?Contact $contact, ?Account $account, ?int $userId = null): array\n {\n $countryCode = null;\n if ($contact && $contact->country_code) {\n $countryCode = $contact->country_code;\n } elseif ($account && $account->country_code) {\n $countryCode = $account->country_code;\n }\n\n try {\n $hsOpportunities = $this->findOpportunities(\n $account ? $account->crm_provider_id : null,\n $contact ? $contact->crm_provider_id : null,\n $userId\n );\n } catch (Exception $e) {\n $hsOpportunities = [];\n }\n\n // If there are multiple opportunities, don't guess, we'll ask later.\n $opportunity = null;\n $stage = null;\n if (! empty($hsOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n }\n\n protected function getCacheKey(string $object, ?int $userId = null): ?string\n {\n $key = $this->team->getId() . $object;\n $keySuffix = $this->getOwnerKeySuffix($userId);\n\n return $key . $keySuffix;\n }\n\n private function getOwnerKeySuffix(?int $userId = null): string\n {\n return $userId === null ? '' : (string) $userId;\n }\n\n /**\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n *}\n */\n public function matchByPhone(string $phone, ?string $rawPhoneNumber = null, ?int $userId = null): ?array\n {\n if (str_contains($phone, '**')) {\n return null;\n }\n\n // trim all whitespaces if present so the lookup doesn't fail\n $phone = str_replace(' ', '', $phone);\n\n // Check if the user is internal.\n if ($this->isPhoneNumberOfTeamMember($phone)) {\n return null;\n }\n\n $response = $this->searchForPhoneNumber($phone);\n if (empty($response)) {\n return null;\n }\n\n // This would ideally importContact instead but the response type differs.\n $contact = $this->findAndSyncContact($response['results'][0]['id']);\n if (! $contact instanceof Contact) {\n return null;\n }\n\n $account = $contact->account;\n $countryCode = $contact->country_code ?? $account->country_code ?? null;\n\n try {\n $hsOpportunities = $this->findOpportunities(\n $account?->crm_provider_id,\n $contact->crm_provider_id,\n $userId\n );\n } catch (Exception $e) {\n $hsOpportunities = [];\n }\n\n $opportunity = null;\n $stage = null;\n\n try {\n if (! empty($hsOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n } catch (Exception $e) {\n $this->logger->debug('[HubSpot] Opportunity failed to sync.', [\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n }\n\n private function isPhoneNumberOfTeamMember(string $phone): bool\n {\n $teamRepository = app(TeamRepository::class);\n $user = $teamRepository->findTeamMemberByPhone($this->team, $phone);\n\n if ($user instanceof User) {\n return true;\n }\n\n return false;\n }\n\n private function findAndSyncContact(string $crmId): ?Contact\n {\n try {\n return $this->syncContact($crmId);\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Phone match failed', [\n 'reason' => $exception->getMessage(),\n ]);\n\n return null;\n }\n }\n\n private function hasResults(array $response): bool\n {\n return isset($response['total']) && is_numeric($response['total']) && $response['total'] > 0;\n }\n\n private function searchForPhoneNumber(string $phone): array\n {\n // Normalizes the provided phone number for the API search.\n $normalizedPhone = $this->normalizePhoneNumber($phone);\n $payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone);\n\n $this->logger->info('[HubSpot] Phone match search triggered', [\n 'phone' => $phone,\n 'normalizedPhone' => $normalizedPhone,\n 'payload' => $payload,\n ]);\n\n $response = $this->handlePhoneSearchRequest($normalizedPhone, $payload);\n\n if (! $this->hasResults($response)) {\n $nationalPhone = preg_replace('/\\D/', '', phone_national(null, $phone));\n $payload = $this->payloadBuilder->generatePhoneSearchPayload($nationalPhone);\n\n $this->logger->info('[HubSpot] Phone match national number search triggered', [\n 'phone' => $phone,\n 'nationalPhone' => $nationalPhone,\n 'payload' => $payload,\n ]);\n\n $response = $this->handlePhoneSearchRequest($phone, $payload);\n }\n\n if (! $this->hasResults($response)) {\n $payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone, true);\n\n $this->logger->info('[HubSpot] Phone match alternative search triggered', [\n 'phone' => $phone,\n 'normalizedPhone' => $normalizedPhone,\n 'payload' => $payload,\n ]);\n\n $response = $this->handlePhoneSearchRequest($phone, $payload);\n }\n\n return $this->hasResults($response) ? $response : [];\n }\n\n private function handlePhoneSearchRequest(string $phone, array $payload): array\n {\n try {\n return $this->client->search('contacts', $payload);\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Phone match failed', [\n 'phone' => $phone,\n 'reason' => $exception->getMessage(),\n ]);\n\n return [];\n }\n }\n\n private function normalizePhoneNumber(string $phone): string\n {\n return ltrim(phone_e164(null, $phone), '+0');\n }\n\n /**\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n public function matchByName(string $name, ?int $userId = null): ?array\n {\n// $payload = $this->payloadBuilder->generateSearchContactsByNamePayload(\n// $name,\n// $this->getContactFields()\n// );\n// $hsContacts = $this->client->getPaginatedData($payload, 'contact');\n// return null;\n\n // Don't waste time searching for single character strings.\n if (\\strlen($name) <= 1) {\n return null;\n }\n\n $cacheKey = $this->getCacheKey($name, $userId);\n\n $result = Cache::remember($cacheKey, 60, function () use ($name, $userId) {\n $payload = $this->payloadBuilder->generateSearchContactsByNamePayload(\n $name,\n $this->getContactFields()\n );\n\n $hsContacts = $this->client->getPaginatedData($payload, 'contact');\n if (empty($hsContacts['results'])) {\n return false;\n }\n\n $contact = $this->importContact($hsContacts['results'][0]);\n if ($contact === null) {\n return false;\n }\n\n $account = $contact->account;\n $countryCode = $contact->country_code ?? $account->country_code ?? null;\n\n try {\n $hsOpportunities = $this->findOpportunities(\n $account ? $account->crm_provider_id : null,\n $contact->crm_provider_id,\n $userId\n );\n } catch (Exception $e) {\n $hsOpportunities = [];\n }\n\n $opportunity = null;\n $stage = null;\n if (! empty($hsOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n });\n\n return is_array($result) ? $result : null;\n }\n\n\n private function convertActivityAssociations(Activity $activity): array\n {\n return [\n 'contactIds' => $this->getParticipantsIds($activity),\n 'companyIds' => $activity->hasAccount() ? [$activity->account->crm_provider_id] : [],\n 'dealIds' => $activity->hasOpportunity() ? [$activity->opportunity->crm_provider_id] : [],\n 'ownerIds' => [],\n ];\n }\n\n private function getParticipantsIds(Activity $activity): array\n {\n $attendees = [];\n\n $participantRepository = app(ParticipantRepository::class);\n $participants = $participantRepository->getParticipantsWhoEnteredMeeting($activity);\n foreach ($participants as $participant) {\n if ($participant->user_id || $participant->isCoach()) {\n continue;\n }\n\n $contact = $participant->contact()->first();\n if ($contact && $contact->crm_provider_id) {\n $attendees[] = $contact->crm_provider_id;\n } else {\n if (! empty($participant->name)) {\n $attendeeData = $this->fetchMissingAttendeeInfo($participant);\n }\n if (! empty($attendeeData['id'])) {\n $attendees[] = $attendeeData['id'];\n }\n }\n }\n\n if ($activity->hasContact()) {\n $attendees[] = $activity->contact->crm_provider_id;\n }\n\n return array_unique($attendees);\n }\n\n private function fetchMissingAttendeeInfo(Participant $participant): array\n {\n // Check if we need to look inside an account context.\n $activity = $participant->getActivity();\n $companyId = $activity->hasAccount() ? $activity->getAccount()->crm_provider_id : null;\n\n // First check the local data.\n /** @var Contact[] $contacts */\n $contacts = $this->team->contacts()\n ->with('account')\n ->where('name', $participant->name)\n ->whereNotNull('email')\n ->get();\n\n foreach ($contacts as $contact) {\n // If we have a company in scope, check the contact is associated to it.\n if (\n $companyId !== null\n && ($contact->account_id === null || $companyId !== $contact->account->crm_provider_id)\n ) {\n continue;\n }\n\n return [\n 'id' => $contact->crm_provider_id,\n 'email' => $contact->email,\n ];\n }\n\n $payload = $this->generateNameSearchPayload($participant->name, 0, 20);\n\n try {\n $response = $this->client->getNewInstance()->crm()->contacts()->searchApi()->doSearch($payload);\n\n // TODO add some logic to choose the most suitable contact if multiple\n foreach ($response['results'] as $object) {\n $properties = $object['properties'];\n if (empty($object['properties']) === false) {\n // Check the company matches the contact.\n // Todo: Move this check inside the API search.\n if ($companyId !== null && $companyId !== $properties['associatedcompanyid']) {\n continue;\n }\n\n return [\n 'id' => $object['id'],\n 'email' => $properties['email'],\n ];\n }\n }\n } catch (Exception $e) {\n $this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [\n 'teamId' => $this->team->id_string,\n 'request' => $payload,\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return [];\n }\n\n /**\n * Store transcripts as note engagement.\n *\n * @throws Exception\n */\n public function createTranscriptNotes(Activity $activity): void\n {\n // For HS no need to check if Crm profile - Log Notes field is enabled\n // We only check if store_transcript toggle is enabled on crm profile.\n $engagement = [\n 'active' => true,\n 'ownerId' => $this->profile->crm_provider_id,\n 'timestamp' => $activity->created_at->tz($activity->user->timezone)->getTimestamp() * 1000,\n 'type' => 'NOTE',\n ];\n\n // Generate activity transcription.\n $transcriptionData = $this->generateTranscription($activity);\n\n // Truncate Notes with max notes length because transcription text could be very long.\n $transcripts = mb_strimwidth($transcriptionData, 0, static::ENGAGEMENT_BODY_MAX_LENGTH);\n\n $metadata = [\n 'body' => $transcripts,\n ];\n\n $associations = $this->convertActivityAssociations($activity);\n\n try {\n $hsEngagement = $this->client\n ->getInstance()\n ->engagements()\n ->create($engagement, $associations, $metadata);\n\n $this->logCrmEngagementManipulation(self::ACTION_CREATE, $engagement, $metadata, $associations);\n\n $noteId = $hsEngagement->data->engagement->id;\n\n // Store crm logged id in transcription.\n $transcription = $activity->getTranscription();\n $transcription->crm_activity_id = $noteId;\n $transcription->save();\n } catch (Exception $e) {\n Sentry::captureException($e);\n }\n }\n\n /*\n * @inheritdoc\n */\n public function updateRecord(string $objectType, string $objectId, array $data, array $headers = []): void\n {\n $payload = [\n 'properties' => $data,\n ];\n\n try {\n switch ($objectType) {\n case FieldData::OBJECT_OPPORTUNITY:\n $this->client->getNewInstance()->crm()->deals()->basicApi()->update($objectId, $payload);\n\n break;\n case FieldData::OBJECT_CONTACT:\n $this->client->getNewInstance()->crm()->contacts()->basicApi()->update($objectId, $payload);\n\n break;\n\n case FieldData::OBJECT_ACCOUNT:\n $this->client->getNewInstance()->crm()->companies()->basicApi()->update($objectId, $payload);\n\n break;\n\n case FieldData::OBJECT_TASK:\n // Endpoint for Engagements not ready\n $engagements = [\n 'type' => 'TASK',\n ];\n $metadata = $data;\n $this->client->getInstance()->engagements()->update($objectId, $engagements, $metadata);\n\n $this->logCrmEngagementManipulation(\n self::ACTION_UPDATE,\n ['crmId' => $objectId],\n $metadata,\n );\n\n break;\n\n default:\n throw new UnexpectedValueException('Unsupported object type \"' . $objectType . '\"');\n }\n } catch (\\HubSpot\\Client\\Crm\\Deals\\ApiException $apiException) {\n $errorMessage = $apiException->getMessage();\n if ($apiException->getResponseBody()) {\n $responseBody = json_decode($apiException->getResponseBody(), true, 512, JSON_THROW_ON_ERROR);\n $errorMessage = $responseBody['message'] ?? $apiException->getMessage();\n }\n\n $this->logger->error(\n '[HubSpot] Update record failed',\n [\n 'objectType' => $objectType,\n 'objectId' => $objectId,\n 'payload' => $payload,\n 'reason' => $errorMessage,\n 'team' => $this->team->getUuid(),\n ]\n );\n\n throw new CrmException($errorMessage);\n }\n }\n\n /*\n * @inheritdoc\n */\n public function getRecord(string $objectType, string $objectId, array $fields = []): array\n {\n switch ($objectType) {\n case FieldData::OBJECT_OPPORTUNITY:\n return $this->client->getInstance()->deals()->getById($objectId)->toArray();\n\n case FieldData::OBJECT_CONTACT:\n return $this->client->getInstance()->contacts()->getById($objectId)->toArray();\n\n case FieldData::OBJECT_ACCOUNT:\n return $this->client->getInstance()->companies()->getById($objectId)->toArray();\n\n case FieldData::OBJECT_TASK:\n return $this->client->getInstance()->engagements()->get($objectId)->toArray();\n\n default:\n throw new UnexpectedValueException('Unsupported object type \"' . $objectType . '\"');\n }\n }\n\n /**\n * @throws BadRequest\n * @throws CrmException\n */\n public function updateStage($crmObject, Stage $stage): void\n {\n $payload = [\n 'properties' => [\n [\n 'name' => 'dealstage',\n 'value' => $stage->crm_provider_id,\n ],\n ],\n ];\n\n try {\n $this->client->getInstance()->deals()->update($crmObject->crm_provider_id, $payload);\n } catch (BadRequest $badRequest) {\n if ($badRequest->getCode() === 403) {\n throw new CrmException(\n \"Sorry, you don't have permission to update this stage.\",\n $badRequest->getCode(),\n $badRequest,\n );\n }\n\n $this->logger->warning('[HubSpot] Stage update failed', [\n 'dealId' => $crmObject->crm_provider_id,\n 'payload' => $payload,\n 'message' => $badRequest->getMessage(),\n ]);\n\n throw $badRequest;\n }\n }\n\n public function generateProviderUrl(string $providerId, string $objectType): ?string\n {\n $url = null;\n $baseUrl = 'https://app.hubspot.com/contacts/' . $this->config->crm_provider_id . '/';\n\n switch ($objectType) {\n case 'account':\n $url = $baseUrl . 'company/' . $providerId;\n\n break;\n\n case 'contact':\n $url = $baseUrl . 'contact/' . $providerId;\n\n break;\n\n case 'opportunity':\n $url = $baseUrl . 'deal/' . $providerId;\n\n break;\n\n case 'task':\n case 'activity':\n return null;\n\n // This should not be deep-linked as per JMNY-3934.\n //$url = $baseUrl.'tasks/list/view/all/?taskId='.$providerId;\n break;\n }\n\n return $url;\n }\n\n public function searchCalls(Carbon $from, Carbon $to, string $activityProvider): array\n {\n $this->logger->info('[HubSpot] Search calls', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n ]);\n\n $calls = [];\n $page = 1;\n\n do {\n try {\n $payload = $this->payloadBuilder->generateGetCallsPayload($from, $to, $activityProvider, $page);\n $response = $this->client->search('calls', $payload);\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Search calls failed', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n 'reason' => $exception->getMessage(),\n ]);\n $response = null;\n }\n\n $responseResults = empty($response['results']) ? [] : $response['results'];\n $calls = array_merge($calls, $responseResults);\n $page++;\n } while (! empty($responseResults));\n\n return $calls;\n }\n\n public function searchCallsForPeriodByPage(Carbon $from, Carbon $to, int $page, bool $retry = true)\n {\n try {\n $payload = $this->payloadBuilder->generateSearchCallsByPeriodPayload($from, $to, $page);\n\n return $this->client->search('calls', $payload);\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Search calls for period failed', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n 'reason' => $exception->getMessage(),\n 'retry' => $retry,\n ]);\n\n if ($retry) {\n sleep(1);\n\n return $this->searchCallsForPeriodByPage($from, $to, $page, false);\n }\n\n return null;\n }\n }\n\n public function searchCallsForPeriod(Carbon $from, Carbon $to): Generator\n {\n $this->logger->info('[HubSpot] Search calls for period', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n ]);\n\n $page = 1;\n\n do {\n $response = $this->searchCallsForPeriodByPage($from, $to, $page);\n\n $responseResults = empty($response['results']) ? [] : $response['results'];\n\n $associationContacts = $this->getAssociationDataForCollection($responseResults, 'calls', 'contacts');\n $associationCompanies = $this->getAssociationDataForCollection($responseResults, 'calls', 'companies');\n $associationDeals = $this->getAssociationDataForCollection($responseResults, 'calls', 'deals');\n\n foreach ($responseResults as $call) {\n $call['associations'] = [\n 'contacts' => $this->importAssociationData($call, $associationContacts),\n 'companies' => $this->importAssociationData($call, $associationCompanies),\n 'deals' => $this->importAssociationData($call, $associationDeals),\n ];\n\n yield $call;\n }\n $page++;\n } while (! empty($responseResults));\n }\n\n public function getCall(string $callId): array\n {\n $this->logger->info('[HubSpot] Get call', [\n 'call_id' => $callId,\n ]);\n\n $searchAttributes = $this->payloadBuilder->getSearchCallAttributes();\n $endpoint = sprintf(\n 'https://api.hubapi.com/crm/v3/objects/calls/%s',\n $callId,\n );\n\n try {\n $response = $this->client->getInstance()->getClient()->request(\n 'GET',\n $endpoint,\n [],\n sprintf(\n 'properties=%s&associations=contacts,companies,deals',\n implode(',', $searchAttributes),\n ),\n );\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Get call failed', [\n 'call_id' => $callId,\n 'reason' => $exception->getMessage(),\n ]);\n $response = null;\n }\n\n return empty($response) ? [] : $response->toArray();\n }\n\n public function bulkAddPlaybackURLToDescription(array $crmUpdateData): array\n {\n $crmUpdateBatches = array_chunk($crmUpdateData, self::BATCH_UPDATE_LIMIT);\n\n $updatedCrmIds = [];\n\n foreach ($crmUpdateBatches as $crmBatch) {\n $payload = $this->payloadBuilder->generatePlaybackAddUrlBatchPayload($crmBatch);\n $updateSuccess = $this->bulkAddPlaybackURLToDescriptionRequest($payload);\n if ($updateSuccess) {\n $updatedCrmIds = array_merge($updatedCrmIds, array_column($crmBatch, 'crm_id'));\n }\n }\n\n return $updatedCrmIds;\n }\n\n private function bulkAddPlaybackURLToDescriptionRequest(array $payload, bool $retry = true): bool\n {\n try {\n $this->client->getNewInstance()->crm()->objects()->batchApi()->update('calls', $payload);\n\n return true;\n } catch (\\HubSpot\\Client\\Crm\\Objects\\ApiException $e) {\n $response = json_decode($e->getResponseBody(), true);\n $retryAfter =\n isset($response['policyName'])\n && $response['policyName'] == self::TEN_SECONDLY_ROLLING_POLICY\n ? self::TEN_SECONDLY_ROLLING_LIMIT\n : 1;\n } catch (Exception $e) {\n $retryAfter = 1;\n }\n\n $this->logger->warning('[HubSpot] Bulk add playback url to CRM failed', [\n 'reason' => $e->getMessage(),\n 'retry' => $retry,\n ]);\n\n if ($retry) {\n sleep($retryAfter);\n\n return $this->bulkAddPlaybackURLToDescriptionRequest($payload, false);\n }\n\n return false;\n }\n\n /**\n * Sometimes we have secondly rate limit error, then retry request after 1 second\n */\n public function searchCallByRecordingURLToken(string $playbackURLToken, bool $retry = true): array\n {\n $payload = $this->payloadBuilder->generateSearchCallByTokenPayload($playbackURLToken);\n\n $this->logger->info('[HubSpot] CRM Search by playback URL token requested', [\n 'request' => $payload,\n ]);\n\n try {\n $response = $this->client->search('calls', $payload);\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Search by playback URL token failed', [\n 'playbackURLToken' => $playbackURLToken,\n 'reason' => $exception->getMessage(),\n 'retry' => $retry,\n ]);\n\n if ($retry) {\n sleep(1);\n\n return $this->searchCallByRecordingURLToken($playbackURLToken, false);\n }\n\n return [];\n }\n\n return empty($response['results']) ? [] : $response['results'][0];\n }\n\n /**\n * Generate transcription for the activity.\n */\n private function generateTranscription(Activity $activity): string\n {\n if (! $this->config->store_transcript) {\n // If sending transcription to activity toggle is disabled\n return '';\n }\n\n $transcriptionSegments = $this->transcriptionService->findTranscriptionByActivity($activity);\n\n if ($transcriptionSegments->isEmpty()) {\n return '';\n }\n\n $transcription = sprintf(\n '<p><strong>Transcript for %s</strong></p><p></p>',\n $activity->title ?? $activity->activity_title,\n );\n\n $roomOwnerParticipant = $activity->findParticipantRoomOwner();\n $roomOwnerParticipantId = $roomOwnerParticipant !== null\n ? $roomOwnerParticipant->getId()\n : null;\n\n\n $transcription .= $transcriptionSegments\n ->map(static function (array $transcriptionSegment) use ($roomOwnerParticipantId): string {\n $isOrganiser = $roomOwnerParticipantId === $transcriptionSegment['participantId']\n && $roomOwnerParticipantId !== null;\n $transcriptColor = $isOrganiser ? '#000000' : '#f0415a';\n\n return sprintf(\n '<span style=\"color: %s;\">%s | </span>%s',\n $transcriptColor,\n $transcriptionSegment['formattedStartsAt'],\n $transcriptionSegment['transcript'],\n );\n })\n ->implode('<br />');\n\n return $transcription;\n }\n\n /**\n * @param array<array{\n * id: string,\n * label: string,\n * value?: string,\n * }> $options\n *\n * @return FieldData[]\n */\n private function importOptions(Field $field, array $options): array\n {\n $fieldValues = [];\n $values = [];\n $sequence = 0;\n\n foreach ($options as $option) {\n $values[] = [\n 'value' => $option['value'] ?? $option['id'],\n 'label' => substr($option['label'], 0, 255),\n 'sequence' => $sequence++,\n ];\n }\n\n $fieldsToPurge = $field->values()->get()->pluck('value')->toArray();\n\n foreach ($values as $value) {\n $value['value'] = substr($value['value'], 0, 255);\n $fieldValues[] = $field->values()->updateOrCreate([\n 'value' => $value['value'],\n ], $value);\n\n // Remove this value from the ones we are going to purge.\n if (($key = array_search($value['value'], $fieldsToPurge, false)) !== false) {\n unset($fieldsToPurge[$key]);\n }\n }\n\n // Delete the old values that are no longer used.\n $field->values()->whereIn('value', $fieldsToPurge)->delete();\n\n return $fieldValues;\n }\n\n public function saveTranscriptionSummaryAsNote(\n ActivityContract $activity,\n string $title,\n string $body,\n ?string $objectId,\n ?NoteObject $noteObject = null,\n ): ?string {\n if ($noteObject === null || $objectId === null) {\n return null;\n }\n\n /** @var User $user */\n $user = $activity->getUser();\n\n $profile = $this->assignCrmOwner($user, $activity);\n if (! $profile instanceof Profile) {\n return null;\n }\n\n $timestamp = Carbon::now($user->getTimezone())->getTimestamp() * 1000;\n $engagement = [\n 'active' => true,\n 'ownerId' => $profile->getAttribute('crm_provider_id'),\n 'timestamp' => $timestamp,\n 'type' => 'NOTE',\n ];\n\n // Truncate Notes with max notes length because transcription text could be very long.\n $body = mb_strimwidth($body, 0, self::ENGAGEMENT_BODY_MAX_LENGTH);\n $metadata = [\n 'body' => $body,\n ];\n\n $associations = $this->convertActivityAssociations($activity);\n\n try {\n $hsActivityId = $this->client->createNote(\n body: $body,\n ownerId: $profile->getCrmProviderId(),\n timestamp: $timestamp,\n objectId: $objectId,\n noteObject: $noteObject,\n );\n\n $this->logCrmEngagementManipulation(self::ACTION_CREATE, $engagement, $metadata, $associations);\n\n $this->logger->info('[HubSpot] Saving Transcription Summary as Note', [\n 'activity' => $activity->getUuid(),\n 'crmActivity' => $hsActivityId,\n ]);\n\n return $hsActivityId;\n } catch (Exception $e) {\n Sentry::captureException($e);\n }\n\n return null;\n }\n\n public function attachSummaryToActivity(ActivityContract $activity, string $summaryTitle, string $summaryContents): bool\n {\n $this->logger->info('[HubSpot] Attaching summary to activity', [\n 'activity' => $activity->getUuid(),\n 'summary_content' => $summaryContents,\n ]);\n\n if (! $activity instanceof Activity) {\n throw new InvalidArgumentException('Expected instance of Activity');\n }\n\n $summary = '<p><strong>' . $summaryTitle . '</strong></p>';\n $summary .= '<p>' . $summaryContents . '</p>';\n $metadata = $this->buildMetadataForSummaryUpdate($activity, $summary);\n\n try {\n $type = $this->matchActivityEngagementType($activity);\n $engagement = ['type' => $type];\n\n $this->client->updateEngagement($activity->getCrmProviderId(), $engagement, $metadata);\n } catch (Exception $e) {\n $this->logger->warning('[HubSpot] Update summary failed', [\n 'activity' => $activity->getUuid(),\n 'reason' => $e->getMessage(),\n ]);\n\n return false;\n }\n\n $this->logCrmEngagementManipulation(\n self::ACTION_UPDATE,\n ['crmId' => $activity->getCrmProviderId()],\n $metadata,\n );\n\n return true;\n }\n\n private function buildMetadataForSummaryUpdate(Activity $activity, string $summary): array\n {\n $descriptionField = $activity->getType() === Activity::TYPE_CONFERENCE ? 'internalMeetingNotes' : 'body';\n $engagement = $this->client->getEngagementData($activity->getCrmProviderId());\n // Meeting without internalMeetingNotes might mean it just does not have any notes;\n $description = $engagement['metadata'][$descriptionField] ?? null;\n\n if (empty($description)) {\n $data = $summary;\n } else {\n // avoid playbook url link to Jiminny being sent twice in the activity description\n $targetUrl = PlaybackUrlBuilder::build($activity);\n\n if (str_contains($description, $targetUrl)) {\n $jiminnyUrl = '<p><a href=\"' . $targetUrl . '\" title=\"Play at Jiminny\">Play at Jiminny</a></p>';\n $summary = str_replace($jiminnyUrl, '', $summary);\n\n $this->logger->info('[HubSpot] Summary modified', [\n 'activity' => $activity->getUuid(),\n 'target_url' => $jiminnyUrl,\n 'modified_summary_content' => $summary,\n ]);\n }\n\n $data = $description . '<p></p>' . $summary;\n }\n\n return [\n $descriptionField => $data,\n ];\n }\n\n public function fetchAndAssociateRelatedActivity(Activity $activity): ?Activity\n {\n return $this->syncRelatedActivityManager->fetchAndAssociateRelatedActivity($activity);\n }\n\n public function fetchRelatedActivity(Activity $activity): array\n {\n return [];\n }\n\n public function getDealsInBulk(array $dealIds): array\n {\n $payload = $this->payloadBuilder->getDealsInBulkPayload($dealIds);\n\n return $this->client->getPaginatedData($payload, 'deals');\n }\n\n /**\n * Extract deal IDs from HubSpot search response.\n *\n * @param array $hubspotResponse The raw HubSpot search API response.\n * @param bool $includeArchived Whether to include archived deals (default: false).\n *\n * @return string[] Array of deal IDs as strings.\n */\n public function extractDealIds(array $hubspotResponse, bool $includeArchived = false): array\n {\n if (empty($hubspotResponse['results'])) {\n return [];\n }\n\n return array_values(\n array_map(\n fn ($deal) => $deal['id'],\n array_filter(\n $hubspotResponse['results'],\n fn ($deal) => $includeArchived || empty($deal['archived'])\n )\n )\n );\n }\n\n public function matchActivityEngagementType(Activity $activity): string\n {\n return match ($activity->getType()) {\n Activity::TYPE_CONFERENCE => self::TYPE_MEETING,\n Activity::TYPE_SOFTPHONE, Activity::TYPE_SOFTPHONE_INBOUND => self::TYPE_CALL,\n default => self::TYPE_NOTE,\n };\n }\n\n private function assignCrmOwner(User $user, ActivityContract $activity): ?Profile\n {\n $profile = $user->getProfile();\n if ($profile instanceof Profile) {\n return $profile;\n }\n\n $this->logger->info('[HubSpot] Unable to save summary. No profile', [\n 'activity' => $activity->getUuid(),\n ]);\n\n return null;\n }\n\n private static function getDealsPipelinesEndpoint(): string\n {\n return self::API_URL . self::ENDPOINT_PIPELINES . self::PIPELINE_OBJECT_TYPE_DEALS;\n }\n\n public function verifyTaskExists(Activity $activity): bool\n {\n $crmProviderId = $activity->getCrmProviderId();\n $cacheKey = \"crm_task_exists:{$this->config->getId()}:$crmProviderId\";\n\n return Cache::remember($cacheKey, self::TASK_VERIFICATION_CACHE_TTL, function () use ($crmProviderId) {\n try {\n $engagement = $this->client->getEngagementData($crmProviderId);\n\n return ! empty($engagement);\n } catch (HttpNotFoundException|BadRequest) {\n // Engagement not found in CRM - this is expected and permanent\n $this->logger->info('[Hubspot] Engagement not found during verification', [\n 'engagement_id' => $crmProviderId,\n 'config_id' => $this->config->getId(),\n ]);\n\n return false;\n }\n // Let other exceptions (network errors, rate limits, etc.) bubble up for retry\n });\n }\n}","depth":4,"on_screen":true,"value":"<?php\n\nnamespace Jiminny\\Services\\Crm\\Hubspot;\n\nuse Carbon\\Carbon;\nuse Exception;\nuse Generator;\nuse GuzzleHttp\\Exception\\RequestException;\nuse Illuminate\\Support\\Facades\\Cache;\nuse InvalidArgumentException;\nuse Jiminny\\Contracts\\Repositories\\TeamRepository;\nuse Jiminny\\Contracts\\Services\\Crm\\ClientInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\FetchRelatedActivityInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\LayoutManagementInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\MatchCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\Provider\\HubspotInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityLookupInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityManipulationInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SavePlaybackLinkToCrmInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SendSummaryToCrmInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SettingsInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmMetadataInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\VerifyTaskExistsInterface;\nuse Jiminny\\Exceptions\\CrmException;\nuse Jiminny\\Exceptions\\HttpNotFoundException;\nuse Jiminny\\Jobs\\Crm\\NoteObject;\nuse Jiminny\\Models\\Account;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Contact;\nuse Jiminny\\Models\\Contracts\\ActivityContract;\nuse Jiminny\\Models\\Crm\\BusinessProcess;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Models\\Crm\\FieldData;\nuse Jiminny\\Models\\Crm\\Layout;\nuse Jiminny\\Models\\Crm\\Profile;\nuse Jiminny\\Models\\Lead;\nuse Jiminny\\Models\\Opportunity;\nuse Jiminny\\Models\\Participant;\nuse Jiminny\\Models\\Playbook;\nuse Jiminny\\Models\\SocialAccount;\nuse Jiminny\\Models\\Stage;\nuse Jiminny\\Models\\User;\nuse Jiminny\\Repositories\\Crm\\CrmEntityRepository;\nuse Jiminny\\Repositories\\Crm\\FieldRepository;\nuse Jiminny\\Repositories\\Crm\\ProfileRepository;\nuse Jiminny\\Repositories\\ParticipantRepository;\nuse Jiminny\\Services\\Avatar\\ProspectPhotoPathService;\nuse Jiminny\\Services\\Crm\\BaseService;\nuse Jiminny\\Services\\Crm\\Hubspot\\Actions\\SyncArchivedProfilesAction;\nuse Jiminny\\Services\\Crm\\Hubspot\\Fields\\ValueNormalizer;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\OpportunitySyncTrait;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\SyncCrmEntitiesTrait;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\SyncFieldsTrait;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\WriteCrmTrait;\nuse Jiminny\\Services\\Crm\\MatchDomainByEmailInterface;\nuse Jiminny\\Services\\Crm\\OpportunitySyncStrategyResolver;\nuse Jiminny\\Services\\Crm\\ResolveCompanyNameByEmailTrait;\nuse Jiminny\\Utils\\PlaybackUrlBuilder;\nuse Sentry;\nuse SevenShores\\Hubspot\\Exceptions\\BadRequest;\nuse Throwable;\nuse UnexpectedValueException;\n\n/**\n * @phpstan-type CrmFieldDefinition array{\n * name: string,\n * label: string,\n * description: string,\n * type: string,\n * fieldType: string,\n * hidden: bool,\n * showCurrencySymbol: bool,\n * options: array<array{\n * id: string,\n * label: string,\n * value?: string,\n * }\n */\nclass Service extends BaseService implements\n HubspotInterface,\n SyncCrmEntitiesInterface,\n SyncCrmMetadataInterface,\n SendSummaryToCrmInterface,\n MatchDomainByEmailInterface,\n SavePlaybackLinkToCrmInterface,\n RemoteEntityManipulationInterface,\n FetchRelatedActivityInterface,\n LayoutManagementInterface,\n SettingsInterface,\n MatchCrmEntitiesInterface,\n RemoteEntityLookupInterface,\n VerifyTaskExistsInterface\n{\n use ResolveCompanyNameByEmailTrait;\n use SyncCrmEntitiesTrait;\n use WriteCrmTrait;\n use SyncFieldsTrait;\n use OpportunitySyncTrait;\n\n private const int ENGAGEMENT_BODY_MAX_LENGTH = 65536;\n\n private const string LOG_DATE_FORMAT = 'Y-m-d H:i:s';\n private const int BATCH_UPDATE_LIMIT = 100;\n private const string TEN_SECONDLY_ROLLING_POLICY = 'TEN_SECONDLY_ROLLING';\n private const int TEN_SECONDLY_ROLLING_LIMIT = 10;\n\n private const string TYPE_NOTE = 'NOTE';\n\n private const string TYPE_MEETING = 'MEETING';\n\n private const string TYPE_CALL = 'CALL';\n\n private const string API_URL = 'https://api.hubapi.com';\n\n // NB: v1 is legacy - v3 is the newest\n private const string ENDPOINT_PIPELINES = '/crm-pipelines/v1/pipelines/';\n private const string PIPELINE_OBJECT_TYPE_DEALS = 'deals';\n\n private const int TASK_VERIFICATION_CACHE_TTL = 86400; // 1 day\n\n /**\n * @var ClientInterface|Client\n */\n protected $client;\n protected OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;\n protected CrmEntityRepository $crmEntityRepository;\n protected ProspectPhotoPathService $prospectPhotoPathService;\n\n private SyncFieldAction $syncFieldAction;\n private PayloadBuilder $payloadBuilder;\n private SyncRelatedActivityManager $syncRelatedActivityManager;\n private SyncArchivedProfilesAction $syncArchivedProfilesAction;\n private WebhookSyncBatchProcessor $batchProcessor;\n\n public function __construct(\n Client $client,\n SyncFieldAction $syncFieldAction,\n PayloadBuilder $payloadBuilder,\n ProspectPhotoPathService $prospectPhotoPathService,\n SyncArchivedProfilesAction $syncArchivedProfilesAction,\n WebhookSyncBatchProcessor $batchProcessor,\n ) {\n parent::__construct();\n\n $this->client = $client;\n $this->syncFieldAction = $syncFieldAction;\n $this->prospectPhotoPathService = $prospectPhotoPathService;\n $this->payloadBuilder = $payloadBuilder;\n $this->syncArchivedProfilesAction = $syncArchivedProfilesAction;\n $this->batchProcessor = $batchProcessor;\n $this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [\n 'client' => $this->client,\n ]);\n $this->syncRelatedActivityManager = app(SyncRelatedActivityManager::class, [\n 'client' => $this->client,\n 'payloadBuilder' => $this->payloadBuilder,\n 'logger' => $this->logger,\n ]);\n $this->crmEntityRepository = app(CrmEntityRepository::class);\n $this->dealFieldsService = app(DealFieldsService::class);\n }\n\n public function getDisplayName(): string\n {\n return 'HubSpot';\n }\n\n protected function getOAuthAccount(User $user): ?SocialAccount\n {\n // In this case, the Account Owner is always the connection for any API operations.\n $owner = $user->team->owner;\n\n return $owner->getSocialAccount(SocialAccount::PROVIDER_HUBSPOT);\n }\n\n public function getClient(): Client\n {\n /** @var Client */\n return $this->client;\n }\n\n /**\n * Convert raw field data into a format compatible with CRM APIs.\n *\n * @param bool $internal Direction of the conversion.\n * True is pulling from CRM, false normalize before sending to CRM.\n */\n public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string\n {\n return ValueNormalizer::normalize(\n fieldType: $fieldType,\n fieldValue: $fieldValue,\n isInbound: $internal,\n );\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultFields(string $activityType): array\n {\n $fields = [];\n\n if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {\n $defaultFields = FieldDefinitions::defaultTaskFields();\n\n // This lazy creates these fields if not already setup.\n foreach ($defaultFields as $defaultField) {\n $fields[] = $this->config->fields()->firstOrCreate($defaultField);\n }\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultActivityField(string $activityType): Field\n {\n /** @var Field $activityField */\n $activityField = $this->config->fields()->where([\n 'crm_provider_id' => 'activityType',\n 'object_type' => $activityType,\n ])->first();\n\n return $activityField;\n }\n\n /**\n * @inheritdoc\n */\n public function getSupportedPlaybookTypes(): array\n {\n return [Playbook::ACTIVITY_TYPE_TASK];\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultActivityLayoutFields(string $activityType, string $layoutType): array\n {\n $fields = [];\n\n if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {\n // Outcome should always be provided calls/meetings.\n $fieldData = [\n [\n 'crm_provider_id' => $layoutType === Layout::TYPE_SOFTPHONE_SUMMARY ? 'disposition' : 'meetingOutcome',\n 'object_type' => Field::OBJECT_TASK,\n ],\n ];\n\n foreach ($fieldData as $data) {\n $field = $this->config->fields()->where($data)->first();\n\n // Only add the field if it is created, which it should be.\n if ($field) {\n $fields[] = $field;\n }\n }\n }\n\n return $fields;\n }\n\n public function getDealInsightsFields(): array\n {\n return FieldDefinitions::dealInsightsFields();\n }\n\n protected function getDefaultFollowupLayoutFields(string $activityType): array\n {\n $fields = [];\n $fieldRepo = app(FieldRepository::class);\n $fieldData = FieldDefinitions::followupFieldsFilter();\n\n foreach ($fieldData as $data) {\n $field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $data);\n\n // Only add the field if it is created, which it should be.\n if ($field) {\n $fields[] = $field;\n }\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function syncField(Field $field): void\n {\n switch ($field->object_type) {\n case Field::OBJECT_ACCOUNT:\n $crmField = $this->client->getInstance()->companyProperties()->get($field->crm_provider_id);\n\n break;\n case Field::OBJECT_CONTACT:\n $crmField = $this->client->getInstance()->contactProperties()->get($field->crm_provider_id);\n\n break;\n case Field::OBJECT_OPPORTUNITY:\n $crmField = $this->client->getInstance()->dealProperties()->get($field->crm_provider_id);\n\n break;\n case Field::OBJECT_TASK:\n $this->syncSingleTaskField($field);\n\n return;\n default:\n return;\n }\n\n $this->syncFieldAction->execute($field, $crmField->toArray());\n }\n\n /**\n * @param array<array{\n * id:string,\n * label:string,\n * value?:string\n * }> $options\n *\n * @throws CrmException\n *\n * @return FieldData[]\n *\n */\n public function importPicklistValues(\n Field $field,\n array $options = [['id' => '', 'label' => '', 'value' => '']],\n ): array {\n if (! empty($options[0]['id']) || ! empty($options[0]['value'])) {\n // We already have the options, no need to fetch them again\n return $this->importOptions($field, $options);\n }\n\n $options = [];\n\n switch ($field->getObjectType()) {\n case Field::OBJECT_ACCOUNT:\n $options = $this->getClient()->fetchPropertyOptions('company', $field->getCrmProviderId());\n\n break;\n\n case Field::OBJECT_CONTACT:\n $options = $this->getClient()->fetchPropertyOptions('contact', $field->getCrmProviderId());\n\n break;\n\n case Field::OBJECT_OPPORTUNITY:\n // Hubspot has different endpoint for stages\n $options = $this->getClient()->fetchOpportunityFieldOptions($field);\n\n break;\n\n case Field::OBJECT_TASK:\n if ($field->getCrmProviderId() === 'disposition') {\n $options = $this->getClient()->fetchDispositionFieldOptions();\n } elseif (in_array($field->getCrmProviderId(), ['meetingOutcome', 'activityType'])) {\n $options = $this->getClient()->fetchMeetingOutcomeFieldOptions($field);\n }\n\n break;\n\n default:\n $this->logger->warning('Invalid object type', [\n 'object_type' => $field->getObjectType(),\n 'field_id' => $field->getId(),\n ]);\n\n throw new CrmException('Invalid object type');\n }\n\n return $this->importOptions($field, $options);\n }\n\n /**\n * @inheritdoc\n */\n public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage\n {\n $missingStage = null;\n\n try {\n // Use the HubSpot API client instead of the SDK crmPipelines() method\n $endpoint = self::getDealsPipelinesEndpoint();\n $pipelinesResponse = $this->client->getInstance()->getClient()->request('GET', $endpoint);\n $pipelines = $pipelinesResponse->data->results;\n } catch (RequestException|BadRequest $exception) {\n throw $exception;\n }\n\n foreach ($pipelines as $pipeline) {\n $stages = [];\n\n // We create a business process to contain the pipeline, and store all stages against it.\n $p = ResponseNormalize::normalizePipeline($pipeline);\n\n // Create/update business process for this pipeline\n $businessProcess = $this->config->businessProcesses()->updateOrCreate([\n 'crm_provider_id' => $p['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($p['label'], 0, 150),\n 'type' => BusinessProcess::TYPE_OPPORTUNITY,\n 'is_selectable' => $p['active'],\n ]);\n\n // A record type is really a clone of the business process, used to store which record uses which pipeline.\n // Create/update record type clone\n $this->config->recordTypes()->updateOrCreate([\n 'crm_provider_id' => $p['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($p['label'], 0, 150),\n 'is_selectable' => $p['active'],\n 'business_process_id' => $businessProcess->id ?? null,\n ]);\n\n // Stages - fetch all existing stages upfront to avoid N+1 queries\n $existingStages = $this->config->stages()\n ->withTrashed()\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->get()\n ->keyBy('crm_provider_id');\n\n foreach ($p['stages'] as $dealStage) {\n $s = ResponseNormalize::normalizeDealStage($dealStage);\n\n /** @var ?Stage $existingStage */\n $existingStage = $existingStages->get($s['id']);\n\n // Restore soft-deleted stages that are now active in HubSpot\n if ($existingStage?->trashed() && $s['active']) {\n $existingStage->restore();\n }\n\n // Upsert stage (updates soft-deleted records without restoring them)\n $stage = $this->config->stages()->withTrashed()->updateOrCreate([\n 'crm_provider_id' => $s['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($s['label'], 0, 50),\n 'label' => mb_strimwidth($s['label'], 0, 191),\n 'type' => Stage::TYPE_OPPORTUNITY,\n 'sequence' => $s['displayOrder'],\n 'is_selectable' => $s['active'],\n 'probability' => $s['probability'] * 100,\n ]);\n\n if ($missingStageName === $s['id']) {\n $missingStage = $stage;\n }\n\n $stages[] = $stage->id;\n }\n\n $businessProcess->stages()->sync($stages);\n }\n\n return $missingStage;\n }\n\n /**\n * @inheritdoc\n */\n public function syncOrganization(): void\n {\n try {\n $endpoint = 'https://api.hubapi.com/integrations/v1/me';\n $response = $this->client->getInstance()->getClient()->request('get', $endpoint);\n\n $accountData = $response->data;\n $this->config->update(['default_currency' => $accountData->currency]);\n } catch (BadRequest $e) {\n throw new CrmException('Could not sync the organization.', $e->getCode(), $e);\n }\n }\n\n /**\n * @inheritdoc\n *\n * @throws CrmException\n */\n public function syncProfiles(?User $userToSearch = null): ?Profile\n {\n $this->syncArchivedProfilesAction->execute($this->team, $this->client, $this->config);\n\n try {\n $owners = $this->client->getOwners();\n } catch (\\HubSpot\\Client\\Crm\\Owners\\ApiException $e) {\n $this->logger->error('[HubSpot] Could not sync the profiles.', [\n 'team_id' => $this->team->getId(),\n 'reason' => $e->getMessage(),\n ]);\n\n throw new CrmException('Could not sync the profiles.', $e->getCode(), $e);\n }\n\n $profileRepository = app(ProfileRepository::class);\n $teamRepository = app(TeamRepository::class);\n\n foreach ($owners as $owner) {\n if ($owner->getArchived()) {\n // not supposed to fetch archived, but log anyway\n $this->logger->warning('[HubSpot] Found archived owner', [\n 'crm_provider_id' => $owner->getId(),\n 'email' => $owner->getEmail(),\n ]);\n\n continue;\n }\n\n $email = $owner->getEmail();\n if ($email === null) {\n continue;\n }\n\n $user = $teamRepository->findActiveTeamMemberByEmail($this->team, $email);\n\n if (! $user instanceof User) {\n continue;\n }\n\n $profile = $profileRepository->updateOrCreateProfile($user, [\n 'crm_configuration_id' => $this->config->getId(),\n 'crm_provider_id' => $owner->getId(),\n ]);\n\n if ($userToSearch && $userToSearch->getId() === $user->getId()) {\n return $profile;\n }\n }\n\n return null;\n }\n\n private function generateNameSearchPayload(string $name, int $offset, int $limit): array\n {\n $payload = [\n 'query' => $name,\n 'sorts' => [\n [\n 'propertyName' => 'modifieddate',\n 'direction' => 'DESCENDING',\n ],\n ],\n 'properties' => [\n 'email',\n 'firstname',\n 'lastname',\n 'country',\n 'phone',\n 'mobilephone',\n 'jobtitle',\n 'hubspot_owner_id',\n 'associatedcompanyid',\n 'photo',\n 'industry',\n 'name',\n 'company',\n ],\n 'limit' => $limit,\n 'after' => $offset,\n ];\n\n $this->logger->debug('[HubSpot] CRM Search requested', [\n 'request' => $payload,\n ]);\n\n return $payload;\n }\n\n /**\n * @inheritdoc\n */\n public function find(string $name, array $scopes): array\n {\n $count = $this->limit ?? 20;\n $offset = $this->offset ?? 0;\n\n /** @var array<int, array<string, mixed>> */\n return Cache::remember(\n key: $this->team->getId() . $name . $count . $offset,\n ttl: 300,\n callback: function () use ($name, $offset, $count): array {\n $data = [];\n\n // Use the new V3 API to find contacts based on additional fields.\n foreach (['companies', 'contacts'] as $objectType) {\n $payload = $this->generateNameSearchPayload($name, $offset, $count);\n $type = $objectType === 'companies' ? 'account' : 'contact';\n\n try {\n $response = $this->client->search($objectType, $payload);\n\n // Build mapped list.\n foreach ($response['results'] as $object) {\n $properties = $object['properties'];\n\n $objectName = $this->buildContactName($properties);\n\n $record = [\n 'crmId' => $object['id'],\n // Pass crmUrl to the FE, needed for success message in the extension when you log activity.\n 'crmUrl' => $this->generateProviderUrl($object['id'], $type),\n 'name' => $objectName,\n 'prospectType' => $type,\n 'phoneNumbers' => [],\n ];\n\n if ($type === 'account') {\n $record['industry'] = $properties['industry'] ?? null;\n } else {\n $record['title'] = $properties['jobtitle'] ?? null;\n $record['organization'] = $properties['company'] ?? null;\n }\n\n $countryCode = $this->buildContactCountry($properties);\n $parsedNumber = $this->buildContactPhone($countryCode, $properties);\n\n // Add phone number to record.\n if (! empty($parsedNumber['phone'])) {\n $record['phoneNumbers'][] = [\n 'number' => $parsedNumber['phone'],\n 'nationalFormat' => phone_national($countryCode, $parsedNumber['phone']),\n 'type' => 'phone',\n ];\n }\n\n // Add mobile phone number to record.\n if (! empty($properties['mobilephone'])) {\n $mobileNumber = phone_e164($countryCode, $properties['mobilephone']);\n if ($mobileNumber !== null) {\n $record['phoneNumbers'][] = [\n 'number' => $mobileNumber,\n 'nationalFormat' => phone_national($countryCode, $mobileNumber),\n 'type' => 'mobile',\n ];\n }\n }\n\n $data[] = $record;\n }\n } catch (BadRequest $e) {\n $this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [\n 'teamId' => $this->team->getUuid(),\n 'request' => $payload,\n 'reason' => $e->getMessage(),\n ]);\n\n throw $e;\n }\n }\n\n return $data;\n },\n );\n }\n\n\n /**\n * @inheritdoc\n */\n public function findOpportunities(?string $crmAccountId, ?string $crmContactId, ?int $userId = null): array\n {\n $data = [];\n $ownerData = [];\n $ownerId = null;\n\n if ($crmAccountId === null) {\n return $data;\n }\n\n if ($userId) {\n $profileRepository = app(ProfileRepository::class);\n $profile = $profileRepository->findProfileByUserId($this->config, $userId);\n\n $ownerId = $profile instanceof Profile ? $profile->getCrmProviderId() : null;\n }\n\n $closedStages = $this->getClosedDealStages();\n $payload = $this->payloadBuilder->generateOpportunitiesSearchPayload(\n $this->config,\n $crmAccountId,\n $closedStages,\n );\n\n $results = $this->client->getPaginatedData($payload, 'deals');\n\n foreach ($results['results'] as $object) {\n $properties = $object['properties'];\n\n $amount = null;\n if (empty($properties['amount']) === false) {\n $currency = $properties['deal_currency_code'] ?? $this->config->default_currency;\n\n // Values can contain commas and any junk so strip them.\n $value = (float) preg_replace('/[^\\d.]/', '', $properties['amount']);\n $amount = formatCurrency($value, $currency);\n }\n\n $businessProcess = $this->config\n ->businessProcesses()\n ->where('crm_provider_id', $properties['pipeline'])\n ->first();\n\n if ($businessProcess === null) {\n // Import it.\n $stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $properties['dealstage']);\n $businessProcess = $this->config\n ->businessProcesses()\n ->where('crm_provider_id', $properties['pipeline'])\n ->first();\n } else {\n $stage = $businessProcess\n ->stages()\n ->where('crm_provider_id', $properties['dealstage'])\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->first();\n\n if ($stage === null) {\n // Import it.\n $stage = $this->importStages(null, $properties['dealstage']);\n }\n }\n\n $recordType = null;\n if ($businessProcess) {\n $recordType = $businessProcess->recordTypes()->first();\n }\n\n $isWon = in_array($properties['dealstage'], $closedStages['won']);\n $isLost = in_array($properties['dealstage'], $closedStages['lost']);\n\n $record = [\n 'crmId' => $object['id'],\n 'name' => $properties['dealname'] ?? 'Unknown Deal',\n 'value' => $amount,\n 'won' => $isWon,\n 'closed' => $isWon || $isLost,\n 'stage' => [\n 'id' => $stage?->getUuid() ?? '',\n 'name' => $stage?->getName() ?? '',\n ],\n ];\n\n if ($recordType) {\n $record += [\n 'recordType' => [\n 'id' => $recordType->id_string,\n 'name' => $recordType->name,\n ],\n ];\n }\n\n if ($ownerId && isset($properties['hubspot_owner_id']) && $properties['hubspot_owner_id'] === $ownerId) {\n $ownerData[] = $record;\n }\n\n $data[] = $record;\n }\n\n if (! empty($ownerData)) {\n return $ownerData;\n }\n\n return $data;\n }\n\n /**\n * @inheritdoc\n */\n public function getTasks(?string $objectType, string $objectId, ?string $opportunityId): array\n {\n $data = [];\n switch ($objectType) {\n case 'contact':\n $hsObject = 'contact';\n\n break;\n case 'account':\n $hsObject = 'company';\n\n break;\n default:\n // This is a hack to prioritise and override a contact/company with a deal.\n if ($opportunityId) {\n $hsObject = 'deal';\n $objectId = $opportunityId;\n } else {\n throw new InvalidArgumentException('Object type not supported.');\n }\n }\n\n $engagementTypes = ['meetings', 'tasks'];\n\n foreach ($engagementTypes as $engagementType) {\n $payload = $this->payloadBuilder->getLinkToTaskPayload($hsObject, $objectId, $engagementType);\n\n $this->logger->info('[HubSpot] CRM Search requested', [\n 'request' => $payload,\n ]);\n\n $engagements = $this->client->getPaginatedData($payload, $engagementType);\n\n foreach ($engagements['results'] as $engagement) {\n if ($engagementType == 'meetings') {\n $title = $engagement['properties']['hs_meeting_title'] ?? 'Scheduled meeting';\n } elseif ($engagementType == 'tasks') {\n $title = $engagement['properties']['hs_task_subject'];\n } else {\n $title = 'Scheduled meeting';\n }\n\n $data[] = [\n 'crmId' => $engagement['id'],\n 'subject' => $title,\n 'due' => $engagement['properties']['hs_timestamp'],\n 'type' => $engagement['properties']['hs_activity_type'] ?? null,\n ];\n }\n }\n\n usort($data, function ($item1, $item2) {\n return $item2['due'] <=> $item1['due'];\n });\n\n return $data;\n }\n\n /**\n * Try to find CRM Objects using email address\n *\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n public function matchExactlyByEmail(string $email, ?int $userId = null): ?array\n {\n $contactProperties = [\n 'email',\n 'firstname',\n 'lastname',\n 'country',\n 'phone',\n 'mobilephone',\n 'jobtitle',\n 'hubspot_owner_id',\n 'associatedcompanyid',\n 'photo',\n ];\n $contact = null;\n $account = null;\n\n try {\n $hsContact = $this->getClient()->getContactByEmail($email, $contactProperties);\n\n if ($hsContact) {\n $contact = $this->importContact($hsContact);\n $account = $contact->account;\n }\n\n $data = $this->convertCrmData($contact, $account, $userId);\n\n return ! empty(array_filter($data)) ? $data : null;\n } catch (BadRequest $e) {\n $this->logger->warning('[HubSpot] Search failed', [\n 'team_id' => $this->team->getId(),\n 'search_identifier' => $email,\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return null;\n }\n\n public function getDomain(string $email): ?string\n {\n return $this->getDomainFromEmail($email);\n }\n\n /**\n * Try to find CRM objects using domain name of the email address\n *\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n public function matchByDomain(string $domain, ?int $userId = null): ?array\n {\n $companyName = $domain;\n\n // Try to find a company matching their email domain.\n $companyProperties = [\n 'country',\n 'phone',\n 'name',\n 'hs_avatar_filemanager_key',\n 'industry',\n 'hubspot_owner_id',\n 'domain',\n ];\n\n try {\n $hsAccounts = $this->client\n ->getInstance()\n ->companies()\n ->searchByDomain($companyName, $companyProperties);\n } catch (Throwable $e) {\n $this->logger->info('[HubSpot] Search failed', [\n 'error' => $e->getMessage(),\n 'domain' => $domain,\n ]);\n\n return null;\n }\n\n $account = null;\n // If there are multiple accounts, don't guess, we'll ask later.\n if (\\count($hsAccounts->data->results) === 1) {\n // Persist this remote object.\n $account = $this->syncAccount($hsAccounts->data->results[0]->companyId);\n }\n\n $data = $this->convertCrmData(null, $account, $userId);\n\n return ! empty(array_filter($data)) ? $data : null;\n }\n\n /**\n * @return array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n protected function convertCrmData(?Contact $contact, ?Account $account, ?int $userId = null): array\n {\n $countryCode = null;\n if ($contact && $contact->country_code) {\n $countryCode = $contact->country_code;\n } elseif ($account && $account->country_code) {\n $countryCode = $account->country_code;\n }\n\n try {\n $hsOpportunities = $this->findOpportunities(\n $account ? $account->crm_provider_id : null,\n $contact ? $contact->crm_provider_id : null,\n $userId\n );\n } catch (Exception $e) {\n $hsOpportunities = [];\n }\n\n // If there are multiple opportunities, don't guess, we'll ask later.\n $opportunity = null;\n $stage = null;\n if (! empty($hsOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n }\n\n protected function getCacheKey(string $object, ?int $userId = null): ?string\n {\n $key = $this->team->getId() . $object;\n $keySuffix = $this->getOwnerKeySuffix($userId);\n\n return $key . $keySuffix;\n }\n\n private function getOwnerKeySuffix(?int $userId = null): string\n {\n return $userId === null ? '' : (string) $userId;\n }\n\n /**\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n *}\n */\n public function matchByPhone(string $phone, ?string $rawPhoneNumber = null, ?int $userId = null): ?array\n {\n if (str_contains($phone, '**')) {\n return null;\n }\n\n // trim all whitespaces if present so the lookup doesn't fail\n $phone = str_replace(' ', '', $phone);\n\n // Check if the user is internal.\n if ($this->isPhoneNumberOfTeamMember($phone)) {\n return null;\n }\n\n $response = $this->searchForPhoneNumber($phone);\n if (empty($response)) {\n return null;\n }\n\n // This would ideally importContact instead but the response type differs.\n $contact = $this->findAndSyncContact($response['results'][0]['id']);\n if (! $contact instanceof Contact) {\n return null;\n }\n\n $account = $contact->account;\n $countryCode = $contact->country_code ?? $account->country_code ?? null;\n\n try {\n $hsOpportunities = $this->findOpportunities(\n $account?->crm_provider_id,\n $contact->crm_provider_id,\n $userId\n );\n } catch (Exception $e) {\n $hsOpportunities = [];\n }\n\n $opportunity = null;\n $stage = null;\n\n try {\n if (! empty($hsOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n } catch (Exception $e) {\n $this->logger->debug('[HubSpot] Opportunity failed to sync.', [\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n }\n\n private function isPhoneNumberOfTeamMember(string $phone): bool\n {\n $teamRepository = app(TeamRepository::class);\n $user = $teamRepository->findTeamMemberByPhone($this->team, $phone);\n\n if ($user instanceof User) {\n return true;\n }\n\n return false;\n }\n\n private function findAndSyncContact(string $crmId): ?Contact\n {\n try {\n return $this->syncContact($crmId);\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Phone match failed', [\n 'reason' => $exception->getMessage(),\n ]);\n\n return null;\n }\n }\n\n private function hasResults(array $response): bool\n {\n return isset($response['total']) && is_numeric($response['total']) && $response['total'] > 0;\n }\n\n private function searchForPhoneNumber(string $phone): array\n {\n // Normalizes the provided phone number for the API search.\n $normalizedPhone = $this->normalizePhoneNumber($phone);\n $payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone);\n\n $this->logger->info('[HubSpot] Phone match search triggered', [\n 'phone' => $phone,\n 'normalizedPhone' => $normalizedPhone,\n 'payload' => $payload,\n ]);\n\n $response = $this->handlePhoneSearchRequest($normalizedPhone, $payload);\n\n if (! $this->hasResults($response)) {\n $nationalPhone = preg_replace('/\\D/', '', phone_national(null, $phone));\n $payload = $this->payloadBuilder->generatePhoneSearchPayload($nationalPhone);\n\n $this->logger->info('[HubSpot] Phone match national number search triggered', [\n 'phone' => $phone,\n 'nationalPhone' => $nationalPhone,\n 'payload' => $payload,\n ]);\n\n $response = $this->handlePhoneSearchRequest($phone, $payload);\n }\n\n if (! $this->hasResults($response)) {\n $payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone, true);\n\n $this->logger->info('[HubSpot] Phone match alternative search triggered', [\n 'phone' => $phone,\n 'normalizedPhone' => $normalizedPhone,\n 'payload' => $payload,\n ]);\n\n $response = $this->handlePhoneSearchRequest($phone, $payload);\n }\n\n return $this->hasResults($response) ? $response : [];\n }\n\n private function handlePhoneSearchRequest(string $phone, array $payload): array\n {\n try {\n return $this->client->search('contacts', $payload);\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Phone match failed', [\n 'phone' => $phone,\n 'reason' => $exception->getMessage(),\n ]);\n\n return [];\n }\n }\n\n private function normalizePhoneNumber(string $phone): string\n {\n return ltrim(phone_e164(null, $phone), '+0');\n }\n\n /**\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n public function matchByName(string $name, ?int $userId = null): ?array\n {\n// $payload = $this->payloadBuilder->generateSearchContactsByNamePayload(\n// $name,\n// $this->getContactFields()\n// );\n// $hsContacts = $this->client->getPaginatedData($payload, 'contact');\n// return null;\n\n // Don't waste time searching for single character strings.\n if (\\strlen($name) <= 1) {\n return null;\n }\n\n $cacheKey = $this->getCacheKey($name, $userId);\n\n $result = Cache::remember($cacheKey, 60, function () use ($name, $userId) {\n $payload = $this->payloadBuilder->generateSearchContactsByNamePayload(\n $name,\n $this->getContactFields()\n );\n\n $hsContacts = $this->client->getPaginatedData($payload, 'contact');\n if (empty($hsContacts['results'])) {\n return false;\n }\n\n $contact = $this->importContact($hsContacts['results'][0]);\n if ($contact === null) {\n return false;\n }\n\n $account = $contact->account;\n $countryCode = $contact->country_code ?? $account->country_code ?? null;\n\n try {\n $hsOpportunities = $this->findOpportunities(\n $account ? $account->crm_provider_id : null,\n $contact->crm_provider_id,\n $userId\n );\n } catch (Exception $e) {\n $hsOpportunities = [];\n }\n\n $opportunity = null;\n $stage = null;\n if (! empty($hsOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n });\n\n return is_array($result) ? $result : null;\n }\n\n\n private function convertActivityAssociations(Activity $activity): array\n {\n return [\n 'contactIds' => $this->getParticipantsIds($activity),\n 'companyIds' => $activity->hasAccount() ? [$activity->account->crm_provider_id] : [],\n 'dealIds' => $activity->hasOpportunity() ? [$activity->opportunity->crm_provider_id] : [],\n 'ownerIds' => [],\n ];\n }\n\n private function getParticipantsIds(Activity $activity): array\n {\n $attendees = [];\n\n $participantRepository = app(ParticipantRepository::class);\n $participants = $participantRepository->getParticipantsWhoEnteredMeeting($activity);\n foreach ($participants as $participant) {\n if ($participant->user_id || $participant->isCoach()) {\n continue;\n }\n\n $contact = $participant->contact()->first();\n if ($contact && $contact->crm_provider_id) {\n $attendees[] = $contact->crm_provider_id;\n } else {\n if (! empty($participant->name)) {\n $attendeeData = $this->fetchMissingAttendeeInfo($participant);\n }\n if (! empty($attendeeData['id'])) {\n $attendees[] = $attendeeData['id'];\n }\n }\n }\n\n if ($activity->hasContact()) {\n $attendees[] = $activity->contact->crm_provider_id;\n }\n\n return array_unique($attendees);\n }\n\n private function fetchMissingAttendeeInfo(Participant $participant): array\n {\n // Check if we need to look inside an account context.\n $activity = $participant->getActivity();\n $companyId = $activity->hasAccount() ? $activity->getAccount()->crm_provider_id : null;\n\n // First check the local data.\n /** @var Contact[] $contacts */\n $contacts = $this->team->contacts()\n ->with('account')\n ->where('name', $participant->name)\n ->whereNotNull('email')\n ->get();\n\n foreach ($contacts as $contact) {\n // If we have a company in scope, check the contact is associated to it.\n if (\n $companyId !== null\n && ($contact->account_id === null || $companyId !== $contact->account->crm_provider_id)\n ) {\n continue;\n }\n\n return [\n 'id' => $contact->crm_provider_id,\n 'email' => $contact->email,\n ];\n }\n\n $payload = $this->generateNameSearchPayload($participant->name, 0, 20);\n\n try {\n $response = $this->client->getNewInstance()->crm()->contacts()->searchApi()->doSearch($payload);\n\n // TODO add some logic to choose the most suitable contact if multiple\n foreach ($response['results'] as $object) {\n $properties = $object['properties'];\n if (empty($object['properties']) === false) {\n // Check the company matches the contact.\n // Todo: Move this check inside the API search.\n if ($companyId !== null && $companyId !== $properties['associatedcompanyid']) {\n continue;\n }\n\n return [\n 'id' => $object['id'],\n 'email' => $properties['email'],\n ];\n }\n }\n } catch (Exception $e) {\n $this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [\n 'teamId' => $this->team->id_string,\n 'request' => $payload,\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return [];\n }\n\n /**\n * Store transcripts as note engagement.\n *\n * @throws Exception\n */\n public function createTranscriptNotes(Activity $activity): void\n {\n // For HS no need to check if Crm profile - Log Notes field is enabled\n // We only check if store_transcript toggle is enabled on crm profile.\n $engagement = [\n 'active' => true,\n 'ownerId' => $this->profile->crm_provider_id,\n 'timestamp' => $activity->created_at->tz($activity->user->timezone)->getTimestamp() * 1000,\n 'type' => 'NOTE',\n ];\n\n // Generate activity transcription.\n $transcriptionData = $this->generateTranscription($activity);\n\n // Truncate Notes with max notes length because transcription text could be very long.\n $transcripts = mb_strimwidth($transcriptionData, 0, static::ENGAGEMENT_BODY_MAX_LENGTH);\n\n $metadata = [\n 'body' => $transcripts,\n ];\n\n $associations = $this->convertActivityAssociations($activity);\n\n try {\n $hsEngagement = $this->client\n ->getInstance()\n ->engagements()\n ->create($engagement, $associations, $metadata);\n\n $this->logCrmEngagementManipulation(self::ACTION_CREATE, $engagement, $metadata, $associations);\n\n $noteId = $hsEngagement->data->engagement->id;\n\n // Store crm logged id in transcription.\n $transcription = $activity->getTranscription();\n $transcription->crm_activity_id = $noteId;\n $transcription->save();\n } catch (Exception $e) {\n Sentry::captureException($e);\n }\n }\n\n /*\n * @inheritdoc\n */\n public function updateRecord(string $objectType, string $objectId, array $data, array $headers = []): void\n {\n $payload = [\n 'properties' => $data,\n ];\n\n try {\n switch ($objectType) {\n case FieldData::OBJECT_OPPORTUNITY:\n $this->client->getNewInstance()->crm()->deals()->basicApi()->update($objectId, $payload);\n\n break;\n case FieldData::OBJECT_CONTACT:\n $this->client->getNewInstance()->crm()->contacts()->basicApi()->update($objectId, $payload);\n\n break;\n\n case FieldData::OBJECT_ACCOUNT:\n $this->client->getNewInstance()->crm()->companies()->basicApi()->update($objectId, $payload);\n\n break;\n\n case FieldData::OBJECT_TASK:\n // Endpoint for Engagements not ready\n $engagements = [\n 'type' => 'TASK',\n ];\n $metadata = $data;\n $this->client->getInstance()->engagements()->update($objectId, $engagements, $metadata);\n\n $this->logCrmEngagementManipulation(\n self::ACTION_UPDATE,\n ['crmId' => $objectId],\n $metadata,\n );\n\n break;\n\n default:\n throw new UnexpectedValueException('Unsupported object type \"' . $objectType . '\"');\n }\n } catch (\\HubSpot\\Client\\Crm\\Deals\\ApiException $apiException) {\n $errorMessage = $apiException->getMessage();\n if ($apiException->getResponseBody()) {\n $responseBody = json_decode($apiException->getResponseBody(), true, 512, JSON_THROW_ON_ERROR);\n $errorMessage = $responseBody['message'] ?? $apiException->getMessage();\n }\n\n $this->logger->error(\n '[HubSpot] Update record failed',\n [\n 'objectType' => $objectType,\n 'objectId' => $objectId,\n 'payload' => $payload,\n 'reason' => $errorMessage,\n 'team' => $this->team->getUuid(),\n ]\n );\n\n throw new CrmException($errorMessage);\n }\n }\n\n /*\n * @inheritdoc\n */\n public function getRecord(string $objectType, string $objectId, array $fields = []): array\n {\n switch ($objectType) {\n case FieldData::OBJECT_OPPORTUNITY:\n return $this->client->getInstance()->deals()->getById($objectId)->toArray();\n\n case FieldData::OBJECT_CONTACT:\n return $this->client->getInstance()->contacts()->getById($objectId)->toArray();\n\n case FieldData::OBJECT_ACCOUNT:\n return $this->client->getInstance()->companies()->getById($objectId)->toArray();\n\n case FieldData::OBJECT_TASK:\n return $this->client->getInstance()->engagements()->get($objectId)->toArray();\n\n default:\n throw new UnexpectedValueException('Unsupported object type \"' . $objectType . '\"');\n }\n }\n\n /**\n * @throws BadRequest\n * @throws CrmException\n */\n public function updateStage($crmObject, Stage $stage): void\n {\n $payload = [\n 'properties' => [\n [\n 'name' => 'dealstage',\n 'value' => $stage->crm_provider_id,\n ],\n ],\n ];\n\n try {\n $this->client->getInstance()->deals()->update($crmObject->crm_provider_id, $payload);\n } catch (BadRequest $badRequest) {\n if ($badRequest->getCode() === 403) {\n throw new CrmException(\n \"Sorry, you don't have permission to update this stage.\",\n $badRequest->getCode(),\n $badRequest,\n );\n }\n\n $this->logger->warning('[HubSpot] Stage update failed', [\n 'dealId' => $crmObject->crm_provider_id,\n 'payload' => $payload,\n 'message' => $badRequest->getMessage(),\n ]);\n\n throw $badRequest;\n }\n }\n\n public function generateProviderUrl(string $providerId, string $objectType): ?string\n {\n $url = null;\n $baseUrl = 'https://app.hubspot.com/contacts/' . $this->config->crm_provider_id . '/';\n\n switch ($objectType) {\n case 'account':\n $url = $baseUrl . 'company/' . $providerId;\n\n break;\n\n case 'contact':\n $url = $baseUrl . 'contact/' . $providerId;\n\n break;\n\n case 'opportunity':\n $url = $baseUrl . 'deal/' . $providerId;\n\n break;\n\n case 'task':\n case 'activity':\n return null;\n\n // This should not be deep-linked as per JMNY-3934.\n //$url = $baseUrl.'tasks/list/view/all/?taskId='.$providerId;\n break;\n }\n\n return $url;\n }\n\n public function searchCalls(Carbon $from, Carbon $to, string $activityProvider): array\n {\n $this->logger->info('[HubSpot] Search calls', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n ]);\n\n $calls = [];\n $page = 1;\n\n do {\n try {\n $payload = $this->payloadBuilder->generateGetCallsPayload($from, $to, $activityProvider, $page);\n $response = $this->client->search('calls', $payload);\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Search calls failed', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n 'reason' => $exception->getMessage(),\n ]);\n $response = null;\n }\n\n $responseResults = empty($response['results']) ? [] : $response['results'];\n $calls = array_merge($calls, $responseResults);\n $page++;\n } while (! empty($responseResults));\n\n return $calls;\n }\n\n public function searchCallsForPeriodByPage(Carbon $from, Carbon $to, int $page, bool $retry = true)\n {\n try {\n $payload = $this->payloadBuilder->generateSearchCallsByPeriodPayload($from, $to, $page);\n\n return $this->client->search('calls', $payload);\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Search calls for period failed', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n 'reason' => $exception->getMessage(),\n 'retry' => $retry,\n ]);\n\n if ($retry) {\n sleep(1);\n\n return $this->searchCallsForPeriodByPage($from, $to, $page, false);\n }\n\n return null;\n }\n }\n\n public function searchCallsForPeriod(Carbon $from, Carbon $to): Generator\n {\n $this->logger->info('[HubSpot] Search calls for period', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n ]);\n\n $page = 1;\n\n do {\n $response = $this->searchCallsForPeriodByPage($from, $to, $page);\n\n $responseResults = empty($response['results']) ? [] : $response['results'];\n\n $associationContacts = $this->getAssociationDataForCollection($responseResults, 'calls', 'contacts');\n $associationCompanies = $this->getAssociationDataForCollection($responseResults, 'calls', 'companies');\n $associationDeals = $this->getAssociationDataForCollection($responseResults, 'calls', 'deals');\n\n foreach ($responseResults as $call) {\n $call['associations'] = [\n 'contacts' => $this->importAssociationData($call, $associationContacts),\n 'companies' => $this->importAssociationData($call, $associationCompanies),\n 'deals' => $this->importAssociationData($call, $associationDeals),\n ];\n\n yield $call;\n }\n $page++;\n } while (! empty($responseResults));\n }\n\n public function getCall(string $callId): array\n {\n $this->logger->info('[HubSpot] Get call', [\n 'call_id' => $callId,\n ]);\n\n $searchAttributes = $this->payloadBuilder->getSearchCallAttributes();\n $endpoint = sprintf(\n 'https://api.hubapi.com/crm/v3/objects/calls/%s',\n $callId,\n );\n\n try {\n $response = $this->client->getInstance()->getClient()->request(\n 'GET',\n $endpoint,\n [],\n sprintf(\n 'properties=%s&associations=contacts,companies,deals',\n implode(',', $searchAttributes),\n ),\n );\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Get call failed', [\n 'call_id' => $callId,\n 'reason' => $exception->getMessage(),\n ]);\n $response = null;\n }\n\n return empty($response) ? [] : $response->toArray();\n }\n\n public function bulkAddPlaybackURLToDescription(array $crmUpdateData): array\n {\n $crmUpdateBatches = array_chunk($crmUpdateData, self::BATCH_UPDATE_LIMIT);\n\n $updatedCrmIds = [];\n\n foreach ($crmUpdateBatches as $crmBatch) {\n $payload = $this->payloadBuilder->generatePlaybackAddUrlBatchPayload($crmBatch);\n $updateSuccess = $this->bulkAddPlaybackURLToDescriptionRequest($payload);\n if ($updateSuccess) {\n $updatedCrmIds = array_merge($updatedCrmIds, array_column($crmBatch, 'crm_id'));\n }\n }\n\n return $updatedCrmIds;\n }\n\n private function bulkAddPlaybackURLToDescriptionRequest(array $payload, bool $retry = true): bool\n {\n try {\n $this->client->getNewInstance()->crm()->objects()->batchApi()->update('calls', $payload);\n\n return true;\n } catch (\\HubSpot\\Client\\Crm\\Objects\\ApiException $e) {\n $response = json_decode($e->getResponseBody(), true);\n $retryAfter =\n isset($response['policyName'])\n && $response['policyName'] == self::TEN_SECONDLY_ROLLING_POLICY\n ? self::TEN_SECONDLY_ROLLING_LIMIT\n : 1;\n } catch (Exception $e) {\n $retryAfter = 1;\n }\n\n $this->logger->warning('[HubSpot] Bulk add playback url to CRM failed', [\n 'reason' => $e->getMessage(),\n 'retry' => $retry,\n ]);\n\n if ($retry) {\n sleep($retryAfter);\n\n return $this->bulkAddPlaybackURLToDescriptionRequest($payload, false);\n }\n\n return false;\n }\n\n /**\n * Sometimes we have secondly rate limit error, then retry request after 1 second\n */\n public function searchCallByRecordingURLToken(string $playbackURLToken, bool $retry = true): array\n {\n $payload = $this->payloadBuilder->generateSearchCallByTokenPayload($playbackURLToken);\n\n $this->logger->info('[HubSpot] CRM Search by playback URL token requested', [\n 'request' => $payload,\n ]);\n\n try {\n $response = $this->client->search('calls', $payload);\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Search by playback URL token failed', [\n 'playbackURLToken' => $playbackURLToken,\n 'reason' => $exception->getMessage(),\n 'retry' => $retry,\n ]);\n\n if ($retry) {\n sleep(1);\n\n return $this->searchCallByRecordingURLToken($playbackURLToken, false);\n }\n\n return [];\n }\n\n return empty($response['results']) ? [] : $response['results'][0];\n }\n\n /**\n * Generate transcription for the activity.\n */\n private function generateTranscription(Activity $activity): string\n {\n if (! $this->config->store_transcript) {\n // If sending transcription to activity toggle is disabled\n return '';\n }\n\n $transcriptionSegments = $this->transcriptionService->findTranscriptionByActivity($activity);\n\n if ($transcriptionSegments->isEmpty()) {\n return '';\n }\n\n $transcription = sprintf(\n '<p><strong>Transcript for %s</strong></p><p></p>',\n $activity->title ?? $activity->activity_title,\n );\n\n $roomOwnerParticipant = $activity->findParticipantRoomOwner();\n $roomOwnerParticipantId = $roomOwnerParticipant !== null\n ? $roomOwnerParticipant->getId()\n : null;\n\n\n $transcription .= $transcriptionSegments\n ->map(static function (array $transcriptionSegment) use ($roomOwnerParticipantId): string {\n $isOrganiser = $roomOwnerParticipantId === $transcriptionSegment['participantId']\n && $roomOwnerParticipantId !== null;\n $transcriptColor = $isOrganiser ? '#000000' : '#f0415a';\n\n return sprintf(\n '<span style=\"color: %s;\">%s | </span>%s',\n $transcriptColor,\n $transcriptionSegment['formattedStartsAt'],\n $transcriptionSegment['transcript'],\n );\n })\n ->implode('<br />');\n\n return $transcription;\n }\n\n /**\n * @param array<array{\n * id: string,\n * label: string,\n * value?: string,\n * }> $options\n *\n * @return FieldData[]\n */\n private function importOptions(Field $field, array $options): array\n {\n $fieldValues = [];\n $values = [];\n $sequence = 0;\n\n foreach ($options as $option) {\n $values[] = [\n 'value' => $option['value'] ?? $option['id'],\n 'label' => substr($option['label'], 0, 255),\n 'sequence' => $sequence++,\n ];\n }\n\n $fieldsToPurge = $field->values()->get()->pluck('value')->toArray();\n\n foreach ($values as $value) {\n $value['value'] = substr($value['value'], 0, 255);\n $fieldValues[] = $field->values()->updateOrCreate([\n 'value' => $value['value'],\n ], $value);\n\n // Remove this value from the ones we are going to purge.\n if (($key = array_search($value['value'], $fieldsToPurge, false)) !== false) {\n unset($fieldsToPurge[$key]);\n }\n }\n\n // Delete the old values that are no longer used.\n $field->values()->whereIn('value', $fieldsToPurge)->delete();\n\n return $fieldValues;\n }\n\n public function saveTranscriptionSummaryAsNote(\n ActivityContract $activity,\n string $title,\n string $body,\n ?string $objectId,\n ?NoteObject $noteObject = null,\n ): ?string {\n if ($noteObject === null || $objectId === null) {\n return null;\n }\n\n /** @var User $user */\n $user = $activity->getUser();\n\n $profile = $this->assignCrmOwner($user, $activity);\n if (! $profile instanceof Profile) {\n return null;\n }\n\n $timestamp = Carbon::now($user->getTimezone())->getTimestamp() * 1000;\n $engagement = [\n 'active' => true,\n 'ownerId' => $profile->getAttribute('crm_provider_id'),\n 'timestamp' => $timestamp,\n 'type' => 'NOTE',\n ];\n\n // Truncate Notes with max notes length because transcription text could be very long.\n $body = mb_strimwidth($body, 0, self::ENGAGEMENT_BODY_MAX_LENGTH);\n $metadata = [\n 'body' => $body,\n ];\n\n $associations = $this->convertActivityAssociations($activity);\n\n try {\n $hsActivityId = $this->client->createNote(\n body: $body,\n ownerId: $profile->getCrmProviderId(),\n timestamp: $timestamp,\n objectId: $objectId,\n noteObject: $noteObject,\n );\n\n $this->logCrmEngagementManipulation(self::ACTION_CREATE, $engagement, $metadata, $associations);\n\n $this->logger->info('[HubSpot] Saving Transcription Summary as Note', [\n 'activity' => $activity->getUuid(),\n 'crmActivity' => $hsActivityId,\n ]);\n\n return $hsActivityId;\n } catch (Exception $e) {\n Sentry::captureException($e);\n }\n\n return null;\n }\n\n public function attachSummaryToActivity(ActivityContract $activity, string $summaryTitle, string $summaryContents): bool\n {\n $this->logger->info('[HubSpot] Attaching summary to activity', [\n 'activity' => $activity->getUuid(),\n 'summary_content' => $summaryContents,\n ]);\n\n if (! $activity instanceof Activity) {\n throw new InvalidArgumentException('Expected instance of Activity');\n }\n\n $summary = '<p><strong>' . $summaryTitle . '</strong></p>';\n $summary .= '<p>' . $summaryContents . '</p>';\n $metadata = $this->buildMetadataForSummaryUpdate($activity, $summary);\n\n try {\n $type = $this->matchActivityEngagementType($activity);\n $engagement = ['type' => $type];\n\n $this->client->updateEngagement($activity->getCrmProviderId(), $engagement, $metadata);\n } catch (Exception $e) {\n $this->logger->warning('[HubSpot] Update summary failed', [\n 'activity' => $activity->getUuid(),\n 'reason' => $e->getMessage(),\n ]);\n\n return false;\n }\n\n $this->logCrmEngagementManipulation(\n self::ACTION_UPDATE,\n ['crmId' => $activity->getCrmProviderId()],\n $metadata,\n );\n\n return true;\n }\n\n private function buildMetadataForSummaryUpdate(Activity $activity, string $summary): array\n {\n $descriptionField = $activity->getType() === Activity::TYPE_CONFERENCE ? 'internalMeetingNotes' : 'body';\n $engagement = $this->client->getEngagementData($activity->getCrmProviderId());\n // Meeting without internalMeetingNotes might mean it just does not have any notes;\n $description = $engagement['metadata'][$descriptionField] ?? null;\n\n if (empty($description)) {\n $data = $summary;\n } else {\n // avoid playbook url link to Jiminny being sent twice in the activity description\n $targetUrl = PlaybackUrlBuilder::build($activity);\n\n if (str_contains($description, $targetUrl)) {\n $jiminnyUrl = '<p><a href=\"' . $targetUrl . '\" title=\"Play at Jiminny\">Play at Jiminny</a></p>';\n $summary = str_replace($jiminnyUrl, '', $summary);\n\n $this->logger->info('[HubSpot] Summary modified', [\n 'activity' => $activity->getUuid(),\n 'target_url' => $jiminnyUrl,\n 'modified_summary_content' => $summary,\n ]);\n }\n\n $data = $description . '<p></p>' . $summary;\n }\n\n return [\n $descriptionField => $data,\n ];\n }\n\n public function fetchAndAssociateRelatedActivity(Activity $activity): ?Activity\n {\n return $this->syncRelatedActivityManager->fetchAndAssociateRelatedActivity($activity);\n }\n\n public function fetchRelatedActivity(Activity $activity): array\n {\n return [];\n }\n\n public function getDealsInBulk(array $dealIds): array\n {\n $payload = $this->payloadBuilder->getDealsInBulkPayload($dealIds);\n\n return $this->client->getPaginatedData($payload, 'deals');\n }\n\n /**\n * Extract deal IDs from HubSpot search response.\n *\n * @param array $hubspotResponse The raw HubSpot search API response.\n * @param bool $includeArchived Whether to include archived deals (default: false).\n *\n * @return string[] Array of deal IDs as strings.\n */\n public function extractDealIds(array $hubspotResponse, bool $includeArchived = false): array\n {\n if (empty($hubspotResponse['results'])) {\n return [];\n }\n\n return array_values(\n array_map(\n fn ($deal) => $deal['id'],\n array_filter(\n $hubspotResponse['results'],\n fn ($deal) => $includeArchived || empty($deal['archived'])\n )\n )\n );\n }\n\n public function matchActivityEngagementType(Activity $activity): string\n {\n return match ($activity->getType()) {\n Activity::TYPE_CONFERENCE => self::TYPE_MEETING,\n Activity::TYPE_SOFTPHONE, Activity::TYPE_SOFTPHONE_INBOUND => self::TYPE_CALL,\n default => self::TYPE_NOTE,\n };\n }\n\n private function assignCrmOwner(User $user, ActivityContract $activity): ?Profile\n {\n $profile = $user->getProfile();\n if ($profile instanceof Profile) {\n return $profile;\n }\n\n $this->logger->info('[HubSpot] Unable to save summary. No profile', [\n 'activity' => $activity->getUuid(),\n ]);\n\n return null;\n }\n\n private static function getDealsPipelinesEndpoint(): string\n {\n return self::API_URL . self::ENDPOINT_PIPELINES . self::PIPELINE_OBJECT_TYPE_DEALS;\n }\n\n public function verifyTaskExists(Activity $activity): bool\n {\n $crmProviderId = $activity->getCrmProviderId();\n $cacheKey = \"crm_task_exists:{$this->config->getId()}:$crmProviderId\";\n\n return Cache::remember($cacheKey, self::TASK_VERIFICATION_CACHE_TTL, function () use ($crmProviderId) {\n try {\n $engagement = $this->client->getEngagementData($crmProviderId);\n\n return ! empty($engagement);\n } catch (HttpNotFoundException|BadRequest) {\n // Engagement not found in CRM - this is expected and permanent\n $this->logger->info('[Hubspot] Engagement not found during verification', [\n 'engagement_id' => $crmProviderId,\n 'config_id' => $this->config->getId(),\n ]);\n\n return false;\n }\n // Let other exceptions (network errors, rate limits, etc.) bubble up for retry\n });\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Project","depth":3,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Project","depth":3,"bounds":{"left":0.011968086,"top":0.047885075,"width":0.024268618,"height":0.024740623},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"New File or Directory…","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Expand Selected","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Collapse All","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Options","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
1468664438918297136
|
-465546842829875097
|
visual_change
|
accessibility
|
NULL
|
Project: faVsco.js, menu
JY-20725-handle-HS-search Project: faVsco.js, menu
JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
19
Previous Highlighted Error
Next Highlighted Error
[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {
"headers":{
"Date":["Thu,07 May 2026 14:21:15 GMT"],
"Content-Type":["application/json;charset=utf-8"],
"Transfer-Encoding":["chunked"],
"Connection":["keep-alive"],
"CF-Ray":["9f80deb8db60dc3a-SOF"],
"CF-Cache-Status":["DYNAMIC"],
"Strict-Transport-Security":["max-age=31536000; includeSubDomains; preload"],
"Vary":["origin,
accept-encoding"],
"access-control-allow-credentials":["false"],
"server-timing":["hcid;desc=\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\",
cfr;desc=\"9f80deb8e7c6dc3a-IAD\""],
"x-content-type-options":["nosniff"],
"x-hubspot-correlation-id":["019e02d0-6fd8-7812-bdba-885b7ccb3ee3"],
"Set-Cookie":["__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-[IP_ADDRESS]-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,
07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None"],
"Report-To":["{
\"endpoints\":[{
\"url\":\"https:\\/\\/a.nel.cloudflare.com\\/report\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\"}],
\"group\":\"cf-nel\",
\"max_age\":604800}"],
"NEL":["{
\"success_fraction\":0.01,
\"report_to\":\"cf-nel\",
\"max_age\":604800}"],
"Server":["cloudflare"]}} {
"correlation_id":"95236535-ec98-4541-b92a-adfa73b69eab",
"trace_id":"c7ab8365-903f-46d4-9403-0e5b551e3545"}
Code changed:
Hide
Sync Changes
Hide This Notification
7
9
6
Previous Highlighted Error
Next Highlighted Error
<?php
namespace Jiminny\Services\Crm\Hubspot;
use Carbon\Carbon;
use Exception;
use Generator;
use GuzzleHttp\Exception\RequestException;
use Illuminate\Support\Facades\Cache;
use InvalidArgumentException;
use Jiminny\Contracts\Repositories\TeamRepository;
use Jiminny\Contracts\Services\Crm\ClientInterface;
use Jiminny\Contracts\Services\Crm\FetchRelatedActivityInterface;
use Jiminny\Contracts\Services\Crm\LayoutManagementInterface;
use Jiminny\Contracts\Services\Crm\MatchCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\Provider\HubspotInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityLookupInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityManipulationInterface;
use Jiminny\Contracts\Services\Crm\SavePlaybackLinkToCrmInterface;
use Jiminny\Contracts\Services\Crm\SendSummaryToCrmInterface;
use Jiminny\Contracts\Services\Crm\SettingsInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmMetadataInterface;
use Jiminny\Contracts\Services\Crm\VerifyTaskExistsInterface;
use Jiminny\Exceptions\CrmException;
use Jiminny\Exceptions\HttpNotFoundException;
use Jiminny\Jobs\Crm\NoteObject;
use Jiminny\Models\Account;
use Jiminny\Models\Activity;
use Jiminny\Models\Contact;
use Jiminny\Models\Contracts\ActivityContract;
use Jiminny\Models\Crm\BusinessProcess;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\FieldData;
use Jiminny\Models\Crm\Layout;
use Jiminny\Models\Crm\Profile;
use Jiminny\Models\Lead;
use Jiminny\Models\Opportunity;
use Jiminny\Models\Participant;
use Jiminny\Models\Playbook;
use Jiminny\Models\SocialAccount;
use Jiminny\Models\Stage;
use Jiminny\Models\User;
use Jiminny\Repositories\Crm\CrmEntityRepository;
use Jiminny\Repositories\Crm\FieldRepository;
use Jiminny\Repositories\Crm\ProfileRepository;
use Jiminny\Repositories\ParticipantRepository;
use Jiminny\Services\Avatar\ProspectPhotoPathService;
use Jiminny\Services\Crm\BaseService;
use Jiminny\Services\Crm\Hubspot\Actions\SyncArchivedProfilesAction;
use Jiminny\Services\Crm\Hubspot\Fields\ValueNormalizer;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\OpportunitySyncTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\SyncCrmEntitiesTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\SyncFieldsTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\WriteCrmTrait;
use Jiminny\Services\Crm\MatchDomainByEmailInterface;
use Jiminny\Services\Crm\OpportunitySyncStrategyResolver;
use Jiminny\Services\Crm\ResolveCompanyNameByEmailTrait;
use Jiminny\Utils\PlaybackUrlBuilder;
use Sentry;
use SevenShores\Hubspot\Exceptions\BadRequest;
use Throwable;
use UnexpectedValueException;
/**
* @phpstan-type CrmFieldDefinition array{
* name: string,
* label: string,
* description: string,
* type: string,
* fieldType: string,
* hidden: bool,
* showCurrencySymbol: bool,
* options: array<array{
* id: string,
* label: string,
* value?: string,
* }
*/
class Service extends BaseService implements
HubspotInterface,
SyncCrmEntitiesInterface,
SyncCrmMetadataInterface,
SendSummaryToCrmInterface,
MatchDomainByEmailInterface,
SavePlaybackLinkToCrmInterface,
RemoteEntityManipulationInterface,
FetchRelatedActivityInterface,
LayoutManagementInterface,
SettingsInterface,
MatchCrmEntitiesInterface,
RemoteEntityLookupInterface,
VerifyTaskExistsInterface
{
use ResolveCompanyNameByEmailTrait;
use SyncCrmEntitiesTrait;
use WriteCrmTrait;
use SyncFieldsTrait;
use OpportunitySyncTrait;
private const int ENGAGEMENT_BODY_MAX_LENGTH = 65536;
private const string LOG_DATE_FORMAT = 'Y-m-d H:i:s';
private const int BATCH_UPDATE_LIMIT = 100;
private const string TEN_SECONDLY_ROLLING_POLICY = 'TEN_SECONDLY_ROLLING';
private const int TEN_SECONDLY_ROLLING_LIMIT = 10;
private const string TYPE_NOTE = 'NOTE';
private const string TYPE_MEETING = 'MEETING';
private const string TYPE_CALL = 'CALL';
private const string API_URL = '[URL_WITH_CREDENTIALS] ClientInterface|Client
*/
protected $client;
protected OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;
protected CrmEntityRepository $crmEntityRepository;
protected ProspectPhotoPathService $prospectPhotoPathService;
private SyncFieldAction $syncFieldAction;
private PayloadBuilder $payloadBuilder;
private SyncRelatedActivityManager $syncRelatedActivityManager;
private SyncArchivedProfilesAction $syncArchivedProfilesAction;
private WebhookSyncBatchProcessor $batchProcessor;
public function __construct(
Client $client,
SyncFieldAction $syncFieldAction,
PayloadBuilder $payloadBuilder,
ProspectPhotoPathService $prospectPhotoPathService,
SyncArchivedProfilesAction $syncArchivedProfilesAction,
WebhookSyncBatchProcessor $batchProcessor,
) {
parent::__construct();
$this->client = $client;
$this->syncFieldAction = $syncFieldAction;
$this->prospectPhotoPathService = $prospectPhotoPathService;
$this->payloadBuilder = $payloadBuilder;
$this->syncArchivedProfilesAction = $syncArchivedProfilesAction;
$this->batchProcessor = $batchProcessor;
$this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [
'client' => $this->client,
]);
$this->syncRelatedActivityManager = app(SyncRelatedActivityManager::class, [
'client' => $this->client,
'payloadBuilder' => $this->payloadBuilder,
'logger' => $this->logger,
]);
$this->crmEntityRepository = app(CrmEntityRepository::class);
$this->dealFieldsService = app(DealFieldsService::class);
}
public function getDisplayName(): string
{
return 'HubSpot';
}
protected function getOAuthAccount(User $user): ?SocialAccount
{
// In this case, the Account Owner is always the connection for any API operations.
$owner = $user->team->owner;
return $owner->getSocialAccount(SocialAccount::PROVIDER_HUBSPOT);
}
public function getClient(): Client
{
/** @var Client */
return $this->client;
}
/**
* Convert raw field data into a format compatible with CRM APIs.
*
* @param bool $internal Direction of the conversion.
* True is pulling from CRM, false normalize before sending to CRM.
*/
public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string
{
return ValueNormalizer::normalize(
fieldType: $fieldType,
fieldValue: $fieldValue,
isInbound: $internal,
);
}
/**
* @inheritdoc
*/
public function getDefaultFields(string $activityType): array
{
$fields = [];
if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {
$defaultFields = FieldDefinitions::defaultTaskFields();
// This lazy creates these fields if not already setup.
foreach ($defaultFields as $defaultField) {
$fields[] = $this->config->fields()->firstOrCreate($defaultField);
}
}
return $fields;
}
/**
* @inheritdoc
*/
public function getDefaultActivityField(string $activityType): Field
{
/** @var Field $activityField */
$activityField = $this->config->fields()->where([
'crm_provider_id' => 'activityType',
'object_type' => $activityType,
])->first();
return $activityField;
}
/**
* @inheritdoc
*/
public function getSupportedPlaybookTypes(): array
{
return [Playbook::ACTIVITY_TYPE_TASK];
}
/**
* @inheritdoc
*/
public function getDefaultActivityLayoutFields(string $activityType, string $layoutType): array
{
$fields = [];
if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {
// Outcome should always be provided calls/meetings.
$fieldData = [
[
'crm_provider_id' => $layoutType === Layout::TYPE_SOFTPHONE_SUMMARY ? 'disposition' : 'meetingOutcome',
'object_type' => Field::OBJECT_TASK,
],
];
foreach ($fieldData as $data) {
$field = $this->config->fields()->where($data)->first();
// Only add the field if it is created, which it should be.
if ($field) {
$fields[] = $field;
}
}
}
return $fields;
}
public function getDealInsightsFields(): array
{
return FieldDefinitions::dealInsightsFields();
}
protected function getDefaultFollowupLayoutFields(string $activityType): array
{
$fields = [];
$fieldRepo = app(FieldRepository::class);
$fieldData = FieldDefinitions::followupFieldsFilter();
foreach ($fieldData as $data) {
$field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $data);
// Only add the field if it is created, which it should be.
if ($field) {
$fields[] = $field;
}
}
return $fields;
}
/**
* @inheritdoc
*/
public function syncField(Field $field): void
{
switch ($field->object_type) {
case Field::OBJECT_ACCOUNT:
$crmField = $this->client->getInstance()->companyProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_CONTACT:
$crmField = $this->client->getInstance()->contactProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_OPPORTUNITY:
$crmField = $this->client->getInstance()->dealProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_TASK:
$this->syncSingleTaskField($field);
return;
default:
return;
}
$this->syncFieldAction->execute($field, $crmField->toArray());
}
/**
* @param array<array{
* id:string,
* label:string,
* value?:string
* }> $options
*
* @throws CrmException
*
* @return FieldData[]
*
*/
public function importPicklistValues(
Field $field,
array $options = [['id' => '', 'label' => '', 'value' => '']],
): array {
if (! empty($options[0]['id']) || ! empty($options[0]['value'])) {
// We already have the options, no need to fetch them again
return $this->importOptions($field, $options);
}
$options = [];
switch ($field->getObjectType()) {
case Field::OBJECT_ACCOUNT:
$options = $this->getClient()->fetchPropertyOptions('company', $field->getCrmProviderId());
break;
case Field::OBJECT_CONTACT:
$options = $this->getClient()->fetchPropertyOptions('contact', $field->getCrmProviderId());
break;
case Field::OBJECT_OPPORTUNITY:
// Hubspot has different endpoint for stages
$options = $this->getClient()->fetchOpportunityFieldOptions($field);
break;
case Field::OBJECT_TASK:
if ($field->getCrmProviderId() === 'disposition') {
$options = $this->getClient()->fetchDispositionFieldOptions();
} elseif (in_array($field->getCrmProviderId(), ['meetingOutcome', 'activityType'])) {
$options = $this->getClient()->fetchMeetingOutcomeFieldOptions($field);
}
break;
default:
$this->logger->warning('Invalid object type', [
'object_type' => $field->getObjectType(),
'field_id' => $field->getId(),
]);
throw new CrmException('Invalid object type');
}
return $this->importOptions($field, $options);
}
/**
* @inheritdoc
*/
public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage
{
$missingStage = null;
try {
// Use the HubSpot API client instead of the SDK crmPipelines() method
$endpoint = self::getDealsPipelinesEndpoint();
$pipelinesResponse = $this->client->getInstance()->getClient()->request('GET', $endpoint);
$pipelines = $pipelinesResponse->data->results;
} catch (RequestException|BadRequest $exception) {
throw $exception;
}
foreach ($pipelines as $pipeline) {
$stages = [];
// We create a business process to contain the pipeline, and store all stages against it.
$p = ResponseNormalize::normalizePipeline($pipeline);
// Create/update business process for this pipeline
$businessProcess = $this->config->businessProcesses()->updateOrCreate([
'crm_provider_id' => $p['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($p['label'], 0, 150),
'type' => BusinessProcess::TYPE_OPPORTUNITY,
'is_selectable' => $p['active'],
]);
// A record type is really a clone of the business process, used to store which record uses which pipeline.
// Create/update record type clone
$this->config->recordTypes()->updateOrCreate([
'crm_provider_id' => $p['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($p['label'], 0, 150),
'is_selectable' => $p['active'],
'business_process_id' => $businessProcess->id ?? null,
]);
// Stages - fetch all existing stages upfront to avoid N+1 queries
$existingStages = $this->config->stages()
->withTrashed()
->where('type', Stage::TYPE_OPPORTUNITY)
->get()
->keyBy('crm_provider_id');
foreach ($p['stages'] as $dealStage) {
$s = ResponseNormalize::normalizeDealStage($dealStage);
/** @var ?Stage $existingStage */
$existingStage = $existingStages->get($s['id']);
// Restore soft-deleted stages that are now active in HubSpot
if ($existingStage?->trashed() && $s['active']) {
$existingStage->restore();
}
// Upsert stage (updates soft-deleted records without restoring them)
$stage = $this->config->stages()->withTrashed()->updateOrCreate([
'crm_provider_id' => $s['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($s['label'], 0, 50),
'label' => mb_strimwidth($s['label'], 0, 191),
'type' => Stage::TYPE_OPPORTUNITY,
'sequence' => $s['displayOrder'],
'is_selectable' => $s['active'],
'probability' => $s['probability'] * 100,
]);
if ($missingStageName === $s['id']) {
$missingStage = $stage;
}
$stages[] = $stage->id;
}
$businessProcess->stages()->sync($stages);
}
return $missingStage;
}
/**
* @inheritdoc
*/
public function syncOrganization(): void
{
try {
$endpoint = '[URL_WITH_CREDENTIALS]
*/
public function find(string $name, array $scopes): array
{
$count = $this->limit ?? 20;
$offset = $this->offset ?? 0;
/** @var array<int, array<string, mixed>> */
return Cache::remember(
key: $this->team->getId() . $name . $count . $offset,
ttl: 300,
callback: function () use ($name, $offset, $count): array {
$data = [];
// Use the new V3 API to find contacts based on additional fields.
foreach (['companies', 'contacts'] as $objectType) {
$payload = $this->generateNameSearchPayload($name, $offset, $count);
$type = $objectType === 'companies' ? 'account' : 'contact';
try {
$response = $this->client->search($objectType, $payload);
// Build mapped list.
foreach ($response['results'] as $object) {
$properties = $object['properties'];
$objectName = $this->buildContactName($properties);
$record = [
'crmId' => $object['id'],
// Pass crmUrl to the FE, needed for success message in the extension when you log activity.
'crmUrl' => $this->generateProviderUrl($object['id'], $type),
'name' => $objectName,
'prospectType' => $type,
'phoneNumbers' => [],
];
if ($type === 'account') {
$record['industry'] = $properties['industry'] ?? null;
} else {
$record['title'] = $properties['jobtitle'] ?? null;
$record['organization'] = $properties['company'] ?? null;
}
$countryCode = $this->buildContactCountry($properties);
$parsedNumber = $this->buildContactPhone($countryCode, $properties);
// Add phone number to record.
if (! empty($parsedNumber['phone'])) {
$record['phoneNumbers'][] = [
'number' => $parsedNumber['phone'],
'nationalFormat' => phone_national($countryCode, $parsedNumber['phone']),
'type' => 'phone',
];
}
// Add mobile phone number to record.
if (! empty($properties['mobilephone'])) {
$mobileNumber = phone_e164($countryCode, $properties['mobilephone']);
if ($mobileNumber !== null) {
$record['phoneNumbers'][] = [
'number' => $mobileNumber,
'nationalFormat' => phone_national($countryCode, $mobileNumber),
'type' => 'mobile',
];
}
}
$data[] = $record;
}
} catch (BadRequest $e) {
$this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [
'teamId' => $this->team->getUuid(),
'request' => $payload,
'reason' => $e->getMessage(),
]);
throw $e;
}
}
return $data;
},
);
}
/**
* @inheritdoc
*/
public function findOpportunities(?string $crmAccountId, ?string $crmContactId, ?int $userId = null): array
{
$data = [];
$ownerData = [];
$ownerId = null;
if ($crmAccountId === null) {
return $data;
}
if ($userId) {
$profileRepository = app(ProfileRepository::class);
$profile = $profileRepository->findProfileByUserId($this->config, $userId);
$ownerId = $profile instanceof Profile ? $profile->getCrmProviderId() : null;
}
$closedStages = $this->getClosedDealStages();
$payload = $this->payloadBuilder->generateOpportunitiesSearchPayload(
$this->config,
$crmAccountId,
$closedStages,
);
$results = $this->client->getPaginatedData($payload, 'deals');
foreach ($results['results'] as $object) {
$properties = $object['properties'];
$amount = null;
if (empty($properties['amount']) === false) {
$currency = $properties['deal_currency_code'] ?? $this->config->default_currency;
// Values can contain commas and any junk so strip them.
$value = (float) preg_replace('/[^\d.]/', '', $properties['amount']);
$amount = formatCurrency($value, $currency);
}
$businessProcess = $this->config
->businessProcesses()
->where('crm_provider_id', $properties['pipeline'])
->first();
if ($businessProcess === null) {
// Import it.
$stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $properties['dealstage']);
$businessProcess = $this->config
->businessProcesses()
->where('crm_provider_id', $properties['pipeline'])
->first();
} else {
$stage = $businessProcess
->stages()
->where('crm_provider_id', $properties['dealstage'])
->where('type', Stage::TYPE_OPPORTUNITY)
->first();
if ($stage === null) {
// Import it.
$stage = $this->importStages(null, $properties['dealstage']);
}
}
$recordType = null;
if ($businessProcess) {
$recordType = $businessProcess->recordTypes()->first();
}
$isWon = in_array($properties['dealstage'], $closedStages['won']);
$isLost = in_array($properties['dealstage'], $closedStages['lost']);
$record = [
'crmId' => $object['id'],
'name' => $properties['dealname'] ?? 'Unknown Deal',
'value' => $amount,
'won' => $isWon,
'closed' => $isWon || $isLost,
'stage' => [
'id' => $stage?->getUuid() ?? '',
'name' => $stage?->getName() ?? '',
],
];
if ($recordType) {
$record += [
'recordType' => [
'id' => $recordType->id_string,
'name' => $recordType->name,
],
];
}
if ($ownerId && isset($properties['hubspot_owner_id']) && $properties['hubspot_owner_id'] === $ownerId) {
$ownerData[] = $record;
}
$data[] = $record;
}
if (! empty($ownerData)) {
return $ownerData;
}
return $data;
}
/**
* @inheritdoc
*/
public function getTasks(?string $objectType, string $objectId, ?string $opportunityId): array
{
$data = [];
switch ($objectType) {
case 'contact':
$hsObject = 'contact';
break;
case 'account':
$hsObject = 'company';
break;
default:
// This is a hack to prioritise and override a contact/company with a deal.
if ($opportunityId) {
$hsObject = 'deal';
$objectId = $opportunityId;
} else {
throw new InvalidArgumentException('Object type not supported.');
}
}
$engagementTypes = ['meetings', 'tasks'];
foreach ($engagementTypes as $engagementType) {
$payload = $this->payloadBuilder->getLinkToTaskPayload($hsObject, $objectId, $engagementType);
$this->logger->info('[HubSpot] CRM Search requested', [
'request' => $payload,
]);
$engagements = $this->client->getPaginatedData($payload, $engagementType);
foreach ($engagements['results'] as $engagement) {
if ($engagementType == 'meetings') {
$title = $engagement['properties']['hs_meeting_title'] ?? 'Scheduled meeting';
} elseif ($engagementType == 'tasks') {
$title = $engagement['properties']['hs_task_subject'];
} else {
$title = 'Scheduled meeting';
}
$data[] = [
'crmId' => $engagement['id'],
'subject' => $title,
'due' => $engagement['properties']['hs_timestamp'],
'type' => $engagement['properties']['hs_activity_type'] ?? null,
];
}
}
usort($data, function ($item1, $item2) {
return $item2['due'] <=> $item1['due'];
});
return $data;
}
/**
* Try to find CRM Objects using email address
*
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchExactlyByEmail(string $email, ?int $userId = null): ?array
{
$contactProperties = [
'email',
'firstname',
'lastname',
'country',
'phone',
'mobilephone',
'jobtitle',
'hubspot_owner_id',
'associatedcompanyid',
'photo',
];
$contact = null;
$account = null;
try {
$hsContact = $this->getClient()->getContactByEmail($email, $contactProperties);
if ($hsContact) {
$contact = $this->importContact($hsContact);
$account = $contact->account;
}
$data = $this->convertCrmData($contact, $account, $userId);
return ! empty(array_filter($data)) ? $data : null;
} catch (BadRequest $e) {
$this->logger->warning('[HubSpot] Search failed', [
'team_id' => $this->team->getId(),
'search_identifier' => $email,
'reason' => $e->getMessage(),
]);
}
return null;
}
public function getDomain(string $email): ?string
{
return $this->getDomainFromEmail($email);
}
/**
* Try to find CRM objects using domain name of the email address
*
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchByDomain(string $domain, ?int $userId = null): ?array
{
$companyName = $domain;
// Try to find a company matching their email domain.
$companyProperties = [
'country',
'phone',
'name',
'hs_avatar_filemanager_key',
'industry',
'hubspot_owner_id',
'domain',
];
try {
$hsAccounts = $this->client
->getInstance()
->companies()
->searchByDomain($companyName, $companyProperties);
} catch (Throwable $e) {
$this->logger->info('[HubSpot] Search failed', [
'error' => $e->getMessage(),
'domain' => $domain,
]);
return null;
}
$account = null;
// If there are multiple accounts, don't guess, we'll ask later.
if (\count($hsAccounts->data->results) === 1) {
// Persist this remote object.
$account = $this->syncAccount($hsAccounts->data->results[0]->companyId);
}
$data = $this->convertCrmData(null, $account, $userId);
return ! empty(array_filter($data)) ? $data : null;
}
/**
* @return array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
protected function convertCrmData(?Contact $contact, ?Account $account, ?int $userId = null): array
{
$countryCode = null;
if ($contact && $contact->country_code) {
$countryCode = $contact->country_code;
} elseif ($account && $account->country_code) {
$countryCode = $account->country_code;
}
try {
$hsOpportunities = $this->findOpportunities(
$account ? $account->crm_provider_id : null,
$contact ? $contact->crm_provider_id : null,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
// If there are multiple opportunities, don't guess, we'll ask later.
$opportunity = null;
$stage = null;
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
protected function getCacheKey(string $object, ?int $userId = null): ?string
{
$key = $this->team->getId() . $object;
$keySuffix = $this->getOwnerKeySuffix($userId);
return $key . $keySuffix;
}
private function getOwnerKeySuffix(?int $userId = null): string
{
return $userId === null ? '' : (string) $userId;
}
/**
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
*}
*/
public function matchByPhone(string $phone, ?string $rawPhoneNumber = null, ?int $userId = null): ?array
{
if (str_contains($phone, '**')) {
return null;
}
// trim all whitespaces if present so the lookup doesn't fail
$phone = str_replace(' ', '', $phone);
// Check if the user is internal.
if ($this->isPhoneNumberOfTeamMember($phone)) {
return null;
}
$response = $this->searchForPhoneNumber($phone);
if (empty($response)) {
return null;
}
// This would ideally importContact instead but the response type differs.
$contact = $this->findAndSyncContact($response['results'][0]['id']);
if (! $contact instanceof Contact) {
return null;
}
$account = $contact->account;
$countryCode = $contact->country_code ?? $account->country_code ?? null;
try {
$hsOpportunities = $this->findOpportunities(
$account?->crm_provider_id,
$contact->crm_provider_id,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
$opportunity = null;
$stage = null;
try {
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
} catch (Exception $e) {
$this->logger->debug('[HubSpot] Opportunity failed to sync.', [
'reason' => $e->getMessage(),
]);
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
private function isPhoneNumberOfTeamMember(string $phone): bool
{
$teamRepository = app(TeamRepository::class);
$user = $teamRepository->findTeamMemberByPhone($this->team, $phone);
if ($user instanceof User) {
return true;
}
return false;
}
private function findAndSyncContact(string $crmId): ?Contact
{
try {
return $this->syncContact($crmId);
} catch (Exception $exception) {
$this->logger->info('[HubSpot] Phone match failed', [
'reason' => $exception->getMessage(),
]);
return null;
}
}
private function hasResults(array $response): bool
{
return isset($response['total']) && is_numeric($response['total']) && $response['total'] > 0;
}
private function searchForPhoneNumber(string $phone): array
{
// Normalizes the provided phone number for the API search.
$normalizedPhone = $this->normalizePhoneNumber($phone);
$payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone);
$this->logger->info('[HubSpot] Phone match search triggered', [
'phone' => $phone,
'normalizedPhone' => $normalizedPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($normalizedPhone, $payload);
if (! $this->hasResults($response)) {
$nationalPhone = preg_replace('/\D/', '', phone_national(null, $phone));
$payload = $this->payloadBuilder->generatePhoneSearchPayload($nationalPhone);
$this->logger->info('[HubSpot] Phone match national number search triggered', [
'phone' => $phone,
'nationalPhone' => $nationalPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($phone, $payload);
}
if (! $this->hasResults($response)) {
$payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone, true);
$this->logger->info('[HubSpot] Phone match alternative search triggered', [
'phone' => $phone,
'normalizedPhone' => $normalizedPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($phone, $payload);
}
return $this->hasResults($response) ? $response : [];
}
private function handlePhoneSearchRequest(string $phone, array $payload): array
{
try {
return $this->client->search('contacts', $payload);
} catch (Exception $exception) {
$this->logger->info('[HubSpot] Phone match failed', [
'phone' => $phone,
'reason' => $exception->getMessage(),
]);
return [];
}
}
private function normalizePhoneNumber(string $phone): string
{
return ltrim(phone_e164(null, $phone), '+0');
}
/**
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchByName(string $name, ?int $userId = null): ?array
{
// $payload = $this->payloadBuilder->generateSearchContactsByNamePayload(
// $name,
// $this->getContactFields()
// );
// $hsContacts = $this->client->getPaginatedData($payload, 'contact');
// return null;
// Don't waste time searching for single character strings.
if (\strlen($name) <= 1) {
return null;
}
$cacheKey = $this->getCacheKey($name, $userId);
$result = Cache::remember($cacheKey, 60, function () use ($name, $userId) {
$payload = $this->payloadBuilder->generateSearchContactsByNamePayload(
$name,
$this->getContactFields()
);
$hsContacts = $this->client->getPaginatedData($payload, 'contact');
if (empty($hsContacts['results'])) {
return false;
}
$contact = $this->importContact($hsContacts['results'][0]);
if ($contact === null) {
return false;
}
$account = $contact->account;
$countryCode = $contact->country_code ?? $account->country_code ?? null;
try {
$hsOpportunities = $this->findOpportunities(
$account ? $account->crm_provider_id : null,
$contact->crm_provider_id,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
$opportunity = null;
$stage = null;
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
});
return is_array($result) ? $result : null;
}
private function convertActivityAssociations(Activity $activity): array
{
return [
'contactIds' => $this->getParticipantsIds($activity),
'companyIds' => $activity->hasAccount() ? [$activity->account->crm_provider_id] : [],
'dealIds' => $activity->hasOpportunity() ? [$activity->opportunity->crm_provider_id] : [],
'ownerIds' => [],
];
}
private function getParticipantsIds(Activity $activity): array
{
$attendees = [];
$participantRepository = app(ParticipantRepository::class);
$participants = $participantRepository->getParticipantsWhoEnteredMeeting($activity);
foreach ($participants as $participant) {
if ($participant->user_id || $participant->isCoach()) {
continue;
}
$contact = $participant->contact()->first();
if ($contact && $contact->crm_provider_id) {
$attendees[] = $contact->crm_provider_id;
} else {
if (! empty($participant->name)) {
$attendeeData = $this->fetchMissingAttendeeInfo($participant);
}
if (! empty($attendeeData['id'])) {
$attendees[] = $attendeeData['id'];
}
}
}
if ($activity->hasContact()) {
$attendees[] = $activity->contact->crm_provider_id;
}
return array_unique($attendees);
}
private function fetchMissingAttendeeInfo(Participant $participant): array
{
// Check if we need to look inside an account context.
$activity = $participant->getActivity();
$companyId = $activity->hasAccount() ? $activity->getAccount()->crm_provider_id : null;
// First check the local data.
/** @var Contact[] $contacts */
$contacts = $this->team->contacts()
->with('account')
->where('name', $participant->name)
->whereNotNull('email')
->get();
foreach ($contacts as $contact) {
// If we have a company in scope, check the contact is associated to it.
if (
$companyId !== null
&& ($contact->account_id === null || $companyId !== $contact->account->crm_provider_id)
) {
continue;
}
return [
'id' => $contact->crm_provider_id,
'email' => $contact->email,
];
}
$payload = $this->generateNameSearchPayload($participant->name, 0, 20);
try {
$response = $this->client->getNewInstance()->crm()->contacts()->searchApi()->doSearch($payload);
// TODO add some logic to choose the most suitable contact if multiple
foreach ($response['results'] as $object) {
$properties = $object['properties'];
if (empty($object['properties']) === false) {
// Check the company matches the contact.
// Todo: Move this check inside the API search.
if ($companyId !== null && $companyId !== $properties['associatedcompanyid']) {
continue;
}
return [
'id' => $object['id'],
'email' => $properties['email'],
];
}
}
} catch (Exception $e) {
$this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [
'teamId' => $this->team->id_string,
'request' => $payload,
'reason' => $e->getMessage(),
]);
}
return [];
}
/**
* Store transcripts as note engagement.
*
* @throws Exception
*/
public function createTranscriptNotes(Activity $activity): void
{
// For HS no need to check if Crm profile - Log Notes field is enabled
// We only check if store_transcript toggle is enabled on crm profile.
$engagement = [
'active' => true,
'ownerId' => $this->profile->crm_provider_id,
'timestamp' => $activity->created_at->tz($activity->user->timezone)->getTimestamp() * 1000,
'type' => 'NOTE',
];
// Generate activity transcription.
$transcriptionData = $this->generateTranscription($activity);
// Truncate Notes with max notes length because transcription text could be very long.
$transcripts = mb_strimwidth($transcriptionData, 0, static::ENGAGEMENT_BODY_MAX_LENGTH);
$metadata = [
'body' => $transcripts,
];
$associations = $this->convertActivityAssociations($activity);
try {
$hsEngagement = $this->client
->getInstance()
->engagements()
->create($engagement, $associations, $metadata);
$this->logCrmEngagementManipulation(self::ACTION_CREATE, $engagement, $metadata, $associations);
$noteId = $hsEngagement->data->engagement->id;
// Store crm logged id in transcription.
$transcription = $activity->getTranscription();
$transcription->crm_activity_id = $noteId;
$transcription->save();
} catch (Exception $e) {
Sentry::captureException($e);
}
}
/*
* @inheritdoc
*/
public function updateRecord(string $objectType, string $objectId, array $data, array $headers = []): void
{
$payload = [
'properties' => $data,
];
try {
switch ($objectType) {
case FieldData::OBJECT_OPPORTUNITY:
$this->client->getNewInstance()->crm()->deals()->basicApi()->update($objectId, $payload);
break;
case FieldData::OBJECT_CONTACT:
$this->client->getNewInstance()->crm()->contacts()->basicApi()->update($objectId, $payload);
break;
case FieldData::OBJECT_ACCOUNT:
$this->client->getNewInstance()->crm()->companies()->basicApi()->update($objectId, $payload);
break;
case FieldData::OBJECT_TASK:
// Endpoint for Engagements not ready
$engagements = [
'type' => 'TASK',
];
$metadata =...
|
9804
|
NULL
|
NULL
|
NULL
|
|
9804
|
444
|
6
|
2026-05-08T13:33:56.782361+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-08/1778 /Users/lukas/.screenpipe/data/data/2026-05-08/1778247236782_m2.jpg...
|
PhpStorm
|
faVsco.js – Service.php
|
1
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
JY-20725-handle-HS-search Project: faVsco.js, menu
JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere...
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"bounds":{"left":0.025930852,"top":0.019952115,"width":0.03856383,"height":0.025538707},"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JY-20725-handle-HS-search-rate-limit, menu","depth":5,"bounds":{"left":0.064494684,"top":0.019952115,"width":0.09541223,"height":0.025538707},"on_screen":true,"help_text":"Git Branch: JY-20725-handle-HS-search-rate-limit","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"bounds":{"left":0.8081782,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"AskJiminnyReportActivityServiceTest","depth":6,"bounds":{"left":0.8234708,"top":0.019952115,"width":0.09208777,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'AskJiminnyReportActivityServiceTest'","depth":6,"bounds":{"left":0.9155585,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'AskJiminnyReportActivityServiceTest'","depth":6,"bounds":{"left":0.9268617,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"bounds":{"left":0.9381649,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"bounds":{"left":0.96609044,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"bounds":{"left":0.9773936,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
-7349965412982217516
|
-8168391185862906426
|
click
|
hybrid
|
NULL
|
Project: faVsco.js, menu
JY-20725-handle-HS-search Project: faVsco.js, menu
JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
PhostormVIewINavigareCodeLaravelKeractorJOOISWindowFV faVsco.js°9 JY-20725-handle-HS-search-rate-limitProiect©) JiminnyDebugCommano.png© BatchSyncCollector.f G DeleteCrmEntityTrait.phpo kematcnactiviyoncrmobjectbetach.org© BatchSyncRedisServ© CheckAndRetryRemoteMatch.php© MatchacuvitycrmData.ongc clientonp© ClosedDealStagesSe© PaginationState.phgC)MatchermData.phg@ DealFieldsService.phHubspotPaqinationservice.ono) Paqinationconria.onoc)Decorateacuiviv.one© FieldDefinitions.phcclass Service extends BaseService 1mplementsm A7 A9 ×6 ^use syncrielosiralcc) FieldTvpeconverter.use upporcunlcysynciralc@ HubspotClientinterfa(C) HubspotTokenManacC) PavloadBuilder.ohpprivate const 1nt ENGAGEMENT_BODY MAX_LENGTH = 655367(C) RemotecrmObiectMaC) ResponseNormalize.(c) Service.ondorivate const strino LOG DATE FORMAT &"Y-m-dHH1.S'*© SyncFieldAction.php1usage(C) SvncRelatedActivitviorivate const int BATCH UPDATE LIMTT = 100-€ WebhookSvncBatch!v MintearationAoo1 usageprivate const string TEN_SECONDLY_ROLLING_POLICY = 'TEN_SECONDLY_ROLLING';>IM Acceccordlusage>D Api>D Config• Mотоprivate const int TEN_SECONDLY_ROLLING_LIMIT = 10;M Silterc1 usageprivate const string TYPE_NOTE = 'NOTE':D JobsD ProspectSearchStratm CorvicoTraiteprivate const string TYPE_MEETING = 'MEETING':© DataClient.php= custom.log x= laravel.logA SF [jiminny@localhost]4 HS_local [jiminny@localhost]# console [PKobJA console leu)A console [STAGING]accept-encoding"],"access-control-allow-credentials":["false"]."server-timing": ["hcid;desc=\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\",cfr;desc=|"9f80deb8e7c6dc3a-IAD\""]"x-content-type-options":["nosniff"],M X19 A V100% 12Fri 8 May 16:33:59AskJiminnyReportActivityServiceTest vCascadeHubspot Rate LimitingHubSpot Rate Limit ReInvestigating Rate LinNew CascadeHubspot erm call Regetinscanceo)-geteuencoprequesct rust, /cauts/searcnysearchCallByRecordingURLToken() (line ~1752)createNote()saveTranscriptionSummaryAsNote() (line ~1896)updateEngagement()attachSummaryToActivity() (line ~1938)getEngagementData()buildMetadataForSummaryUpdate() (line ~1960)+0..SEARCHBURSTBURSTBURSTTdgeaslleslldlaa"x-hubspot-correlation-id":("019e02d0-6fd8-7812-bdba-885b7ccb3ee3"]."set-LookLe":_ct._om=s1urcaugxvrzksopacronzvyknzinubzovniabelcmoy-1//81050/0-[IP_ADDRESS]-r1.299cUkxIgeszro2gDBTWM00.Ut207-May-26 14:51:15 GMT: domain=.hubapi.com;Http0nly: Secure: SameSite=None"],"керогс-10":"1"endpornts"."Lnlmuos:valVa.nelcloudrLare. com neDontVvLrSENYALSy PorYmSyOSUnXYE-4SCKYKORSDSWHSMO-04 20Y0X 46743 XVDHMSK"max age":604800-"],"success_fraction\":0.01,"max ade":604800-""Serven"•["cloudflare"1}} {"correlation_id":"95236535-ec98-4541-b92a-adfa73b69eab","tnace id"."c7ab8365-903f-46d4-9403-0e5b551e3545"}getPaginatedData($payload, 'deals')Servicetraits/writecrmtratt.onogetClient()->getEngagementData()getClient()->updateEngagement(,getDealsInBulk() (line ~2003)Method contextmaryToActivityo line ~120)upsertActivityo) (line ~172)updateAct vitvassociat ionso line ~295)updateActivityAssociations line~303hasEngagementActivityType line ~332updateTask line ~352createActiv1ty uine~393)createMeetingV5() (line ~484)saverolLowupAct1v1ty() (line ~598)SEARCHRate LimitBURSTBURSTBURSTIBURSTBURSTBURSTBURSTBURSTBURS1BURSTConsole,LOg XChanaes 12 files= env.local aor@ Client.oho apn/Services/Crm/Hubspot© HandleHubspotRateLimit.php app/Jobs/Middleware@ HubsnotClientinterface.phn.ann/Services/Crm/Hubspot© HubspotPaginationService.php app/Services/Crm/Hubspot/Pagination@.liminnvDehuaCommandinhn. ann/Concole/Commanddphp logging.php config© MatchActivityCrmData.php app/Jobs/Crm© MatchCrmData.php app/Jobs/Activity/Import©PaginationState.php app/Services/Crm/Hubspot/Pagination© RateLimitException.php app/Exceptionsc service.pnp agUnversioned Files 9 files=.env.nikilocal aprE.env.other appC) CanAccessAiReportsTest.php tests/Unit/Policies© CreateMockAskJiminnyReportResultCommand.php app/Console/Commands/R_k tavicon.ico public=ids.txt aodiaraw soll querv sall aool@ SimulateWebhooksCommand.ohn apn/Console/Commands/Crm/HubspotPatch successfully applied (moments ago)TJ0 + → Side-by-side viewer •8 35f036ac app/Services/Crm/Hubspot/Service.phpDo not ignoreHighlight wordsx 13 B ?private const string TYPE_NOTE = 'NOTE':private const string TYPE_MEETING = 'MEETING':liminnvl Services| Crm| Hubsnot > Service > find() VAUAAAAASdata = ml.// Use the new V3 API to find contacts based on additional fields.foreach (['companies', 'contacts'] as SobiectType) {/crm/vs/opnects, sobneccivoe . search'"Spayload = Sthis->qenerateNameSearchPavload(Sname. $offset. Scount):Stype = SobiectType === 'companies' ? 'account' : 'contact':try {$response = $this->client->getInstance()->getClient()->request('POST', $endpoint, [= Spavload.// Build manned Listforeach (Sresnonsef'results'] as Sobiect) {Soronenties = Sobiect['oronerties'1)Ask anytning (dtL)+ ‹› CodeS AdaptiveCurront vorcionprivate const string TYPE_NOTE = 'NOTE';private const string TYPE_MEETING = 'MEETING':liminnv| Services| Crm| Hubsnot > Service > find() nnASdata = []:// Use the new V3 API to find contacts based on additional fields.foreach (['companies', 'contacts'] as SobjectType) {Spayload = Sthis->generateNameSearchPayload(Sname. Soffset. $count):Stype = SobiectType === 'companies' ? 'account' : 'contact':tryfSresponse = Sthis->client->search(SobiectType. Spayload):// Build mapped list.foreach (Sresponsef'results'] as Sobiect)fsorobertles = sobnect"pronerties'12 differencesW Windsurf Toams 100-1UTF.8io 4 spaces...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
9803
|
443
|
4
|
2026-05-08T13:33:53.986916+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-08/1778 /Users/lukas/.screenpipe/data/data/2026-05-08/1778247233986_m1.jpg...
|
PhpStorm
|
faVsco.js – Service.php
|
1
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
iTerm2Shell Edit ViewSessionScripts|ProfilesWind iTerm2Shell Edit ViewSessionScripts|ProfilesWindowHelp‹ $0 hil100% C8APP (-zsh)DOCKER- ₴81DEV (docker)882JY-20773-fix-automated-reports-user-pilot-trackingJY-20157-AJ-report-not-send-notificationJY-20508-notify-before-AJ-report-expirationJY-20372-ai-reports-promotion-pagesJY-20352-sync-opportunities-without-a-local-owner-user-id-is-nullJY-20738-debug-AJ-tracking-UPAPP (-zsh)-zshJY-18909-automated-reports-ask-jiminnyJY-20692-fix-integration-app-[API_KEY]@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ co -b JY-20725-handle-HS-search-rate-limitSwitched to a new branch 'JY-20725-handle-HS-search-rate-limit'Lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (JY-20725-handle-HS-search-rate-limit) $ I• 84screenpipe*•$5-zshFri 8 May 16:33:57T₴1|₴6APP...
|
NULL
|
8740093310388782953
|
NULL
|
click
|
ocr
|
NULL
|
iTerm2Shell Edit ViewSessionScripts|ProfilesWind iTerm2Shell Edit ViewSessionScripts|ProfilesWindowHelp‹ $0 hil100% C8APP (-zsh)DOCKER- ₴81DEV (docker)882JY-20773-fix-automated-reports-user-pilot-trackingJY-20157-AJ-report-not-send-notificationJY-20508-notify-before-AJ-report-expirationJY-20372-ai-reports-promotion-pagesJY-20352-sync-opportunities-without-a-local-owner-user-id-is-nullJY-20738-debug-AJ-tracking-UPAPP (-zsh)-zshJY-18909-automated-reports-ask-jiminnyJY-20692-fix-integration-app-[API_KEY]@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ co -b JY-20725-handle-HS-search-rate-limitSwitched to a new branch 'JY-20725-handle-HS-search-rate-limit'Lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (JY-20725-handle-HS-search-rate-limit) $ I• 84screenpipe*•$5-zshFri 8 May 16:33:57T₴1|₴6APP...
|
9801
|
NULL
|
NULL
|
NULL
|