|
7123
|
Project: faVsco.js, menu
master, menu
Start Listen Project: faVsco.js, menu
master, 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
<?php
declare(strict_types=1);
namespace Jiminny\Component\Utility\Service;
use Illuminate\Cache\RateLimiter;
use Jiminny\Contracts\Http\RateLimited;
use Jiminny\Contracts\Http\RateLimitInterface;
class ProviderRateLimiter
{
protected RateLimiter $rateLimiter;
public function __construct(RateLimiter $rateLimiter)
{
$this->rateLimiter = $rateLimiter;
}
public function canMakeRequest(RateLimited $provider): bool
{
/** @var RateLimitInterface $rateLimit */
foreach ($provider->getRateLimits() as $rateLimit) {
$key = $rateLimit->getKey();
if ($this->rateLimiter->tooManyAttempts($key, $rateLimit->getQuota())) {
return false;
}
}
return true;
}
public function requestAvailableIn(RateLimited $provider): int
{
return $provider->getRateLimits()->isNotEmpty()
? $provider->getRateLimits()
->map(fn (RateLimitInterface $rateLimit): int => $this->rateLimiter->availableIn($rateLimit->getKey()))
->max()
: 0
;
}
public function incrementRequestCount(RateLimited $provider): void
{
/** @var RateLimitInterface $rateLimit */
foreach ($provider->getRateLimits() as $rateLimit) {
$this->rateLimiter->hit($rateLimit->getKey(), $rateLimit->getWindow());
}
}
}
Sync Changes
Hide This Notification
Code changed:
Hide
2
69
2
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\Component\Utility\Service\ProviderRateLimiter;
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)
{
if (! $this->rateLimiter->canMakeRequest($this->config)) {
$retryAfter = $this->rateLimiter->requestAvailableIn($this->config);
$this->log->warning('[Hubspot] Rate limit exceeded, deferring request', [
'team_id' => $this->config->team_id,
'config_id' => $this->config->getId(),
'retry_after' => $retryAfter,
]);
throw new RateLimitException(
'Hubspot rate limit reached for configuration ' . $this->config->getId(),
$retryAfter,
);
}
$this->rateLimiter->incrementRequestCount($this->config);
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
{
return method_exists($e, 'getCode') && (int) $e->getCode() === 429;
}
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;
}
}
// For search APIs, headers are often missing - check response body for policyName
if (method_exists($e, 'getResponseBody')) {
$body = $e->getResponseBody();
if (is_string($body)) {
$body = json_decode($body, true) ?? [];
}
$policyName = $body['policyName'] ?? $body['policy'] ?? null;
// Map policy names to retry delays
if ($policyName === 'TEN_SECONDLY_ROLLING' || $policyName === 'ten_secondly_rolling') {
return 10;
}
if ($policyName === 'SECONDLY' || $policyName === 'secondly') {
return 1;
}
}
// Also check nested context object if present
if (method_exists($e, 'getResponseBody')) {
$body = $e->getResponseBody();
if (is_string($body)) {
$body = json_decode($body, true) ?? [];
}
$context = $body['context'] ?? [];
$policyName = $context['policyName'] ?? null;
if ($policyName === 'TEN_SECONDLY_ROLLING' || $policyName === 'ten_secondly_rolling') {
return 10;
}
if ($policyName === 'SECONDLY' || $policyName === 'secondly') {
return 1;
}
}
$this->log->debug('[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
{
// Check for specific HubSpot API exception types first
if ($e instanceof BadRequest) {
// BadRequest can contain 401 status codes
return $e->getCode() === 401;
}
// Check for HTTP client exceptions with status codes
if ($e instanceof \GuzzleHttp\Exception\RequestException && $e->hasResponse()) {
$response = $e->getResponse();
if ($response !== null) {
return $response->getStatusCode() === 401;
}
}
// Check for Guzzle HTTP exceptions
if ($e instanceof \GuzzleHttp\Exception\ClientException) {
return $e->getCode() === 401;
}
// Fallback to string matching as last resort, but be more specific
$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) && 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 (\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...
|
PhpStorm
|
faVsco.js – Client.php
|
NULL
|
7123
|
|
7124
|
Project: faVsco.js, menu
master, menu
Start Listen Project: faVsco.js, menu
master, 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
<?php
declare(strict_types=1);
namespace Jiminny\Component\Utility\Service;
use Illuminate\Cache\RateLimiter;
use Jiminny\Contracts\Http\RateLimited;
use Jiminny\Contracts\Http\RateLimitInterface;
class ProviderRateLimiter
{
protected RateLimiter $rateLimiter;
public function __construct(RateLimiter $rateLimiter)
{
$this->rateLimiter = $rateLimiter;
}
public function canMakeRequest(RateLimited $provider): bool
{
/** @var RateLimitInterface $rateLimit */
foreach ($provider->getRateLimits() as $rateLimit) {
$key = $rateLimit->getKey();
if ($this->rateLimiter->tooManyAttempts($key, $rateLimit->getQuota())) {
return false;
}
}
return true;
}
public function requestAvailableIn(RateLimited $provider): int
{
return $provider->getRateLimits()->isNotEmpty()
? $provider->getRateLimits()
->map(fn (RateLimitInterface $rateLimit): int => $this->rateLimiter->availableIn($rateLimit->getKey()))
->max()
: 0
;
}
public function incrementRequestCount(RateLimited $provider): void
{
/** @var RateLimitInterface $rateLimit */
foreach ($provider->getRateLimits() as $rateLimit) {
$this->rateLimiter->hit($rateLimit->getKey(), $rateLimit->getWindow());
}
}
}
Sync Changes
Hide This Notification
Code changed:
Hide
2
69
2
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\Component\Utility\Service\ProviderRateLimiter;
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)
{
if (! $this->rateLimiter->canMakeRequest($this->config)) {
$retryAfter = $this->rateLimiter->requestAvailableIn($this->config);
$this->log->warning('[Hubspot] Rate limit exceeded, deferring request', [
'team_id' => $this->config->team_id,
'config_id' => $this->config->getId(),
'retry_after' => $retryAfter,
]);
throw new RateLimitException(
'Hubspot rate limit reached for configuration ' . $this->config->getId(),
$retryAfter,
);
}
$this->rateLimiter->incrementRequestCount($this->config);
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
{
return method_exists($e, 'getCode') && (int) $e->getCode() === 429;
}
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;
}
}
// For search APIs, headers are often missing - check response body for policyName
if (method_exists($e, 'getResponseBody')) {
$body = $e->getResponseBody();
if (is_string($body)) {
$body = json_decode($body, true) ?? [];
}
$policyName = $body['policyName'] ?? $body['policy'] ?? null;
// Map policy names to retry delays
if ($policyName === 'TEN_SECONDLY_ROLLING' || $policyName === 'ten_secondly_rolling') {
return 10;
}
if ($policyName === 'SECONDLY' || $policyName === 'secondly') {
return 1;
}
}
// Also check nested context object if present
if (method_exists($e, 'getResponseBody')) {
$body = $e->getResponseBody();
if (is_string($body)) {
$body = json_decode($body, true) ?? [];
}
$context = $body['context'] ?? [];
$policyName = $context['policyName'] ?? null;
if ($policyName === 'TEN_SECONDLY_ROLLING' || $policyName === 'ten_secondly_rolling') {
return 10;
}
if ($policyName === 'SECONDLY' || $policyName === 'secondly') {
return 1;
}
}
$this->log->debug('[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
{
// Check for specific HubSpot API exception types first
if ($e instanceof BadRequest) {
// BadRequest can contain 401 status codes
return $e->getCode() === 401;
}
// Check for HTTP client exceptions with status codes
if ($e instanceof \GuzzleHttp\Exception\RequestException && $e->hasResponse()) {
$response = $e->getResponse();
if ($response !== null) {
return $response->getStatusCode() === 401;
}
}
// Check for Guzzle HTTP exceptions
if ($e instanceof \GuzzleHttp\Exception\ClientException) {
return $e->getCode() === 401;
}
// Fallback to string matching as last resort, but be more specific
$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) && 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 (\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...
|
PhpStorm
|
faVsco.js – Client.php
|
NULL
|
7124
|
|
7125
|
Project: faVsco.js, menu
master, menu
Start Listen Project: faVsco.js, menu
master, 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
<?php
declare(strict_types=1);
namespace Jiminny\Component\Utility\Service;
use Illuminate\Cache\RateLimiter;
use Jiminny\Contracts\Http\RateLimited;
use Jiminny\Contracts\Http\RateLimitInterface;
class ProviderRateLimiter
{
protected RateLimiter $rateLimiter;
public function __construct(RateLimiter $rateLimiter)
{
$this->rateLimiter = $rateLimiter;
}
public function canMakeRequest(RateLimited $provider): bool
{
/** @var RateLimitInterface $rateLimit */
foreach ($provider->getRateLimits() as $rateLimit) {
$key = $rateLimit->getKey();
if ($this->rateLimiter->tooManyAttempts($key, $rateLimit->getQuota())) {
return false;
}
}
return true;
}
public function requestAvailableIn(RateLimited $provider): int
{
return $provider->getRateLimits()->isNotEmpty()
? $provider->getRateLimits()
->map(fn (RateLimitInterface $rateLimit): int => $this->rateLimiter->availableIn($rateLimit->getKey()))
->max()
: 0
;
}
public function incrementRequestCount(RateLimited $provider): void
{
/** @var RateLimitInterface $rateLimit */
foreach ($provider->getRateLimits() as $rateLimit) {
$this->rateLimiter->hit($rateLimit->getKey(), $rateLimit->getWindow());
}
}
}
Sync Changes
Hide This Notification
Code changed:
Hide
2
69
2
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\Component\Utility\Service\ProviderRateLimiter;
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)
{
if (! $this->rateLimiter->canMakeRequest($this->config)) {
$retryAfter = $this->rateLimiter->requestAvailableIn($this->config);
$this->log->warning('[Hubspot] Rate limit exceeded, deferring request', [
'team_id' => $this->config->team_id,
'config_id' => $this->config->getId(),
'retry_after' => $retryAfter,
]);
throw new RateLimitException(
'Hubspot rate limit reached for configuration ' . $this->config->getId(),
$retryAfter,
);
}
$this->rateLimiter->incrementRequestCount($this->config);
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
{
return method_exists($e, 'getCode') && (int) $e->getCode() === 429;
}
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;
}
}
// For search APIs, headers are often missing - check response body for policyName
if (method_exists($e, 'getResponseBody')) {
$body = $e->getResponseBody();
if (is_string($body)) {
$body = json_decode($body, true) ?? [];
}
$policyName = $body['policyName'] ?? $body['policy'] ?? null;
// Map policy names to retry delays
if ($policyName === 'TEN_SECONDLY_ROLLING' || $policyName === 'ten_secondly_rolling') {
return 10;
}
if ($policyName === 'SECONDLY' || $policyName === 'secondly') {
return 1;
}
}
// Also check nested context object if present
if (method_exists($e, 'getResponseBody')) {
$body = $e->getResponseBody();
if (is_string($body)) {
$body = json_decode($body, true) ?? [];
}
$context = $body['context'] ?? [];
$policyName = $context['policyName'] ?? null;
if ($policyName === 'TEN_SECONDLY_ROLLING' || $policyName === 'ten_secondly_rolling') {
return 10;
}
if ($policyName === 'SECONDLY' || $policyName === 'secondly') {
return 1;
}
}
$this->log->debug('[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
{
// Check for specific HubSpot API exception types first
if ($e instanceof BadRequest) {
// BadRequest can contain 401 status codes
return $e->getCode() === 401;
}
// Check for HTTP client exceptions with status codes
if ($e instanceof \GuzzleHttp\Exception\RequestException && $e->hasResponse()) {
$response = $e->getResponse();
if ($response !== null) {
return $response->getStatusCode() === 401;
}
}
// Check for Guzzle HTTP exceptions
if ($e instanceof \GuzzleHttp\Exception\ClientException) {
return $e->getCode() === 401;
}
// Fallback to string matching as last resort, but be more specific
$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) && 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 (\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...
|
PhpStorm
|
faVsco.js – Client.php
|
NULL
|
7125
|
|
7126
|
Project: faVsco.js, menu
master, menu
Start Listen Project: faVsco.js, menu
master, 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
<?php
declare(strict_types=1);
namespace Jiminny\Component\Utility\Service;
use Illuminate\Cache\RateLimiter;
use Jiminny\Contracts\Http\RateLimited;
use Jiminny\Contracts\Http\RateLimitInterface;
class ProviderRateLimiter
{
protected RateLimiter $rateLimiter;
public function __construct(RateLimiter $rateLimiter)
{
$this->rateLimiter = $rateLimiter;
}
public function canMakeRequest(RateLimited $provider): bool
{
/** @var RateLimitInterface $rateLimit */
foreach ($provider->getRateLimits() as $rateLimit) {
$key = $rateLimit->getKey();
if ($this->rateLimiter->tooManyAttempts($key, $rateLimit->getQuota())) {
return false;
}
}
return true;
}
public function requestAvailableIn(RateLimited $provider): int
{
return $provider->getRateLimits()->isNotEmpty()
? $provider->getRateLimits()
->map(fn (RateLimitInterface $rateLimit): int => $this->rateLimiter->availableIn($rateLimit->getKey()))
->max()
: 0
;
}
public function incrementRequestCount(RateLimited $provider): void
{
/** @var RateLimitInterface $rateLimit */
foreach ($provider->getRateLimits() as $rateLimit) {
$this->rateLimiter->hit($rateLimit->getKey(), $rateLimit->getWindow());
}
}
}
Sync Changes
Hide This Notification
Code changed:
Hide
2
69
2
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\Component\Utility\Service\ProviderRateLimiter;
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)
{
if (! $this->rateLimiter->canMakeRequest($this->config)) {
$retryAfter = $this->rateLimiter->requestAvailableIn($this->config);
$this->log->warning('[Hubspot] Rate limit exceeded, deferring request', [
'team_id' => $this->config->team_id,
'config_id' => $this->config->getId(),
'retry_after' => $retryAfter,
]);
throw new RateLimitException(
'Hubspot rate limit reached for configuration ' . $this->config->getId(),
$retryAfter,
);
}
$this->rateLimiter->incrementRequestCount($this->config);
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
{
return method_exists($e, 'getCode') && (int) $e->getCode() === 429;
}
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;
}
}
// For search APIs, headers are often missing - check response body for policyName
if (method_exists($e, 'getResponseBody')) {
$body = $e->getResponseBody();
if (is_string($body)) {
$body = json_decode($body, true) ?? [];
}
$policyName = $body['policyName'] ?? $body['policy'] ?? null;
// Map policy names to retry delays
if ($policyName === 'TEN_SECONDLY_ROLLING' || $policyName === 'ten_secondly_rolling') {
return 10;
}
if ($policyName === 'SECONDLY' || $policyName === 'secondly') {
return 1;
}
}
// Also check nested context object if present
if (method_exists($e, 'getResponseBody')) {
$body = $e->getResponseBody();
if (is_string($body)) {
$body = json_decode($body, true) ?? [];
}
$context = $body['context'] ?? [];
$policyName = $context['policyName'] ?? null;
if ($policyName === 'TEN_SECONDLY_ROLLING' || $policyName === 'ten_secondly_rolling') {
return 10;
}
if ($policyName === 'SECONDLY' || $policyName === 'secondly') {
return 1;
}
}
$this->log->debug('[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
{
// Check for specific HubSpot API exception types first
if ($e instanceof BadRequest) {
// BadRequest can contain 401 status codes
return $e->getCode() === 401;
}
// Check for HTTP client exceptions with status codes
if ($e instanceof \GuzzleHttp\Exception\RequestException && $e->hasResponse()) {
$response = $e->getResponse();
if ($response !== null) {
return $response->getStatusCode() === 401;
}
}
// Check for Guzzle HTTP exceptions
if ($e instanceof \GuzzleHttp\Exception\ClientException) {
return $e->getCode() === 401;
}
// Fallback to string matching as last resort, but be more specific
$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) && 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 (\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...
|
PhpStorm
|
faVsco.js – Client.php
|
NULL
|
7126
|
|
7127
|
Project: faVsco.js, menu
master, menu
Start Listen Project: faVsco.js, menu
master, 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
<?php
declare(strict_types=1);
namespace Jiminny\Component\Utility\Service;
use Illuminate\Cache\RateLimiter;
use Jiminny\Contracts\Http\RateLimited;
use Jiminny\Contracts\Http\RateLimitInterface;
class ProviderRateLimiter
{
protected RateLimiter $rateLimiter;
public function __construct(RateLimiter $rateLimiter)
{
$this->rateLimiter = $rateLimiter;
}
public function canMakeRequest(RateLimited $provider): bool
{
/** @var RateLimitInterface $rateLimit */
foreach ($provider->getRateLimits() as $rateLimit) {
$key = $rateLimit->getKey();
if ($this->rateLimiter->tooManyAttempts($key, $rateLimit->getQuota())) {
return false;
}
}
return true;
}
public function requestAvailableIn(RateLimited $provider): int
{
return $provider->getRateLimits()->isNotEmpty()
? $provider->getRateLimits()
->map(fn (RateLimitInterface $rateLimit): int => $this->rateLimiter->availableIn($rateLimit->getKey()))
->max()
: 0
;
}
public function incrementRequestCount(RateLimited $provider): void
{
/** @var RateLimitInterface $rateLimit */
foreach ($provider->getRateLimits() as $rateLimit) {
$this->rateLimiter->hit($rateLimit->getKey(), $rateLimit->getWindow());
}
}
}
Sync Changes
Hide This Notification
Code changed:
Hide
2
69
2
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\Component\Utility\Service\ProviderRateLimiter;
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)
{
if (! $this->rateLimiter->canMakeRequest($this->config)) {
$retryAfter = $this->rateLimiter->requestAvailableIn($this->config);
$this->log->warning('[Hubspot] Rate limit exceeded, deferring request', [
'team_id' => $this->config->team_id,
'config_id' => $this->config->getId(),
'retry_after' => $retryAfter,
]);
throw new RateLimitException(
'Hubspot rate limit reached for configuration ' . $this->config->getId(),
$retryAfter,
);
}
$this->rateLimiter->incrementRequestCount($this->config);
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
{
return method_exists($e, 'getCode') && (int) $e->getCode() === 429;
}
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;
}
}
// For search APIs, headers are often missing - check response body for policyName
if (method_exists($e, 'getResponseBody')) {
$body = $e->getResponseBody();
if (is_string($body)) {
$body = json_decode($body, true) ?? [];
}
$policyName = $body['policyName'] ?? $body['policy'] ?? null;
// Map policy names to retry delays
if ($policyName === 'TEN_SECONDLY_ROLLING' || $policyName === 'ten_secondly_rolling') {
return 10;
}
if ($policyName === 'SECONDLY' || $policyName === 'secondly') {
return 1;
}
}
// Also check nested context object if present
if (method_exists($e, 'getResponseBody')) {
$body = $e->getResponseBody();
if (is_string($body)) {
$body = json_decode($body, true) ?? [];
}
$context = $body['context'] ?? [];
$policyName = $context['policyName'] ?? null;
if ($policyName === 'TEN_SECONDLY_ROLLING' || $policyName === 'ten_secondly_rolling') {
return 10;
}
if ($policyName === 'SECONDLY' || $policyName === 'secondly') {
return 1;
}
}
$this->log->debug('[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
{
// Check for specific HubSpot API exception types first
if ($e instanceof BadRequest) {
// BadRequest can contain 401 status codes
return $e->getCode() === 401;
}
// Check for HTTP client exceptions with status codes
if ($e instanceof \GuzzleHttp\Exception\RequestException && $e->hasResponse()) {
$response = $e->getResponse();
if ($response !== null) {
return $response->getStatusCode() === 401;
}
}
// Check for Guzzle HTTP exceptions
if ($e instanceof \GuzzleHttp\Exception\ClientException) {
return $e->getCode() === 401;
}
// Fallback to string matching as last resort, but be more specific
$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) && 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 (\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...
|
PhpStorm
|
faVsco.js – Client.php
|
NULL
|
7127
|
|
7128
|
Project: faVsco.js, menu
master, menu
Start Listen Project: faVsco.js, menu
master, 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
<?php
declare(strict_types=1);
namespace Jiminny\Component\Utility\Service;
use Illuminate\Cache\RateLimiter;
use Jiminny\Contracts\Http\RateLimited;
use Jiminny\Contracts\Http\RateLimitInterface;
class ProviderRateLimiter
{
protected RateLimiter $rateLimiter;
public function __construct(RateLimiter $rateLimiter)
{
$this->rateLimiter = $rateLimiter;
}
public function canMakeRequest(RateLimited $provider): bool
{
/** @var RateLimitInterface $rateLimit */
foreach ($provider->getRateLimits() as $rateLimit) {
$key = $rateLimit->getKey();
if ($this->rateLimiter->tooManyAttempts($key, $rateLimit->getQuota())) {
return false;
}
}
return true;
}
public function requestAvailableIn(RateLimited $provider): int
{
return $provider->getRateLimits()->isNotEmpty()
? $provider->getRateLimits()
->map(fn (RateLimitInterface $rateLimit): int => $this->rateLimiter->availableIn($rateLimit->getKey()))
->max()
: 0
;
}
public function incrementRequestCount(RateLimited $provider): void
{
/** @var RateLimitInterface $rateLimit */
foreach ($provider->getRateLimits() as $rateLimit) {
$this->rateLimiter->hit($rateLimit->getKey(), $rateLimit->getWindow());
}
}
}
Sync Changes
Hide This Notification
Code changed:
Hide
2
69
2
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\Component\Utility\Service\ProviderRateLimiter;
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)
{
if (! $this->rateLimiter->canMakeRequest($this->config)) {
$retryAfter = $this->rateLimiter->requestAvailableIn($this->config);
$this->log->warning('[Hubspot] Rate limit exceeded, deferring request', [
'team_id' => $this->config->team_id,
'config_id' => $this->config->getId(),
'retry_after' => $retryAfter,
]);
throw new RateLimitException(
'Hubspot rate limit reached for configuration ' . $this->config->getId(),
$retryAfter,
);
}
$this->rateLimiter->incrementRequestCount($this->config);
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
{
return method_exists($e, 'getCode') && (int) $e->getCode() === 429;
}
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;
}
}
// For search APIs, headers are often missing - check response body for policyName
if (method_exists($e, 'getResponseBody')) {
$body = $e->getResponseBody();
if (is_string($body)) {
$body = json_decode($body, true) ?? [];
}
$policyName = $body['policyName'] ?? $body['policy'] ?? null;
// Map policy names to retry delays
if ($policyName === 'TEN_SECONDLY_ROLLING' || $policyName === 'ten_secondly_rolling') {
return 10;
}
if ($policyName === 'SECONDLY' || $policyName === 'secondly') {
return 1;
}
}
// Also check nested context object if present
if (method_exists($e, 'getResponseBody')) {
$body = $e->getResponseBody();
if (is_string($body)) {
$body = json_decode($body, true) ?? [];
}
$context = $body['context'] ?? [];
$policyName = $context['policyName'] ?? null;
if ($policyName === 'TEN_SECONDLY_ROLLING' || $policyName === 'ten_secondly_rolling') {
return 10;
}
if ($policyName === 'SECONDLY' || $policyName === 'secondly') {
return 1;
}
}
$this->log->debug('[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
{
// Check for specific HubSpot API exception types first
if ($e instanceof BadRequest) {
// BadRequest can contain 401 status codes
return $e->getCode() === 401;
}
// Check for HTTP client exceptions with status codes
if ($e instanceof \GuzzleHttp\Exception\RequestException && $e->hasResponse()) {
$response = $e->getResponse();
if ($response !== null) {
return $response->getStatusCode() === 401;
}
}
// Check for Guzzle HTTP exceptions
if ($e instanceof \GuzzleHttp\Exception\ClientException) {
return $e->getCode() === 401;
}
// Fallback to string matching as last resort, but be more specific
$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) && 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 (\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...
|
PhpStorm
|
faVsco.js – Client.php
|
NULL
|
7128
|
|
7129
|
Project: faVsco.js, menu
master, menu
Start Listen Project: faVsco.js, menu
master, 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
<?php
declare(strict_types=1);
namespace Jiminny\Component\Utility\Service;
use Illuminate\Cache\RateLimiter;
use Jiminny\Contracts\Http\RateLimited;
use Jiminny\Contracts\Http\RateLimitInterface;
class ProviderRateLimiter
{
protected RateLimiter $rateLimiter;
public function __construct(RateLimiter $rateLimiter)
{
$this->rateLimiter = $rateLimiter;
}
public function canMakeRequest(RateLimited $provider): bool
{
/** @var RateLimitInterface $rateLimit */
foreach ($provider->getRateLimits() as $rateLimit) {
$key = $rateLimit->getKey();
if ($this->rateLimiter->tooManyAttempts($key, $rateLimit->getQuota())) {
return false;
}
}
return true;
}
public function requestAvailableIn(RateLimited $provider): int
{
return $provider->getRateLimits()->isNotEmpty()
? $provider->getRateLimits()
->map(fn (RateLimitInterface $rateLimit): int => $this->rateLimiter->availableIn($rateLimit->getKey()))
->max()
: 0
;
}
public function incrementRequestCount(RateLimited $provider): void
{
/** @var RateLimitInterface $rateLimit */
foreach ($provider->getRateLimits() as $rateLimit) {
$this->rateLimiter->hit($rateLimit->getKey(), $rateLimit->getWindow());
}
}
}
Sync Changes
Hide This Notification
Code changed:
Hide
2
69
2
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\Component\Utility\Service\ProviderRateLimiter;
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)
{
if (! $this->rateLimiter->canMakeRequest($this->config)) {
$retryAfter = $this->rateLimiter->requestAvailableIn($this->config);
$this->log->warning('[Hubspot] Rate limit exceeded, deferring request', [
'team_id' => $this->config->team_id,
'config_id' => $this->config->getId(),
'retry_after' => $retryAfter,
]);
throw new RateLimitException(
'Hubspot rate limit reached for configuration ' . $this->config->getId(),
$retryAfter,
);
}
$this->rateLimiter->incrementRequestCount($this->config);
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
{
return method_exists($e, 'getCode') && (int) $e->getCode() === 429;
}
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;
}
}
// For search APIs, headers are often missing - check response body for policyName
if (method_exists($e, 'getResponseBody')) {
$body = $e->getResponseBody();
if (is_string($body)) {
$body = json_decode($body, true) ?? [];
}
$policyName = $body['policyName'] ?? $body['policy'] ?? null;
// Map policy names to retry delays
if ($policyName === 'TEN_SECONDLY_ROLLING' || $policyName === 'ten_secondly_rolling') {
return 10;
}
if ($policyName === 'SECONDLY' || $policyName === 'secondly') {
return 1;
}
}
// Also check nested context object if present
if (method_exists($e, 'getResponseBody')) {
$body = $e->getResponseBody();
if (is_string($body)) {
$body = json_decode($body, true) ?? [];
}
$context = $body['context'] ?? [];
$policyName = $context['policyName'] ?? null;
if ($policyName === 'TEN_SECONDLY_ROLLING' || $policyName === 'ten_secondly_rolling') {
return 10;
}
if ($policyName === 'SECONDLY' || $policyName === 'secondly') {
return 1;
}
}
$this->log->debug('[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
{
// Check for specific HubSpot API exception types first
if ($e instanceof BadRequest) {
// BadRequest can contain 401 status codes
return $e->getCode() === 401;
}
// Check for HTTP client exceptions with status codes
if ($e instanceof \GuzzleHttp\Exception\RequestException && $e->hasResponse()) {
$response = $e->getResponse();
if ($response !== null) {
return $response->getStatusCode() === 401;
}
}
// Check for Guzzle HTTP exceptions
if ($e instanceof \GuzzleHttp\Exception\ClientException) {
return $e->getCode() === 401;
}
// Fallback to string matching as last resort, but be more specific
$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) && 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 (\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...
|
PhpStorm
|
faVsco.js – Client.php
|
NULL
|
7129
|
|
7130
|
Project: faVsco.js, menu
master, menu
Start Listen Project: faVsco.js, menu
master, 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
<?php
declare(strict_types=1);
namespace Jiminny\Component\Utility\Service;
use Illuminate\Cache\RateLimiter;
use Jiminny\Contracts\Http\RateLimited;
use Jiminny\Contracts\Http\RateLimitInterface;
class ProviderRateLimiter
{
protected RateLimiter $rateLimiter;
public function __construct(RateLimiter $rateLimiter)
{
$this->rateLimiter = $rateLimiter;
}
public function canMakeRequest(RateLimited $provider): bool
{
/** @var RateLimitInterface $rateLimit */
foreach ($provider->getRateLimits() as $rateLimit) {
$key = $rateLimit->getKey();
if ($this->rateLimiter->tooManyAttempts($key, $rateLimit->getQuota())) {
return false;
}
}
return true;
}
public function requestAvailableIn(RateLimited $provider): int
{
return $provider->getRateLimits()->isNotEmpty()
? $provider->getRateLimits()
->map(fn (RateLimitInterface $rateLimit): int => $this->rateLimiter->availableIn($rateLimit->getKey()))
->max()
: 0
;
}
public function incrementRequestCount(RateLimited $provider): void
{
/** @var RateLimitInterface $rateLimit */
foreach ($provider->getRateLimits() as $rateLimit) {
$this->rateLimiter->hit($rateLimit->getKey(), $rateLimit->getWindow());
}
}
}
Sync Changes
Hide This Notification
Code changed:
Hide
2
69
2
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\Component\Utility\Service\ProviderRateLimiter;
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)
{
if (! $this->rateLimiter->canMakeRequest($this->config)) {
$retryAfter = $this->rateLimiter->requestAvailableIn($this->config);
$this->log->warning('[Hubspot] Rate limit exceeded, deferring request', [
'team_id' => $this->config->team_id,
'config_id' => $this->config->getId(),
'retry_after' => $retryAfter,
]);
throw new RateLimitException(
'Hubspot rate limit reached for configuration ' . $this->config->getId(),
$retryAfter,
);
}
$this->rateLimiter->incrementRequestCount($this->config);
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
{
return method_exists($e, 'getCode') && (int) $e->getCode() === 429;
}
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;
}
}
// For search APIs, headers are often missing - check response body for policyName
if (method_exists($e, 'getResponseBody')) {
$body = $e->getResponseBody();
if (is_string($body)) {
$body = json_decode($body, true) ?? [];
}
$policyName = $body['policyName'] ?? $body['policy'] ?? null;
// Map policy names to retry delays
if ($policyName === 'TEN_SECONDLY_ROLLING' || $policyName === 'ten_secondly_rolling') {
return 10;
}
if ($policyName === 'SECONDLY' || $policyName === 'secondly') {
return 1;
}
}
// Also check nested context object if present
if (method_exists($e, 'getResponseBody')) {
$body = $e->getResponseBody();
if (is_string($body)) {
$body = json_decode($body, true) ?? [];
}
$context = $body['context'] ?? [];
$policyName = $context['policyName'] ?? null;
if ($policyName === 'TEN_SECONDLY_ROLLING' || $policyName === 'ten_secondly_rolling') {
return 10;
}
if ($policyName === 'SECONDLY' || $policyName === 'secondly') {
return 1;
}
}
$this->log->debug('[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
{
// Check for specific HubSpot API exception types first
if ($e instanceof BadRequest) {
// BadRequest can contain 401 status codes
return $e->getCode() === 401;
}
// Check for HTTP client exceptions with status codes
if ($e instanceof \GuzzleHttp\Exception\RequestException && $e->hasResponse()) {
$response = $e->getResponse();
if ($response !== null) {
return $response->getStatusCode() === 401;
}
}
// Check for Guzzle HTTP exceptions
if ($e instanceof \GuzzleHttp\Exception\ClientException) {
return $e->getCode() === 401;
}
// Fallback to string matching as last resort, but be more specific
$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) && 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 (\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...
|
PhpStorm
|
faVsco.js – Client.php
|
NULL
|
7130
|
|
7131
|
Project: faVsco.js, menu
master, menu
Start Listen Project: faVsco.js, menu
master, 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
<?php
declare(strict_types=1);
namespace Jiminny\Component\Utility\Service;
use Illuminate\Cache\RateLimiter;
use Jiminny\Contracts\Http\RateLimited;
use Jiminny\Contracts\Http\RateLimitInterface;
class ProviderRateLimiter
{
protected RateLimiter $rateLimiter;
public function __construct(RateLimiter $rateLimiter)
{
$this->rateLimiter = $rateLimiter;
}
public function canMakeRequest(RateLimited $provider): bool
{
/** @var RateLimitInterface $rateLimit */
foreach ($provider->getRateLimits() as $rateLimit) {
$key = $rateLimit->getKey();
if ($this->rateLimiter->tooManyAttempts($key, $rateLimit->getQuota())) {
return false;
}
}
return true;
}
public function requestAvailableIn(RateLimited $provider): int
{
return $provider->getRateLimits()->isNotEmpty()
? $provider->getRateLimits()
->map(fn (RateLimitInterface $rateLimit): int => $this->rateLimiter->availableIn($rateLimit->getKey()))
->max()
: 0
;
}
public function incrementRequestCount(RateLimited $provider): void
{
/** @var RateLimitInterface $rateLimit */
foreach ($provider->getRateLimits() as $rateLimit) {
$this->rateLimiter->hit($rateLimit->getKey(), $rateLimit->getWindow());
}
}
}
Sync Changes
Hide This Notification
Code changed:
Hide
2
69
2
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\Component\Utility\Service\ProviderRateLimiter;
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)
{
if (! $this->rateLimiter->canMakeRequest($this->config)) {
$retryAfter = $this->rateLimiter->requestAvailableIn($this->config);
$this->log->warning('[Hubspot] Rate limit exceeded, deferring request', [
'team_id' => $this->config->team_id,
'config_id' => $this->config->getId(),
'retry_after' => $retryAfter,
]);
throw new RateLimitException(
'Hubspot rate limit reached for configuration ' . $this->config->getId(),
$retryAfter,
);
}
$this->rateLimiter->incrementRequestCount($this->config);
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
{
return method_exists($e, 'getCode') && (int) $e->getCode() === 429;
}
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;
}
}
// For search APIs, headers are often missing - check response body for policyName
if (method_exists($e, 'getResponseBody')) {
$body = $e->getResponseBody();
if (is_string($body)) {
$body = json_decode($body, true) ?? [];
}
$policyName = $body['policyName'] ?? $body['policy'] ?? null;
// Map policy names to retry delays
if ($policyName === 'TEN_SECONDLY_ROLLING' || $policyName === 'ten_secondly_rolling') {
return 10;
}
if ($policyName === 'SECONDLY' || $policyName === 'secondly') {
return 1;
}
}
// Also check nested context object if present
if (method_exists($e, 'getResponseBody')) {
$body = $e->getResponseBody();
if (is_string($body)) {
$body = json_decode($body, true) ?? [];
}
$context = $body['context'] ?? [];
$policyName = $context['policyName'] ?? null;
if ($policyName === 'TEN_SECONDLY_ROLLING' || $policyName === 'ten_secondly_rolling') {
return 10;
}
if ($policyName === 'SECONDLY' || $policyName === 'secondly') {
return 1;
}
}
$this->log->debug('[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
{
// Check for specific HubSpot API exception types first
if ($e instanceof BadRequest) {
// BadRequest can contain 401 status codes
return $e->getCode() === 401;
}
// Check for HTTP client exceptions with status codes
if ($e instanceof \GuzzleHttp\Exception\RequestException && $e->hasResponse()) {
$response = $e->getResponse();
if ($response !== null) {
return $response->getStatusCode() === 401;
}
}
// Check for Guzzle HTTP exceptions
if ($e instanceof \GuzzleHttp\Exception\ClientException) {
return $e->getCode() === 401;
}
// Fallback to string matching as last resort, but be more specific
$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) && 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 (\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...
|
PhpStorm
|
faVsco.js – Client.php
|
NULL
|
7131
|
|
7132
|
Switch workspaces… (Jiminny Inc) Has new messages
Switch workspaces… (Jiminny Inc) Has new messages
Home
Home
DMs
DMs
Activity
Activity
Files
Files
Later
Later
More…
More
Unreads
Threads
Huddles
Drafts & sent
1
Directories
jiminny-x-integration-app
platform-inner-team
ai-chapter
alerts
backend
bugs
confusion-clinic
curiosity_lab
engineering
general
jiminny-bg
platform-tickets
product_launches
random
releases
sofia-office
support
thank-yous
the_people_of_jiminny
Galya Dimitrova
Aneliya Angelova
,
Nikolay Yankov
,
Steliyan Georgiev
Stoyan Tanev
Stefka Stoyanova
Ves
Aneliya Angelova
Vasil Vasilev
James Graham
Nikolay Ivanov
Lukas Kovalik
you
Toast
Jira Cloud
Messages
Messages
Files
Files
Bookmarks
Bookmarks
Add and Edit Channel Tabs
Canvas
List
Folder
Jump to date
CircleCI
APP
Yesterday at 5:29:41 PM
5:29 PM
Deployment Successful! tada emoji
Deployment Successful!
Project
: app
When
: 05/07/2026 14:29:40
Tag
:
View Job
View Job
CircleCI
APP
Yesterday at 6:18:57 PM
6:18 PM
New commits deployed to Prophet Prod-US:
[3da5aed](
https://github.com/jiminny/prophet/commit/3da5aed8434b1d6381a35317d9ca45fd7c751e61
https://github.com/jiminny/prophet/commit/3da5aed8434b1d6381a35317d9ca45fd7c751e61
) - Revert "[JY-205680](
https://jiminny.atlassian.net/browse/JY-205680
https://jiminny.atlassian.net/browse/JY-205680
): Relax action items assignee (#502)" (#503) (steliyan-g)
Yesterday at 6:19:24 PM
6:19
New commits deployed to Prophet Prod-EU:
[3da5aed](
https://github.com/jiminny/prophet/commit/3da5aed8434b1d6381a35317d9ca45fd7c751e61
https://github.com/jiminny/prophet/commit/3da5aed8434b1d6381a35317d9ca45fd7c751e61
) - Revert "[JY-205680](
https://jiminny.atlassian.net/browse/JY-205680
https://jiminny.atlassian.net/browse/JY-205680
): Relax action items assignee (#502)" (#503) (steliyan-g)
Jump to date
CircleCI
APP
Today at 9:25:47 AM
9:25 AM
Deployment Successful! tada emoji
Deployment Successful!
Project
: app
When
: 05/08/2026 06:25:46
Tag
:
View Job
View Job
React with white_check_mark
React with eyes
React with raised_hands
Add reaction…
Reply in thread
Forward message…
Save for later
More actions
New
CircleCI
APP
Today at 10:16:21 AM
10:16 AM
Deployment Successful! tada emoji
Deployment Successful!
Project
: app
When
: 05/08/2026 07:16:21
Tag
:
View Job
View Job
React with white_check_mark
React with eyes
React with raised_hands
Add reaction…
Reply in thread
Forward message…
Save for later
More actions
Channel releases
iTerm2ShellEditViewSessionScriptsProfilesWindowHelp-zshlahl= Support Daily • in 3 h 32 mA100% C8Fri 8 May 11:28:18181DOCKER₴1APP (-zsh)-rw-r--r--1lukasstaff32768lukasstaff3254832drwxr-xr-x8lukasstaffstaff256-rw-r--r--lukas28408-rw-r--r--lukasstaff566164-rw-r--r--lukasstaff81437-rwxr-xr-xlukasstaff14994-rw-r--r--1lukasstaff3167lukas@Lukas-Kovaliks-MacBook-Pro-JiminnyDEV (-zsh)₴828 May09:25db.sqlite-shm8 May11:12db.sqlite-wal6 May20:27pipes6 May21:02screenpipe.2026-05-06.0.10g7 May21:508 May11:12screenpipe.2026-05-07.0.10gscreenpipe.2026-05-08.0.l0g6 May20:26screenpipe_sync.sh7 May09:23sync.log~/.screenpipe $ screenpipe_sync.sh 2026-05-07*3zsh:commandnot found:screenpipe_sync.shlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny~/.screenpipe $ ~/.screenpipe/screenpipe_sync.sh 2026-05-07[2026-05-0811:13:29][2026-05-0811:13:29J[2026-05-0811:13:29JScreenpipesync startingfor: 2026-05-07====-zsh• 84|screenpipe"* *5[+00m00s]PreflightchecksSourceDB:OK(1.0G)[2026-05-0811:13:29JERROR: NAS not mounted at/Volumes/screenpipelukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/.screenpipe $ ~/.screenpipe/screenpipe_sync.sh 2026-05-07[2026-05-0811:13:52]=====[2026-05-08 11:13:52]Screenpipe sync starting for: 2026-05-07[2026-05-08 11:13:52][+00m00s] • Preflight checksSource DB:NAS mount:Archive DB:Data dir:OKOK(1.0G)/Volumes/screenpipeexists( 10G)OK(266 files, 306M)[+00m01s] • Counting source rows for 2026-05-07frames:elements:ui_events:ocr_text:meetings:6262623002741216702[+00m02s] • Initialising tables, indexes, FTScreating tablescreating indexescreating FTS tables• Om00s• Om00s• Om00s[+00m02s] • Syncing data for 2026-05-07video_chunks• 0m01sframes (6262 rows)• Parse error near line 3: table nas.frames has 24 columns but 30 values weresuppliedlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/.screenpipe $ I-zsh...
|
Slack
|
releases (Channel) - Jiminny Inc - 6 new items - S releases (Channel) - Jiminny Inc - 6 new items - Slack...
|
NULL
|
7132
|
|
7133
|
Switch workspaces… (Jiminny Inc) Has new messages
Switch workspaces… (Jiminny Inc) Has new messages
Home
Home
DMs
DMs
Activity
Activity
Files
Files
Later
Later
More…
More
Unreads
Threads
Huddles
Drafts & sent
1
Directories
jiminny-x-integration-app
platform-inner-team
ai-chapter
alerts
backend
bugs
confusion-clinic
curiosity_lab
engineering
general
jiminny-bg
platform-tickets
product_launches
random
releases
sofia-office
support
thank-yous
the_people_of_jiminny
Galya Dimitrova
Aneliya Angelova
,
Nikolay Yankov
,
Steliyan Georgiev
Stoyan Tanev
Stefka Stoyanova
Ves
Aneliya Angelova
Vasil Vasilev
James Graham
Nikolay Ivanov
Lukas Kovalik
you
Toast
Jira Cloud
Messages
Messages
Files
Files
Bookmarks
Bookmarks
Add and Edit Channel Tabs
Canvas
List
Folder
Jump to date
CircleCI
APP
Yesterday at 5:29:41 PM
5:29 PM
Deployment Successful! tada emoji
Deployment Successful!
Project
: app
When
: 05/07/2026 14:29:40
Tag
:
View Job
View Job
CircleCI
APP
Yesterday at 6:18:57 PM
6:18 PM
New commits deployed to Prophet Prod-US:
[3da5aed](
https://github.com/jiminny/prophet/commit/3da5aed8434b1d6381a35317d9ca45fd7c751e61
https://github.com/jiminny/prophet/commit/3da5aed8434b1d6381a35317d9ca45fd7c751e61
) - Revert "[JY-205680](
https://jiminny.atlassian.net/browse/JY-205680
https://jiminny.atlassian.net/browse/JY-205680
): Relax action items assignee (#502)" (#503) (steliyan-g)
Yesterday at 6:19:24 PM
6:19
New commits deployed to Prophet Prod-EU:
[3da5aed](
https://github.com/jiminny/prophet/commit/3da5aed8434b1d6381a35317d9ca45fd7c751e61
https://github.com/jiminny/prophet/commit/3da5aed8434b1d6381a35317d9ca45fd7c751e61
) - Revert "[JY-205680](
https://jiminny.atlassian.net/browse/JY-205680
https://jiminny.atlassian.net/browse/JY-205680
): Relax action items assignee (#502)" (#503) (steliyan-g)
Jump to date
CircleCI
APP
Today at 9:25:47 AM
9:25 AM
Deployment Successful! tada emoji
Deployment Successful!
Project
: app
When
: 05/08/2026 06:25:46
Tag
:
View Job
View Job
React with white_check_mark
React with eyes
React with raised_hands
Add reaction…
Reply in thread
Forward message…
Save for later
More actions
New
CircleCI
APP
Today at 10:16:21 AM
10:16 AM
Deployment Successful! tada emoji
Deployment Successful!
Project
: app
When
: 05/08/2026 07:16:21
Tag
:
View Job
View Job
React with white_check_mark
React with eyes
React with raised_hands
Add reaction…
Reply in thread
Forward message…
Save for later
More actions
Channel releases
DM:ActivityLateMoreSlackcalVIewJiminny ... ~# engineering# general# jiminny-bg# platform-tickets# product launches# random# releases# soha-office# support# thank-vous# the people of jimi..ó- Direct messagesGalya Dimit…4(3 Aneliva Angelova..Stoyan Tanev €8 Stefka Stovanovae VesAneliva Angelovaa Vasil Vasilev¿ James Grahame. Nikolay IvanovLukas Kovalik y...#:AppsToastJira Cloud> D ProspectSearchStr:> 0 Redis.v D ServiceTraitsTOnoortunitvsvnd() SvncCrmEntities 109T SuncFieldstirait.() WriteCrmTrait.ol 104• M UtilsM WebhookC) BatchSvncCollectoi 107@ RatchSvncPedicSerc) Client nho(C) ClocedDea|Stadecs@ DealFioldcService r 111MistonWindowHelp@ Describe what you are looking forjectbetach.ong© MatchActivityCrmData.php(©) CrmActivityService.phg# releases8 22entonpx© CheckAndRetryRemoteMatch.php© JiminnyDebugCommand.phpMessagese Files• BookmarksIBdasaediuHubsporclientinterace.png© PaginationState.phg©)Paginationconrig.pnghttos:omthuu.camzmunv/oroohet/commit/3daSaed8434b1d6381a35317d9ca45fd7c) synchubspotobiecis.ongC751e61) - Revert "JY-205680]([URL_WITH_CREDENTIALS] Successful!Project:When:dpp05/08/2026 06-25•46Tag:View Jobilable Sanicalbieircled App 10-14AMO Denloyment SuccessfullSlakeRequest (Sthis->confia)) {imiter->requestAvailableIn(Sthis->config);Prolect.When:05/08/20260/-16:1pot] Rate limit exceeded, deferring request', [->config->team_id,->config->getIdO,yArcelView JobMessage #releaseslimit reached for configuration' . $this->config->getIdO,lAasthis->rateL1m1ter->1ncrementRequestcount(sth1s->cont100stryfreturn saoncallo} catch (Throwable $e) {if (Sthis->isHubsnotRateLimit($e)) {SretrvAfter = Sthis->narseRetrvAfter(Se):Sthis->lo0->warning('[Hubsnotl Received 429 from APT'. ['team id'= Sthis->confio->teamidiIconfia 1d1=> $this->config->getId(),Inptny aftent => SnotnvAftenInpason!= Se->aetMeççane@.=custom.logElaravel.log4 SF jiminny@localhost]A HS_local [jiminny@localhost]A console (PROD]A console [EU]A console [STAGING]tiò accounts jiminny(use...class Providerratelimitenprotected Ratelimiter SrateLimiter:public function __construct(RateLimiter $rateLimiter)(...}public function canMakeRequest(RateLimited $provider): bool/** Ovar RateLimitInterface $rateLimit */foreach (Sprovider->getRateLimits as $rateLimit) ‹$key = SrateLimit->getKeyO;if (Sthis->rateLimiter->tooManyAttempts($key, SrateLimit->getQuotaO)) {return truepubLic tunction requestava1lableln kateLimited Sprovider: 1ntreturn Sprovider->getRateLimits@->isNotEmotv@Sproviden->getRateLimits@->man(fn (RateLimitIntenface SrateLimit): int => Sthis->rateLimiter-›availableIn(SrateLiinublic function incrementReauestCountRateLimited Soroviden): void** Qvan Ratel imitIntenface Sratelsimit *íforeach (Sprovider->getRateLimits as $rateLimit) ‹teLimit-›getKey, $rateLimit->getWindow0):helsuppon Dally • In 3h 32m100% CFri 8 May 11:28:18CascadeHubspot Rate Limiting, Hubspot Rate Limitis thrown and handler has middleware. Apply those three scenarios to public function handle (): void (Sthis-SdisnatchAnother.lob0Il Alreadv ranI Throws RateLimit ExceptionSthis->finalizeProcessing0;Consider only reactive rate linit wot, syt eCldent-Chpe (6398 . Think care), ly and consider ail potentiay issues and boill necksAsk anvthina (4D)Claude Onus 4.7 Medium* Reiect allUTE.RAccent allifo 4 spaces...
|
Slack
|
releases (Channel) - Jiminny Inc - 6 new items - S releases (Channel) - Jiminny Inc - 6 new items - Slack...
|
NULL
|
7133
|
|
7134
|
Switch workspaces… (Jiminny Inc) Has new messages
Switch workspaces… (Jiminny Inc) Has new messages
Home
Home
DMs
DMs
Activity
Activity
Files
Files
Later
Later
More…
More
Unreads
Threads
Huddles
Drafts & sent
1
Directories
jiminny-x-integration-app
platform-inner-team
ai-chapter
alerts
backend
bugs
confusion-clinic
curiosity_lab
engineering
general
jiminny-bg
platform-tickets
product_launches
random
releases
sofia-office
support
thank-yous
the_people_of_jiminny
Galya Dimitrova
Aneliya Angelova
,
Nikolay Yankov
,
Steliyan Georgiev
Stoyan Tanev
Stefka Stoyanova
Ves
Aneliya Angelova
Vasil Vasilev
James Graham
Nikolay Ivanov
Lukas Kovalik
you
Toast
Jira Cloud
Messages
Messages
Files
Files
Untitled
Untitled
Add and Edit Channel Tabs
Canvas
List
Folder
Jump to date
Galya Dimitrova
Apr 28th at 1:31:43 PM
1:31 PM
по принцип е добре всеки юзър да си го получава сутринта по долу горе едно и също време всеки ден
Apr 28th at 1:32:13 PM
1:32
в случая на уикенда казуса е че репорта за петък няма да може да се получи в понеделник сутринта ли
Lukas Kovalik
Apr 28th at 1:32:37 PM
1:32 PM
иска ми се да го сменя и да има крон job през час и дас си калкулира според report creator дали да го прати
Galya Dimitrova
Apr 28th at 1:33:18 PM
1:33 PM
искаш ли да се чуем
Lukas Kovalik
Apr 28th at 1:33:22 PM
1:33 PM
ами проблем е че с UTC 01:00 събота е следобяд петък в щати
Apr 28th at 1:33:25 PM
1:33
da
React with white_check_mark
React with eyes
React with raised_hands
Add reaction…
Reply in thread
Forward message…
Save for later
More actions
A huddle happened
Apr 28th at 1:33:32 PM
1:33 PM
You and
Galya Dimitrova
were in the huddle for
23m
.
React with white_check_mark
React with eyes
React with raised_hands
Add reaction…
Reply in thread
Forward message…
Save for later
More actions
Galya Dimitrova
Apr 28th at 6:27:20 PM
6:27 PM
Понеже си отпуска ако трябва и друг може да довърши нещата
React with white_check_mark
React with eyes
React with raised_hands
Add reaction…
Reply in thread
Forward message…
Save for later
More actions
Jump to date
New
Galya Dimitrova
Today at 11:01:42 AM
11:01 AM
Здравей, имам един проблем
React with white_check_mark
React with eyes
React with raised_hands
Add reaction…
Reply in thread
Forward message…
Save for later
More actions
Today at 11:02:07 AM
11:02
за AJ Reports нали в Datadog ги направихме да се логват към същия евент към който и другите и само типа да е различен
React with white_check_mark
React with eyes
React with raised_hands
Add reaction…
Reply in thread
Forward message…
Save for later
More actions
Today at 11:02:51 AM
11:02
обаче така се оказа че се включват във всичките графики където са и другите и развалят числата. Сега не мога да видя колко компании ползват едните и колко другите.
React with white_check_mark
React with eyes
React with raised_hands
Add reaction…
Reply in thread
Forward message…
Save for later
More actions
Today at 11:03:13 AM
11:03
сигурно има някаква сложна формула която да се измисли ама се чудя дали няма да е по добре да ги сложим да се тракват под друг евент
React with white_check_mark
React with eyes
React with raised_hands
Add reaction…
Reply in thread
Forward message…
Save for later
More actions
Channel
ActivityLateMoreSlackcalVIewJiminny ... ~# engineering# general# jiminny-bg# platform-tickets# product launches# random# releases# soha-ofhce# support# thank-vous# the people of jimi..O-ADirect-messagesikolay. Galya Dimitrova ey3 Aneliya An.Jigl..Stoyan Tanev €8 Stefka Stovanovae VesAneliva Angelovaa Vasil Vasilev¿ James Grahame. Nikolay IvanovLukas Kovalik y...#:Apps& ToastJira Cloud> D ProspectSearchStr:D Redisv D ServiceTraitsTOnoortunitvsvnd(*) SvncCrmEntitiesT SuncFieldstirait.() WriteCrmTrait.ol• M UtilsM WebhookC) BatchSvncCollectot@ RatchSvncPedicSerc) Client nho(C) ClocedDea|Stadecs@ DoalFieldsService rmistonWindowhelp@ Describe what you are looking forGalya DimitrovaMessagese FilesUntitledTuesday. April 28thvA huddle happened 1:33 PMYou and Calva Dimitrova were in thenuddle tor 25m.Galya Dimitrova 6:27 PMПонеже си отпуска ако трябва и другможе ла ловъоши нешатаGalva Dimitrova # 11:01 AMЗлравей, имам елин проблемза A kedorts нали в Datadog гинапоавихме ла се логват кьм съшияевент кьм който и поугите и само типаїда е различенобаче така се оказа че се включват въввсичките графики кълето са и лоугите ипазвалят числата, Сега не мога ла виляколко компании ползват елните и колко.novrитeсигуоно има някаква сложна формулакоято ла се измисли ама се чуля далиняма ла е по лоове ла ги сложим ла сеtoakвaт noл novr ebeнnMessage Galya Dimitrova In a meeting •+ Aaljectbetach.ong© MatchActivityCrmData.php(©) CrmActivityService.phgentonpx© CheckAndRetryRemoteMatch.php© JiminnyDebugCommand.phpuHubsporclientinterace.png© PaginationState.phg©)Paginationconrig.pngc) synchubspotobiecis.ongeCrmMatcherJob.phpC) MatchCrmData.php© Activity.phcviceDecorator.php0 Servicelntertace.phpeots HubspetetentInterfaceA2 A69 X2A• schis->gecnewinscance@->crmo->...nilable SanicalbilakeRequest (Sthis->confia)) {imiter->requestAvailableIn(Sthis->config);pot] Rate limit exceeded, deferring request', !->config->team_id,->config->getIdO.yArcellimit reached for configuration' . $this->config->getIdO,lSthis->rateLimiter->incrementRequestCount(Sthis->config):tryfreturn saoncallo} catch (Throwable $e) {if (Sthis->isHubsnotRateLimit($e)) {SretrvAfter = Sthis->narseRetrvAfter(Se):Sthis->lo0->warning('[Hubspotl Received 429 from APT'.'team id'Iconfia 1di= Sthis->confio->teamidi= Sthic-sconfia-saettd@).Inptny aftent => SnotnvAften..= Se->aetMeççane@=custom.logElaravel.log4 SF jiminny@localhost]A HS_local [jiminny@localhost]A console (PROD]A console [EU]A console [STAGING]tiò accounts jiminny(use...class Providerratelimitenorotected RateLimiter SrateLimiter:public function __construct(RateLimiter $rateLimiter)(...}public function canMakeRequest(RateLimited $provider): bool/** Ovar RateLimitInterface $rateLimit */foreach (Sprovider->getRateLimits as $rateLimit) ‹$key = SrateLimit->getKeyO;if (Sthis->rateLimiter->tooManyAttempts($key, $rateLimit->getQuotaO)) ‹return truepubLic tunction requestavallableln kateLimited Sprovider: 1ntreturn Sprovider->getRateLimits@->isNotEmptv@Sproviden->getRateLimits@->man(fn (RateLimitIntenface SrateLimit): int => Sthis->rateLimiter->availableIn(SrateLiihelsupoon Dally • In 3h 32m100% CFri 8 May 11:28:20CascadeHubspot Rate Limiting, Hubspot Rate Limitare. Apply those three scenarios to public function handle(): void {Sscimnsenice snatenhegyanc ."Il Alreadv ranI Throws RateLimit ExceptionSthis->finalizeProcessinaO:Consider only reactive rate limnit vot, Syt CCldent Chde (63-98 Think care), ly and consider ail potental issues and bottlnecksThinking II'm working through three different scenarios for handling rate limiting: one where the raw 429 exception bubbles up directly, anotherWhere the client catches it and throws a custom KateLimitexception with retry intormation, and a third that adas middleware handlingnublic function incrementReauestCountRateLimited Soroviden)• void** Avan Ratel imitIntenface Sratelsimit *foreach (Sprovider->getRateLimits as $rateLimit) ‹teLimit-›getKey, $rateLimit->getWindow0):Ask anvthina (&4D)Claude Onus 4.7 Medium* Reiect allUTE.RAccent allifo 4 spaces...
|
Slack
|
Galya Dimitrova (DM) - Jiminny Inc - 5 new items - Galya Dimitrova (DM) - Jiminny Inc - 5 new items - Slack...
|
NULL
|
7134
|
|
7135
|
Switch workspaces… (Jiminny Inc) Has new messages
Switch workspaces… (Jiminny Inc) Has new messages
Home
Home
DMs
DMs
Activity
Activity
Files
Files
Later
Later
More…
More
Unreads
Threads
Huddles
Drafts & sent
1
Directories
jiminny-x-integration-app
platform-inner-team
ai-chapter
alerts
backend
bugs
confusion-clinic
curiosity_lab
engineering
general
jiminny-bg
platform-tickets
product_launches
random
releases
sofia-office
support
thank-yous
the_people_of_jiminny
Galya Dimitrova
Aneliya Angelova
,
Nikolay Yankov
,
Steliyan Georgiev
Stoyan Tanev
Stefka Stoyanova
Ves
Aneliya Angelova
Vasil Vasilev
James Graham
Nikolay Ivanov
Lukas Kovalik
you
Toast
Jira Cloud
Messages
Messages
Files
Files
Untitled
Untitled
Add and Edit Channel Tabs
Canvas
List
Folder
Jump to date
Galya Dimitrova
Apr 28th at 1:31:43 PM
1:31 PM
по принцип е добре всеки юзър да си го получава сутринта по долу горе едно и също време всеки ден
Apr 28th at 1:32:13 PM
1:32
в случая на уикенда казуса е че репорта за петък няма да може да се получи в понеделник сутринта ли
Lukas Kovalik
Apr 28th at 1:32:37 PM
1:32 PM
иска ми се да го сменя и да има крон job през час и дас си калкулира според report creator дали да го прати
Galya Dimitrova
Apr 28th at 1:33:18 PM
1:33 PM
искаш ли да се чуем
Lukas Kovalik
Apr 28th at 1:33:22 PM
1:33 PM
ами проблем е че с UTC 01:00 събота е следобяд петък в щати
Apr 28th at 1:33:25 PM
1:33
da
React with white_check_mark
React with eyes
React with raised_hands
Add reaction…
Reply in thread
Forward message…
Save for later
More actions
A huddle happened
Apr 28th at 1:33:32 PM
1:33 PM
You and
Galya Dimitrova
were in the huddle for
23m
.
React with white_check_mark
React with eyes
React with raised_hands
Add reaction…
Reply in thread
Forward message…
Save for later
More actions
Galya Dimitrova
Apr 28th at 6:27:20 PM
6:27 PM
Понеже си отпуска ако трябва и друг може да довърши нещата
React with white_check_mark
React with eyes
React with raised_hands
Add reaction…
Reply in thread
Forward message…
Save for later
More actions
Jump to date
New
Galya Dimitrova
Today at 11:01:42 AM
11:01 AM
Здравей, имам един проблем
React with white_check_mark
React with eyes
React with raised_hands
Add reaction…
Reply in thread
Forward message…
Save for later
More actions
Today at 11:02:07 AM
11:02
за AJ Reports нали в Datadog ги направихме да се логват към същия евент към който и другите и само типа да е различен
React with white_check_mark
React with eyes
React with raised_hands
Add reaction…
Reply in thread
Forward message…
Save for later
More actions
Today at 11:02:51 AM
11:02
обаче така се оказа че се включват във всичките графики където са и другите и развалят числата. Сега не мога да видя колко компании ползват едните и колко другите.
React with white_check_mark
React with eyes
React with raised_hands
Add reaction…
Reply in thread
Forward message…
Save for later
More actions
Today at 11:03:13 AM
11:03
сигурно има някаква сложна формула която да се измисли ама се чудя дали няма да е по добре да ги сложим да се тракват под друг евент
React with white_check_mark
React with eyes
React with raised_hands
Add reaction…
Reply in thread
Forward message…
Save for later
More actions
Channel
iTerm2ShellEditViewSessionScriptsProfilesWindowHelplahl= Support Daily • in 3 h 32 m-zsh100% C8Fri 8 May 11:28:34T81DOCKER₴1APP (-zsh)-rw-r--r--1lukasstaff32768lukasstaff3254832drwxr-xr-x8lukasstaffstaff256-rw-r--r--lukas28408-rw-r--r--lukasstaff566164-rw-r--r--lukasstaff81437-rwxr-xr-xlukasstaff14994-rw-r--r--1lukasstaff3167lukas@Lukas-Kovaliks-MacBook-Pro-JiminnyDEV (-zsh)₴828 May09:25db.sqlite-shm8 May11:12db.sqlite-wal6 May20:27pipes6 May21:02screenpipe.2026-05-06.0.10g7 May21:508 May11:12screenpipe.2026-05-07.0.10gscreenpipe.2026-05-08.0.l0g6 May20:26screenpipe_sync.sh7 May09:23sync.log~/.screenpipe $ screenpipe_sync.sh 2026-05-07*3zsh:commandnot found:screenpipe_sync.shlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny~/.screenpipe $ ~/.screenpipe/screenpipe_sync.sh 2026-05-07[2026-05-0811:13:29][2026-05-0811:13:29J[2026-05-0811:13:29JScreenpipesync startingfor: 2026-05-07====-zsh• 84screenpipe*[+00m00s]PreflightchecksSourceDB:OK(1.0G)[2026-05-0811:13:29JERROR: NAS not mounted at/Volumes/screenpipelukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/.screenpipe $ ~/.screenpipe/screenpipe_sync.sh 2026-05-07[2026-05-0811:13:52]=====[2026-05-08 11:13:52]Screenpipe sync starting for: 2026-05-07[2026-05-08 11:13:52][+00m00s] • Preflight checksSource DB:NAS mount:Archive DB:Data dir:OKOK(1.0G)/Volumes/screenpipeexists( 10G)OK(266 files, 306M)[+00m01s] • Counting source rows for 2026-05-07frames:elements:ui_events:ocr_text:meetings:6262623002741216702[+00m02s] • Initialising tables, indexes, FTScreating tablescreating indexescreating FTS tables• Om00s• Om00s• Om00s[+00m02s] • Syncing data for 2026-05-07video_chunks• 0m01sframes (6262 rows)• Parse error near line 3: table nas.frames has 24 columns but 30 values weresuppliedlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/.screenpipe $ I•₴5-zsh...
|
Slack
|
Galya Dimitrova (DM) - Jiminny Inc - 5 new items - Galya Dimitrova (DM) - Jiminny Inc - 5 new items - Slack...
|
NULL
|
7135
|
|
7136
|
Project: faVsco.js, menu
master, menu
Start Listen Project: faVsco.js, menu
master, 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
<?php
declare(strict_types=1);
namespace Jiminny\Component\Utility\Service;
use Illuminate\Cache\RateLimiter;
use Jiminny\Contracts\Http\RateLimited;
use Jiminny\Contracts\Http\RateLimitInterface;
class ProviderRateLimiter
{
protected RateLimiter $rateLimiter;
public function __construct(RateLimiter $rateLimiter)
{
$this->rateLimiter = $rateLimiter;
}
public function canMakeRequest(RateLimited $provider): bool
{
/** @var RateLimitInterface $rateLimit */
foreach ($provider->getRateLimits() as $rateLimit) {
$key = $rateLimit->getKey();
if ($this->rateLimiter->tooManyAttempts($key, $rateLimit->getQuota())) {
return false;
}
}
return true;
}
public function requestAvailableIn(RateLimited $provider): int
{
return $provider->getRateLimits()->isNotEmpty()
? $provider->getRateLimits()
->map(fn (RateLimitInterface $rateLimit): int => $this->rateLimiter->availableIn($rateLimit->getKey()))
->max()
: 0
;
}
public function incrementRequestCount(RateLimited $provider): void
{
/** @var RateLimitInterface $rateLimit */
foreach ($provider->getRateLimits() as $rateLimit) {
$this->rateLimiter->hit($rateLimit->getKey(), $rateLimit->getWindow());
}
}
}
Sync Changes
Hide This Notification
Code changed:
Hide
2
69
2
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\Component\Utility\Service\ProviderRateLimiter;
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)
{
if (! $this->rateLimiter->canMakeRequest($this->config)) {
$retryAfter = $this->rateLimiter->requestAvailableIn($this->config);
$this->log->warning('[Hubspot] Rate limit exceeded, deferring request', [
'team_id' => $this->config->team_id,
'config_id' => $this->config->getId(),
'retry_after' => $retryAfter,
]);
throw new RateLimitException(
'Hubspot rate limit reached for configuration ' . $this->config->getId(),
$retryAfter,
);
}
$this->rateLimiter->incrementRequestCount($this->config);
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
{
return method_exists($e, 'getCode') && (int) $e->getCode() === 429;
}
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;
}
}
// For search APIs, headers are often missing - check response body for policyName
if (method_exists($e, 'getResponseBody')) {
$body = $e->getResponseBody();
if (is_string($body)) {
$body = json_decode($body, true) ?? [];
}
$policyName = $body['policyName'] ?? $body['policy'] ?? null;
// Map policy names to retry delays
if ($policyName === 'TEN_SECONDLY_ROLLING' || $policyName === 'ten_secondly_rolling') {
return 10;
}
if ($policyName === 'SECONDLY' || $policyName === 'secondly') {
return 1;
}
}
// Also check nested context object if present
if (method_exists($e, 'getResponseBody')) {
$body = $e->getResponseBody();
if (is_string($body)) {
$body = json_decode($body, true) ?? [];
}
$context = $body['context'] ?? [];
$policyName = $context['policyName'] ?? null;
if ($policyName === 'TEN_SECONDLY_ROLLING' || $policyName === 'ten_secondly_rolling') {
return 10;
}
if ($policyName === 'SECONDLY' || $policyName === 'secondly') {
return 1;
}
}
$this->log->debug('[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
{
// Check for specific HubSpot API exception types first
if ($e instanceof BadRequest) {
// BadRequest can contain 401 status codes
return $e->getCode() === 401;
}
// Check for HTTP client exceptions with status codes
if ($e instanceof \GuzzleHttp\Exception\RequestException && $e->hasResponse()) {
$response = $e->getResponse();
if ($response !== null) {
return $response->getStatusCode() === 401;
}
}
// Check for Guzzle HTTP exceptions
if ($e instanceof \GuzzleHttp\Exception\ClientException) {
return $e->getCode() === 401;
}
// Fallback to string matching as last resort, but be more specific
$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) && 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 (\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...
|
PhpStorm
|
faVsco.js – Client.php
|
NULL
|
7136
|
|
7137
|
Project: faVsco.js, menu
master, menu
Start Listen Project: faVsco.js, menu
master, 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
<?php
declare(strict_types=1);
namespace Jiminny\Component\Utility\Service;
use Illuminate\Cache\RateLimiter;
use Jiminny\Contracts\Http\RateLimited;
use Jiminny\Contracts\Http\RateLimitInterface;
class ProviderRateLimiter
{
protected RateLimiter $rateLimiter;
public function __construct(RateLimiter $rateLimiter)
{
$this->rateLimiter = $rateLimiter;
}
public function canMakeRequest(RateLimited $provider): bool
{
/** @var RateLimitInterface $rateLimit */
foreach ($provider->getRateLimits() as $rateLimit) {
$key = $rateLimit->getKey();
if ($this->rateLimiter->tooManyAttempts($key, $rateLimit->getQuota())) {
return false;
}
}
return true;
}
public function requestAvailableIn(RateLimited $provider): int
{
return $provider->getRateLimits()->isNotEmpty()
? $provider->getRateLimits()
->map(fn (RateLimitInterface $rateLimit): int => $this->rateLimiter->availableIn($rateLimit->getKey()))
->max()
: 0
;
}
public function incrementRequestCount(RateLimited $provider): void
{
/** @var RateLimitInterface $rateLimit */
foreach ($provider->getRateLimits() as $rateLimit) {
$this->rateLimiter->hit($rateLimit->getKey(), $rateLimit->getWindow());
}
}
}
Sync Changes
Hide This Notification
Code changed:
Hide
2
69
2
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\Component\Utility\Service\ProviderRateLimiter;
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)
{
if (! $this->rateLimiter->canMakeRequest($this->config)) {
$retryAfter = $this->rateLimiter->requestAvailableIn($this->config);
$this->log->warning('[Hubspot] Rate limit exceeded, deferring request', [
'team_id' => $this->config->team_id,
'config_id' => $this->config->getId(),
'retry_after' => $retryAfter,
]);
throw new RateLimitException(
'Hubspot rate limit reached for configuration ' . $this->config->getId(),
$retryAfter,
);
}
$this->rateLimiter->incrementRequestCount($this->config);
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
{
return method_exists($e, 'getCode') && (int) $e->getCode() === 429;
}
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;
}
}
// For search APIs, headers are often missing - check response body for policyName
if (method_exists($e, 'getResponseBody')) {
$body = $e->getResponseBody();
if (is_string($body)) {
$body = json_decode($body, true) ?? [];
}
$policyName = $body['policyName'] ?? $body['policy'] ?? null;
// Map policy names to retry delays
if ($policyName === 'TEN_SECONDLY_ROLLING' || $policyName === 'ten_secondly_rolling') {
return 10;
}
if ($policyName === 'SECONDLY' || $policyName === 'secondly') {
return 1;
}
}
// Also check nested context object if present
if (method_exists($e, 'getResponseBody')) {
$body = $e->getResponseBody();
if (is_string($body)) {
$body = json_decode($body, true) ?? [];
}
$context = $body['context'] ?? [];
$policyName = $context['policyName'] ?? null;
if ($policyName === 'TEN_SECONDLY_ROLLING' || $policyName === 'ten_secondly_rolling') {
return 10;
}
if ($policyName === 'SECONDLY' || $policyName === 'secondly') {
return 1;
}
}
$this->log->debug('[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
{
// Check for specific HubSpot API exception types first
if ($e instanceof BadRequest) {
// BadRequest can contain 401 status codes
return $e->getCode() === 401;
}
// Check for HTTP client exceptions with status codes
if ($e instanceof \GuzzleHttp\Exception\RequestException && $e->hasResponse()) {
$response = $e->getResponse();
if ($response !== null) {
return $response->getStatusCode() === 401;
}
}
// Check for Guzzle HTTP exceptions
if ($e instanceof \GuzzleHttp\Exception\ClientException) {
return $e->getCode() === 401;
}
// Fallback to string matching as last resort, but be more specific
$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) && 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 (\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...
|
PhpStorm
|
faVsco.js – Client.php
|
NULL
|
7137
|
|
7138
|
Switch workspaces… (Jiminny Inc) Has new messages
Switch workspaces… (Jiminny Inc) Has new messages
Home
Home
DMs
DMs
Activity
Activity
Files
Files
Later
Later
More…
More
Unreads
Threads
Huddles
Drafts & sent
1
Directories
jiminny-x-integration-app
platform-inner-team
ai-chapter
alerts
backend
bugs
confusion-clinic
curiosity_lab
engineering
general
jiminny-bg
platform-tickets
product_launches
random
releases
sofia-office
support
thank-yous
the_people_of_jiminny
Galya Dimitrova
Aneliya Angelova
,
Nikolay Yankov
,
Steliyan Georgiev
Stoyan Tanev
Stefka Stoyanova
Ves
Aneliya Angelova
Vasil Vasilev
James Graham
Nikolay Ivanov
Lukas Kovalik
you
Toast
Jira Cloud
Messages
Messages
Files
Files
Untitled
Untitled
Add and Edit Channel Tabs
Canvas
List
Folder
Jump to date
Galya Dimitrova
Apr 28th at 1:31:43 PM
1:31 PM
по принцип е добре всеки юзър да си го получава сутринта по долу горе едно и също време всеки ден
Apr 28th at 1:32:13 PM
1:32
в случая на уикенда казуса е че репорта за петък няма да може да се получи в понеделник сутринта ли
Lukas Kovalik
Apr 28th at 1:32:37 PM
1:32 PM
иска ми се да го сменя и да има крон job през час и дас си калкулира според report creator дали да го прати
Galya Dimitrova
Apr 28th at 1:33:18 PM
1:33 PM
искаш ли да се чуем
Lukas Kovalik
Apr 28th at 1:33:22 PM
1:33 PM
ами проблем е че с UTC 01:00 събота е следобяд петък в щати
Apr 28th at 1:33:25 PM
1:33
da
React with white_check_mark
React with eyes
React with raised_hands
Add reaction…
Reply in thread
Forward message…
Save for later
More actions
A huddle happened
Apr 28th at 1:33:32 PM
1:33 PM
You and
Galya Dimitrova
were in the huddle for
23m
.
React with white_check_mark
React with eyes
React with raised_hands
Add reaction…
Reply in thread
Forward message…
Save for later
More actions
Galya Dimitrova
Apr 28th at 6:27:20 PM
6:27 PM
Понеже си отпуска ако трябва и друг може да довърши нещата
React with white_check_mark
React with eyes
React with raised_hands
Add reaction…
Reply in thread
Forward message…
Save for later
More actions
Jump to date
New
Galya Dimitrova
Today at 11:01:42 AM
11:01 AM
Здравей, имам един проблем
React with white_check_mark
React with eyes
React with raised_hands
Add reaction…
Reply in thread
Forward message…
Save for later
More actions
Today at 11:02:07 AM
11:02
за AJ Reports нали в Datadog ги направихме да се логват към същия евент към който и другите и само типа да е различен
React with white_check_mark
React with eyes
React with raised_hands
Add reaction…
Reply in thread
Forward message…
Save for later
More actions
Today at 11:02:51 AM
11:02
обаче така се оказа че се включват във всичките графики където са и другите и развалят числата. Сега не мога да видя колко компании ползват едните и колко другите.
React with white_check_mark
React with eyes
React with raised_hands
Add reaction…
Reply in thread
Forward message…
Save for later
More actions
Today at 11:03:13 AM
11:03
iTerm2ShellEditViewSessionScriptsProfilesWindowHelplahl= Support Daily • in 3 h 32 m-zsh100% C8Fri 8 May 11:28:52T81DOCKER₴1APP (-zsh)-rw-r--r--1lukasstaff32768lukasstaff3254832drwxr-xr-x8lukasstaffstaff256-rw-r--r--lukas28408-rw-r--r--lukasstaff566164-rw-r--r--lukasstaff81437-rwxr-xr-xlukasstaff14994-rw-r--r--1lukasstaff3167lukas@Lukas-Kovaliks-MacBook-Pro-JiminnyDEV (-zsh)₴828 May09:25db.sqlite-shm8 May11:12db.sqlite-wal6 May20:27pipes6 May21:02screenpipe.2026-05-06.0.10g7 May21:508 May11:12screenpipe.2026-05-07.0.10gscreenpipe.2026-05-08.0.l0g6 May20:26screenpipe_sync.sh7 May09:23sync.log~/.screenpipe $ screenpipe_sync.sh 2026-05-07*3zsh:commandnot found:screenpipe_sync.shlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny~/.screenpipe $ ~/.screenpipe/screenpipe_sync.sh 2026-05-07[2026-05-0811:13:29][2026-05-0811:13:29J[2026-05-0811:13:29JScreenpipesync startingfor: 2026-05-07====-zsh• 84screenpipe*[+00m00s]PreflightchecksSourceDB:OK(1.0G)[2026-05-0811:13:29JERROR: NAS not mounted at/Volumes/screenpipelukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/.screenpipe $ ~/.screenpipe/screenpipe_sync.sh 2026-05-07[2026-05-0811:13:52]=====[2026-05-08 11:13:52]Screenpipe sync starting for: 2026-05-07[2026-05-08 11:13:52][+00m00s] • Preflight checksSource DB:NAS mount:Archive DB:Data dir:OKOK(1.0G)/Volumes/screenpipeexists( 10G)OK(266 files, 306M)[+00m01s] • Counting source rows for 2026-05-07frames:elements:ui_events:ocr_text:meetings:6262623002741216702[+00m02s] • Initialising tables, indexes, FTScreating tablescreating indexescreating FTS tables• Om00s• Om00s• Om00s[+00m02s] • Syncing data for 2026-05-07video_chunks• 0m01sframes (6262 rows)• Parse error near line 3: table nas.frames has 24 columns but 30 values weresuppliedlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/.screenpipe $ I•₴5-zsh...
|
Slack
|
Galya Dimitrova (DM) - Jiminny Inc - 5 new items - Galya Dimitrova (DM) - Jiminny Inc - 5 new items - Slack...
|
NULL
|
7138
|
|
7139
|
Switch workspaces… (Jiminny Inc) Has new messages
Switch workspaces… (Jiminny Inc) Has new messages
Home
Home
DMs
DMs
Activity
Activity
Files
Files
Later
Later
More…
More
Unreads
Threads
Huddles
Drafts & sent
1
Directories
jiminny-x-integration-app
platform-inner-team
ai-chapter
alerts
backend
bugs
confusion-clinic
curiosity_lab
engineering
general
jiminny-bg
platform-tickets
product_launches
random
releases
sofia-office
support
thank-yous
the_people_of_jiminny
Galya Dimitrova
Aneliya Angelova
,
Nikolay Yankov
,
Steliyan Georgiev
Stoyan Tanev
Stefka Stoyanova
Ves
Aneliya Angelova
Vasil Vasilev
James Graham
Nikolay Ivanov
Lukas Kovalik
you
Toast
Jira Cloud
Messages
Messages
Files
Files
Untitled
Untitled
Add and Edit Channel Tabs
Canvas
List
Folder
Jump to date
Galya Dimitrova
Apr 28th at 1:31:43 PM
1:31 PM
по принцип е добре всеки юзър да си го получава сутринта по долу горе едно и също време всеки ден
Apr 28th at 1:32:13 PM
1:32
в случая на уикенда казуса е че репорта за петък няма да може да се получи в понеделник сутринта ли
Lukas Kovalik
Apr 28th at 1:32:37 PM
1:32 PM
иска ми се да го сменя и да има крон job през час и дас си калкулира според report creator дали да го прати
Galya Dimitrova
Apr 28th at 1:33:18 PM
1:33 PM
искаш ли да се чуем
Lukas Kovalik
Apr 28th at 1:33:22 PM
1:33 PM
ами проблем е че с UTC 01:00 събота е следобяд петък в щати
Apr 28th at 1:33:25 PM
1:33
da
React with white_check_mark
React with eyes
React with raised_hands
Add reaction…
Reply in thread
Forward message…
Save for later
More actions
A huddle happened
Apr 28th at 1:33:32 PM
1:33 PM
You and
Galya Dimitrova
were in the huddle for
23m
.
React with white_check_mark
React with eyes
React with raised_hands
Add reaction…
Reply in thread
Forward message…
Save for later
More actions
Galya Dimitrova
Apr 28th at 6:27:20 PM
6:27 PM
Понеже си отпуска ако трябва и друг може да довърши нещата
React with white_check_mark
React with eyes
React with raised_hands
Add reaction…
Reply in thread
Forward message…
Save for later
More actions
Jump to date
New
Galya Dimitrova
Today at 11:01:42 AM
11:01 AM
Здравей, имам един проблем
React with white_check_mark
React with eyes
React with raised_hands
Add reaction…
Reply in thread
Forward message…
Save for later
More actions
Today at 11:02:07 AM
11:02
за AJ Reports нали в Datadog ги направихме да се логват към същия евент към който и другите и само типа да е различен
React with white_check_mark
React with eyes
React with raised_hands
Add reaction…
Reply in thread
Forward message…
Save for later
More actions
Today at 11:02:51 AM
11:02
обаче така се оказа че се включват във всичките графики където са и другите и развалят числата. Сега не мога да видя колко компании ползват едните и колко другите.
React with white_check_mark
React with eyes
React with raised_hands
Add reaction…
Reply in thread
Forward message…
ActivityLateMoreSlackcalVIewJiminny... ~# engineering# general# jiminny-bg# platform-tickets# product launches# random# releases# soha-ofhce# support# thank-vous# the people of jimi..ó- Direct messagesGalya Dimitrova(3 Aneliva Angelova.2o Stoyan Tanev8 Stefka Stovanovae VesAneliva Angelovaa Vasil Vasilev¿ James Grahame. Nikolay IvanovLukas Kovalik y...#:Apps& ToastJira Cloud> D ProspectSearchStr:D Redisv D ServiceTraitsTOnoortunitvsvnd() SvncCrmEntitiesT SuncFieldstirait.() WriteCrmTrait.ol• M UtilsM WebhookC) BatchSvncCollectot@ RatchSvncPedicSerc) Client nho(C) ClocedDea|Stadecs@ DoalFieldsService rMistonWindowhelp@ Describe what you are looking forGalya DimitrovaMessagese FilesUntitledTuesday. April 28thvA huddle happened 1:33 PMYou and Calva Dimitrova were in thenuddle tor 25m.Galya Dimitrova 6:27 PMПонеже си отпуска ако трябва и другможе ла ловъоши нешатаGalva Dimitrova # 11:01 AMЗлравей, имам елин проблемза A kedorts нали в Datadog гинапоавихме ла се логват кьм съшияевент кьм който и поугите и само типаїда е различенобаче така се оказа че се включват въввсичките графики кълето са и лоугите ипазвалят числата, Сега не мога ла виляколко компании ползват елните и колко.novrитeсигуоно има някаква сложна формулакоято ла се измисли ама се чуля далиняма ла е по лоове ла ги сложим ла сеtoakвaт пол novr eвeнnMessage Galya Dingitrova In a meeting •...•Aa.jectbetach.ong© MatchActivityCrmData.php(©) CrmActivityService.phgentonpx© CheckAndRetryRemoteMatch.php© JiminnyDebugCommand.phpuHubsporclientinterace.png© PaginationState.phg©)Paginationeonrig.png© SyncHubspotObiects.phdeCrmMatcherJob.phpC) MatchCrmData.php© Activity.phoviceDecorator.pho0 Servicelntertace.phpeots HubspetetentInterfaceA2 A69 X2A• schis->gecnewinscance@->crmo->...nilable SanicalbilakeRequest (Sthis->confia)){imiter->requestAvailableIn(Sthis->config);pot] Rate limit exceeded, deferring request', !->config->team_id,->config->getIdO.YATLel,limit reached for configuration' . $this->config->getIdO,lSthis->rateLimiter->incrementRequestCount(Sthis->confiq):try{return saoncallo} catch (Throwable $e) {if (Sthis->isHubsnotRateLimit($e)) {SretrvAfter = Sthis->narseRetrvAfter(Se):Sthis->lo0->warning('[Hubsnotl Received 429 from APT'. ['team id'Iconfia 1di= Sthis->confio->teamidi= Sthic-sconfia-saettd@).Inptny aftent => SnotnvAften..= Se->aetMeççane@=custom.logElaravel.log4 SF jiminny@localhost]A HS_local [jiminny@localhost]tiò accounts jiminny(A console (PROD]# console [eu)A console [STAGING]use...class Providerratelimitenorotected RateLimiter SrateLimiter:public function __construct(RateLimiter $rateLimiter)(...}public function canMakeRequest(RateLimited $provider): bool/** Ovar RateLimitInterface $rateLimit */foreach (Sprovider->getRateLimits as $rateLimit) ‹$key = SrateLimit->getKeyO;if (Sthis->rateLimiter->tooManyAttempts($key, SrateLimit->getQuotaO)) {return truepubLic tunction requestavallableln kateLimited Sprovider: 1ntreturn Sprovider->getRateLimits@->isNotEmptv@Sproviden->getRateLimits@->man(fn (RateLimitIntenface SrateLimit): int => Sthis->rateLimiter->availableIn(SrateLiinublic function incnementReauestCountRateLimited Soroviden)• void** Qvan Ratel imitIntenface Sratelsimit *íforeach (Sprovider->getRateLimits as $rateLimit) ‹eLimit-›getKey, SrateLimit->getWindowO):supoon Dally • In 3h 32m100% 12Fri 8 May 11:28:53CascadeHubspot Rate Limiting, Hubspot Rate Limit+0 ..aphppublic Tunccion handle: voldSthis->uodateDatabaseoSthis->dispatchAnotherJob();>matchByName(...);schasotind etzer rocessingeStep 1: Side effect// Step 2: side effect// Step 3: throws11 Step 4: never runsScenario 1: Raw 429 (No Client wrapping, No middleware)The HubSoot SDK throws HubSpot\CLient\Crm\*\ ApiException with code 429. Steps 1 & 2 already executed.Per Caller TypeCallerWhat HappensQueued .Jobarks attemptnury — nalafter $tries exhausted: permanent failure,exist. so search 429s without headers lose all timina infocaller must implement try/catch manuallvconsoleComiException bubbles to artisan command → command• Cron/scheduler sees failure• No automatic retry unlessietonereunelictener (syncEvcontion hubbloe un to whoovor dienatchod theCusontion traverces uneynected boundaries. The triggerincevent. If event dispatched from a job → bubbles toCritical Bottleneck for Scenario 1Search endpoint 429s often lack Retry-After headers (HubSpot bug). Without client parsing the response body for policyNamethe system has no way to know whether to wait 1s or 10s. Laravel's sbackoff is a guess that's almost always wrong.Scenario 2• Client throws Patel imitFycention((retrvAfter). NO middlewareClient catches 429, parses retry-after from headers/body, throws typed exception. Handler doesn't catch it.What Chanaes vs Scenario 1Excention is tvoed (RateLimitEycention ) — fillAccent alliAsk anvthina (&4D)Claude Onus 4.7 Medium88.57 (656 charc 15 line hreakc)JUTE.Afo 4 spaces...
|
Slack
|
Galya Dimitrova (DM) - Jiminny Inc - 5 new items - Galya Dimitrova (DM) - Jiminny Inc - 5 new items - Slack...
|
NULL
|
7139
|
|
7140
|
Switch workspaces… (Jiminny Inc) Has new messages
Switch workspaces… (Jiminny Inc) Has new messages
Home
Home
DMs
DMs
Activity
Activity
Files
Files
Later
Later
More…
More
Unreads
Threads
Huddles
Drafts & sent
1
Directories
jiminny-x-integration-app
platform-inner-team
ai-chapter
alerts
backend
bugs
confusion-clinic
curiosity_lab
engineering
general
jiminny-bg
platform-tickets
product_launches
random
releases
sofia-office
support
thank-yous
the_people_of_jiminny
Galya Dimitrova
Aneliya Angelova
,
Nikolay Yankov
,
Steliyan Georgiev
Stoyan Tanev
Stefka Stoyanova
Ves
Aneliya Angelova
Vasil Vasilev
James Graham
Nikolay Ivanov
Lukas Kovalik
you
Toast
Jira Cloud
Messages
Messages
Files
Files
Untitled
Untitled
Add and Edit Channel Tabs
Canvas
List
Folder
Jump to date
Galya Dimitrova
Apr 28th at 1:31:43 PM
1:31 PM
по принцип е добре всеки юзър да си го получава сутринта по долу горе едно и също време всеки ден
Apr 28th at 1:32:13 PM
1:32
в случая на уикенда казуса е че репорта за петък няма да може да се получи в понеделник сутринта ли
Lukas Kovalik
Apr 28th at 1:32:37 PM
1:32 PM
иска ми се да го сменя и да има крон job през час и дас си калкулира според report creator дали да го прати
Galya Dimitrova
Apr 28th at 1:33:18 PM
1:33 PM
искаш ли да се чуем
Lukas Kovalik
Apr 28th at 1:33:22 PM
1:33 PM
ами проблем е че с UTC 01:00 събота е следобяд петък в щати
Apr 28th at 1:33:25 PM
1:33
da
React with white_check_mark
React with eyes
React with raised_hands
Add reaction…
Reply in thread
Forward message…
Save for later
More actions
A huddle happened
Apr 28th at 1:33:32 PM
1:33 PM
You and
Galya Dimitrova
were in the huddle for
23m
.
React with white_check_mark
React with eyes
React with raised_hands
Add reaction…
Reply in thread
Forward message…
Save for later
More actions
Galya Dimitrova
Apr 28th at 6:27:20 PM
6:27 PM
Понеже си отпуска ако трябва и друг може да довърши нещата
React with white_check_mark
React with eyes
React with raised_hands
Add reaction…
Reply in thread
Forward message…
Save for later
More actions
Jump to date
New
Galya Dimitrova
Today at 11:01:42 AM
11:01 AM
Здравей, имам един проблем
React with white_check_mark
React with eyes
React with raised_hands
Add reaction…
Reply in thread
Forward message…
Save for later
More actions
Today at 11:02:07 AM
11:02
за AJ Reports нали в Datadog ги направихме да се логват към същия евент към който и другите и само типа да е различен
React with white_check_mark
React with eyes
React with raised_hands
Add reaction…
Reply in thread
Forward message…
Save for later
More actions
Today at 11:02:51 AM
11:02
обаче така се оказа че се включват във всичките графики където са и другите и развалят числата. Сега не мога да видя колко компании ползват едните и колко другите.
React with white_check_mark
React with eyes
React with raised_hands
Add reaction…
Reply in thread
Forward message…
Save for later
More actions
Today at 11:03:13 AM
11:03
сигурно има някаква сложна формула която да се измисли ама се чудя дали няма да е по добре да ги сложим да се тракват под друг евент
React with white_check_mark
React with eyes
React with raised_hands
Add reaction…
Reply in thread
Forward message…
Save for later
More actions
zdrast
zdrast
Shift + Return to add a new line
Shift + Return
to add a new line
Channel
ActivityLateMoreSlackcalVIewJiminny ... ~# engineering# general# jiminny-bg# platform-tickets# product launches# random# releases# soha-ofhce# support# thank-vous# the people of jimi..ó- Direct messagesGalya Dimitrova(3 Aneliva Angelova.2o Stoyan Tanev8 Stefka Stovanovae VesAneliva Angelovaa Vasil Vasilev¿ James Grahame. Nikolay IvanovLukas Kovalik y...#:Apps& ToastJira Cloud> D ProspectSearchStr:D Redisv D ServiceTraitsTOnoortunitvsvnd(*) SvncCrmEntitiesT SuncFieldstirait.() WriteCrmTrait.ol• M UtilsM WebhookC) BatchSvncCollectot@ RatchSvncPedicSerc) Client nho(C) ClocedDea|Stadecs@ DoalFieldsService rMistonWindowhelp@ Describe what you are looking forGalya DimitrovaMessagese FilesUntitledTuesday. April 28thvA huddle happened 1:33 PMYou and Calva Dimitrova were in thenuddle tor 25m.Galya Dimitrova 6:27 PMПонеже си отпуска ако трябва и другможе ла ловъоши нешатаGalva Dimitrova # 11:01 AMЗлравей, имам елин проблемза A kedorts нали в Datadog гинапоавихме ла се логват кьм съшияевент кьм който и поугите и сэмо типада е различенобаче така се оказа че се включват въввсичките графики кълето са и лоугите ипазвалят числата, Сега не мога ла виляколко компании ползват елните и колко.novrитeсигуоно има някаква сложна формулакоято ла се измисли ама се чуля далиняма ла е по лоове ла ги сложим ла сеtoakвaт пол novr eвeнn+ Aa €jectbetach.ong© MatchActivityCrmData.php(©) CrmActivityService.phgentonpx© CheckAndRetryRemoteMatch.php© JiminnyDebugCommand.phpuHubsporclientinterace.png© PaginationState.phg©)Paginationeonrig.png© SyncHubspotObiects.phdC) MatchCrmData.php© Activity.phcviceDecorator.pho0 Servicelntertace.phpeots HubspetetentInterfaceA2 A69 X2A• schis->gecnewinscance@->crmo->...nilable SanicalbilakeRequest (Sthis->confia)) {imiter->requestAvailableIn(Sthis->config);pot] Rate limit exceeded, deferring request', !->config->team_id,->config->getIdO.YATLel,limit reached for configuration' . $this->config->getIdO,lSthis->rateLimiter->incrementRequestCount(Sthis->confiq):try{return saoncallo} catch (Throwable $e) {if (Sthis->isHubspotRateLimit($e)) ≤SretrvAfter = Sthis->oarseRetrvAfter(Se):Sthis->lo0->warning('[Hubsnotl Received 429 from APT'. ['team id'Iconfia 1di= Sthis->confio->teamidi= Sthic-sconfia-saettd@).Inptny aftent => SnotnvAften..= Se->aetMeççane@=custom.logElaravel.log4 SF jiminny@localhost]A HS_local [jiminny@localhost]tiò accounts jiminny(A console (PROD]# console [eu)A console [STAGING]use...class Providerratelimitenorotected RateLimiter SrateLimiter:public function __construct(RateLimiter $rateLimiter)(...}public function canMakeRequest(RateLimited $provider): bool/** Ovar RateLimitInterface $rateLimit */foreach (Sprovider->getRateLimits as $rateLimit) ‹$key = SrateLimit->getKeyO:if (Sthis->rateLimiter->tooManyAttempts($key, SrateLimit->getQuotaO)) {return truepubLic tunction requestavallableln kateLimited Sprovider : 1ntreturn Sprovider->getRateLimits@->isNotEmptv@Sproviden->getRateLimits@->man(fn (RateLimitIntenface SrateLimit): int => Sthis->rateLimiter->availableIn(SrateLiinublic function incrementReauestCountRateLimited Soroviden)• void** Avan Ratel imitIntenface Sratel imit *^foreach (Sprovider->getRateLimits as $rateLimit) ‹eLimit-›getKey, SrateLimit->getWindowO):supoon Dally • In 3h 32m100% 12Fri 8 May 11:28:55CascadeHubspot Rate Limiting, Hubspot Rate Limi+0 ..Scenario 1: Raw 429 (No Client wrapping, No middleware)The Hubspot sDK throws HubSpot Client Crm * ApiException with code 429. Steps 1 & 2 already executed.Per Caller TypeCallerWhat [EMAIL] escapes handlleo → Laravel marks attemot•Wrona delav (30s vs 1s actual)• Wasted retries: Side-eftectfailed → releases with Sbackoft (e.q.. 30s → onduplication on each retry• Generic exception in Sentry - hardretry. Steps 1 & 2 re-execute → may hit rate limitto tilter from real errors• parseRetrvAtter loaic doesn'?exist, so search 429s without headers lose all timing infoControllerhandler → HTTP 500 to user. Steps 1 & 2 committed•User sees raw error• If user clicks retry → Steps 1 & 2execute again → duplicate dispatches• No queue release -exception bubbles to artisan command → commandexits with non-zero code. Steps 1 & 2 committed.• Cron/scheduler sees tailure• No automatic retry unlesswrapped• If command processes a batch, remaining items notListener (syncqueued via ShoulSame as queued job - listener IS a jobSame as queued jobnon-queuedevent. If event dispatched from a job → bubbles tole() → thatjoo talls. Itrom amiddleware (trany) catches KateLimitexception only — noucontroller 7 ouu.raw ApzexceptionCritical Bottleneck for Scenario 1lSoarch endnoint 120c often lack Petrv-After headers (HubSpot bua). Without Client parsing the response body for policyName.the sustem has no wav to know whether to wait 1s or 10c. Laravel's shackoff is a quess that's almost alwavs wrong.Scenario 2: Client throws RateLimitEycention(SretrvAfter). NO middlewareClient catches 429, parses retry-after from headers/body, throws typed exception. Handler doesn't catch it.What Changes vs Scenario 17 Exception is typed (RateLimitException ) - filterable in Sentry/loas• retryAfter is populated correctly (1s or 10s)But nobody reads Sretrvafter — t's iust sittina on the excention obiectPer Caller TuneDifference fromCallerWhat HappensscenarioQueuedException escapes → Laravel still uses Sbackoff (wrong delay). Sentry shows RateLimitExceptionMarginally better locinstead of ApiException. Same retry exhaustion → permanent failureAccent alliAsk anvthina (*4L)Claude Onus 4.7 MediumW Windsurf Toams 88.57 (656 charc 15 line hreakc)UTF.8fo 4 spaces...
|
Slack
|
Galya Dimitrova (DM) - Jiminny Inc - 5 new items - Galya Dimitrova (DM) - Jiminny Inc - 5 new items - Slack...
|
NULL
|
7140
|
|
7141
|
Switch workspaces… (Jiminny Inc) Has new messages
Switch workspaces… (Jiminny Inc) Has new messages
Home
Home
DMs
DMs
Activity
Activity
Files
Files
Later
Later
More…
More
Unreads
Threads
Huddles
Drafts & sent
1
Directories
jiminny-x-integration-app
platform-inner-team
ai-chapter
alerts
backend
bugs
confusion-clinic
curiosity_lab
engineering
general
jiminny-bg
platform-tickets
product_launches
random
releases
sofia-office
support
thank-yous
the_people_of_jiminny
Galya Dimitrova
Aneliya Angelova
,
Nikolay Yankov
,
Steliyan Georgiev
Stoyan Tanev
Stefka Stoyanova
Ves
Aneliya Angelova
Vasil Vasilev
James Graham
Nikolay Ivanov
Lukas Kovalik
you
Toast
Jira Cloud
Messages
Messages
Files
Files
Untitled
Untitled
Add and Edit Channel Tabs
Canvas
List
Folder
Jump to date
Galya Dimitrova
Apr 28th at 1:31:43 PM
1:31 PM
по принцип е добре всеки юзър да си го получава сутринта по долу горе едно и също време всеки ден
Apr 28th at 1:32:13 PM
1:32
в случая на уикенда казуса е че репорта за петък няма да може да се получи в понеделник сутринта ли
Lukas Kovalik
Apr 28th at 1:32:37 PM
1:32 PM
иска ми се да го сменя и да има крон job през час и дас си калкулира според report creator дали да го прати
Galya Dimitrova
Apr 28th at 1:33:18 PM
1:33 PM
искаш ли да се чуем
Lukas Kovalik
Apr 28th at 1:33:22 PM
1:33 PM
ами проблем е че с UTC 01:00 събота е следобяд петък в щати
Apr 28th at 1:33:25 PM
1:33
da
React with white_check_mark
React with eyes
React with raised_hands
Add reaction…
Reply in thread
Forward message…
Save for later
More actions
A huddle happened
Apr 28th at 1:33:32 PM
1:33 PM
You and
Galya Dimitrova
were in the huddle for
23m
.
React with white_check_mark
React with eyes
React with raised_hands
Add reaction…
Reply in thread
Forward message…
Save for later
More actions
Galya Dimitrova
Apr 28th at 6:27:20 PM
6:27 PM
Понеже си отпуска ако трябва и друг може да довърши нещата
React with white_check_mark
React with eyes
React with raised_hands
Add reaction…
Reply in thread
Forward message…
Save for later
More actions
Jump to date
New
Galya Dimitrova
Today at 11:01:42 AM
11:01 AM
Здравей, имам един проблем
React with white_check_mark
React with eyes
React with raised_hands
Add reaction…
Reply in thread
Forward message…
Save for later
More actions
Today at 11:02:07 AM
11:02
за AJ Reports нали в Datadog ги направихме да се логват към същия евент към който и другите и само типа да е различен
React with white_check_mark
React with eyes
React with raised_hands
Add reaction…
Reply in thread
Forward message…
Save for later
More actions
Today at 11:02:51 AM
11:02
обаче така се оказа че се включват във всичките графики където са и другите и развалят числата. Сега не мога да видя колко компании ползват едните и колко другите.
React with white_check_mark
React with eyes
React with raised_hands
Add reaction…
Reply in thread
Forward message…
Save for later
More actions
Today at 11:03:13 AM
11:03
сигурно има някаква сложна формула която да се измисли ама се чудя дали няма да е по добре да ги сложим да се тракват под друг евент
React with white_check_mark
React with eyes
React with raised_hands
Add reaction…
Reply in thread
Forward message…
Save for later
More actions
здрасти, гледам го и ще
здрасти, гледам го и ще
Shift + Return to add a new line
Shift + Return
to add a new line
Channel
ActivityLateMoreSlackcalVIewJiminny... ~# engineering# general# jiminny-bg# platform-tickets# product launches# random# releases# soha-ofhce# support# thank-vous# the people of jimi..ó- Direct messagesGalya Dimitrova(3 Aneliva Angelova.2o Stoyan Tanev8 Stefka Stovanovae VesAneliva Angelovaa Vasil Vasilev¿ James Grahame. Nikolay IvanovLukas Kovalik y...#:Apps& ToastJira Cloud> D ProspectSearchStr:D Redisv D ServiceTraitsTOnoortunitvsvnd() SvncCrmEntitiesT SuncFieldstirait.() WriteCrmTrait.ol• M UtilsM WebhookC) BatchSvncCollectot@ RatchSvncPedicSerc) Client nho(C) ClocedDea|Stadecs@ DoalFieldsService rmistonWindowhelp@ Describe what you are looking forGalya DimitrovaMessagese FilesUntitledTuesday. April 28thvA huddle happened 1:33 PMYou and Calva Dimitrova were in thenuddle tor 25m.Galya Dimitrova 6:27 PMПонеже си отпуска ако трябва и другможе ла ловъоши нешатаGalva Dimitrova # 11:01 AMЗлравей, имам елин проблемза A kedorts нали в Datadog гинапоавихме ла се логват кьм съшияевент кьм който и поугите и само типаїда е различенобаче така се оказа че се включват въввсичките графики кълето са и лоугите ипазвалят числата, Сега не мога ла виляколко компании ползват елните и колко.novrитeсигуоно има някаква сложна формулакоято ла се измисли ама се чуля далиняма ла е по лоове ла ги сложим ла сеtoakвaт пол novr eвeнnздрасти, гледам го и ш.Aajectbetach.ong© MatchActivityCrmData.php(©) CrmActivityService.phgentonpx© CheckAndRetryRemoteMatch.php© JiminnyDebugCommand.phpuHubsporclientinterace.png© PaginationState.phg©)Paginationeonrig.pngc) synchubspotobiecis.ongeCrmMatcherJob.phpC) MatchCrmData.php© Activity.phcviceDecorator.pho0 Servicelntertace.phpeots HubspetetentInterfaceA2 A69 X2A• schis->gecnewinscance@->crmo->...nilable SanicalbilakeRequest (Sthis->confia)){imiter->requestAvailableIn(Sthis->config);pot] Rate limit exceeded, deferring request', !->config->team_id,->config->getIdO.YATLel,limit reached for configuration' . $this->config->getIdO,lSthis->rateLimiter->incrementRequestCount(Sthis->confiq):tryfreturn saoncallo} catch (Throwable $e) {if (Sthis->isHubsnotRateLimit($e)) {SretrvAfter = Sthis->oarseRetrvAfter(Se):Sthis->lo0->warning('[Hubsnotl Received 429 from APT'. ['team id'Iconfia 1di= Sthis->confio->teamidi= Sthic-sconfia-saettd@).Inptny aftent => SnotnvAften..= Se->aetMeççane@• suppont Dally • In 3n 31m100% 12Fri 8 May 11:29:10+0 ..=custom.logElaravel.log4 SF jiminny@localhost]A HS_local jiminny@localhost]tiò accounts jiminny(A console (PROD]# console [eu)A console [STAGING]use...class Providerratelimitenorotected RateLimiter SrateLimiter:public function __construct(RateLimiter $rateLimiter)(...}public function canMakeRequest(RateLimited $provider): bool/** Ovar RateLimitInterface $rateLimit */foreach (Sprovider->getRateLimits as $rateLimit) ‹$key = SrateLimit->getKeyO;if (Sthis->rateLimiter->tooManyAttempts($key, SrateLimit->getQuotaO)) ‹return truepubLic tunction requestava1lableln kateLimited Sprovider: 1ntreturn Sprovider->getRateLimits@->isNotEmptv@Sproviden->getRateLimits@->man(fn (RateLimitIntenface SrateLimit): int => Sthis->rateLimiter-›availableIn(SrateLiinublic function incrementReauestCountRateLimited Soroviden): void** Avan Ratel imitIntenface Sratel imit *^foreach (Sprovider->getRateLimits as $rateLimit) ‹eLimit-›getKey, SrateLimit->getWindowO):CascadeHubspot Rate Limiting, Hubspot Rate LimitPer Caller lypeCallerWhat HappensQueued JobMiddleware wraps handle). When matchByName) throws → caught → Sjob→>release(1) or release(10) . Attempt count Not incremented. Steps1 & 2 still re-execute on retry (idempotency concern). On retry: rate limitMiddleware does NOT apply - middleware is a queue-pipeline constructcontrollelbenaves identically to scenario . Caller must implement try/ catchmanually to get any beneritSame — middleware doesn't apply to artisan commands. Behaves likecommancscenario c.WatnletuiloitetteListener is a job - declare middleware on the listener class. Then it worksexaculy like a queued Jobnoineothrough the event dispatch →back into handle" → cauaht bv that iob's middleware.Where Middleware Actuallv HelpsContextQueued otvYesQueued Listener (ShouldQueue )Yes (declare on listenerSync listener dispatched from a queued iob with middlewareYes (caught at job level)Sync listener dispatched from controller/consoleDirect controller calniront cancalo aolSide-Effect Duplication Problem (All 3 Scenarios)0 phpsthic-sundatehatahacesthis->dispatchAnotherJob)):every retry - duplicate iobs!$crmService->matchByName(...);Throwsschis-rinalizerrocessinoonly runs arter successScenarioNumber of Duplicate Side-Effects (worst case)1 (raw 429, no middleware)Ask anvthina (&4DClaude Onus 4.7 MediumNotesV Eventually completes A Steps 1 & 2Still duplicated each retry until successMiddleware is irrelevant nereMiddleware is irrelevant hereWorks if lictener declares middlewarerotected if dispatched* Reiect allAccent alliW Windsurf Toams 88.57 (656 charc 15 line hreake)UTE.Rfo 4 spaces...
|
Slack
|
Galya Dimitrova (DM) - Jiminny Inc - 5 new items - Galya Dimitrova (DM) - Jiminny Inc - 5 new items - Slack...
|
NULL
|
7141
|
|
7142
|
Project: faVsco.js, menu
master, menu
Start Listen Project: faVsco.js, menu
master, 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
<?php
declare(strict_types=1);
namespace Jiminny\Component\Utility\Service;
use Illuminate\Cache\RateLimiter;
use Jiminny\Contracts\Http\RateLimited;
use Jiminny\Contracts\Http\RateLimitInterface;
class ProviderRateLimiter
{
protected RateLimiter $rateLimiter;
public function __construct(RateLimiter $rateLimiter)
{
$this->rateLimiter = $rateLimiter;
}
public function canMakeRequest(RateLimited $provider): bool
{
/** @var RateLimitInterface $rateLimit */
foreach ($provider->getRateLimits() as $rateLimit) {
$key = $rateLimit->getKey();
if ($this->rateLimiter->tooManyAttempts($key, $rateLimit->getQuota())) {
return false;
}
}
return true;
}
public function requestAvailableIn(RateLimited $provider): int
{
return $provider->getRateLimits()->isNotEmpty()
? $provider->getRateLimits()
->map(fn (RateLimitInterface $rateLimit): int => $this->rateLimiter->availableIn($rateLimit->getKey()))
->max()
: 0
;
}
public function incrementRequestCount(RateLimited $provider): void
{
/** @var RateLimitInterface $rateLimit */
foreach ($provider->getRateLimits() as $rateLimit) {
$this->rateLimiter->hit($rateLimit->getKey(), $rateLimit->getWindow());
}
}
}
Sync Changes
Hide This Notification
Code changed:
Hide
2
69
2
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\Component\Utility\Service\ProviderRateLimiter;
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)
{
if (! $this->rateLimiter->canMakeRequest($this->config)) {
$retryAfter = $this->rateLimiter->requestAvailableIn($this->config);
$this->log->warning('[Hubspot] Rate limit exceeded, deferring request', [
'team_id' => $this->config->team_id,
'config_id' => $this->config->getId(),
'retry_after' => $retryAfter,
]);
throw new RateLimitException(
'Hubspot rate limit reached for configuration ' . $this->config->getId(),
$retryAfter,
);
}
$this->rateLimiter->incrementRequestCount($this->config);
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
{
return method_exists($e, 'getCode') && (int) $e->getCode() === 429;
}
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;
}
}
// For search APIs, headers are often missing - check response body for policyName
if (method_exists($e, 'getResponseBody')) {
$body = $e->getResponseBody();
if (is_string($body)) {
$body = json_decode($body, true) ?? [];
}
$policyName = $body['policyName'] ?? $body['policy'] ?? null;
// Map policy names to retry delays
if ($policyName === 'TEN_SECONDLY_ROLLING' || $policyName === 'ten_secondly_rolling') {
return 10;
}
if ($policyName === 'SECONDLY' || $policyName === 'secondly') {
return 1;
}
}
// Also check nested context object if present
if (method_exists($e, 'getResponseBody')) {
$body = $e->getResponseBody();
if (is_string($body)) {
$body = json_decode($body, true) ?? [];
}
$context = $body['context'] ?? [];
$policyName = $context['policyName'] ?? null;
if ($policyName === 'TEN_SECONDLY_ROLLING' || $policyName === 'ten_secondly_rolling') {
return 10;
}
if ($policyName === 'SECONDLY' || $policyName === 'secondly') {
return 1;
}
}
$this->log->debug('[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
{
// Check for specific HubSpot API exception types first
if ($e instanceof BadRequest) {
// BadRequest can contain 401 status codes
return $e->getCode() === 401;
}
// Check for HTTP client exceptions with status codes
if ($e instanceof \GuzzleHttp\Exception\RequestException && $e->hasResponse()) {
$response = $e->getResponse();
if ($response !== null) {
return $response->getStatusCode() === 401;
}
}
// Check for Guzzle HTTP exceptions
if ($e instanceof \GuzzleHttp\Exception\ClientException) {
return $e->getCode() === 401;
}
// Fallback to string matching as last resort, but be more specific
$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) && 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 (\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...
|
PhpStorm
|
faVsco.js – Client.php
|
NULL
|
7142
|
|
7143
|
Project: faVsco.js, menu
master, menu
Start Listen Project: faVsco.js, menu
master, 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
<?php
declare(strict_types=1);
namespace Jiminny\Component\Utility\Service;
use Illuminate\Cache\RateLimiter;
use Jiminny\Contracts\Http\RateLimited;
use Jiminny\Contracts\Http\RateLimitInterface;
class ProviderRateLimiter
{
protected RateLimiter $rateLimiter;
public function __construct(RateLimiter $rateLimiter)
{
$this->rateLimiter = $rateLimiter;
}
public function canMakeRequest(RateLimited $provider): bool
{
/** @var RateLimitInterface $rateLimit */
foreach ($provider->getRateLimits() as $rateLimit) {
$key = $rateLimit->getKey();
if ($this->rateLimiter->tooManyAttempts($key, $rateLimit->getQuota())) {
return false;
}
}
return true;
}
public function requestAvailableIn(RateLimited $provider): int
{
return $provider->getRateLimits()->isNotEmpty()
? $provider->getRateLimits()
->map(fn (RateLimitInterface $rateLimit): int => $this->rateLimiter->availableIn($rateLimit->getKey()))
->max()
: 0
;
}
public function incrementRequestCount(RateLimited $provider): void
{
/** @var RateLimitInterface $rateLimit */
foreach ($provider->getRateLimits() as $rateLimit) {
$this->rateLimiter->hit($rateLimit->getKey(), $rateLimit->getWindow());
}
}
}
Sync Changes
Hide This Notification
Code changed:
Hide
2
69
2
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\Component\Utility\Service\ProviderRateLimiter;
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)
{
if (! $this->rateLimiter->canMakeRequest($this->config)) {
$retryAfter = $this->rateLimiter->requestAvailableIn($this->config);
$this->log->warning('[Hubspot] Rate limit exceeded, deferring request', [
'team_id' => $this->config->team_id,
'config_id' => $this->config->getId(),
'retry_after' => $retryAfter,
]);
throw new RateLimitException(
'Hubspot rate limit reached for configuration ' . $this->config->getId(),
$retryAfter,
);
}
$this->rateLimiter->incrementRequestCount($this->config);
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
{
return method_exists($e, 'getCode') && (int) $e->getCode() === 429;
}
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;
}
}
// For search APIs, headers are often missing - check response body for policyName
if (method_exists($e, 'getResponseBody')) {
$body = $e->getResponseBody();
if (is_string($body)) {
$body = json_decode($body, true) ?? [];
}
$policyName = $body['policyName'] ?? $body['policy'] ?? null;
// Map policy names to retry delays
if ($policyName === 'TEN_SECONDLY_ROLLING' || $policyName === 'ten_secondly_rolling') {
return 10;
}
if ($policyName === 'SECONDLY' || $policyName === 'secondly') {
return 1;
}
}
// Also check nested context object if present
if (method_exists($e, 'getResponseBody')) {
$body = $e->getResponseBody();
if (is_string($body)) {
$body = json_decode($body, true) ?? [];
}
$context = $body['context'] ?? [];
$policyName = $context['policyName'] ?? null;
if ($policyName === 'TEN_SECONDLY_ROLLING' || $policyName === 'ten_secondly_rolling') {
return 10;
}
if ($policyName === 'SECONDLY' || $policyName === 'secondly') {
return 1;
}
}
$this->log->debug('[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
{
// Check for specific HubSpot API exception types first
if ($e instanceof BadRequest) {
// BadRequest can contain 401 status codes
return $e->getCode() === 401;
}
// Check for HTTP client exceptions with status codes
if ($e instanceof \GuzzleHttp\Exception\RequestException && $e->hasResponse()) {
$response = $e->getResponse();
if ($response !== null) {
return $response->getStatusCode() === 401;
}
}
// Check for Guzzle HTTP exceptions
if ($e instanceof \GuzzleHttp\Exception\ClientException) {
return $e->getCode() === 401;
}
// Fallback to string matching as last resort, but be more specific
$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) && 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 (\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...
|
PhpStorm
|
faVsco.js – Client.php
|
NULL
|
7143
|
|
7144
|
Project Files
Preview
Filter
Open in Find Tool Win Project Files
Preview
Filter
Open in Find Tool Window
Component/AskJiminnyAi/OnDemandLevel/Events/AskAnythingAbortedChatCompleted.php
Open In Right Split...
|
PhpStorm
|
|
NULL
|
7144
|
|
7145
|
Switch workspaces… (Jiminny Inc) Has new messages
Switch workspaces… (Jiminny Inc) Has new messages
Home
Home
DMs
DMs
Activity
Activity
Files
Files
Later
Later
More…
More
Unreads
Threads
Huddles
Drafts & sent
1
Directories
jiminny-x-integration-app
platform-inner-team
ai-chapter
alerts
backend
bugs
confusion-clinic
curiosity_lab
engineering
general
jiminny-bg
platform-tickets
product_launches
random
releases
sofia-office
support
thank-yous
the_people_of_jiminny
Galya Dimitrova
Aneliya Angelova
,
Nikolay Yankov
,
Steliyan Georgiev
Stoyan Tanev
Stefka Stoyanova
Ves
Aneliya Angelova
Vasil Vasilev
James Graham
Nikolay Ivanov
Lukas Kovalik
you
Toast
Jira Cloud
Messages
Messages
Files
Files
Untitled
Untitled
Add and Edit Channel Tabs
Canvas
List
Folder
Jump to date
Galya Dimitrova
Apr 28th at 1:31:43 PM
1:31 PM
по принцип е добре всеки юзър да си го получава сутринта по долу горе едно и също време всеки ден
Apr 28th at 1:32:13 PM
1:32
в случая на уикенда казуса е че репорта за петък няма да може да се получи в понеделник сутринта ли
Lukas Kovalik
Apr 28th at 1:32:37 PM
1:32 PM
иска ми се да го сменя и да има крон job през час и дас си калкулира според report creator дали да го прати
Galya Dimitrova
Apr 28th at 1:33:18 PM
1:33 PM
искаш ли да се чуем
Lukas Kovalik
Apr 28th at 1:33:22 PM
1:33 PM
ами проблем е че с UTC 01:00 събота е следобяд петък в щати
Apr 28th at 1:33:25 PM
1:33
da
React with white_check_mark
React with eyes
React with raised_hands
Add reaction…
Reply in thread
Forward message…
Save for later
More actions
A huddle happened
Apr 28th at 1:33:32 PM
1:33 PM
You and
Galya Dimitrova
were in the huddle for
23m
.
React with white_check_mark
React with eyes
React with raised_hands
Add reaction…
Reply in thread
Forward message…
Save for later
More actions
Galya Dimitrova
Apr 28th at 6:27:20 PM
6:27 PM
Понеже си отпуска ако трябва и друг може да довърши нещата
React with white_check_mark
React with eyes
React with raised_hands
Add reaction…
Reply in thread
Forward message…
Save for later
More actions
Jump to date
New
Galya Dimitrova
Today at 11:01:42 AM
11:01 AM
Здравей, имам един проблем
React with white_check_mark
React with eyes
React with raised_hands
Add reaction…
Reply in thread
Forward message…
Save for later
More actions
Today at 11:02:07 AM
11:02
за AJ Reports нали в Datadog ги направихме да се логват към същия евент към който и другите и само типа да е различен
React with white_check_mark
React with eyes
React with raised_hands
Add reaction…
Reply in thread
Forward message…
Save for later
More actions
Today at 11:02:51 AM
11:02
обаче така се оказа че се включват във всичките графики където са и другите и развалят числата. Сега не мога да видя колко компании ползват едните и колко другите.
React with white_check_mark
React with eyes
React with raised_hands
Add reaction…
Reply in thread
Forward message…
Save for later
More actions
Today at 11:03:13 AM
11:03
iTerm2ShellEditViewSessionScriptsProfilesWindowHelplihl= Support Daily • in 3 h 31 mA-zsh100% C8Fri 8 May 11:29:17T81DOCKER₴1APP (-zsh)-rw-r--r--1lukasstaff32768lukasstaff3254832drwxr-xr-x8lukasstaffstaff256-rw-r--r--lukas28408-rw-r--r--lukasstaff566164-rw-r--r--lukasstaff81437-rwxr-xr-xlukasstaff14994-rw-r--r--1lukasstaff3167lukas@Lukas-Kovaliks-MacBook-Pro-JiminnyDEV (-zsh)₴828 May09:25db.sqlite-shm8 May11:12db.sqlite-wal6 May20:27pipes6 May21:02screenpipe.2026-05-06.0.10g7 May21:508 May11:12screenpipe.2026-05-07.0.10gscreenpipe.2026-05-08.0.l0g6 May20:26screenpipe_sync.sh7 May09:23sync.log~/.screenpipe $ screenpipe_sync.sh 2026-05-07*3zsh:commandnot found:screenpipe_sync.shlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny~/.screenpipe $ ~/.screenpipe/screenpipe_sync.sh 2026-05-07[2026-05-0811:13:29][2026-05-0811:13:29J[2026-05-0811:13:29JScreenpipesync startingfor: 2026-05-07====-zsh• 84screenpipe*•₴5[+00m00s]PreflightchecksSourceDB:OK(1.0G)[2026-05-0811:13:29JERROR: NAS not mounted at/Volumes/screenpipelukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/.screenpipe $ ~/.screenpipe/screenpipe_sync.sh 2026-05-07[2026-05-0811:13:52]=====[2026-05-08 11:13:52]Screenpipe sync starting for: 2026-05-07[2026-05-08 11:13:52][+00m00s] • Preflight checksSource DB:NAS mount:Archive DB:Data dir:OKOK(1.0G)/Volumes/screenpipeexists( 10G)OK(266 files, 306M)[+00m01s] • Counting source rows for 2026-05-07frames:elements:ui_events:ocr_text:meetings:6262623002741216702[+00m02s] • Initialising tables, indexes, FTScreating tablescreating indexescreating FTS tables• Om00s• Om00s• Om00s[+00m02s] • Syncing data for 2026-05-07video_chunks• 0m01sframes (6262 rows)• Parse error near line 3: table nas.frames has 24 columns but 30 values weresuppliedlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/.screenpipe $ I-zsh...
|
Slack
|
Galya Dimitrova (DM) - Jiminny Inc - 5 new items - Galya Dimitrova (DM) - Jiminny Inc - 5 new items - Slack...
|
NULL
|
7145
|
|
7146
|
Switch workspaces… (Jiminny Inc) Has new messages
Switch workspaces… (Jiminny Inc) Has new messages
Home
Home
DMs
DMs
Activity
Activity
Files
Files
Later
Later
More…
More
Unreads
Threads
Huddles
Drafts & sent
1
Directories
jiminny-x-integration-app
platform-inner-team
ai-chapter
alerts
backend
bugs
confusion-clinic
curiosity_lab
engineering
general
jiminny-bg
platform-tickets
product_launches
random
releases
sofia-office
support
thank-yous
the_people_of_jiminny
Galya Dimitrova
Aneliya Angelova
,
Nikolay Yankov
,
Steliyan Georgiev
Stoyan Tanev
Stefka Stoyanova
Ves
Aneliya Angelova
Vasil Vasilev
James Graham
Nikolay Ivanov
Lukas Kovalik
you
Toast
Jira Cloud
Messages
Messages
Files
Files
Untitled
Untitled
Add and Edit Channel Tabs
Canvas
List
Folder
Jump to date
Galya Dimitrova
Apr 28th at 1:31:43 PM
1:31 PM
по принцип е добре всеки юзър да си го получава сутринта по долу горе едно и също време всеки ден
Apr 28th at 1:32:13 PM
1:32
в случая на уикенда казуса е че репорта за петък няма да може да се получи в понеделник сутринта ли
Lukas Kovalik
Apr 28th at 1:32:37 PM
1:32 PM
иска ми се да го сменя и да има крон job през час и дас си калкулира според report creator дали да го прати
Galya Dimitrova
Apr 28th at 1:33:18 PM
1:33 PM
искаш ли да се чуем
Lukas Kovalik
Apr 28th at 1:33:22 PM
1:33 PM
ами проблем е че с UTC 01:00 събота е следобяд петък в щати
Apr 28th at 1:33:25 PM
1:33
da
React with white_check_mark
React with eyes
React with raised_hands
Add reaction…
Reply in thread
Forward message…
Save for later
More actions
A huddle happened
Apr 28th at 1:33:32 PM
1:33 PM
You and
Galya Dimitrova
were in the huddle for
23m
.
React with white_check_mark
React with eyes
React with raised_hands
Add reaction…
Reply in thread
Forward message…
Save for later
More actions
Galya Dimitrova
Apr 28th at 6:27:20 PM
6:27 PM
Понеже си отпуска ако трябва и друг може да довърши нещата
React with white_check_mark
React with eyes
React with raised_hands
Add reaction…
Reply in thread
Forward message…
Save for later
More actions
Jump to date
New
Galya Dimitrova
Today at 11:01:42 AM
11:01 AM
Здравей, имам един проблем
React with white_check_mark
React with eyes
React with raised_hands
Add reaction…
Reply in thread
Forward message…
Save for later
More actions
Today at 11:02:07 AM
11:02
за AJ Reports нали в Datadog ги направихме да се логват към същия евент към който и другите и само типа да е различен
React with white_check_mark
React with eyes
React with raised_hands
Add reaction…
Reply in thread
Forward message…
Save for later
More actions
Today at 11:02:51 AM
11:02
обаче така се оказа че се включват във всичките графики където са и другите и развалят числата. Сега не мога да видя колко компании ползват едните и колко другите.
React with white_check_mark
React with eyes
React with raised_hands
Add reaction…
Reply in thread
Forward message…
Save for later
More actions
Today at 11:03:13 AM
11:03
сигурно има някаква сложна формула която да се измисли ама се чудя дали няма да е по добре да ги сложим да се тракват под друг евент
React with white_check_mark
React with eyes
React with raised_hands
Add reaction…
Reply in thread
Forward message…
Save for later
More actions
здрасти, гледам го и ще ти пиша
здрасти, гледам го и ще ти пиша
Shift + Return to add a new line
Shift + Return
to add a new line
Channel
HomeActivityLateMoreSlackcalVIewJiminny... ~# engineering# general# jiminny-bg# platform-tickets# product launches# random# releases# soha-ofhce# support# thank-vous# the people of jimi..ó- Direct messagesGalya Dimitrova(3 Aneliva Angelova.2o Stoyan Tanev8 Stefka StovanovaVesAneliva Angelovaa Vasil Vasilev¿ James Grahame. Nikolay IvanovLukas Kovalik y...#:Apps& ToastJira Cloud> D ProspectSearchStr:D Redisv D ServiceTraitsTOnoortunitvsvnd() SvncCrmEntitiesT SuncFieldstirait.() WriteCrmTrait.ol 104• M UtilsM WebhookC) BatchSvncCollectot@ RatchSvncPedicSerc) Client nho(C) ClocedDea|Stadecs@ DoalFieldsService rmistonWindowhelp@ Describe what you are looking forGalya DimitrovaMessagesr FilesUntitledTuesday. April 28thvA huddle happened 1:33 PMYou and Calva Dimitrova were in thenuddle tor 25m.Galya Dimitrova 6:27 PMПонеже си отпуска ако трябва и другможе ла ловъоши нешатаGalva Dimitrova # 11:01 AMЗлравей, имам елин проблемза A kedorts нали в Datadog гинапоавихме ла се логват кьм съшияевент кьм който и поугите и само типаїда е различенобаче така се оказа че се включват въввсичките графики кълето са и лоугите ипазвалят числата, Сега не мога ла виляколко компании ползват елните и колко.novrитeсигуоно има някаква сложна формулакоято ла се измисли ама се чуля далиняма ла е по лоове ла ги сложим ла сеtoakвaт пол novr eвeнnздрасти, гледам го и ще ти пиша•Aaljectbetach.ong(©) CrmActivityService.phgentonpx© CheckAndRetryRemoteMatch.php© JiminnyDebugCommand.phpuHubsporclientinterace.png© PaginationState.phg©)Paginationeonrig.png© SyncHubspotObiects.phdeCrmMatcherJob.phpC) MatchCrmData.php© Activity.phcviceDecorator.pho0 Servicelntertace.phpeots HubspetetentInterface42 A69 X2 A• schis->gechewinscance@->crmo->...nhllable SaniCall)lakeRequestSthis->conf10))imiter->requestAvailableIn(Sthis->config):spot] Rate limit exceeded, deferring request', [s->config->team_id,s->config->getId.yATellimit reached for configuration' . $this->config->getIdSthis->rateLimiter->incrementRequestCount(Sthis->confiq):tryfreturn saoncallo} catch (Throwable $e) {if (Sthis->isHubsnotRateLimit($e)) {SretrvAfter = Sthis->narseRetrvAfter(Se)*Sthis->lo0->warning('[Hubsnotl Received 429 from APT'. ['team id'Iconfia 1d1= Sthis->confio->teamidi= Sthic-sconfia-saettd@).Inptny aftent => SnotnvAften..= Se->aetMeççane@© ProviderRateLimiter.php X = custom.logElaravel.log4 SF jiminny@localhost]A HS_local [jiminny@localhost]tiò accounts fimA console (PROD]# console [eu)A console [STAGING]use...class Providerratelimitenorotected RateLimiter SrateLimiter:public function __construct(RateLimiter $rateLimiter)(...}public function canMakeRequest(RateLimited Sprovider): bool/** Ovar RateLimitInterface $rateLimit */foreach (Sprovider->getRateLimits as $rateLimit){$key = SrateLimit->getKeyO:if (Sthis->rateLimiter->tooManyAttempts($key, SrateLimit->getQuotaO)) ‹return truepubLic tunction requestava1lableln kateLimited Sprovider: 1ntreturn Sprovider->getRateLimits(->isNotEmptv@Sproviden->getRateLimits@->man(fn (RateLimitInterface SrateLimit): int => Sthis->rateLimiter-›availableIn(SrateLii->maxonublic function incrementReauestCountRateLimited Soroviden): void** Avan Ratel imitIntenface Sratel imit *^foreach (Sprovider->getRateLimits as $rateLimit){mit-›getKey, $rateLimit->getWindow0):• suppont Dally • In 3n 31m100% 12Fri 8 May 11:29:18CascadeHubspot Rate Limiting• Hubspot Rate Limi+0 ..$this->finalizeProcessing():Only runs after successNumber of Duplicate Side-Effects (worst case)1 (raw 429, ndUo to 3 retries x 2 side effects = 6 duplicate executions, then permanent fail. dispatchAnotherJob fires up to2typed exception, noSame as Scenario 1. 6 duplicate executions.3typed + middlewareCould be many more (10÷ because middleware doesn't increment attempts. Job releases until rate limit clearsMore duplicate dispatchAnotherJob calls than Scenarios 1 & 2Important: Scenario 3 actuallv amolities the side-effect duplication problem because it allows unlimited retries! The iob keeos.releasing & re-running updatedatabase() and dispatchAnotherJob) until rate limit clears.Mitigation (Independent of Scenario)The handler must be idemootent OR ordered to out the failina call first.• phppublic function handle(): void {Sresult = Scrmservice->matchbvName...+ Move risky call FiRsTSthis->uodateDatabase(Sresult):II Now only runs after successSthie.wienstchAnotharlohl),Thic is tho renl fiv — middleware alone dosenit colve dunlicato cido offonteSummary MatrixliotanorListenerQueued .lobSvnc ControllerConsole(queued)Listener (Syncfrom Controller)Wrong delaynerm failX HTTP 500X Same asX HTTP 500no typedhandlingScenario 2.!Can tru/catchCan trv/catchX Same aserm fail (typecloas onlv)Catch typedCorrect delav7 if declaredCaught at iobConScenario 1 → 2 is mostly cosmetic for runAsk anvthina (&4D)Claude Onus 4.7 MediumAccent alliW Windsurf Teams 81•55UTF.Afo 4 spaces...
|
Slack
|
Galya Dimitrova (DM) - Jiminny Inc - 5 new items - Galya Dimitrova (DM) - Jiminny Inc - 5 new items - Slack...
|
NULL
|
7146
|
|
7147
|
Project Files
Preview
Filter
Open in Find Tool Win Project Files
Preview
Filter
Open in Find Tool Window
BadRequest
HttpBadRequestException.php .../app/Exceptions/HttpBadRequestException.php, exception class
HttpBadRequestWithErrorReasonException.php .../app/Exceptions/HttpBadRequestWithErrorReasonException.php, exception class
HttpBadRequestException.php.html build/coverage/Exceptions
DeleteScorecardRuleRequest.php.html build/coverage/Http/Requests/Settings/AutoScoring
HttpBadRequestException.php .../app/Exceptions/HttpBadRequestException.php, exception class
HttpBadRequestWithErrorReasonException.php .../app/Exceptions/HttpBadRequestWithErrorReasonException.php, exception class
HttpBadRequestException.php.html build/coverage/Exceptions
DeleteScorecardRuleRequest.php.html build/coverage/Http/Requests/Settings/AutoScoring
Exceptions/HttpBadRequestException.php
Open In Right Split...
|
PhpStorm
|
|
NULL
|
7147
|
|
7148
|
Project: faVsco.js, menu
master, menu
Start Listen Project: faVsco.js, menu
master, 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
<?php
declare(strict_types=1);
namespace Jiminny\Component\Utility\Service;
use Illuminate\Cache\RateLimiter;
use Jiminny\Contracts\Http\RateLimited;
use Jiminny\Contracts\Http\RateLimitInterface;
class ProviderRateLimiter
{
protected RateLimiter $rateLimiter;
public function __construct(RateLimiter $rateLimiter)
{
$this->rateLimiter = $rateLimiter;
}
public function canMakeRequest(RateLimited $provider): bool
{
/** @var RateLimitInterface $rateLimit */
foreach ($provider->getRateLimits() as $rateLimit) {
$key = $rateLimit->getKey();
if ($this->rateLimiter->tooManyAttempts($key, $rateLimit->getQuota())) {
return false;
}
}
return true;
}
public function requestAvailableIn(RateLimited $provider): int
{
return $provider->getRateLimits()->isNotEmpty()
? $provider->getRateLimits()
->map(fn (RateLimitInterface $rateLimit): int => $this->rateLimiter->availableIn($rateLimit->getKey()))
->max()
: 0
;
}
public function incrementRequestCount(RateLimited $provider): void
{
/** @var RateLimitInterface $rateLimit */
foreach ($provider->getRateLimits() as $rateLimit) {
$this->rateLimiter->hit($rateLimit->getKey(), $rateLimit->getWindow());
}
}
}
Code changed:
Hide
Sync Changes
Hide This Notification
Analyzing…
<?php
declare(strict_types=1);
namespace Jiminny\Http\Controllers\Webhook;
use Carbon\Carbon;
use Illuminate\Contracts\Bus\Dispatcher as BusDispatcher;
use Illuminate\Contracts\Events\Dispatcher as EventDispatcher;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Jiminny\Events\AutomatedReports\AutomatedReportGenerated;
use Jiminny\Exceptions\ModelNotFoundException;
use Jiminny\Http\Controllers\AbstractController;
use Jiminny\Jobs\AutomatedReports\SendReportJob;
use Jiminny\Models\AutomatedReportResult;
use Jiminny\Services\Kiosk\AutomatedReports\AutomatedReportsCallbackService;
use Jiminny\Services\Kiosk\AutomatedReports\AutomatedReportsService;
use Psr\Log\LoggerInterface;
use Throwable;
class ReportController extends AbstractController
{
/**
* Log prefix for all log messages
*/
private const string LOG_PREFIX = '[Report Ready]';
public function __construct(
private readonly AutomatedReportsService $automatedReportsService,
private readonly BusDispatcher $dispatcher,
private readonly LoggerInterface $logger,
private readonly AutomatedReportsCallbackService $callbackService,
private readonly EventDispatcher $eventDispatcher,
) {
}
public function ready(Request $request): JsonResponse
{
$payload = $request->all();
$now = Carbon::now();
$this->logger->info(self::LOG_PREFIX . ' Webhook received', [
'payload' => $payload,
]);
// validate
$reportUuid = $this->callbackService->getResultUuid($payload);
if (empty($reportUuid)) {
return response()->json(['status' => 'error', 'message' => 'Request ID is empty'], status: 400);
}
try {
$report = $this->automatedReportsService->getReportResult($reportUuid);
// validate
if ($this->callbackService->isProcessed($report)) {
$this->logger->warning(self::LOG_PREFIX . ' Report has been already processed', [
'uuid' => $reportUuid,
'currentStatus' => $report->getStatusLabel(),
]);
return response()->json(['status' => 'already_processed']);
}
// always try to get a child podcast cause report configuration cannot be trusted
$reportPodcast = $this->automatedReportsService->findChildResult(
result: $report,
type: AutomatedReportsService::MEDIA_TYPE_PODCAST
);
// update results
$report->update([
'status' => $this->callbackService->getPrimaryStatus($report, $payload),
'response' => $payload,
'generated_at' => $now,
]);
// if a podcast is set, update it
$reportPodcast?->update([
'status' => $this->callbackService->getPodcastStatus($payload),
'response' => $payload,
'generated_at' => $now,
]);
$this->logger->info(self::LOG_PREFIX . ' Report has been processed', [
'uuid' => $reportUuid,
'child_uuid' => $reportPodcast?->getUuid(),
]);
if (! $this->callbackService->isSuccess($payload)) {
$this->logger->warning(self::LOG_PREFIX . ' Error creating report', $payload);
return response()->json(['status' => 'ok']);
}
// If one-off, send the report immediately, if not leave it for the scheduler (automated-reports:send)
if ($report->getReport()->getFrequency() === AutomatedReportsService::FREQUENCY_ONE_OFF) {
// send the primary report
$this->dispatcher->dispatch(new SendReportJob($reportUuid));
// send the podcast report if it set and generated
if ($reportPodcast && $reportPodcast->getStatus() === AutomatedReportResult::STATUS_GENERATED) {
$this->dispatcher->dispatch(new SendReportJob(reportUuid: $reportPodcast->getUuid()));
}
}
// Track Datadog metrics for automated reports
$automatedReport = $report->getReport();
$this->callbackService->pushToDatadog($automatedReport, $report);
if ($reportPodcast) {
$this->callbackService->pushToDatadog($automatedReport, $reportPodcast);
}
$this->logger->info(self::LOG_PREFIX . ' Triggering Event for UserPilot tracking', [
'report_uuid' => $automatedReport->getUuid(),
'result_uuid' => $reportUuid,
]);
$this->eventDispatcher->dispatch(new AutomatedReportGenerated($automatedReport));
} catch (ModelNotFoundException $exception) {
$this->logger->error(self::LOG_PREFIX . ' Report not found', [
'uuid' => $reportUuid,
'error' => $exception->getMessage(),
]);
return response()->json(['status' => 'error', 'message' => 'Report not found'], status: 404);
} catch (Throwable $exception) {
$this->logger->error(self::LOG_PREFIX . ' Failed to update report status', [
'uuid' => $reportUuid,
'error' => $exception->getMessage(),
]);
return response()->json(['status' => 'error', 'message' => 'Failed to update report status'], status: 500);
}
return response()->json(['status' => 'ok']);
}
}
Project
Project
New File or Directory…
Expand Selected
Collapse All
Options
Hide...
|
PhpStorm
|
faVsco.js – ReportController.php
|
NULL
|
7148
|
|
7149
|
Project: faVsco.js, menu
master, menu
Start Listen Project: faVsco.js, menu
master, 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
<?php
declare(strict_types=1);
namespace Jiminny\Component\Utility\Service;
use Illuminate\Cache\RateLimiter;
use Jiminny\Contracts\Http\RateLimited;
use Jiminny\Contracts\Http\RateLimitInterface;
class ProviderRateLimiter
{
protected RateLimiter $rateLimiter;
public function __construct(RateLimiter $rateLimiter)
{
$this->rateLimiter = $rateLimiter;
}
public function canMakeRequest(RateLimited $provider): bool
{
/** @var RateLimitInterface $rateLimit */
foreach ($provider->getRateLimits() as $rateLimit) {
$key = $rateLimit->getKey();
if ($this->rateLimiter->tooManyAttempts($key, $rateLimit->getQuota())) {
return false;
}
}
return true;
}
public function requestAvailableIn(RateLimited $provider): int
{
return $provider->getRateLimits()->isNotEmpty()
? $provider->getRateLimits()
->map(fn (RateLimitInterface $rateLimit): int => $this->rateLimiter->availableIn($rateLimit->getKey()))
->max()
: 0
;
}
public function incrementRequestCount(RateLimited $provider): void
{
/** @var RateLimitInterface $rateLimit */
foreach ($provider->getRateLimits() as $rateLimit) {
$this->rateLimiter->hit($rateLimit->getKey(), $rateLimit->getWindow());
}
}
}...
|
PhpStorm
|
faVsco.js – ReportController.php
|
NULL
|
7149
|
|
7150
|
Project: faVsco.js, menu
master, menu
Start Listen Project: faVsco.js, menu
master, 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
<?php
declare(strict_types=1);
namespace Jiminny\Component\Utility\Service;
use Illuminate\Cache\RateLimiter;
use Jiminny\Contracts\Http\RateLimited;
use Jiminny\Contracts\Http\RateLimitInterface;
class ProviderRateLimiter
{
protected RateLimiter $rateLimiter;
public function __construct(RateLimiter $rateLimiter)
{
$this->rateLimiter = $rateLimiter;
}
public function canMakeRequest(RateLimited $provider): bool
{
/** @var RateLimitInterface $rateLimit */
foreach ($provider->getRateLimits() as $rateLimit) {
$key = $rateLimit->getKey();
if ($this->rateLimiter->tooManyAttempts($key, $rateLimit->getQuota())) {
return false;
}
}
return true;
}
public function requestAvailableIn(RateLimited $provider): int
{
return $provider->getRateLimits()->isNotEmpty()
? $provider->getRateLimits()
->map(fn (RateLimitInterface $rateLimit): int => $this->rateLimiter->availableIn($rateLimit->getKey()))
->max()
: 0
;
}
public function incrementRequestCount(RateLimited $provider): void
{
/** @var RateLimitInterface $rateLimit */
foreach ($provider->getRateLimits() as $rateLimit) {
$this->rateLimiter->hit($rateLimit->getKey(), $rateLimit->getWindow());
}
}
}
Code changed:
Hide
Sync Changes
Hide This Notification
Analyzing…
<?php
declare(strict_types=1);
namespace Jiminny\Http\Controllers\Webhook;
use Carbon\Carbon;
use Illuminate\Contracts\Bus\Dispatcher as BusDispatcher;
use Illuminate\Contracts\Events\Dispatcher as EventDispatcher;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Jiminny\Events\AutomatedReports\AutomatedReportGenerated;
use Jiminny\Exceptions\ModelNotFoundException;
use Jiminny\Http\Controllers\AbstractController;
use Jiminny\Jobs\AutomatedReports\SendReportJob;
use Jiminny\Models\AutomatedReportResult;
use Jiminny\Services\Kiosk\AutomatedReports\AutomatedReportsCallbackService;
use Jiminny\Services\Kiosk\AutomatedReports\AutomatedReportsService;
use Psr\Log\LoggerInterface;
use Throwable;
class ReportController extends AbstractController
{
/**
* Log prefix for all log messages
*/
private const string LOG_PREFIX = '[Report Ready]';
public function __construct(
private readonly AutomatedReportsService $automatedReportsService,
private readonly BusDispatcher $dispatcher,
private readonly LoggerInterface $logger,
private readonly AutomatedReportsCallbackService $callbackService,
private readonly EventDispatcher $eventDispatcher,
) {
}
public function ready(Request $request): JsonResponse
{
$payload = $request->all();
$now = Carbon::now();
$this->logger->info(self::LOG_PREFIX . ' Webhook received', [
'payload' => $payload,
]);
// validate
$reportUuid = $this->callbackService->getResultUuid($payload);
if (empty($reportUuid)) {
return response()->json(['status' => 'error', 'message' => 'Request ID is empty'], status: 400);
}
try {
$report = $this->automatedReportsService->getReportResult($reportUuid);
// validate
if ($this->callbackService->isProcessed($report)) {
$this->logger->warning(self::LOG_PREFIX . ' Report has been already processed', [
'uuid' => $reportUuid,
'currentStatus' => $report->getStatusLabel(),
]);
return response()->json(['status' => 'already_processed']);
}
// always try to get a child podcast cause report configuration cannot be trusted
$reportPodcast = $this->automatedReportsService->findChildResult(
result: $report,
type: AutomatedReportsService::MEDIA_TYPE_PODCAST
);
// update results
$report->update([
'status' => $this->callbackService->getPrimaryStatus($report, $payload),
'response' => $payload,
'generated_at' => $now,
]);
// if a podcast is set, update it
$reportPodcast?->update([
'status' => $this->callbackService->getPodcastStatus($payload),
'response' => $payload,
'generated_at' => $now,
]);
$this->logger->info(self::LOG_PREFIX . ' Report has been processed', [
'uuid' => $reportUuid,
'child_uuid' => $reportPodcast?->getUuid(),
]);
if (! $this->callbackService->isSuccess($payload)) {
$this->logger->warning(self::LOG_PREFIX . ' Error creating report', $payload);
return response()->json(['status' => 'ok']);
}
// If one-off, send the report immediately, if not leave it for the scheduler (automated-reports:send)
if ($report->getReport()->getFrequency() === AutomatedReportsService::FREQUENCY_ONE_OFF) {
// send the primary report
$this->dispatcher->dispatch(new SendReportJob($reportUuid));
// send the podcast report if it set and generated
if ($reportPodcast && $reportPodcast->getStatus() === AutomatedReportResult::STATUS_GENERATED) {
$this->dispatcher->dispatch(new SendReportJob(reportUuid: $reportPodcast->getUuid()));
}
}
// Track Datadog metrics for automated reports
$automatedReport = $report->getReport();
$this->callbackService->pushToDatadog($automatedReport, $report);
if ($reportPodcast) {
$this->callbackService->pushToDatadog($automatedReport, $reportPodcast);
}
$this->logger->info(self::LOG_PREFIX . ' Triggering Event for UserPilot tracking', [
'report_uuid' => $automatedReport->getUuid(),
'result_uuid' => $reportUuid,
]);
$this->eventDispatcher->dispatch(new AutomatedReportGenerated($automatedReport));
} catch (ModelNotFoundException $exception) {
$this->logger->error(self::LOG_PREFIX . ' Report not found', [
'uuid' => $reportUuid,
'error' => $exception->getMessage(),
]);
return response()->json(['status' => 'error', 'message' => 'Report not found'], status: 404);
} catch (Throwable $exception) {
$this->logger->error(self::LOG_PREFIX . ' Failed to update report status', [
'uuid' => $reportUuid,
'error' => $exception->getMessage(),
]);
return response()->json(['status' => 'error', 'message' => 'Failed to update report status'], status: 500);
}
return response()->json(['status' => 'ok']);
}
}
Project
Project
New File or Directory…
Expand Selected
Collapse All
Options
Hide...
|
PhpStorm
|
faVsco.js – ReportController.php
|
NULL
|
7150
|
|
7151
|
Project: faVsco.js, menu
master, menu
Start Listen Project: faVsco.js, menu
master, 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
<?php
declare(strict_types=1);
namespace Jiminny\Component\Utility\Service;
use Illuminate\Cache\RateLimiter;
use Jiminny\Contracts\Http\RateLimited;
use Jiminny\Contracts\Http\RateLimitInterface;
class ProviderRateLimiter
{
protected RateLimiter $rateLimiter;
public function __construct(RateLimiter $rateLimiter)
{
$this->rateLimiter = $rateLimiter;
}
public function canMakeRequest(RateLimited $provider): bool
{
/** @var RateLimitInterface $rateLimit */
foreach ($provider->getRateLimits() as $rateLimit) {
$key = $rateLimit->getKey();
if ($this->rateLimiter->tooManyAttempts($key, $rateLimit->getQuota())) {
return false;
}
}
return true;
}
public function requestAvailableIn(RateLimited $provider): int
{
return $provider->getRateLimits()->isNotEmpty()
? $provider->getRateLimits()
->map(fn (RateLimitInterface $rateLimit): int => $this->rateLimiter->availableIn($rateLimit->getKey()))
->max()
: 0
;
}
public function incrementRequestCount(RateLimited $provider): void
{
/** @var RateLimitInterface $rateLimit */
foreach ($provider->getRateLimits() as $rateLimit) {
$this->rateLimiter->hit($rateLimit->getKey(), $rateLimit->getWindow());
}
}
}
Code changed:
Hide
Sync Changes
Hide This Notification
<?php
declare(strict_types=1);
namespace Jiminny\Http\Controllers\Webhook;
use Carbon\Carbon;
use Illuminate\Contracts\Bus\Dispatcher as BusDispatcher;
use Illuminate\Contracts\Events\Dispatcher as EventDispatcher;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Jiminny\Events\AutomatedReports\AutomatedReportGenerated;
use Jiminny\Exceptions\ModelNotFoundException;
use Jiminny\Http\Controllers\AbstractController;
use Jiminny\Jobs\AutomatedReports\SendReportJob;
use Jiminny\Models\AutomatedReportResult;
use Jiminny\Services\Kiosk\AutomatedReports\AutomatedReportsCallbackService;
use Jiminny\Services\Kiosk\AutomatedReports\AutomatedReportsService;
use Psr\Log\LoggerInterface;
use Throwable;
class ReportController extends AbstractController
{
/**
* Log prefix for all log messages
*/
private const string LOG_PREFIX = '[Report Ready]';
public function __construct(
private readonly AutomatedReportsService $automatedReportsService,
private readonly BusDispatcher $dispatcher,
private readonly LoggerInterface $logger,
private readonly AutomatedReportsCallbackService $callbackService,
private readonly EventDispatcher $eventDispatcher,
) {
}
public function ready(Request $request): JsonResponse
{
$payload = $request->all();
$now = Carbon::now();
$this->logger->info(self::LOG_PREFIX . ' Webhook received', [
'payload' => $payload,
]);
// validate
$reportUuid = $this->callbackService->getResultUuid($payload);
if (empty($reportUuid)) {
return response()->json(['status' => 'error', 'message' => 'Request ID is empty'], status: 400);
}
try {
$report = $this->automatedReportsService->getReportResult($reportUuid);
// validate
if ($this->callbackService->isProcessed($report)) {
$this->logger->warning(self::LOG_PREFIX . ' Report has been already processed', [
'uuid' => $reportUuid,
'currentStatus' => $report->getStatusLabel(),
]);
return response()->json(['status' => 'already_processed']);
}
// always try to get a child podcast cause report configuration cannot be trusted
$reportPodcast = $this->automatedReportsService->findChildResult(
result: $report,
type: AutomatedReportsService::MEDIA_TYPE_PODCAST
);
// update results
$report->update([
'status' => $this->callbackService->getPrimaryStatus($report, $payload),
'response' => $payload,
'generated_at' => $now,
]);
// if a podcast is set, update it
$reportPodcast?->update([
'status' => $this->callbackService->getPodcastStatus($payload),
'response' => $payload,
'generated_at' => $now,
]);
$this->logger->info(self::LOG_PREFIX . ' Report has been processed', [
'uuid' => $reportUuid,
'child_uuid' => $reportPodcast?->getUuid(),
]);
if (! $this->callbackService->isSuccess($payload)) {
$this->logger->warning(self::LOG_PREFIX . ' Error creating report', $payload);
return response()->json(['status' => 'ok']);
}
// If one-off, send the report immediately, if not leave it for the scheduler (automated-reports:send)
if ($report->getReport()->getFrequency() === AutomatedReportsService::FREQUENCY_ONE_OFF) {
// send the primary report
$this->dispatcher->dispatch(new SendReportJob($reportUuid));
// send the podcast report if it set and generated
if ($reportPodcast && $reportPodcast->getStatus() === AutomatedReportResult::STATUS_GENERATED) {
$this->dispatcher->dispatch(new SendReportJob(reportUuid: $reportPodcast->getUuid()));
}
}
// Track Datadog metrics for automated reports
$automatedReport = $report->getReport();
$this->callbackService->pushToDatadog($automatedReport, $report);
if ($reportPodcast) {
$this->callbackService->pushToDatadog($automatedReport, $reportPodcast);
}
$this->logger->info(self::LOG_PREFIX . ' Triggering Event for UserPilot tracking', [
'report_uuid' => $automatedReport->getUuid(),
'result_uuid' => $reportUuid,
]);
$this->eventDispatcher->dispatch(new AutomatedReportGenerated($automatedReport));
} catch (ModelNotFoundException $exception) {
$this->logger->error(self::LOG_PREFIX . ' Report not found', [
'uuid' => $reportUuid,
'error' => $exception->getMessage(),
]);
return response()->json(['status' => 'error', 'message' => 'Report not found'], status: 404);
} catch (Throwable $exception) {
$this->logger->error(self::LOG_PREFIX . ' Failed to update report status', [
'uuid' => $reportUuid,
'error' => $exception->getMessage(),
]);
return response()->json(['status' => 'error', 'message' => 'Failed to update report status'], status: 500);
}
return response()->json(['status' => 'ok']);
}
}
Project
Project
New File or Directory…
Expand Selected
Collapse All
Options
Hide...
|
PhpStorm
|
faVsco.js – ReportController.php
|
NULL
|
7151
|
|
7152
|
Project: faVsco.js, menu
master, menu
Start Listen Project: faVsco.js, menu
master, 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
<?php
declare(strict_types=1);
namespace Jiminny\Component\Utility\Service;
use Illuminate\Cache\RateLimiter;
use Jiminny\Contracts\Http\RateLimited;
use Jiminny\Contracts\Http\RateLimitInterface;
class ProviderRateLimiter
{
protected RateLimiter $rateLimiter;
public function __construct(RateLimiter $rateLimiter)
{
$this->rateLimiter = $rateLimiter;
}
public function canMakeRequest(RateLimited $provider): bool
{
/** @var RateLimitInterface $rateLimit */
foreach ($provider->getRateLimits() as $rateLimit) {
$key = $rateLimit->getKey();
if ($this->rateLimiter->tooManyAttempts($key, $rateLimit->getQuota())) {
return false;
}
}
return true;
}
public function requestAvailableIn(RateLimited $provider): int
{
return $provider->getRateLimits()->isNotEmpty()
? $provider->getRateLimits()
->map(fn (RateLimitInterface $rateLimit): int => $this->rateLimiter->availableIn($rateLimit->getKey()))
->max()
: 0
;
}
public function incrementRequestCount(RateLimited $provider): void
{
/** @var RateLimitInterface $rateLimit */
foreach ($provider->getRateLimits() as $rateLimit) {
$this->rateLimiter->hit($rateLimit->getKey(), $rateLimit->getWindow());
}
}
}
Code changed:
Hide
Sync Changes
Hide This Notification
<?php
declare(strict_types=1);
namespace Jiminny\Http\Controllers\Webhook;
use Carbon\Carbon;
use Illuminate\Contracts\Bus\Dispatcher as BusDispatcher;
use Illuminate\Contracts\Events\Dispatcher as EventDispatcher;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Jiminny\Events\AutomatedReports\AutomatedReportGenerated;
use Jiminny\Exceptions\ModelNotFoundException;
use Jiminny\Http\Controllers\AbstractController;
use Jiminny\Jobs\AutomatedReports\SendReportJob;
use Jiminny\Models\AutomatedReportResult;
use Jiminny\Services\Kiosk\AutomatedReports\AutomatedReportsCallbackService;
use Jiminny\Services\Kiosk\AutomatedReports\AutomatedReportsService;
use Psr\Log\LoggerInterface;
use Throwable;
class ReportController extends AbstractController
{
/**
* Log prefix for all log messages
*/
private const string LOG_PREFIX = '[Report Ready]';
public function __construct(
private readonly AutomatedReportsService $automatedReportsService,
private readonly BusDispatcher $dispatcher,
private readonly LoggerInterface $logger,
private readonly AutomatedReportsCallbackService $callbackService,
private readonly EventDispatcher $eventDispatcher,
) {
}
public function ready(Request $request): JsonResponse
{
$payload = $request->all();
$now = Carbon::now();
$this->logger->info(self::LOG_PREFIX . ' Webhook received', [
'payload' => $payload,
]);
// validate
$reportUuid = $this->callbackService->getResultUuid($payload);
if (empty($reportUuid)) {
return response()->json(['status' => 'error', 'message' => 'Request ID is empty'], status: 400);
}
try {
$report = $this->automatedReportsService->getReportResult($reportUuid);
// validate
if ($this->callbackService->isProcessed($report)) {
$this->logger->warning(self::LOG_PREFIX . ' Report has been already processed', [
'uuid' => $reportUuid,
'currentStatus' => $report->getStatusLabel(),
]);
return response()->json(['status' => 'already_processed']);
}
// always try to get a child podcast cause report configuration cannot be trusted
$reportPodcast = $this->automatedReportsService->findChildResult(
result: $report,
type: AutomatedReportsService::MEDIA_TYPE_PODCAST
);
// update results
$report->update([
'status' => $this->callbackService->getPrimaryStatus($report, $payload),
'response' => $payload,
'generated_at' => $now,
]);
// if a podcast is set, update it
$reportPodcast?->update([
'status' => $this->callbackService->getPodcastStatus($payload),
'response' => $payload,
'generated_at' => $now,
]);
$this->logger->info(self::LOG_PREFIX . ' Report has been processed', [
'uuid' => $reportUuid,
'child_uuid' => $reportPodcast?->getUuid(),
]);
if (! $this->callbackService->isSuccess($payload)) {
$this->logger->warning(self::LOG_PREFIX . ' Error creating report', $payload);
return response()->json(['status' => 'ok']);
}
// If one-off, send the report immediately, if not leave it for the scheduler (automated-reports:send)
if ($report->getReport()->getFrequency() === AutomatedReportsService::FREQUENCY_ONE_OFF) {
// send the primary report
$this->dispatcher->dispatch(new SendReportJob($reportUuid));
// send the podcast report if it set and generated
if ($reportPodcast && $reportPodcast->getStatus() === AutomatedReportResult::STATUS_GENERATED) {
$this->dispatcher->dispatch(new SendReportJob(reportUuid: $reportPodcast->getUuid()));
}
}
// Track Datadog metrics for automated reports
$automatedReport = $report->getReport();
$this->callbackService->pushToDatadog($automatedReport, $report);
if ($reportPodcast) {
$this->callbackService->pushToDatadog($automatedReport, $reportPodcast);
}
$this->logger->info(self::LOG_PREFIX . ' Triggering Event for UserPilot tracking', [
'report_uuid' => $automatedReport->getUuid(),
'result_uuid' => $reportUuid,
]);
$this->eventDispatcher->dispatch(new AutomatedReportGenerated($automatedReport));
} catch (ModelNotFoundException $exception) {
$this->logger->error(self::LOG_PREFIX . ' Report not found', [
'uuid' => $reportUuid,
'error' => $exception->getMessage(),
]);
return response()->json(['status' => 'error', 'message' => 'Report not found'], status: 404);
} catch (Throwable $exception) {
$this->logger->error(self::LOG_PREFIX . ' Failed to update report status', [
'uuid' => $reportUuid,
'error' => $exception->getMessage(),
]);
return response()->json(['status' => 'error', 'message' => 'Failed to update report status'], status: 500);
}
return response()->json(['status' => 'ok']);
}
}
Project
Project
New File or Directory…
Expand Selected
Collapse All
Options
Hide...
|
PhpStorm
|
faVsco.js – ReportController.php
|
NULL
|
7152
|
|
7153
|
Switch workspaces… (Jiminny Inc) Has new messages
Switch workspaces… (Jiminny Inc) Has new messages
Home
Home
DMs
DMs
Activity
Activity
Files
Files
Later
Later
More…
More
Unreads
Threads
Huddles
Drafts & sent
1
Directories
jiminny-x-integration-app
platform-inner-team
ai-chapter
alerts
backend
bugs
confusion-clinic
curiosity_lab
engineering
general
jiminny-bg
platform-tickets
product_launches
random
releases
sofia-office
support
thank-yous
the_people_of_jiminny
Galya Dimitrova
Aneliya Angelova
,
Nikolay Yankov
,
Steliyan Georgiev
Stoyan Tanev
Stefka Stoyanova
Ves
Aneliya Angelova
Vasil Vasilev
James Graham
Nikolay Ivanov
Lukas Kovalik
you
Toast
Jira Cloud
Messages
Messages
Files
Files
Untitled
Untitled
Add and Edit Channel Tabs
Canvas
List
Folder
Jump to date
Galya Dimitrova
Apr 28th at 1:31:43 PM
1:31 PM
по принцип е добре всеки юзър да си го получава сутринта по долу горе едно и също време всеки ден
Apr 28th at 1:32:13 PM
1:32
в случая на уикенда казуса е че репорта за петък няма да може да се получи в понеделник сутринта ли
Lukas Kovalik
Apr 28th at 1:32:37 PM
1:32 PM
иска ми се да го сменя и да има крон job през час и дас си калкулира според report creator дали да го прати
Galya Dimitrova
Apr 28th at 1:33:18 PM
1:33 PM
искаш ли да се чуем
Lukas Kovalik
Apr 28th at 1:33:22 PM
1:33 PM
ами проблем е че с UTC 01:00 събота е следобяд петък в щати
Apr 28th at 1:33:25 PM
1:33
da
React with white_check_mark
React with eyes
React with raised_hands
Add reaction…
Reply in thread
Forward message…
Save for later
More actions
A huddle happened
Apr 28th at 1:33:32 PM
1:33 PM
You and
Galya Dimitrova
were in the huddle for
23m
.
React with white_check_mark
React with eyes
React with raised_hands
Add reaction…
Reply in thread
Forward message…
Save for later
More actions
Galya Dimitrova
Apr 28th at 6:27:20 PM
6:27 PM
Понеже си отпуска ако трябва и друг може да довърши нещата
React with white_check_mark
React with eyes
iTerm2ShellEditViewSessionScriptsProfilesWindowHelplihl= Support Daily • in 3 h 31 m-zsh100% C8Fri 8 May 11:29:40181DOCKER₴1APP (-zsh)-rw-r--r--1lukasstaff32768lukasstaff3254832drwxr-xr-x8lukasstaffstaff256-rw-r--r--lukas28408-rw-r--r--lukasstaff566164-rw-r--r--lukasstaff81437-rwxr-xr-xlukasstaff14994-rw-r--r--1lukasstaff3167lukas@Lukas-Kovaliks-MacBook-Pro-JiminnyDEV (-zsh)₴828 May09:25db.sqlite-shm8 May11:12db.sqlite-wal6 May20:27pipes6 May21:02screenpipe.2026-05-06.0.10g7 May21:508 May11:12screenpipe.2026-05-07.0.10gscreenpipe.2026-05-08.0.l0g6 May20:26screenpipe_sync.sh7 May09:23sync.log~/.screenpipe $ screenpipe_sync.sh 2026-05-07*3zsh:commandnot found:screenpipe_sync.shlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny~/.screenpipe $ ~/.screenpipe/screenpipe_sync.sh 2026-05-07[2026-05-0811:13:29][2026-05-0811:13:29J[2026-05-0811:13:29JScreenpipesync startingfor: 2026-05-07====-zsh• 84|screenpipe*[+00m00s]PreflightchecksSourceDB:OK(1.0G)[2026-05-0811:13:29JERROR: NAS not mounted at/Volumes/screenpipelukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/.screenpipe $ ~/.screenpipe/screenpipe_sync.sh 2026-05-07[2026-05-0811:13:52]=====[2026-05-08 11:13:52]Screenpipe sync starting for: 2026-05-07[2026-05-08 11:13:52][+00m00s] • Preflight checksSource DB:NAS mount:Archive DB:Data dir:OKOK(1.0G)/Volumes/screenpipeexists( 10G)OK(266 files, 306M)[+00m01s] • Counting source rows for 2026-05-07frames:elements:ui_events:ocr_text:meetings:6262623002741216702[+00m02s] • Initialising tables, indexes, FTScreating tablescreating indexescreating FTS tables• Om00s• Om00s• Om00s[+00m02s] • Syncing data for 2026-05-07video_chunks• 0m01sframes (6262 rows)• Parse error near line 3: table nas.frames has 24 columns but 30 values weresuppliedlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/.screenpipe $ I*= *65-zsh...
|
Slack
|
Galya Dimitrova (DM) - Jiminny Inc - 5 new items - Galya Dimitrova (DM) - Jiminny Inc - 5 new items - Slack...
|
NULL
|
7153
|
|
7154
|
Switch workspaces… (Jiminny Inc) Has new messages
Switch workspaces… (Jiminny Inc) Has new messages
Home
Home
DMs
DMs
Activity
Activity
Files
Files
Later
Later
More…
More
Unreads
Threads
Huddles
Drafts & sent
1
Directories
jiminny-x-integration-app
platform-inner-team
ai-chapter
alerts
backend
bugs
confusion-clinic
curiosity_lab
engineering
general
jiminny-bg
platform-tickets
product_launches
random
releases
sofia-office
support
thank-yous
the_people_of_jiminny
Galya Dimitrova
Aneliya Angelova
,
Nikolay Yankov
,
Steliyan Georgiev
Stoyan Tanev
Stefka Stoyanova
Ves
Aneliya Angelova
Vasil Vasilev
James Graham
Nikolay Ivanov
Lukas Kovalik
you
Toast
Jira Cloud
Messages
Messages
Files
Files
Untitled
Untitled
Add and Edit Channel Tabs
Canvas
List
Folder
Jump to date
Galya Dimitrova
Apr 28th at 1:31:43 PM
1:31 PM
по принцип е добре всеки юзър да си го получава сутринта по долу горе едно и също време всеки ден
Apr 28th at 1:32:13 PM
1:32
в случая на уикенда казуса е че репорта за петък няма да може да се получи в понеделник сутринта ли
Lukas Kovalik
Apr 28th at 1:32:37 PM
1:32 PM
иска ми се да го сменя и да има крон job през час и дас си калкулира според report creator дали да го прати
Galya Dimitrova
Apr 28th at 1:33:18 PM
1:33 PM
искаш ли да се чуем
Lukas Kovalik
Apr 28th at 1:33:22 PM
1:33 PM
ами проблем е че с UTC 01:00 събота е следобяд петък в щати
Apr 28th at 1:33:25 PM
1:33
da
React with white_check_mark
React with eyes
React with raised_hands
Add reaction…
Reply in thread
Forward message…
Save for later
More actions
A huddle happened
Apr 28th at 1:33:32 PM
1:33 PM
You and
Galya Dimitrova
were in the huddle for
23m
.
React with white_check_mark
React with eyes
React with raised_hands
Add reaction…
Reply in thread
Forward message…
Save for later
More actions
Galya Dimitrova
Apr 28th at 6:27:20 PM
6:27 PM
Понеже си отпуска ако трябва и друг може да довърши нещата
React with white_check_mark
React with eyes
React with raised_hands
Add reaction…
Reply in thread
Forward message…
Save for later
More actions
Jump to date
New
Galya Dimitrova
Today at 11:01:42 AM
11:01 AM
Здравей, имам един проблем
React with white_check_mark
React with eyes
React with raised_hands
Add reaction…
Reply in thread
Forward message…
Save for later
More actions
Today at 11:02:07 AM
11:02
за AJ Reports нали в Datadog ги направихме да се логват към същия евент към който и другите и само типа да е различен
React with white_check_mark
React with eyes
React with raised_hands
Add reaction…
Reply in thread
Forward message…
Save for later
More actions
Today at 11:02:51 AM
11:02
обаче така се оказа че се включват във всичките графики където са и другите и развалят числата. Сега не мога да видя колко компании ползват едните и колко другите.
React with white_check_mark
React with eyes
React with raised_hands
Add reaction…
Reply in thread
Forward message…
Save for later
More actions
Today at 11:03:13 AM
11:03
сигурно има някаква сложна формула която да се измисли ама се чудя дали няма да е по добре да ги сложим да се тракват под друг евент
React with white_check_mark
React with eyes
React with raised_hands
Add reaction…
Reply in thread
Forward message…
Save for later
More actions
здрасти, гледам го и ще ти пиша
здрасти, гледам го и ще ти пиша
Shift + Return to add a new line
Shift + Return
to add a new line
Channel
HomeActivityLateMoreSlackcalVIewJiminny... ~# engineering# general# jiminny-bg# platform-tickets# product launches# random# releases# soha-ofhce# support# thank-vous# the people of jimi..ó- Direct messagesGalya Dimitrova(3 Aneliva Angelova.2o Stoyan Tanev8 Stefka StovanovaVesAneliva Angelovaa Vasil Vasilev¿ James Grahame. Nikolay IvanovLukas Kovalik y...:::ADOS& ToastJira Cloudv D Jobsv Activity> M Dialoadv M imnortC) CreateParticinants. 118C) DownloadTrack nhr 119(C) HudrateCrmDataRv 126C) ImnortCall.ohr(C) ImnortFyternalActiv 122C) ImnortTwilioVideoS 123Q IsActivityReadyForf 124C MatchermData nhn 120© UpdateCrmFieldDat 12€© UpdateCustomCrm 127MistonWindowhelpu are looking torGalya DimitrovaMessagese FilesUntitledA huddle happened 1:33 PMYou and Calva Dimitrova were in theGalya Dimitrova 6:27 PMПонеже си отпуска ако трябва и другможе ла ловъоши нешатаGalva Dimitrova # 11:01 AMза A kedorts нали в Datadog гинапоавихме ла се логват кьм съшияевент кьм който и поугите и сэмо типада е различенобаче така се оказа че се включват въввсичките графики кълето са и лоугите ипазвалят числата, Сега не мога ла виляколко компании ползват елните и колко.novrитeсигуоно има някаква сложна формулакоято ла се измисли ама се чуля далиняма ла е по лоове ла ги сложим ла сеtoakвaт пoл novг eвeнтздрасти, гледам го и ще ти пиша+ Aaljectbetach.ong(©) CrmActivityService.phpkeporcontroller.onpxУ спескалакешукеmоteмaten.pnрHubspotPaginationservice.pnpuHubspotclientintenace.phgwOpportunitysyncIrait.onoTImportBatchJobTrait.phpalCalllidJob.pho© ConferenceCrmMatcherJob.phpC) MatchCrmData.php(C) CachedCrmServiceDecorator.php0 Servicelntertace.ohoControllerun V 12jest): JsonResponseruubdoteryetoudul).e->1sSuccessSoavLoad)*(self::LO6_PREFIX • ' Error creating report', $payload):ha'status' => 'ok'):ort immediately, if not leave it for the scheduler (automated-reports:send)getFrequency( === AutomatedReportsService::FREQUENCY_ONE_OFF) {batch(new SendReportJob(SreportUuid)):ort if it set and generatedSreportPodcast->getStatus === AutomatedReportResult::STATUS_GENERATED) <•dispatch(new SendReportJob(reportUuid: SreportPodcast->getUuidO)):or automated reports->getreportonshlobatadoa SautomatedReport, Sreportr>pushToDatadoa(SautomatedReport. SreportPodcast):Sthis->loager->info(self::L0G PREFIX ' Triagerina Event for UserPilot tracking'. ['report uuid' => SautomatedReport->getluid@."resut uuid' => Srenontluid.J):Sthis->eventDisnatchen->disnatch.newAutomatedRenontGenenated/SautomatedRenont0} catch (ModelNotFoundException $exception) {'uuid' => Srenontlluid.'error' => $exception->getMessageO,return response(->ison(['status' => 'error', 'message' => 'Report not found']. status: 404):} catch (Throwable Sexception) &© ProviderRateLimiter.php X = custom.logElaravel.log4 SF jiminny@localhost]A HS_local [jiminny@localhost]tiò accounts fimA console (PROD]# console [eu)A console [STAGING]use...class Providerratelimitenorotected RateLimiter SrateLimiter:public function __construct(RateLimiter $rateLimiter)(...}public function canMakeRequest(RateLimited $provider): bool/** Ovar RateLimitInterface $rateLimit */foreach (Sprovider->getRateLimits as $rateLimit) ‹$key = SrateLimit->getKeyO:if (Sthis->rateLimiter->tooManyAttempts($key, SrateLimit->getQuotaO)) ‹return true:pubLic tunction requestava1lableln kateLimited Sprovider: 1ntreturn Sprovider->getRateLimits@->isNotEmptv@Sproviden->getRateLimits@(RateLimitInterface SrateLimit): int => Sthis->rateLimiter-›availableIn(Sratelnublic function incrementRequestCountRateLimited Sorovider)• void** Avan Ratel imitIntenface Sratelimit *^foreach (Sprovider->getRateLimits as $rateLimit){Sthicesnatmit-snotkovol Cnatol imit-sao+Windowoh•• suppont Dally • In 3n 31m100% 12Fri 8 May 11:29:40CascadeHubspot Rate LimitingHubspot Rate Limit H+0 ..The handler must be idemootent OR ordered to out the failina calll tirste• phppublic function handle(): void {Sresult = ScrmService->matchByName(...):+ Move risky call FIRSNow only runs after successSthis->dispatchAnotherJob():Sthis->finalizeProcessing):This is the real fix — middleware alone doesn't solve duplicate side effectssummany mauiixListener (SyncQueued.lohlSvnc ControllerConcole(queued)Trom controler)Scenario 1: Rawx Wrong delayX HTTр 500Command failsSame as•HTTP 500job, no typedA Can try/catchif addedA Can try/catchSame asA Caller carUesOycxecouonCxceoloScenario 3:Correct delay.V If declaredScenario 2Conc usions1. Scenario 1 & 2 ic moctlv cosmetic for runtime hehavior The retrvAfter comnutation ic wasted without a concumer2. Scenario 2 → 3 is where actual behavior changes - but only for queued contexts2. Middleware is queue-onlv. Controllers console commands and non-queued listeners need exolicit trulcatch hlocks to benefifrom RateLimitException4. Side-effect duplication is orthogonal to all three scenarios. Middleware can actually make it worse by allowing unlimited retriesThe solution is handler idempotency or reorderina so failing calls run first.5. Best architectureClient throws tvned Ratel imitFycention with retrvAfter (Scenario 2's imorovement)• Queued iobs/listeners declare the middleware (Scenario 3 for queue contexts)• Sunchronous callers use exolicit try/catch to handle the tvoed excentionHandiers structure work so that side eftects come after the network call, or are idempotentAsk anvthina (*4L)Claude Onus 4.7 MediumAccent alli111-25UTE.RIfo 4 spaces...
|
Slack
|
Galya Dimitrova (DM) - Jiminny Inc - 5 new items - Galya Dimitrova (DM) - Jiminny Inc - 5 new items - Slack...
|
NULL
|
7154
|
|
7155
|
Switch workspaces… (Jiminny Inc) Has new messages
Switch workspaces… (Jiminny Inc) Has new messages
Home
Home
DMs
DMs
Activity
Activity
Files
Files
Later
Later
More…
More
Unreads
Threads
Huddles
Drafts & sent
1
Directories
jiminny-x-integration-app
platform-inner-team
ai-chapter
alerts
backend
bugs
confusion-clinic
curiosity_lab
engineering
general
jiminny-bg
platform-tickets
product_launches
random
releases
sofia-office
support
thank-yous
the_people_of_jiminny
Galya Dimitrova
Aneliya Angelova
,
Nikolay Yankov
,
Steliyan Georgiev
Stoyan Tanev
Stefka Stoyanova
Ves
Aneliya Angelova
Vasil Vasilev
James Graham
Nikolay Ivanov
Lukas Kovalik
you
Toast
Jira Cloud
Messages
Messages
Files
Files
Untitled
Untitled
Add and Edit Channel Tabs
Canvas
List
Folder
Jump to date
Galya Dimitrova
Apr 28th at 1:31:43 PM
1:31 PM
по принцип е добре всеки юзър да си го получава сутринта по долу горе едно и също време всеки ден
Apr 28th at 1:32:13 PM
1:32
в случая на уикенда казуса е че репорта за петък няма да може да се получи в понеделник сутринта ли
Lukas Kovalik
Apr 28th at 1:32:37 PM
1:32 PM
иска ми се да го сменя и да има крон job през час и дас си калкулира според report creator дали да го прати
Galya Dimitrova
Apr 28th at 1:33:18 PM
1:33 PM
искаш ли да се чуем
Lukas Kovalik
Apr 28th at 1:33:22 PM
1:33 PM
ами проблем е че с UTC 01:00 събота е следобяд петък в щати
Apr 28th at 1:33:25 PM
1:33
da
React with white_check_mark
React with eyes
React with raised_hands
Add reaction…
Reply in thread
Forward message…
Save for later
More actions
A huddle happened
Apr 28th at 1:33:32 PM
1:33 PM
You and
Galya Dimitrova
were in the huddle for
23m
.
React with white_check_mark
React with eyes
React with raised_hands
Add reaction…
Reply in thread
Forward message…
Save for later
More actions
Galya Dimitrova
Apr 28th at 6:27:20 PM
6:27 PM
Понеже си отпуска ако трябва и друг може да довърши нещата
React with white_check_mark
React with eyes
React with raised_hands
Add reaction…
Reply in thread
Forward message…
Save for later
More actions
Jump to date
New
Galya Dimitrova
Today at 11:01:42 AM
11:01 AM
Здравей, имам един проблем
React with white_check_mark
React with eyes
React with raised_hands
Add reaction…
Reply in thread
Forward message…
Save for later
More actions
Today at 11:02:07 AM
11:02
за AJ Reports нали в Datadog ги направихме да се логват към същия евент към който и другите и само типа да е различен
React with white_check_mark
React with eyes
React with raised_hands
Add reaction…
Reply in thread
Forward message…
Save for later
More actions
Today at 11:02:51 AM
11:02
обаче така се оказа че се включват във всичките графики където са и другите и развалят числата. Сега не мога да видя колко компании ползват едните и колко другите.
React with white_check_mark
React with eyes
React with raised_hands
Add reaction…
Reply in thread
Forward message…
Save for later
More actions
Today at 11:03:13 AM
11:03
сигурно има някаква сложна формула която да се измисли ама се чудя дали няма да е по добре да ги сложим да се тракват под друг евент
React with white_check_mark
React with eyes
React with raised_hands
Add reaction…
Reply in thread
Forward message…
Save for later
More actions
здрасти, гледам го и ще ти пиша
здрасти, гледам го и ще ти пиша
Shift + Return to add a new line
Shift + Return
to add a new line
Channel
iTerm2ShellEditViewSessionScriptsProfilesWindowHelp-zshlihl= Support Daily • in 3 h 31 mA100% C8Fri 8 May 11:29:41T81DOCKER₴1APP (-zsh)-rw-r--r--1lukasstaff32768lukasstaff3254832drwxr-xr-x8lukasstaffstaff256-rw-r--r--lukas28408-rw-r--r--lukasstaff566164-rw-r--r--lukasstaff81437-rwxr-xr-xlukasstaff14994-rw-r--r--1lukasstaff3167lukas@Lukas-Kovaliks-MacBook-Pro-JiminnyDEV (-zsh)₴828 May09:25db.sqlite-shm8 May11:12db.sqlite-wal6 May20:27pipes6 May21:02screenpipe.2026-05-06.0.10g7 May21:508 May11:12screenpipe.2026-05-07.0.10gscreenpipe.2026-05-08.0.l0g6 May20:26screenpipe_sync.sh7 May09:23sync.log~/.screenpipe $ screenpipe_sync.sh 2026-05-07*3zsh:commandnot found:screenpipe_sync.shlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny~/.screenpipe $ ~/.screenpipe/screenpipe_sync.sh 2026-05-07[2026-05-0811:13:29][2026-05-0811:13:29J[2026-05-0811:13:29JScreenpipesync startingfor: 2026-05-07====-zsh• 84|screenpipe*[+00m00s]PreflightchecksSourceDB:OK(1.0G)[2026-05-0811:13:29JERROR: NAS not mounted at/Volumes/screenpipelukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/.screenpipe $ ~/.screenpipe/screenpipe_sync.sh 2026-05-07[2026-05-0811:13:52]=====[2026-05-08 11:13:52]Screenpipe sync starting for: 2026-05-07[2026-05-08 11:13:52][+00m00s] • Preflight checksSource DB:NAS mount:Archive DB:Data dir:OKOK(1.0G)/Volumes/screenpipeexists( 10G)OK(266 files, 306M)[+00m01s] • Counting source rows for 2026-05-07frames:elements:ui_events:ocr_text:meetings:6262623002741216702[+00m02s] • Initialising tables, indexes, FTScreating tablescreating indexescreating FTS tables• Om00s• Om00s• Om00s[+00m02s] • Syncing data for 2026-05-07video_chunks• 0m01sframes (6262 rows)• Parse error near line 3: table nas.frames has 24 columns but 30 values weresuppliedlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/.screenpipe $ I-zsh...
|
Slack
|
Galya Dimitrova (DM) - Jiminny Inc - 5 new items - Galya Dimitrova (DM) - Jiminny Inc - 5 new items - Slack...
|
NULL
|
7155
|
|
7156
|
Project: faVsco.js, menu
master, menu
Start Listen Project: faVsco.js, menu
master, 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
<?php
declare(strict_types=1);
namespace Jiminny\Component\Utility\Service;
use Illuminate\Cache\RateLimiter;
use Jiminny\Contracts\Http\RateLimited;
use Jiminny\Contracts\Http\RateLimitInterface;
class ProviderRateLimiter
{
protected RateLimiter $rateLimiter;
public function __construct(RateLimiter $rateLimiter)
{
$this->rateLimiter = $rateLimiter;
}
public function canMakeRequest(RateLimited $provider): bool
{
/** @var RateLimitInterface $rateLimit */
foreach ($provider->getRateLimits() as $rateLimit) {
$key = $rateLimit->getKey();
if ($this->rateLimiter->tooManyAttempts($key, $rateLimit->getQuota())) {
return false;
}
}
return true;
}
public function requestAvailableIn(RateLimited $provider): int
{
return $provider->getRateLimits()->isNotEmpty()
? $provider->getRateLimits()
->map(fn (RateLimitInterface $rateLimit): int => $this->rateLimiter->availableIn($rateLimit->getKey()))
->max()
: 0
;
}
public function incrementRequestCount(RateLimited $provider): void
{
/** @var RateLimitInterface $rateLimit */
foreach ($provider->getRateLimits() as $rateLimit) {
$this->rateLimiter->hit($rateLimit->getKey(), $rateLimit->getWindow());
}
}
}
Code changed:
Hide
Sync Changes
Hide This Notification
<?php
declare(strict_types=1);
namespace Jiminny\Http\Controllers\Webhook;
use Carbon\Carbon;
use Illuminate\Contracts\Bus\Dispatcher as BusDispatcher;
use Illuminate\Contracts\Events\Dispatcher as EventDispatcher;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Jiminny\Events\AutomatedReports\AutomatedReportGenerated;
use Jiminny\Exceptions\ModelNotFoundException;
use Jiminny\Http\Controllers\AbstractController;
use Jiminny\Jobs\AutomatedReports\SendReportJob;
use Jiminny\Models\AutomatedReportResult;
use Jiminny\Services\Kiosk\AutomatedReports\AutomatedReportsCallbackService;
use Jiminny\Services\Kiosk\AutomatedReports\AutomatedReportsService;
use Psr\Log\LoggerInterface;
use Throwable;
class ReportController extends AbstractController
{
/**
* Log prefix for all log messages
*/
private const string LOG_PREFIX = '[Report Ready]';
public function __construct(
private readonly AutomatedReportsService $automatedReportsService,
private readonly BusDispatcher $dispatcher,
private readonly LoggerInterface $logger,
private readonly AutomatedReportsCallbackService $callbackService,
private readonly EventDispatcher $eventDispatcher,
) {
}
public function ready(Request $request): JsonResponse
{
$payload = $request->all();
$now = Carbon::now();
$this->logger->info(self::LOG_PREFIX . ' Webhook received', [
'payload' => $payload,
]);
// validate
$reportUuid = $this->callbackService->getResultUuid($payload);
if (empty($reportUuid)) {
return response()->json(['status' => 'error', 'message' => 'Request ID is empty'], status: 400);
}
try {
$report = $this->automatedReportsService->getReportResult($reportUuid);
// validate
if ($this->callbackService->isProcessed($report)) {
$this->logger->warning(self::LOG_PREFIX . ' Report has been already processed', [
'uuid' => $reportUuid,
'currentStatus' => $report->getStatusLabel(),
]);
return response()->json(['status' => 'already_processed']);
}
// always try to get a child podcast cause report configuration cannot be trusted
$reportPodcast = $this->automatedReportsService->findChildResult(
result: $report,
type: AutomatedReportsService::MEDIA_TYPE_PODCAST
);
// update results
$report->update([
'status' => $this->callbackService->getPrimaryStatus($report, $payload),
'response' => $payload,
'generated_at' => $now,
]);
// if a podcast is set, update it
$reportPodcast?->update([
'status' => $this->callbackService->getPodcastStatus($payload),
'response' => $payload,
'generated_at' => $now,
]);
$this->logger->info(self::LOG_PREFIX . ' Report has been processed', [
'uuid' => $reportUuid,
'child_uuid' => $reportPodcast?->getUuid(),
]);
if (! $this->callbackService->isSuccess($payload)) {
$this->logger->warning(self::LOG_PREFIX . ' Error creating report', $payload);
return response()->json(['status' => 'ok']);
}
// If one-off, send the report immediately, if not leave it for the scheduler (automated-reports:send)
if ($report->getReport()->getFrequency() === AutomatedReportsService::FREQUENCY_ONE_OFF) {
// send the primary report
$this->dispatcher->dispatch(new SendReportJob($reportUuid));
// send the podcast report if it set and generated
if ($reportPodcast && $reportPodcast->getStatus() === AutomatedReportResult::STATUS_GENERATED) {
$this->dispatcher->dispatch(new SendReportJob(reportUuid: $reportPodcast->getUuid()));
}
}
// Track Datadog metrics for automated reports
$automatedReport = $report->getReport();
$this->callbackService->pushToDatadog($automatedReport, $report);
if ($reportPodcast) {
$this->callbackService->pushToDatadog($automatedReport, $reportPodcast);
}
$this->logger->info(self::LOG_PREFIX . ' Triggering Event for UserPilot tracking', [
'report_uuid' => $automatedReport->getUuid(),
'result_uuid' => $reportUuid,
]);
$this->eventDispatcher->dispatch(new AutomatedReportGenerated($automatedReport));
} catch (ModelNotFoundException $exception) {
$this->logger->error(self::LOG_PREFIX . ' Report not found', [
'uuid' => $reportUuid,
'error' => $exception->getMessage(),
]);
return response()->json(['status' => 'error', 'message' => 'Report not found'], status: 404);
} catch (Throwable $exception) {
$this->logger->error(self::LOG_PREFIX . ' Failed to update report status', [
'uuid' => $reportUuid,
'error' => $exception->getMessage(),
]);
return response()->json(['status' => 'error', 'message' => 'Failed to update report status'], status: 500);
}
return response()->json(['status' => 'ok']);
}
}
Project
Project
New File or Directory…
Expand Selected
Collapse All
Options
Hide...
|
PhpStorm
|
faVsco.js – ReportController.php
|
NULL
|
7156
|
|
7157
|
Project: faVsco.js, menu
master, menu
Start Listen Project: faVsco.js, menu
master, 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
<?php
declare(strict_types=1);
namespace Jiminny\Component\Utility\Service;
use Illuminate\Cache\RateLimiter;
use Jiminny\Contracts\Http\RateLimited;
use Jiminny\Contracts\Http\RateLimitInterface;
class ProviderRateLimiter
{
protected RateLimiter $rateLimiter;
public function __construct(RateLimiter $rateLimiter)
{
$this->rateLimiter = $rateLimiter;
}
public function canMakeRequest(RateLimited $provider): bool
{
/** @var RateLimitInterface $rateLimit */
foreach ($provider->getRateLimits() as $rateLimit) {
$key = $rateLimit->getKey();
if ($this->rateLimiter->tooManyAttempts($key, $rateLimit->getQuota())) {
return false;
}
}
return true;
}
public function requestAvailableIn(RateLimited $provider): int
{
return $provider->getRateLimits()->isNotEmpty()
? $provider->getRateLimits()
->map(fn (RateLimitInterface $rateLimit): int => $this->rateLimiter->availableIn($rateLimit->getKey()))
->max()
: 0
;
}
public function incrementRequestCount(RateLimited $provider): void
{
/** @var RateLimitInterface $rateLimit */
foreach ($provider->getRateLimits() as $rateLimit) {
$this->rateLimiter->hit($rateLimit->getKey(), $rateLimit->getWindow());
}
}
}
Code changed:
Hide
Sync Changes
Hide This Notification
<?php
declare(strict_types=1);
namespace Jiminny\Http\Controllers\Webhook;
use Carbon\Carbon;
use Illuminate\Contracts\Bus\Dispatcher as BusDispatcher;
use Illuminate\Contracts\Events\Dispatcher as EventDispatcher;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Jiminny\Events\AutomatedReports\AutomatedReportGenerated;
use Jiminny\Exceptions\ModelNotFoundException;
use Jiminny\Http\Controllers\AbstractController;
use Jiminny\Jobs\AutomatedReports\SendReportJob;
use Jiminny\Models\AutomatedReportResult;
use Jiminny\Services\Kiosk\AutomatedReports\AutomatedReportsCallbackService;
use Jiminny\Services\Kiosk\AutomatedReports\AutomatedReportsService;
use Psr\Log\LoggerInterface;
use Throwable;
class ReportController extends AbstractController
{
/**
* Log prefix for all log messages
*/
private const string LOG_PREFIX = '[Report Ready]';
public function __construct(
private readonly AutomatedReportsService $automatedReportsService,
private readonly BusDispatcher $dispatcher,
private readonly LoggerInterface $logger,
private readonly AutomatedReportsCallbackService $callbackService,
private readonly EventDispatcher $eventDispatcher,
) {
}
public function ready(Request $request): JsonResponse
{
$payload = $request->all();
$now = Carbon::now();
$this->logger->info(self::LOG_PREFIX . ' Webhook received', [
'payload' => $payload,
]);
// validate
$reportUuid = $this->callbackService->getResultUuid($payload);
if (empty($reportUuid)) {
return response()->json(['status' => 'error', 'message' => 'Request ID is empty'], status: 400);
}
try {
$report = $this->automatedReportsService->getReportResult($reportUuid);
// validate
if ($this->callbackService->isProcessed($report)) {
$this->logger->warning(self::LOG_PREFIX . ' Report has been already processed', [
'uuid' => $reportUuid,
'currentStatus' => $report->getStatusLabel(),
]);
return response()->json(['status' => 'already_processed']);
}
// always try to get a child podcast cause report configuration cannot be trusted
$reportPodcast = $this->automatedReportsService->findChildResult(
result: $report,
type: AutomatedReportsService::MEDIA_TYPE_PODCAST
);
// update results
$report->update([
'status' => $this->callbackService->getPrimaryStatus($report, $payload),
'response' => $payload,
'generated_at' => $now,
]);
// if a podcast is set, update it
$reportPodcast?->update([
'status' => $this->callbackService->getPodcastStatus($payload),
'response' => $payload,
'generated_at' => $now,
]);
$this->logger->info(self::LOG_PREFIX . ' Report has been processed', [
'uuid' => $reportUuid,
'child_uuid' => $reportPodcast?->getUuid(),
]);
if (! $this->callbackService->isSuccess($payload)) {
$this->logger->warning(self::LOG_PREFIX . ' Error creating report', $payload);
return response()->json(['status' => 'ok']);
}
// If one-off, send the report immediately, if not leave it for the scheduler (automated-reports:send)
if ($report->getReport()->getFrequency() === AutomatedReportsService::FREQUENCY_ONE_OFF) {
// send the primary report
$this->dispatcher->dispatch(new SendReportJob($reportUuid));
// send the podcast report if it set and generated
if ($reportPodcast && $reportPodcast->getStatus() === AutomatedReportResult::STATUS_GENERATED) {
$this->dispatcher->dispatch(new SendReportJob(reportUuid: $reportPodcast->getUuid()));
}
}
// Track Datadog metrics for automated reports
$automatedReport = $report->getReport();
$this->callbackService->pushToDatadog($automatedReport, $report);
if ($reportPodcast) {
$this->callbackService->pushToDatadog($automatedReport, $reportPodcast);
}
$this->logger->info(self::LOG_PREFIX . ' Triggering Event for UserPilot tracking', [
'report_uuid' => $automatedReport->getUuid(),
'result_uuid' => $reportUuid,
]);
$this->eventDispatcher->dispatch(new AutomatedReportGenerated($automatedReport));
} catch (ModelNotFoundException $exception) {
$this->logger->error(self::LOG_PREFIX . ' Report not found', [
'uuid' => $reportUuid,
'error' => $exception->getMessage(),
]);
return response()->json(['status' => 'error', 'message' => 'Report not found'], status: 404);
} catch (Throwable $exception) {
$this->logger->error(self::LOG_PREFIX . ' Failed to update report status', [
'uuid' => $reportUuid,
'error' => $exception->getMessage(),
]);
return response()->json(['status' => 'error', 'message' => 'Failed to update report status'], status: 500);
}
return response()->json(['status' => 'ok']);
}
}
Project
Project
New File or Directory…
Expand Selected
Collapse All
Options
Hide...
|
PhpStorm
|
faVsco.js – ReportController.php
|
NULL
|
7157
|
|
7158
|
Project: faVsco.js, menu
master, menu
Start Listen Project: faVsco.js, menu
master, 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
<?php
declare(strict_types=1);
namespace Jiminny\Component\Utility\Service;
use Illuminate\Cache\RateLimiter;
use Jiminny\Contracts\Http\RateLimited;
use Jiminny\Contracts\Http\RateLimitInterface;
class ProviderRateLimiter
{
protected RateLimiter $rateLimiter;
public function __construct(RateLimiter $rateLimiter)
{
$this->rateLimiter = $rateLimiter;
}
public function canMakeRequest(RateLimited $provider): bool
{
/** @var RateLimitInterface $rateLimit */
foreach ($provider->getRateLimits() as $rateLimit) {
$key = $rateLimit->getKey();
if ($this->rateLimiter->tooManyAttempts($key, $rateLimit->getQuota())) {
return false;
}
}
return true;
}
public function requestAvailableIn(RateLimited $provider): int
{
return $provider->getRateLimits()->isNotEmpty()
? $provider->getRateLimits()
->map(fn (RateLimitInterface $rateLimit): int => $this->rateLimiter->availableIn($rateLimit->getKey()))
->max()
: 0
;
}
public function incrementRequestCount(RateLimited $provider): void
{
/** @var RateLimitInterface $rateLimit */
foreach ($provider->getRateLimits() as $rateLimit) {
$this->rateLimiter->hit($rateLimit->getKey(), $rateLimit->getWindow());
}
}
}
Code changed:
Hide
Sync Changes
Hide This Notification
<?php
declare(strict_types=1);
namespace Jiminny\Http\Controllers\Webhook;
use Carbon\Carbon;
use Illuminate\Contracts\Bus\Dispatcher as BusDispatcher;
use Illuminate\Contracts\Events\Dispatcher as EventDispatcher;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Jiminny\Events\AutomatedReports\AutomatedReportGenerated;
use Jiminny\Exceptions\ModelNotFoundException;
use Jiminny\Http\Controllers\AbstractController;
use Jiminny\Jobs\AutomatedReports\SendReportJob;
use Jiminny\Models\AutomatedReportResult;
use Jiminny\Services\Kiosk\AutomatedReports\AutomatedReportsCallbackService;
use Jiminny\Services\Kiosk\AutomatedReports\AutomatedReportsService;
use Psr\Log\LoggerInterface;
use Throwable;
class ReportController extends AbstractController
{
/**
* Log prefix for all log messages
*/
private const string LOG_PREFIX = '[Report Ready]';
public function __construct(
private readonly AutomatedReportsService $automatedReportsService,
private readonly BusDispatcher $dispatcher,
private readonly LoggerInterface $logger,
private readonly AutomatedReportsCallbackService $callbackService,
private readonly EventDispatcher $eventDispatcher,
) {
}
public function ready(Request $request): JsonResponse
{
$payload = $request->all();
$now = Carbon::now();
$this->logger->info(self::LOG_PREFIX . ' Webhook received', [
'payload' => $payload,
]);
// validate
$reportUuid = $this->callbackService->getResultUuid($payload);
if (empty($reportUuid)) {
return response()->json(['status' => 'error', 'message' => 'Request ID is empty'], status: 400);
}
try {
$report = $this->automatedReportsService->getReportResult($reportUuid);
// validate
if ($this->callbackService->isProcessed($report)) {
$this->logger->warning(self::LOG_PREFIX . ' Report has been already processed', [
'uuid' => $reportUuid,
'currentStatus' => $report->getStatusLabel(),
]);
return response()->json(['status' => 'already_processed']);
}
// always try to get a child podcast cause report configuration cannot be trusted
$reportPodcast = $this->automatedReportsService->findChildResult(
result: $report,
type: AutomatedReportsService::MEDIA_TYPE_PODCAST
);
// update results
$report->update([
'status' => $this->callbackService->getPrimaryStatus($report, $payload),
'response' => $payload,
'generated_at' => $now,
]);
// if a podcast is set, update it
$reportPodcast?->update([
'status' => $this->callbackService->getPodcastStatus($payload),
'response' => $payload,
'generated_at' => $now,
]);
$this->logger->info(self::LOG_PREFIX . ' Report has been processed', [
'uuid' => $reportUuid,
'child_uuid' => $reportPodcast?->getUuid(),
]);
if (! $this->callbackService->isSuccess($payload)) {
$this->logger->warning(self::LOG_PREFIX . ' Error creating report', $payload);
return response()->json(['status' => 'ok']);
}
// If one-off, send the report immediately, if not leave it for the scheduler (automated-reports:send)
if ($report->getReport()->getFrequency() === AutomatedReportsService::FREQUENCY_ONE_OFF) {
// send the primary report
$this->dispatcher->dispatch(new SendReportJob($reportUuid));
// send the podcast report if it set and generated
if ($reportPodcast && $reportPodcast->getStatus() === AutomatedReportResult::STATUS_GENERATED) {
$this->dispatcher->dispatch(new SendReportJob(reportUuid: $reportPodcast->getUuid()));
}
}
// Track Datadog metrics for automated reports
$automatedReport = $report->getReport();
$this->callbackService->pushToDatadog($automatedReport, $report);
if ($reportPodcast) {
$this->callbackService->pushToDatadog($automatedReport, $reportPodcast);
}
$this->logger->info(self::LOG_PREFIX . ' Triggering Event for UserPilot tracking', [
'report_uuid' => $automatedReport->getUuid(),
'result_uuid' => $reportUuid,
]);
$this->eventDispatcher->dispatch(new AutomatedReportGenerated($automatedReport));
} catch (ModelNotFoundException $exception) {
$this->logger->error(self::LOG_PREFIX . ' Report not found', [
'uuid' => $reportUuid,
'error' => $exception->getMessage(),
]);
return response()->json(['status' => 'error', 'message' => 'Report not found'], status: 404);
} catch (Throwable $exception) {
$this->logger->error(self::LOG_PREFIX . ' Failed to update report status', [
'uuid' => $reportUuid,
'error' => $exception->getMessage(),
]);
return response()->json(['status' => 'error', 'message' => 'Failed to update report status'], status: 500);
}
return response()->json(['status' => 'ok']);
}
}
Project
Project
New File or Directory…
Expand Selected
Collapse All
Options
Hide...
|
PhpStorm
|
faVsco.js – ReportController.php
|
NULL
|
7158
|
|
7159
|
Project: faVsco.js, menu
master, menu
Start Listen Project: faVsco.js, menu
master, 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
<?php
declare(strict_types=1);
namespace Jiminny\Component\Utility\Service;
use Illuminate\Cache\RateLimiter;
use Jiminny\Contracts\Http\RateLimited;
use Jiminny\Contracts\Http\RateLimitInterface;
class ProviderRateLimiter
{
protected RateLimiter $rateLimiter;
public function __construct(RateLimiter $rateLimiter)
{
$this->rateLimiter = $rateLimiter;
}
public function canMakeRequest(RateLimited $provider): bool
{
/** @var RateLimitInterface $rateLimit */
foreach ($provider->getRateLimits() as $rateLimit) {
$key = $rateLimit->getKey();
if ($this->rateLimiter->tooManyAttempts($key, $rateLimit->getQuota())) {
return false;
}
}
return true;
}
public function requestAvailableIn(RateLimited $provider): int
{
return $provider->getRateLimits()->isNotEmpty()
? $provider->getRateLimits()
->map(fn (RateLimitInterface $rateLimit): int => $this->rateLimiter->availableIn($rateLimit->getKey()))
->max()
: 0
;
}
public function incrementRequestCount(RateLimited $provider): void
{
/** @var RateLimitInterface $rateLimit */
foreach ($provider->getRateLimits() as $rateLimit) {
$this->rateLimiter->hit($rateLimit->getKey(), $rateLimit->getWindow());
}
}
}
Code changed:
Hide
Sync Changes
Hide This Notification
<?php
declare(strict_types=1);
namespace Jiminny\Http\Controllers\Webhook;
use Carbon\Carbon;
use Illuminate\Contracts\Bus\Dispatcher as BusDispatcher;
use Illuminate\Contracts\Events\Dispatcher as EventDispatcher;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Jiminny\Events\AutomatedReports\AutomatedReportGenerated;
use Jiminny\Exceptions\ModelNotFoundException;
use Jiminny\Http\Controllers\AbstractController;
use Jiminny\Jobs\AutomatedReports\SendReportJob;
use Jiminny\Models\AutomatedReportResult;
use Jiminny\Services\Kiosk\AutomatedReports\AutomatedReportsCallbackService;
use Jiminny\Services\Kiosk\AutomatedReports\AutomatedReportsService;
use Psr\Log\LoggerInterface;
use Throwable;
class ReportController extends AbstractController
{
/**
* Log prefix for all log messages
*/
private const string LOG_PREFIX = '[Report Ready]';
public function __construct(
private readonly AutomatedReportsService $automatedReportsService,
private readonly BusDispatcher $dispatcher,
private readonly LoggerInterface $logger,
private readonly AutomatedReportsCallbackService $callbackService,
private readonly EventDispatcher $eventDispatcher,
) {
}
public function ready(Request $request): JsonResponse
{
$payload = $request->all();
$now = Carbon::now();
$this->logger->info(self::LOG_PREFIX . ' Webhook received', [
'payload' => $payload,
]);
// validate
$reportUuid = $this->callbackService->getResultUuid($payload);
if (empty($reportUuid)) {
return response()->json(['status' => 'error', 'message' => 'Request ID is empty'], status: 400);
}
try {
$report = $this->automatedReportsService->getReportResult($reportUuid);
// validate
if ($this->callbackService->isProcessed($report)) {
$this->logger->warning(self::LOG_PREFIX . ' Report has been already processed', [
'uuid' => $reportUuid,
'currentStatus' => $report->getStatusLabel(),
]);
return response()->json(['status' => 'already_processed']);
}
// always try to get a child podcast cause report configuration cannot be trusted
$reportPodcast = $this->automatedReportsService->findChildResult(
result: $report,
type: AutomatedReportsService::MEDIA_TYPE_PODCAST
);
// update results
$report->update([
'status' => $this->callbackService->getPrimaryStatus($report, $payload),
'response' => $payload,
'generated_at' => $now,
]);
// if a podcast is set, update it
$reportPodcast?->update([
'status' => $this->callbackService->getPodcastStatus($payload),
'response' => $payload,
'generated_at' => $now,
]);
$this->logger->info(self::LOG_PREFIX . ' Report has been processed', [
'uuid' => $reportUuid,
'child_uuid' => $reportPodcast?->getUuid(),
]);
if (! $this->callbackService->isSuccess($payload)) {
$this->logger->warning(self::LOG_PREFIX . ' Error creating report', $payload);
return response()->json(['status' => 'ok']);
}
// If one-off, send the report immediately, if not leave it for the scheduler (automated-reports:send)
if ($report->getReport()->getFrequency() === AutomatedReportsService::FREQUENCY_ONE_OFF) {
// send the primary report
$this->dispatcher->dispatch(new SendReportJob($reportUuid));
// send the podcast report if it set and generated
if ($reportPodcast && $reportPodcast->getStatus() === AutomatedReportResult::STATUS_GENERATED) {
$this->dispatcher->dispatch(new SendReportJob(reportUuid: $reportPodcast->getUuid()));
}
}
// Track Datadog metrics for automated reports
$automatedReport = $report->getReport();
$this->callbackService->pushToDatadog($automatedReport, $report);
if ($reportPodcast) {
$this->callbackService->pushToDatadog($automatedReport, $reportPodcast);
}
$this->logger->info(self::LOG_PREFIX . ' Triggering Event for UserPilot tracking', [
'report_uuid' => $automatedReport->getUuid(),
'result_uuid' => $reportUuid,
]);
$this->eventDispatcher->dispatch(new AutomatedReportGenerated($automatedReport));
} catch (ModelNotFoundException $exception) {
$this->logger->error(self::LOG_PREFIX . ' Report not found', [
'uuid' => $reportUuid,
'error' => $exception->getMessage(),
]);
return response()->json(['status' => 'error', 'message' => 'Report not found'], status: 404);
} catch (Throwable $exception) {
$this->logger->error(self::LOG_PREFIX . ' Failed to update report status', [
'uuid' => $reportUuid,
'error' => $exception->getMessage(),
]);
return response()->json(['status' => 'error', 'message' => 'Failed to update report status'], status: 500);
}
return response()->json(['status' => 'ok']);
}
}
Project
Project
New File or Directory…
Expand Selected
Collapse All
Options
Hide...
|
PhpStorm
|
faVsco.js – ReportController.php
|
NULL
|
7159
|
|
7160
|
Switch workspaces… (Jiminny Inc) Has new messages
Switch workspaces… (Jiminny Inc) Has new messages
Home
Home
DMs
DMs
Activity
Activity
Files
Files
Later
Later
More…
More
Unreads
Threads
Huddles
Drafts & sent
1
Directories
jiminny-x-integration-app
platform-inner-team
ai-chapter
alerts
backend
bugs
confusion-clinic
curiosity_lab
engineering
general
jiminny-bg
platform-tickets
product_launches
random
releases
sofia-office
support
thank-yous
the_people_of_jiminny
Galya Dimitrova
Aneliya Angelova
,
Nikolay Yankov
,
Steliyan Georgiev
Stoyan Tanev
Stefka Stoyanova
Ves
Aneliya Angelova
Vasil Vasilev
James Graham
Nikolay Ivanov
Lukas Kovalik
you
Toast
Jira Cloud
Messages
Messages
Files
Files
Untitled
Untitled
Add and Edit Channel Tabs
Canvas
List
Folder
Jump to date
Galya Dimitrova
Apr 28th at 1:31:43 PM
1:31 PM
по принцип е добре всеки юзър да си го получава сутринта по долу горе едно и също време всеки ден
Apr 28th at 1:32:13 PM
1:32
в случая на уикенда казуса е че репорта за петък няма да може да се получи в понеделник сутринта ли
Lukas Kovalik
Apr 28th at 1:32:37 PM
1:32 PM
иска ми се да го сменя и да има крон job през час и дас си калкулира според report creator дали да го прати
Galya Dimitrova
Apr 28th at 1:33:18 PM
1:33 PM
искаш ли да се чуем
Lukas Kovalik
Apr 28th at 1:33:22 PM
1:33 PM
ами проблем е че с UTC 01:00 събота е следобяд петък в щати
Apr 28th at 1:33:25 PM
1:33
da
React with white_check_mark
React with eyes
React with raised_hands
Add reaction…
Reply in thread
Forward message…
Save for later
More actions
A huddle happened
Apr 28th at 1:33:32 PM
1:33 PM
You and
Galya Dimitrova
were in the huddle for
23m
.
React with white_check_mark
React with eyes
React with raised_hands
Add reaction…
Reply in thread
Forward message…
Save for later
More actions
Galya Dimitrova
Apr 28th at 6:27:20 PM
6:27 PM
Понеже си отпуска ако трябва и друг може да довърши нещата
React with white_check_mark
React with eyes
React with raised_hands
Add reaction…
Reply in thread
Forward message…
Save for later
More actions
Jump to date
New
Galya Dimitrova
Today at 11:01:42 AM
11:01 AM
Здравей, имам един проблем
React with white_check_mark
React with eyes
React with raised_hands
Add reaction…
Reply in thread
Forward message…
Save for later
More actions
Today at 11:02:07 AM
11:02
за AJ Reports нали в Datadog ги направихме да се логват към същия евент към който и другите и само типа да е различен
React with white_check_mark
React with eyes
React with raised_hands
Add reaction…
Reply in thread
Forward message…
Save for later
More actions
Today at 11:02:51 AM
11:02
обаче така се оказа че се включват във всичките графики където са и другите и развалят числата. Сега не мога да видя колко компании ползват едните и колко другите.
React with white_check_mark
React with eyes
React with raised_hands
Add reaction…
Reply in thread
Forward message…
Save for later
More actions
Today at 11:03:13 AM
11:03
сигурно има някаква сложна формула която да се измисли ама се чудя дали няма да е по добре да ги сложим да се тракват под друг евент
React with white_check_mark
React with eyes
React with raised_hands
Add reaction…
Reply in thread
Forward message…
Save for later
More actions
здрасти, гледам го и ще ти пиша
здрасти, гледам го и ще ти пиша
Shift + Return to add a new line
Shift + Return
to add a new line
Channel
iTerm2ShellEditViewSessionScriptsProfilesWindowHelplahl= Support Daily • in 3 h 30 mA-zsh100% <78Fri 8 May 11:30:17181DOCKER₴1APP (-zsh)-rw-r--r--1lukasstaff32768lukasstaff3254832drwxr-xr-x8lukasstaffstaff256-rw-r--r--lukas28408-rw-r--r--lukasstaff566164-rw-r--r--lukasstaff81437-rwxr-xr-xlukasstaff14994-rw-r--r--1lukasstaff3167lukas@Lukas-Kovaliks-MacBook-Pro-JiminnyDEV (-zsh)₴828 May09:25db.sqlite-shm8 May11:12db.sqlite-wal6 May20:27pipes6 May21:02screenpipe.2026-05-06.0.10g7 May21:508 May11:12screenpipe.2026-05-07.0.10gscreenpipe.2026-05-08.0.l0g6 May20:26screenpipe_sync.sh7 May09:23sync.log~/.screenpipe $ screenpipe_sync.sh 2026-05-07*3zsh:commandnot found:screenpipe_sync.shlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny~/.screenpipe $ ~/.screenpipe/screenpipe_sync.sh 2026-05-07[2026-05-0811:13:29][2026-05-0811:13:29J[2026-05-0811:13:29JScreenpipesync startingfor: 2026-05-07====-zsh• 84|screenpipe*•$5[+00m00s]PreflightchecksSourceDB:OK(1.0G)[2026-05-0811:13:29JERROR: NAS not mounted at/Volumes/screenpipelukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/.screenpipe $ ~/.screenpipe/screenpipe_sync.sh 2026-05-07[2026-05-0811:13:52]=====[2026-05-08 11:13:52]Screenpipe sync starting for: 2026-05-07[2026-05-08 11:13:52][+00m00s] • Preflight checksSource DB:NAS mount:Archive DB:Data dir:OKOK(1.0G)/Volumes/screenpipeexists( 10G)OK(266 files, 306M)[+00m01s] • Counting source rows for 2026-05-07frames:elements:ui_events:ocr_text:meetings:6262623002741216702[+00m02s] • Initialising tables, indexes, FTScreating tablescreating indexescreating FTS tables• Om00s• Om00s• Om00s[+00m02s] • Syncing data for 2026-05-07video_chunks• 0m01sframes (6262 rows)• Parse error near line 3: table nas.frames has 24 columns but 30 values weresuppliedlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/.screenpipe $ I-zsh...
|
Slack
|
Galya Dimitrova (DM) - Jiminny Inc - 5 new items - Galya Dimitrova (DM) - Jiminny Inc - 5 new items - Slack...
|
NULL
|
7160
|
|
7161
|
Switch workspaces… (Jiminny Inc) Has new messages
Switch workspaces… (Jiminny Inc) Has new messages
Home
Home
DMs
DMs
Activity
Activity
Files
Files
Later
Later
More…
More
Unreads
Threads
Huddles
Drafts & sent
1
Directories
jiminny-x-integration-app
platform-inner-team
ai-chapter
alerts
backend
bugs
confusion-clinic
curiosity_lab
engineering
general
jiminny-bg
platform-tickets
product_launches
random
releases
sofia-office
support
thank-yous
the_people_of_jiminny
Galya Dimitrova
Aneliya Angelova
,
Nikolay Yankov
,
Steliyan Georgiev
Stoyan Tanev
Stefka Stoyanova
Ves
Aneliya Angelova
Vasil Vasilev
James Graham
Nikolay Ivanov
Lukas Kovalik
you
Toast
Jira Cloud
Messages
Messages
Files
Files
Untitled
Untitled
Add and Edit Channel Tabs
Canvas
List
Folder
Jump to date
Galya Dimitrova
Apr 28th at 1:31:43 PM
1:31 PM
по принцип е добре всеки юзър да си го получава сутринта по долу горе едно и също време всеки ден
Apr 28th at 1:32:13 PM
1:32
в случая на уикенда казуса е че репорта за петък няма да може да се получи в понеделник сутринта ли
Lukas Kovalik
Apr 28th at 1:32:37 PM
1:32 PM
иска ми се да го сменя и да има крон job през час и дас си калкулира според report creator дали да го прати
Galya Dimitrova
Apr 28th at 1:33:18 PM
1:33 PM
искаш ли да се чуем
Lukas Kovalik
Apr 28th at 1:33:22 PM
1:33 PM
ами проблем е че с UTC 01:00 събота е следобяд петък в щати
Apr 28th at 1:33:25 PM
1:33
da
React with white_check_mark
React with eyes
React with raised_hands
Add reaction…
Reply in thread
Forward message…
Save for later
More actions
A huddle happened
Apr 28th at 1:33:32 PM
1:33 PM
You and
Galya Dimitrova
were in the huddle for
23m
.
React with white_check_mark
React with eyes
React with raised_hands
Add reaction…
Reply in thread
Forward message…
Save for later
More actions
Galya Dimitrova
Apr 28th at 6:27:20 PM
6:27 PM
Понеже си отпуска ако трябва и друг може да довърши нещата
React with white_check_mark
React with eyes
React with raised_hands
Add reaction…
Reply in thread
Forward message…
Save for later
More actions
Jump to date
New
Galya Dimitrova
Today at 11:01:42 AM
11:01 AM
Здравей, имам един проблем
React with white_check_mark
React with eyes
React with raised_hands
Add reaction…
Reply in thread
Forward message…
Save for later
More actions
Today at 11:02:07 AM
11:02
за AJ Reports нали в Datadog ги направихме да се логват към същия евент към който и другите и само типа да е различен
React with white_check_mark
React with eyes
React with raised_hands
Add reaction…
Reply in thread
Forward message…
Save for later
More actions
Today at 11:02:51 AM
11:02
обаче така се оказа че се включват във всичките графики където са и другите и развалят числата. Сега не мога да видя колко компании ползват едните и колко другите.
React with white_check_mark
React with eyes
React with raised_hands
Add reaction…
Reply in thread
Forward message…
Save for later
More actions
Today at 11:03:13 AM
11:03
сигурно има някаква сложна формула която да се измисли ама се чудя дали няма да е по добре да ги сложим да се тракват под друг евент
React with white_check_mark
React with eyes
React with raised_hands
Add reaction…
Reply in thread
Forward message…
Save for later
More actions
здрасти, гледам го и ще ти пиша
здрасти, гледам го и ще ти пиша
Shift + Return to add a new line
Shift + Return
to add a new line
Channel
ActivityLateMoreSlackcalVIewJiminny... ~# engineering# general# jiminny-bg# platform-tickets¿oroduc aunches# random# releases# soha-ofhce# support# thank-vous# the people of jimi..ó- Direct messagesGalya Dimitrova(3 Aneliva Angelova.2o Stoyan Tanev8 Stefka StovanovaVesAneliva Angelovaa Vasil Vasilev¿ James Grahame. Nikolay IvanovLukas Kovalik y...#:Apps& ToastJira Cloudv D Jobsv Activity> M Dialoadv M imnort@ CreateParticinants: 118C) DownloadTrack nhr 119(C) HudrateCrmDataRv.C) ImnortCall.ohrC) ImnortSyternalActiv 124C) ImnortTwilioVideoS 123Q IsActivityReadyForf 124C MatchCrmData nhn 1l23© UpdateCrmFieldDat(e) lindatoCuctomCrmMistonWindowhelpu are looking torGalya DimitrovaMessagese FilesUntitledA huddle happened 1:33 PMYou and Calva Dimitrova were in thenuddle tor 25m.Galya Dimitrova 6:27 PMПонеже си отпуска ако трябва и другможе ла ловъоши нешатаGalva Dimitrova # 11:01 AMЗлравей, имам елин проблемза A kedorts нали в Datadog гинапоавихме ла се логват кьм съшияевент кьм който и поугите и сэмо типада е различенобаче така се оказа че се включват въввсичките графики кълето са и лоугите ипазвалят числата, Сега не мога ла виляколко компании ползват елните и колко.novrитeсигуоно има някаква сложна формулакоято ла се измисли ама се чуля далиняма ла е по лоове ла ги сложим ла сеtoakвaт пол novr eвeнnздрасти, гледам го и ще ти пишах+ Aaljectbetach.ong© ReportController.php X© AutomatedReportsCallbackService.phpC DetachActivityObiect.pnp© RateLimitExc© ResponseException.phpBadkequest.oho© OpportunitySyncTrait.phpC) HydrateCrmDataByExternalCallidJob.php(C) ConterenceCrmMatcherJob.phpateCrmDataResolver.ohpC) CachedCrmServiceDecorator.phoControllerlest): JsonResponsesSuccess(Spayload)) {::LOG_PREFIX . ' Error creating report', $payload):tatus' => 'ok']):immediately, if not leave it for the scheduler (automated-reports:send)equency® === AutomatedReportsService::FREQUENCY ONE OFF) {new sendkeportJobsreportuu1d.nlif it set and generated•tPodcast->getStatus() === AutomatedReportResult::STATUS GENERATED) {atchunew sendrenortJob renortuuld: sreportrodcast->cetuuzd 00):Renortoratadod SautomatedRenont, Srenont):hToDatadog(SautomatedReport, $reportPodcast):Sthis->logger->info(self::LOG_PREFIX . ' Triggering Event for UserPilot tracking', ['report_vuid' => $automatedReport->getUuid,'result vuid' => SreportUuid.Sthis->eventDispatcher->dispatch(new AutomatedReportGenerated(SautomatedReport)):} catch (ModelNotFoundException $exception) {schis-> logger->error selt::LuG PRErlX.• Report not found'. ['uuid' = SreportUuid,'error' => Sexception->getMessage@.return response@->isond'status =>'error'.'message' => 'Report not found']. status: 404):} catch (Throwable Sexception){un V 12© ProviderRateLimiter.php X = custom.logElaravel.log4 SF jiminny@localhost]A HS_local [jiminny@localhost]tiò accounts jiminny(A console (PROD]# console [eu)A console [STAGING]use...class Providerratelimitenorotected RateLimiter SrateLimiter:public function __construct(RateLimiter $rateLimiter)(...}public function canMakeRequest(RateLimited $provider): bool/** Ovar RateLimitInterface $rateLimit */foreach (Sprovider->getRateLimits as $rateLimit) ‹$key = SrateLimit->getKeyO;if (Sthis->rateLimiter->tooManyAttempts($key, SrateLimit->getQuotaO)) {return truepubLic tunction requestava1lableln kateLimited Sprovider: 1ntreturn Sprovider->getRateLimits@->isNotEmptv@Sproviden->getRateLimits@(RateLimitInterface SrateLimit): int => Sthis->rateLimiter->availableIn(Sratelnublic function incrementRequestCountRateLimited Sorovider)• void** Avan Ratel imitIntenface Sratel imit *^foreach (Sprovider->getRateLimits as $rateLimit){Sthic-snatoimit->getKey, SrateLimit->getWindowO):suppont Dally • In sn 30m100% 12Fri 8 May 11:30:17CascadeHubspot Rate LimitingHubSpot Rate Limit H+0 ..The handler must be idemootent OR ordered to out the failina callltirste• phppublic function handle(): void {Sresult = ScrmService->matchByName(...):+ Move risky call FIRSNow only runs after successSthis->dispatchAnotherJob():Sthis->finalizeProcessing):This is the real fix — middleware alone doesn't solve duplicate side effectssummany mauiixListener (SyncQueued.lohlSvnc ControllerConcole(queued)Scenario 1: Rawx Wrong delayX HTTр 500Command failsSame as•HTTP 500job, no typedA Can try/catchif addedA Can try/catchSame asA Caller carUesOycxecouonCxceoloScenario 3:Correct delay.V If declaredScenario 2Conc usions1. Scenario 1 & 2 ic moctlv cosmetic for runtime hehavior The retrvAfter comnutation ic wasted without a concumer2. Scenario 2 → 3 is where actual behavior changes - but only for queued contexts2. Middleware is queue-onlv. Controllers console commands and non-queued listeners need exolicit trulcatch hlocks to benefifrom RateLimitException4. Side-effect duplication is orthogonal to all three scenarios. Middleware can actually make it worse by allowing unlimited retriesThe solution is handler idempotency or reorderina so failing calls run first.5. Best architectureClient throws tvned Ratel imitFycention with retrvAfter (Scenario 2's imorovement)•Queued iobs/listeners declare the middleware (Scenario 3 for queue contexts)• Sunchronous callers use exolicit try/catch to handle the tvoed excentionHandiers structure work so that side eftects come after the network call, or are idempotentAsk anvthina (*4L)Claude Onus 4.7 MediumAccent alliW Windsurf Toams 100-15UTF.Rfo 4 spaces...
|
Slack
|
Galya Dimitrova (DM) - Jiminny Inc - 5 new items - Galya Dimitrova (DM) - Jiminny Inc - 5 new items - Slack...
|
NULL
|
7161
|
|
7162
|
Switch workspaces… (Jiminny Inc) Has new messages
Switch workspaces… (Jiminny Inc) Has new messages
Home
Home
DMs
DMs
Activity
Activity
Files
Files
Later
Later
More…
More
Unreads
Threads
Huddles
Drafts & sent
1
Directories
jiminny-x-integration-app
platform-inner-team
ai-chapter
alerts
backend
bugs
confusion-clinic
curiosity_lab
engineering
general
jiminny-bg
platform-tickets
product_launches
random
releases
sofia-office
support
thank-yous
the_people_of_jiminny
Galya Dimitrova
Aneliya Angelova
,
Nikolay Yankov
,
Steliyan Georgiev
Stoyan Tanev
Stefka Stoyanova
Ves
Aneliya Angelova
Vasil Vasilev
James Graham
Nikolay Ivanov
Lukas Kovalik
you
Toast
Jira Cloud
Messages
Messages
Files
Files
Untitled
Untitled
Add and Edit Channel Tabs
Canvas
List
Folder
Jump to date
Galya Dimitrova
Apr 28th at 1:31:43 PM
1:31 PM
по принцип е добре всеки юзър да си го получава сутринта по долу горе едно и също време всеки ден
Apr 28th at 1:32:13 PM
1:32
в случая на уикенда казуса е че репорта за петък няма да може да се получи в понеделник сутринта ли
Lukas Kovalik
Apr 28th at 1:32:37 PM
1:32 PM
иска ми се да го сменя и да има крон job през час и дас си калкулира според report creator дали да го прати
Galya Dimitrova
Apr 28th at 1:33:18 PM
1:33 PM
искаш ли да се чуем
Lukas Kovalik
Apr 28th at 1:33:22 PM
1:33 PM
ами проблем е че с UTC 01:00 събота е следобяд петък в щати
Apr 28th at 1:33:25 PM
1:33
da
React with white_check_mark
React with eyes
React with raised_hands
Add reaction…
Reply in thread
Forward message…
Save for later
More actions
A huddle happened
Apr 28th at 1:33:32 PM
1:33 PM
You and
Galya Dimitrova
were in the huddle for
23m
.
React with white_check_mark
React with eyes
React with raised_hands
Add reaction…
Reply in thread
Forward message…
Save for later
More actions
Galya Dimitrova
Apr 28th at 6:27:20 PM
6:27 PM
Понеже си отпуска ако трябва и друг може да довърши нещата
React with white_check_mark
React with eyes
React with raised_hands
Add reaction…
Reply in thread
Forward message…
Save for later
More actions
Jump to date
New
Galya Dimitrova
Today at 11:01:42 AM
11:01 AM
Здравей, имам един проблем
React with white_check_mark
React with eyes
React with raised_hands
Add reaction…
Reply in thread
Forward message…
Save for later
More actions
Today at 11:02:07 AM
11:02
за AJ Reports нали в Datadog ги направихме да се логват към същия евент към който и другите и само типа да е различен
React with white_check_mark
React with eyes
React with raised_hands
Add reaction…
Reply in thread
Forward message…
Save for later
More actions
Today at 11:02:51 AM
11:02
обаче така се оказа че се включват във всичките графики където са и другите и развалят числата. Сега не мога да видя колко компании ползват едните и колко другите.
React with white_check_mark
React with eyes
React with raised_hands
Add reaction…
Reply in thread
Forward message…
Save for later
More actions
Today at 11:03:13 AM
11:03
сигурно има някаква сложна формула която да се измисли ама се чудя дали няма да е по добре да ги сложим да се тракват под друг евент
React with white_check_mark
React with eyes
React with raised_hands
Add reaction…
Reply in thread
Forward message…
Save for later
More actions
здрасти, гледам го и ще ти пиша
здрасти, гледам го и ще ти пиша
Shift + Return to add a new line
Shift + Return
to add a new line
Channel
ActivityLateMoreSlackcalVIewJiminny...y# engineering# general# jiminny-bg# platform-tickets# product launches# random# releases# soha-ofhce# support# thank-vous# the people of jimi..ó- Direct messagesGalya Dimitrova(3 Aneliva Angelova.2o Stoyan Tanev8 Stefka StovanovaVesAneliva Angelovaa Vasil Vasilev¿ James Grahame. Nikolay IvanovLukas Kovalik y...#:Apps& ToastJira Cloudv D Jobsv Activity> M Dialoadv M imoort@ CreateParticinants: 118C) DownloadTrack nhr 119(C) HudrateCrmDataRv.C) ImnortCall.ohrC) ImnortSyternalActiv 124C) ImnortTwilioVideoS 123Q IsActivityReadyForf 124C MatchCrmData nhn 1l23© UpdateCrmFieldDat© UpdateCustomCrmMistonWindowhelpu are looking torGalya DimitrovaMessagese FilesUntitledA huddle happened 1:33 PMYou and Calva Dimitrova were in thenuddle tor 25m.Galya Dimitrova 6:27 PMПонеже си отпуска ако трябва и другможе ла ловъоши нешатаGalva Dimitrova # 11:01 AMЗлравей, имам елин проблемза A kedorts нали в Datadog гинапоавихме ла се логват кьм съшияевент кьм който и поугите и сэмо типада е различенобаче така се оказа че се включват въввсичките графики кълето са и лоугите ипазвалят числата, Сега не мога ла виляколко компании ползват елните и колко.novrитeсигуоно има някаква сложна формулакоято ла се измисли ама се чуля далиняма ла е по лоове ла ги сложим ла сеtoakвaт пол novr eвeнnздрасти, гледам го и ще ти пиша ?+ Aaljectbetach.ong© ReportController.php X© AutomatedReportsCallbackService.phpC DetachActivityObiect.pnp© RateLimitExc© ResponseException.phpBadkequest.oho© OpportunitySyncTrait.phpC) HydrateCrmDataByExternalCallidJob.php(C) ConterenceCrmMatcherJob.phpateCrmDataResolver.ohpC) CachedCrmServiceDecorator.phoControllerlest): JsonResponsesSuccess(Spayload)) {::LOG_PREFIX . ' Error creating report', $payload):tatus' => 'ok']):immediately, if not leave it for the scheduler (automated-reports:send)equency® === AutomatedReportsService::FREQUENCY ONE OFF) {new sendkeportJobsreportuu1d.nlif it set and generated•tPodcast->getStatus() === AutomatedReportResult::STATUS GENERATED) {atchunew sendrenortJob renortuuld: sreportrodcast->cetuuzd 00):Renortoratadod SautomatedRenont, Srenont):hToDatadog(SautomatedReport, $reportPodcast):Sthis->logger->info(self::LOG_PREFIX . ' Triggering Event for UserPilot tracking', ['report_vuid' => $automatedReport->getUuid,'result vuid' => SreportUuid.Sthis->eventDispatcher->dispatch(new AutomatedReportGenerated(SautomatedReport)):} catch (ModelNotFoundException $exception) {schis-> logger->error selt::LuG PRErlX.• Report not found'. ['uuid' = SreportUuid,'error' => Sexception->getMessage@.return response@->isond'status' =>'error''message' => 'Report not found']. status: 404):} catch (Throwable Sexception){Sthis->loqger->error(self::LOG_PREFIX . ' Failed to update report status'. [un V 12© ProviderRateLimiter.php X = custom.logElaravel.log4 SF jiminny@localhost]A HS_local [jiminny@localhost]tiò accounts jiminny(A console (PROD]# console [eu)A console [STAGING]use...class Providerratelimitenorotected RateLimiter SrateLimiter:public function __construct(RateLimiter $rateLimiter)(...}public function canMakeRequest(RateLimited $provider): bool/** Ovar RateLimitInterface $rateLimit */foreach (Sprovider->getRateLimits as $rateLimit) ‹$key = SrateLimit->getKeyO;if (Sthis->rateLimiter->tooManyAttempts($key, SrateLimit->getQuotaO)) {return truepubLic tunction requestava1lableln kateLimited Sprovider: 1ntreturn Sprovider->getRateLimits(->isNotEmptv@Sproviden->getRateLimits@(RateLimitInterface SrateLimit): int => Sthis->rateLimiter->availableIn(Sratelnublic function incrementRequestCountRateLimited Sorovider)• void** Avan Ratel imitIntenface Sratel imit *^foreach (Sprovider->getRateLimits as $rateLimit){Sthic-snatoimit->getKey, SrateLimit->getWindowO):suppont Dally • In sn 30m100% 12Fri 8 May 11:30:22CascadeHubspot Rate LimitingHubspot Rate Limit H+0 ..The handler must be idemootent OR ordered to out the failina callltirste• phppublic function handle(): void {Sresult = ScrmService->matchByName(...):+ Move risky call FIRSNow only runs after successSthis->dispatchAnotherJob():Sthis->finalizeProcessing):This is the real fix — middleware alone doesn't solve duplicate side effectssummany mauiixListener (SyncQueued.lohlSvnc ControllerConcole(queued)Trom controler)Scenario 1: Rawx Wrong delayX HTTр 500Command failsSame as•HTTP 500job, no typedA Can try/catchif addedA Can try/catchSame asA Caller carUesOycxecouonCxceoloScenario 3:Correct delay.V If declaredScenario 2Conc usions1. Scenario 1 & 2 ic moctlv cosmetic for runtime hehavior The retrvAfter comnutation ic wasted without a concumer2. Scenario 2 → 3 is where actual behavior changes - but only for queued contexts2. Middleware is queue-onlv. Controllers console commands and non-queued listeners need exolicit trulcatch hlocks to benefifrom RateLimitException4. Side-effect duplication is orthogonal to all three scenarios. Middleware can actually make it worse by allowing unlimited retriesThe solution is handler idempotency or reorderina so failing calls run first.5. Best architectureClient throws tvned Ratel imitFycention with retrvAfter (Scenario 2's imorovement)•Queued iobs/listeners declare the middleware (Scenario 3 for queue contexts)• Sunchronous callers use exolicit try/catch to handle the tvoed excentionHandiers structure work so that side eftects come after the network call, or are idempotentAsk anvthina (&4D)Claude Onus 4.7 MediumAccent alliW Windsurf Toams 100-15UTF.Rfo 4 spaces...
|
Slack
|
Galya Dimitrova (DM) - Jiminny Inc - 5 new items - Galya Dimitrova (DM) - Jiminny Inc - 5 new items - Slack...
|
NULL
|
7162
|
|
7163
|
Project: faVsco.js, menu
master, menu
Start Listen Project: faVsco.js, menu
master, 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
<?php
declare(strict_types=1);
namespace Jiminny\Component\Utility\Service;
use Illuminate\Cache\RateLimiter;
use Jiminny\Contracts\Http\RateLimited;
use Jiminny\Contracts\Http\RateLimitInterface;
class ProviderRateLimiter
{
protected RateLimiter $rateLimiter;
public function __construct(RateLimiter $rateLimiter)
{
$this->rateLimiter = $rateLimiter;
}
public function canMakeRequest(RateLimited $provider): bool
{
/** @var RateLimitInterface $rateLimit */
foreach ($provider->getRateLimits() as $rateLimit) {
$key = $rateLimit->getKey();
if ($this->rateLimiter->tooManyAttempts($key, $rateLimit->getQuota())) {
return false;
}
}
return true;
}
public function requestAvailableIn(RateLimited $provider): int
{
return $provider->getRateLimits()->isNotEmpty()
? $provider->getRateLimits()
->map(fn (RateLimitInterface $rateLimit): int => $this->rateLimiter->availableIn($rateLimit->getKey()))
->max()
: 0
;
}
public function incrementRequestCount(RateLimited $provider): void
{
/** @var RateLimitInterface $rateLimit */
foreach ($provider->getRateLimits() as $rateLimit) {
$this->rateLimiter->hit($rateLimit->getKey(), $rateLimit->getWindow());
}
}
}
Code changed:
Hide
Sync Changes
Hide This Notification
<?php
declare(strict_types=1);
namespace Jiminny\Http\Controllers\Webhook;
use Carbon\Carbon;
use Illuminate\Contracts\Bus\Dispatcher as BusDispatcher;
use Illuminate\Contracts\Events\Dispatcher as EventDispatcher;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Jiminny\Events\AutomatedReports\AutomatedReportGenerated;
use Jiminny\Exceptions\ModelNotFoundException;
use Jiminny\Http\Controllers\AbstractController;
use Jiminny\Jobs\AutomatedReports\SendReportJob;
use Jiminny\Models\AutomatedReportResult;
use Jiminny\Services\Kiosk\AutomatedReports\AutomatedReportsCallbackService;
use Jiminny\Services\Kiosk\AutomatedReports\AutomatedReportsService;
use Psr\Log\LoggerInterface;
use Throwable;
class ReportController extends AbstractController
{
/**
* Log prefix for all log messages
*/
private const string LOG_PREFIX = '[Report Ready]';
public function __construct(
private readonly AutomatedReportsService $automatedReportsService,
private readonly BusDispatcher $dispatcher,
private readonly LoggerInterface $logger,
private readonly AutomatedReportsCallbackService $callbackService,
private readonly EventDispatcher $eventDispatcher,
) {
}
public function ready(Request $request): JsonResponse
{
$payload = $request->all();
$now = Carbon::now();
$this->logger->info(self::LOG_PREFIX . ' Webhook received', [
'payload' => $payload,
]);
// validate
$reportUuid = $this->callbackService->getResultUuid($payload);
if (empty($reportUuid)) {
return response()->json(['status' => 'error', 'message' => 'Request ID is empty'], status: 400);
}
try {
$report = $this->automatedReportsService->getReportResult($reportUuid);
// validate
if ($this->callbackService->isProcessed($report)) {
$this->logger->warning(self::LOG_PREFIX . ' Report has been already processed', [
'uuid' => $reportUuid,
'currentStatus' => $report->getStatusLabel(),
]);
return response()->json(['status' => 'already_processed']);
}
// always try to get a child podcast cause report configuration cannot be trusted
$reportPodcast = $this->automatedReportsService->findChildResult(
result: $report,
type: AutomatedReportsService::MEDIA_TYPE_PODCAST
);
// update results
$report->update([
'status' => $this->callbackService->getPrimaryStatus($report, $payload),
'response' => $payload,
'generated_at' => $now,
]);
// if a podcast is set, update it
$reportPodcast?->update([
'status' => $this->callbackService->getPodcastStatus($payload),
'response' => $payload,
'generated_at' => $now,
]);
$this->logger->info(self::LOG_PREFIX . ' Report has been processed', [
'uuid' => $reportUuid,
'child_uuid' => $reportPodcast?->getUuid(),
]);
if (! $this->callbackService->isSuccess($payload)) {
$this->logger->warning(self::LOG_PREFIX . ' Error creating report', $payload);
return response()->json(['status' => 'ok']);
}
// If one-off, send the report immediately, if not leave it for the scheduler (automated-reports:send)
if ($report->getReport()->getFrequency() === AutomatedReportsService::FREQUENCY_ONE_OFF) {
// send the primary report
$this->dispatcher->dispatch(new SendReportJob($reportUuid));
// send the podcast report if it set and generated
if ($reportPodcast && $reportPodcast->getStatus() === AutomatedReportResult::STATUS_GENERATED) {
$this->dispatcher->dispatch(new SendReportJob(reportUuid: $reportPodcast->getUuid()));
}
}
// Track Datadog metrics for automated reports
$automatedReport = $report->getReport();
$this->callbackService->pushToDatadog($automatedReport, $report);
if ($reportPodcast) {
$this->callbackService->pushToDatadog($automatedReport, $reportPodcast);
}
$this->logger->info(self::LOG_PREFIX . ' Triggering Event for UserPilot tracking', [
'report_uuid' => $automatedReport->getUuid(),
'result_uuid' => $reportUuid,
]);
$this->eventDispatcher->dispatch(new AutomatedReportGenerated($automatedReport));
} catch (ModelNotFoundException $exception) {
$this->logger->error(self::LOG_PREFIX . ' Report not found', [
'uuid' => $reportUuid,
'error' => $exception->getMessage(),
]);
return response()->json(['status' => 'error', 'message' => 'Report not found'], status: 404);
} catch (Throwable $exception) {
$this->logger->error(self::LOG_PREFIX . ' Failed to update report status', [
'uuid' => $reportUuid,
'error' => $exception->getMessage(),
]);
return response()->json(['status' => 'error', 'message' => 'Failed to update report status'], status: 500);
}
return response()->json(['status' => 'ok']);
}
}
Project
Project
New File or Directory…
Expand Selected
Collapse All
Options
Hide...
|
PhpStorm
|
faVsco.js – ReportController.php
|
NULL
|
7163
|
|
7164
|
Project: faVsco.js, menu
master, menu
Start Listen Project: faVsco.js, menu
master, 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
<?php
declare(strict_types=1);
namespace Jiminny\Component\Utility\Service;
use Illuminate\Cache\RateLimiter;
use Jiminny\Contracts\Http\RateLimited;
use Jiminny\Contracts\Http\RateLimitInterface;
class ProviderRateLimiter
{
protected RateLimiter $rateLimiter;
public function __construct(RateLimiter $rateLimiter)
{
$this->rateLimiter = $rateLimiter;
}
public function canMakeRequest(RateLimited $provider): bool
{
/** @var RateLimitInterface $rateLimit */
foreach ($provider->getRateLimits() as $rateLimit) {
$key = $rateLimit->getKey();
if ($this->rateLimiter->tooManyAttempts($key, $rateLimit->getQuota())) {
return false;
}
}
return true;
}
public function requestAvailableIn(RateLimited $provider): int
{
return $provider->getRateLimits()->isNotEmpty()
? $provider->getRateLimits()
->map(fn (RateLimitInterface $rateLimit): int => $this->rateLimiter->availableIn($rateLimit->getKey()))
->max()
: 0
;
}
public function incrementRequestCount(RateLimited $provider): void
{
/** @var RateLimitInterface $rateLimit */
foreach ($provider->getRateLimits() as $rateLimit) {
$this->rateLimiter->hit($rateLimit->getKey(), $rateLimit->getWindow());
}
}
}
Code changed:
Hide
Sync Changes
Hide This Notification
<?php
declare(strict_types=1);
namespace Jiminny\Http\Controllers\Webhook;
use Carbon\Carbon;
use Illuminate\Contracts\Bus\Dispatcher as BusDispatcher;
use Illuminate\Contracts\Events\Dispatcher as EventDispatcher;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Jiminny\Events\AutomatedReports\AutomatedReportGenerated;
use Jiminny\Exceptions\ModelNotFoundException;
use Jiminny\Http\Controllers\AbstractController;
use Jiminny\Jobs\AutomatedReports\SendReportJob;
use Jiminny\Models\AutomatedReportResult;
use Jiminny\Services\Kiosk\AutomatedReports\AutomatedReportsCallbackService;
use Jiminny\Services\Kiosk\AutomatedReports\AutomatedReportsService;
use Psr\Log\LoggerInterface;
use Throwable;
class ReportController extends AbstractController
{
/**
* Log prefix for all log messages
*/
private const string LOG_PREFIX = '[Report Ready]';
public function __construct(
private readonly AutomatedReportsService $automatedReportsService,
private readonly BusDispatcher $dispatcher,
private readonly LoggerInterface $logger,
private readonly AutomatedReportsCallbackService $callbackService,
private readonly EventDispatcher $eventDispatcher,
) {
}
public function ready(Request $request): JsonResponse
{
$payload = $request->all();
$now = Carbon::now();
$this->logger->info(self::LOG_PREFIX . ' Webhook received', [
'payload' => $payload,
]);
// validate
$reportUuid = $this->callbackService->getResultUuid($payload);
if (empty($reportUuid)) {
return response()->json(['status' => 'error', 'message' => 'Request ID is empty'], status: 400);
}
try {
$report = $this->automatedReportsService->getReportResult($reportUuid);
// validate
if ($this->callbackService->isProcessed($report)) {
$this->logger->warning(self::LOG_PREFIX . ' Report has been already processed', [
'uuid' => $reportUuid,
'currentStatus' => $report->getStatusLabel(),
]);
return response()->json(['status' => 'already_processed']);
}
// always try to get a child podcast cause report configuration cannot be trusted
$reportPodcast = $this->automatedReportsService->findChildResult(
result: $report,
type: AutomatedReportsService::MEDIA_TYPE_PODCAST
);
// update results
$report->update([
'status' => $this->callbackService->getPrimaryStatus($report, $payload),
'response' => $payload,
'generated_at' => $now,
]);
// if a podcast is set, update it
$reportPodcast?->update([
'status' => $this->callbackService->getPodcastStatus($payload),
'response' => $payload,
'generated_at' => $now,
]);
$this->logger->info(self::LOG_PREFIX . ' Report has been processed', [
'uuid' => $reportUuid,
'child_uuid' => $reportPodcast?->getUuid(),
]);
if (! $this->callbackService->isSuccess($payload)) {
$this->logger->warning(self::LOG_PREFIX . ' Error creating report', $payload);
return response()->json(['status' => 'ok']);
}
// If one-off, send the report immediately, if not leave it for the scheduler (automated-reports:send)
if ($report->getReport()->getFrequency() === AutomatedReportsService::FREQUENCY_ONE_OFF) {
// send the primary report
$this->dispatcher->dispatch(new SendReportJob($reportUuid));
// send the podcast report if it set and generated
if ($reportPodcast && $reportPodcast->getStatus() === AutomatedReportResult::STATUS_GENERATED) {
$this->dispatcher->dispatch(new SendReportJob(reportUuid: $reportPodcast->getUuid()));
}
}
// Track Datadog metrics for automated reports
$automatedReport = $report->getReport();
$this->callbackService->pushToDatadog($automatedReport, $report);
if ($reportPodcast) {
$this->callbackService->pushToDatadog($automatedReport, $reportPodcast);
}
$this->logger->info(self::LOG_PREFIX . ' Triggering Event for UserPilot tracking', [
'report_uuid' => $automatedReport->getUuid(),
'result_uuid' => $reportUuid,
]);
$this->eventDispatcher->dispatch(new AutomatedReportGenerated($automatedReport));
} catch (ModelNotFoundException $exception) {
$this->logger->error(self::LOG_PREFIX . ' Report not found', [
'uuid' => $reportUuid,
'error' => $exception->getMessage(),
]);
return response()->json(['status' => 'error', 'message' => 'Report not found'], status: 404);
} catch (Throwable $exception) {
$this->logger->error(self::LOG_PREFIX . ' Failed to update report status', [
'uuid' => $reportUuid,
'error' => $exception->getMessage(),
]);
return response()->json(['status' => 'error', 'message' => 'Failed to update report status'], status: 500);
}
return response()->json(['status' => 'ok']);
}
}
Project
Project
New File or Directory…
Expand Selected
Collapse All
Options
Hide...
|
PhpStorm
|
faVsco.js – ReportController.php
|
NULL
|
7164
|
|
7165
|
Switch workspaces… (Jiminny Inc) Has new messages
Switch workspaces… (Jiminny Inc) Has new messages
Home
Home
DMs
DMs
Activity
Activity
Files
Files
Later
Later
More…
More
Unreads
Threads
Huddles
Drafts & sent
1
Directories
jiminny-x-integration-app
platform-inner-team
ai-chapter
alerts
backend
bugs
confusion-clinic
curiosity_lab
engineering
general
jiminny-bg
platform-tickets
product_launches
random
releases
sofia-office
support
thank-yous
the_people_of_jiminny
Galya Dimitrova
Aneliya Angelova
,
Nikolay Yankov
,
Steliyan Georgiev
Stoyan Tanev
Stefka Stoyanova
Ves
Aneliya Angelova
Vasil Vasilev
James Graham
Nikolay Ivanov
Lukas Kovalik
you
Toast
Jira Cloud
Messages
Messages
Files
Files
Untitled
Untitled
Add and Edit Channel Tabs
Canvas
List
Folder
Jump to date
Galya Dimitrova
Apr 28th at 1:31:43 PM
1:31 PM
по принцип е добре всеки юзър да си го получава сутринта по долу горе едно и също време всеки ден
Apr 28th at 1:32:13 PM
1:32
в случая на уикенда казуса е че репорта за петък няма да може да се получи в понеделник сутринта ли
Lukas Kovalik
Apr 28th at 1:32:37 PM
1:32 PM
иска ми се да го сменя и да има крон job през час и дас си калкулира според report creator дали да го прати
Galya Dimitrova
Apr 28th at 1:33:18 PM
1:33 PM
искаш ли да се чуем
Lukas Kovalik
Apr 28th at 1:33:22 PM
1:33 PM
ами проблем е че с UTC 01:00 събота е следобяд петък в щати
Apr 28th at 1:33:25 PM
1:33
da
React with white_check_mark
React with eyes
React with raised_hands
Add reaction…
Reply in thread
Forward message…
Save for later
More actions
A huddle happened
Apr 28th at 1:33:32 PM
1:33 PM
You and
Galya Dimitrova
were in the huddle for
23m
.
React with white_check_mark
React with eyes
React with raised_hands
Add reaction…
Reply in thread
Forward message…
Save for later
More actions
Galya Dimitrova
Apr 28th at 6:27:20 PM
6:27 PM
Понеже си отпуска ако трябва и друг може да довърши нещата
React with white_check_mark
React with eyes
React with raised_hands
Add reaction…
Reply in thread
Forward message…
Save for later
More actions
Jump to date
New
Galya Dimitrova
Today at 11:01:42 AM
11:01 AM
Здравей, имам един проблем
React with white_check_mark
React with eyes
React with raised_hands
Add reaction…
Reply in thread
Forward message…
Save for later
More actions
Today at 11:02:07 AM
11:02
за AJ Reports нали в Datadog ги направихме да се логват към същия евент към който и другите и само типа да е различен
React with white_check_mark
React with eyes
React with raised_hands
Add reaction…
Reply in thread
Forward message…
Save for later
More actions
Today at 11:02:51 AM
11:02
обаче така се оказа че се включват във всичките графики където са и другите и развалят числата. Сега не мога да видя колко компании ползват едните и колко другите.
React with white_check_mark
React with eyes
React with raised_hands
Add reaction…
Reply in thread
Forward message…
Save for later
More actions
Today at 11:03:13 AM
11:03
сигурно има някаква сложна формула която да се измисли ама се чудя дали няма да е по добре да ги сложим да се тракват под друг евент
React with white_check_mark
React with eyes
React with raised_hands
Add reaction…
Reply in thread
Forward message…
Save for later
More actions
здрасти, погледнах го и то си пуска общ
здрасти, погледнах го и то си пуска общ
Shift + Return to add a new line
Shift + Return
to add a new line
Channel
PhostormProledeycodebasecontroller.onpve) calendarcontroller9 ReportController.phAutomatedkeporscallbackservice.onp© OpportunitySyncTrait.phpe commentcontexuinten© ConferencesOptinOut( U ImportBatchJobTrait.phpC)HydrateCrmDataByExternalCallidJob.phg(C) ConterenceCrmMatcherJob.phpc) controller.phpExportController.php© Activity.php(C) DeraultUpdateCrmDataResolver.ohpC) CachedCrmServiceDecorator.phoclass RevortController extends Abstractcontrollerc)Frontendcontroller.oh( FrontendControllerTra@ GeocodinaController.pun V 12@ LiveCoachController.p(C) Missina TeamControlle© MobileController.php© NotificationProviderCoФ) PlavbackController.ph|Yc) Plavlictcontrolier ohn© PusherController.php© SlackController.php8a SunnortController.php(C) ToamSetunController: 1001© UserAutomatedReport 103welcomecontroller.on• @ Middleware(c) Kernel.phpPlavlistTrackResourceTra 11€(t) ValidateCrmConnectionR, 111M integrationsa InteractionsO Jobsv ActivityM Dialoadv M imnort@ CreateParticinants: 118C) DownloadTrack nhr 119(C) HudrateCrmDataRv.C) ImnortCall.ohrC) ImnortSyternalActiv 124C) ImnortTwilioVideoS 123Q IsActivityReadyForf 124C MatchCrmData nhn 1l23(e) lindatoCrmSioldhnt© UpdateCustomCrmarQube for INE suaapublicfunction ready(Request spequeste: JsonResponse,if (! $this->callbackService->isSuccess(Spayload)) {Sthis->logger->warning(self::LOG_PREFIX.' Error creating report', $payload):return response->json(['status' => 'ok']):I/ If one-off, send the report immediately, if not leave it for the scheduler (automated-reports:send)if (Sreport->qetReport©->qetFrequency• === AutomatedReportsService::FREQUENCY_ONE_OFF) {I/send the primary reportSthis->dispatcher->dispatch(new SendReportJob(SreportUuid))// send the podcast report if it set and generatedif (SreportPodcast && SreportPodcast->qetStatus() === AutomatedRenortResult::STATUS GENERATED) {Sthis->dispatcher->dispatch(new SendReportJob(reportUuid: SreportPodcast->qetUuid@)):I/ Track Datadog metrics for automated reportsSautomatedRenort = srenort->oetRenort0iSthis->callbackService->nushToDatadoa(SautomatedRenort Srenort):ifSrenortPodcast)Sthis->callbhackServi.ce->nushToDatadoaSautomatedRenort. SrenortPodcast)•Sthis->logger->info(self::LOG_PREFIX. ' Triggering Event for UserPilot tracking', ['report_uuid' => $automatedReport->getUuid,Inecul+ uuid!=> SnonontlluidSthis->eventDispatcher->dispatch(new AutomatedReportGenerated(SautomatedReport)):} catch (ModelNotFoundException $exception) {Sthis->logger->error(self::L0G_PREFIX.Report not found', ['vuid' => SreportUuid'error' => Sexception->qetMessageO.return response@->ison(['status' => 'error'.'message' => 'Report not found']. status: 404):catch Uhrowable sexcention) <ons: Detect more security issues in your PHP files // Try SonarQube Cloud for free // Download SonarQube Server // Learn more // Don't ask again (yesterday 10:25)© ProviderRateLimiter.php X = custom.logElaravel.log4 SF jiminny@localhost]A HS_local [jiminny@localhost]tiò accounts jiminsA console (PROD]# console [eu)A console [STAGING]use...class Providerratelimitenorotected RateLimiter SrateLimiter:public function __construct(RateLimiter $rateLimiter)(...}public function canMakeRequest(RateLimited $provider): bool/** Ovar RateLimitInterface $rateLimit */foreach (Sprovider->getRateLimits as $rateLimit) ‹$key = SrateLimit->getKeyO:if (Sthis->rateLimiter->tooManyAttempts($key, SrateLimit->getQuotaO)) ‹return true:pubLic tunction requestava1lableln kateLimited Sprovider: 1ntreturn Sprovider->getRateLimits(->isNotEmptv@Sprovider->getRateLimitso(RateLimitInterface SrateLimit): int => Sthis->rateLimiter-›availableIn(Sratelnublic function incrementRequestCountRateLimited Sorovider)• void** Avan Ratel imitIntenface Sratel imit *^foreach (Sprovider->getRateLimits as $rateLimit){Sthicesnat.imit->getKey, SrateLimit->getWindowO):suppont Dally • In sn 30m100% 12Fri 8 May 11:30:46AskJiminnyReportActivityServiceTest vCascadeHubspot Rate LimitingHubSpot Rate Limit H+0 ..The handler must be idemootent OR ordered to out the failina calll tirste• phppublic function handle(): void {Sresult = ScrmService->matchByName(...):+ Move risky call FIRSNow only runs after successSthis->dispatchAnotherJob():Sthis->finalizeProcessing):This is the real fix — middleware alone doesn't solve duplicate side effectssummany mauiixListener (SyncQueued.lohlSvnc ControllerConcole(queued)Trom controler)Scenario 1: Rawx Wrong delayX HTTр 500Command failsSame as•HTTP 500job, no typedA Can try/catchif addedA Can try/catchSame asA Caller carUesOycxecouonCxceoloScenario 3:Correct delay.V If declaredCaught at jobScenario 2Conc usions1. Scenario 1 & 2 ic moctlv cosmetic for runtime hehavior The retrvAfter comnutation ic wasted without a concumer2. Scenario 2 → 3 is where actual behavior changes - but only for queued contexts2. Middleware is queue-onlv. Controllers console commands and non-queued listeners need exolicit trulcatch hlocks to benefifrom RateLimitException4. Side-effect duplication is orthogonal to all three scenarios. Middleware can actually make it worse by allowing unlimited retriesThe solution is handler idempotency or reorderina so failing calls run first.5. Best architectureClient throws tvned Ratel imitFycention with retrvAfter (Scenario 2's imorovement)• Queued iobs/listeners declare the middleware (Scenario 3 for queue contexts)• Sunchronous callers use exolicit try/catch to handle the tvoed excentionHandiers structure work so that side eftects come after the network call, or are idempotentAsk anvthina (&4D)Claude Onus 4.7 MediumAccent alliWN Windsurf Toams 109.52UTF.8io 4 spaces...
|
Slack
|
Galya Dimitrova (DM) - Jiminny Inc - 5 new items - Galya Dimitrova (DM) - Jiminny Inc - 5 new items - Slack...
|
NULL
|
7165
|
|
7166
|
Switch workspaces… (Jiminny Inc) Has new messages
Switch workspaces… (Jiminny Inc) Has new messages
Home
Home
DMs
DMs
Activity
Activity
Files
Files
Later
Later
More…
More
Unreads
Threads
Huddles
Drafts & sent
1
Directories
jiminny-x-integration-app
platform-inner-team
ai-chapter
alerts
backend
bugs
confusion-clinic
curiosity_lab
engineering
general
jiminny-bg
platform-tickets
product_launches
random
releases
sofia-office
support
thank-yous
the_people_of_jiminny
Galya Dimitrova
Aneliya Angelova
,
Nikolay Yankov
,
Steliyan Georgiev
Stoyan Tanev
Stefka Stoyanova
Ves
Aneliya Angelova
Vasil Vasilev
James Graham
Nikolay Ivanov
Lukas Kovalik
you
Toast
Jira Cloud
Messages
Messages
Files
Files
Untitled
Untitled
Add and Edit Channel Tabs
Canvas
List
Folder
Jump to date
Galya Dimitrova
Apr 28th at 1:31:43 PM
1:31 PM
по принцип е добре всеки юзър да си го получава сутринта по долу горе едно и също време всеки ден
Apr 28th at 1:32:13 PM
1:32
в случая на уикенда казуса е че репорта за петък няма да може да се получи в понеделник сутринта ли
Lukas Kovalik
Apr 28th at 1:32:37 PM
1:32 PM
иска ми се да го сменя и да има крон job през час и дас си калкулира според report creator дали да го прати
Galya Dimitrova
Apr 28th at 1:33:18 PM
1:33 PM
искаш ли да се чуем
Lukas Kovalik
Apr 28th at 1:33:22 PM
1:33 PM
ами проблем е че с UTC 01:00 събота е следобяд петък в щати
Apr 28th at 1:33:25 PM
1:33
da
React with white_check_mark
React with eyes
React with raised_hands
Add reaction…
Reply in thread
Forward message…
Save for later
More actions
A huddle happened
Apr 28th at 1:33:32 PM
1:33 PM
You and
Galya Dimitrova
were in the huddle for
23m
.
React with white_check_mark
React with eyes
React with raised_hands
Add reaction…
Reply in thread
Forward message…
Save for later
More actions
Galya Dimitrova
Apr 28th at 6:27:20 PM
6:27 PM
Понеже си отпуска ако трябва и друг може да довърши нещата
React with white_check_mark
React with eyes
React with raised_hands
Add reaction…
Reply in thread
Forward message…
Save for later
More actions
Jump to date
New
Galya Dimitrova
Today at 11:01:42 AM
11:01 AM
Здравей, имам един проблем
React with white_check_mark
React with eyes
React with raised_hands
Add reaction…
Reply in thread
Forward message…
Save for later
More actions
Today at 11:02:07 AM
11:02
за AJ Reports нали в Datadog ги направихме да се логват към същия евент към който и другите и само типа да е различен
React with white_check_mark
React with eyes
React with raised_hands
Add reaction…
Reply in thread
Forward message…
Save for later
More actions
Today at 11:02:51 AM
11:02
обаче така се оказа че се включват във всичките графики където са и другите и развалят числата. Сега не мога да видя колко компании ползват едните и колко другите.
React with white_check_mark
React with eyes
React with raised_hands
Add reaction…
Reply in thread
Forward message…
Save for later
More actions
Today at 11:03:13 AM
11:03
сигурно има някаква сложна формула която да се измисли ама се чудя дали няма да е по добре да ги сложим да се тракват под друг евент
React with white_check_mark
React with eyes
React with raised_hands
Add reaction…
Reply in thread
Forward message…
Save for later
More actions
здрасти, погледнах го и то си пуска общ
здрасти, погледнах го и то си пуска общ
Shift + Return to add a new line
Shift + Return
to add a new line
Channel
iTerm2ShellEditViewSessionScriptsProfilesWindowHelp-zshlahl= Support Daily • in 3 h 30 mA100% <78Fri 8 May 11:30:46181DOCKER₴1APP (-zsh)-rw-r--r--1lukasstaff32768lukasstaff3254832drwxr-xr-x8lukasstaffstaff256-rw-r--r--lukas28408-rw-r--r--lukasstaff566164-rw-r--r--lukasstaff81437-rwxr-xr-xlukasstaff14994-rw-r--r--1lukasstaff3167lukas@Lukas-Kovaliks-MacBook-Pro-JiminnyDEV (-zsh)₴828 May09:25db.sqlite-shm8 May11:12db.sqlite-wal6 May20:27pipes6 May21:02screenpipe.2026-05-06.0.10g7 May21:508 May11:12screenpipe.2026-05-07.0.10gscreenpipe.2026-05-08.0.l0g6 May20:26screenpipe_sync.sh7 May09:23sync.log~/.screenpipe $ screenpipe_sync.sh 2026-05-07*3zsh:commandnot found:screenpipe_sync.shlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny~/.screenpipe $ ~/.screenpipe/screenpipe_sync.sh 2026-05-07[2026-05-0811:13:29][2026-05-0811:13:29J[2026-05-0811:13:29JScreenpipesync startingfor: 2026-05-07====-zsh• 84|screenpipe**- *5[+00m00s]PreflightchecksSourceDB:OK(1.0G)[2026-05-0811:13:29JERROR: NAS not mounted at/Volumes/screenpipelukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/.screenpipe $ ~/.screenpipe/screenpipe_sync.sh 2026-05-07[2026-05-0811:13:52]=====[2026-05-08 11:13:52]Screenpipe sync starting for: 2026-05-07[2026-05-08 11:13:52][+00m00s] • Preflight checksSource DB:NAS mount:Archive DB:Data dir:OKOK(1.0G)/Volumes/screenpipeexists( 10G)OK(266 files, 306M)[+00m01s] • Counting source rows for 2026-05-07frames:elements:ui_events:ocr_text:meetings:6262623002741216702[+00m02s] • Initialising tables, indexes, FTScreating tablescreating indexescreating FTS tables• Om00s• Om00s• Om00s[+00m02s] • Syncing data for 2026-05-07video_chunks• 0m01sframes (6262 rows)• Parse error near line 3: table nas.frames has 24 columns but 30 values weresuppliedlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/.screenpipe $ I-zsh...
|
Slack
|
Galya Dimitrova (DM) - Jiminny Inc - 5 new items - Galya Dimitrova (DM) - Jiminny Inc - 5 new items - Slack...
|
NULL
|
7166
|
|
7167
|
Switch workspaces… (Jiminny Inc) Has new messages
Switch workspaces… (Jiminny Inc) Has new messages
Home
Home
DMs
DMs
Activity
Activity
Files
Files
Later
Later
More…
More
Unreads
Threads
Huddles
Drafts & sent
1
Directories
jiminny-x-integration-app
platform-inner-team
ai-chapter
alerts
backend
bugs
confusion-clinic
curiosity_lab
engineering
general
jiminny-bg
platform-tickets
product_launches
random
releases
sofia-office
support
thank-yous
the_people_of_jiminny
Galya Dimitrova
Aneliya Angelova
,
Nikolay Yankov
,
Steliyan Georgiev
Stoyan Tanev
Stefka Stoyanova
Ves
Aneliya Angelova
Vasil Vasilev
James Graham
Nikolay Ivanov
Lukas Kovalik
you
Toast
Jira Cloud
Messages
Messages
Files
Files
Untitled
Untitled
Add and Edit Channel Tabs
Canvas
List
Folder
Jump to date
Apr 28th at 1:32:13 PM
1:32
в случая на уикенда казуса е че репорта за петък няма да може да се получи в понеделник сутринта ли
Lukas Kovalik
Apr 28th at 1:32:37 PM
1:32 PM
иска ми се да го сменя и да има крон job през час и дас си калкулира според report creator дали да го прати
Galya Dimitrova
Apr 28th at 1:33:18 PM
1:33 PM
искаш ли да се чуем
Lukas Kovalik
Apr 28th at 1:33:22 PM
1:33 PM
ами проблем е че с UTC 01:00 събота е следобяд петък в щати
Apr 28th at 1:33:25 PM
1:33
da
A huddle happened
Apr 28th at 1:33:32 PM
1:33 PM
You and
Galya Dimitrova
were in the huddle for
23m
.
Galya Dimitrova
Apr 28th at 6:27:20 PM
6:27 PM
Понеже си отпуска ако трябва и друг може да довърши нещата
React with white_check_mark
React with eyes
React with raised_hands
Add reaction…
Reply in thread
Forward message…
Save for later
More actions
Jump to date
New
Galya Dimitrova
Today at 11:01:42 AM
11:01 AM
Здравей, имам един проблем
React with white_check_mark
React with eyes
React with raised_hands
Add reaction…
Reply in thread
Forward message…
Save for later
More actions
Today at 11:02:07 AM
11:02
за AJ Reports нали в Datadog ги направихме да се логват към същия евент към който и другите и само типа да е различен
React with white_check_mark
React with eyes
React with raised_hands
Add reaction…
Reply in thread
Forward message…
Save for later
More actions
Today at 11:02:51 AM
11:02
обаче така се оказа че се включват във всичките графики където са и другите и развалят числата. Сега не мога да видя колко компании ползват едните и колко другите.
React with white_check_mark
React with eyes
React with raised_hands
Add reaction…
Reply in thread
Forward message…
Save for later
More actions
Today at 11:03:13 AM
11:03
сигурно има някаква сложна формула която да се измисли ама се чудя дали няма да е по добре да ги сложим да се тракват под друг евент
React with white_check_mark
React with eyes
React with raised_hands
Add reaction…
Reply in thread
Forward message…
Save for later
More actions
Lukas Kovalik
Today at 11:31:05 AM
11:31 AM
здрасти, погледнах го и то си пуска общ event
React with white_check_mark
React with eyes
React with raised_hands
Add reaction…
Reply in thread
Forward message…
Save for later
More actions
ако е
ако е
Shift + Return to add a new line
Shift + Return
to add a new line
Channel
iTerm2ShellEditViewSessionScriptsProfilesWindowHelplahl= Support Daily • in 3 h 29 mБГ-zsh100% C8Fri 8 May 11:31:17T81DOCKER₴1APP (-zsh)-rw-r--r--1lukasstaff32768lukasstaff3254832drwxr-xr-x8lukasstaffstaff256-rw-r--r--lukas28408-rw-r--r--lukasstaff566164-rw-r--r--lukasstaff81437-rwxr-xr-xlukasstaff14994-rw-r--r--1lukasstaff3167lukas@Lukas-Kovaliks-MacBook-Pro-JiminnyDEV (-zsh)₴828 May09:25db.sqlite-shm8 May11:12db.sqlite-wal6 May20:27pipes6 May21:02screenpipe.2026-05-06.0.10g7 May21:508 May11:12screenpipe.2026-05-07.0.10gscreenpipe.2026-05-08.0.l0g6 May20:26screenpipe_sync.sh7 May09:23sync.log~/.screenpipe $ screenpipe_sync.sh 2026-05-07*3zsh:commandnot found:screenpipe_sync.shlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny~/.screenpipe $ ~/.screenpipe/screenpipe_sync.sh 2026-05-07[2026-05-0811:13:29][2026-05-0811:13:29J[2026-05-0811:13:29JScreenpipesync startingfor: 2026-05-07====-zsh• 84screenpipe*•₴5[+00m00s]PreflightchecksSourceDB:OK(1.0G)[2026-05-0811:13:29JERROR: NAS not mounted at/Volumes/screenpipelukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/.screenpipe $ ~/.screenpipe/screenpipe_sync.sh 2026-05-07[2026-05-0811:13:52]=====[2026-05-08 11:13:52]Screenpipe sync starting for: 2026-05-07[2026-05-08 11:13:52][+00m00s] • Preflight checksSource DB:NAS mount:Archive DB:Data dir:OKOK(1.0G)/Volumes/screenpipeexists( 10G)OK(266 files, 306M)[+00m01s] • Counting source rows for 2026-05-07frames:elements:ui_events:ocr_text:meetings:6262623002741216702[+00m02s] • Initialising tables, indexes, FTScreating tablescreating indexescreating FTS tables• Om00s• Om00s• Om00s[+00m02s] • Syncing data for 2026-05-07video_chunks• 0m01sframes (6262 rows)• Parse error near line 3: table nas.frames has 24 columns but 30 values weresuppliedlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/.screenpipe $ I-zsh...
|
Slack
|
Galya Dimitrova (DM) - Jiminny Inc - 5 new items - Galya Dimitrova (DM) - Jiminny Inc - 5 new items - Slack...
|
NULL
|
7167
|
|
7168
|
Switch workspaces… (Jiminny Inc) Has new messages
Switch workspaces… (Jiminny Inc) Has new messages
Home
Home
DMs
DMs
Activity
Activity
Files
Files
Later
Later
More…
More
Unreads
Threads
Huddles
Drafts & sent
1
Directories
jiminny-x-integration-app
platform-inner-team
ai-chapter
alerts
backend
bugs
confusion-clinic
curiosity_lab
engineering
general
jiminny-bg
platform-tickets
product_launches
random
releases
sofia-office
support
thank-yous
the_people_of_jiminny
Galya Dimitrova
Aneliya Angelova
,
Nikolay Yankov
,
Steliyan Georgiev
Stoyan Tanev
Stefka Stoyanova
Ves
Aneliya Angelova
Vasil Vasilev
James Graham
Nikolay Ivanov
Lukas Kovalik
you
Toast
Jira Cloud
Messages
Messages
Files
Files
Untitled
Untitled
Add and Edit Channel Tabs
Canvas
List
Folder
Jump to date
Apr 28th at 1:32:13 PM
1:32
в случая на уикенда казуса е че репорта за петък няма да може да се получи в понеделник сутринта ли
Lukas Kovalik
Apr 28th at 1:32:37 PM
1:32 PM
иска ми се да го сменя и да има крон job през час и дас си калкулира според report creator дали да го прати
Galya Dimitrova
Apr 28th at 1:33:18 PM
1:33 PM
искаш ли да се чуем
Lukas Kovalik
Apr 28th at 1:33:22 PM
1:33 PM
ами проблем е че с UTC 01:00 събота е следобяд петък в щати
Apr 28th at 1:33:25 PM
1:33
da
A huddle happened
Apr 28th at 1:33:32 PM
1:33 PM
You and
Galya Dimitrova
were in the huddle for
23m
.
Galya Dimitrova
Apr 28th at 6:27:20 PM
6:27 PM
Понеже си отпуска ако трябва и друг може да довърши нещата
React with white_check_mark
React with eyes
React with raised_hands
Add reaction…
Reply in thread
Forward message…
Save for later
More actions
Jump to date
New
Galya Dimitrova
Today at 11:01:42 AM
11:01 AM
Здравей, имам един проблем
React with white_check_mark
React with eyes
React with raised_hands
Add reaction…
Reply in thread
Forward message…
Save for later
More actions
Today at 11:02:07 AM
11:02
за AJ Reports нали в Datadog ги направихме да се логват към същия евент към който и другите и само типа да е различен
React with white_check_mark
React with eyes
React with raised_hands
Add reaction…
Reply in thread
Forward message…
Save for later
More actions
Today at 11:02:51 AM
11:02
обаче така се оказа че се включват във всичките графики където са и другите и развалят числата. Сега не мога да видя колко компании ползват едните и колко другите.
React with white_check_mark
React with eyes
React with raised_hands
Add reaction…
Reply in thread
Forward message…
Save for later
More actions
Today at 11:03:13 AM
11:03
сигурно има някаква сложна формула която да се измисли ама се чудя дали няма да е по добре да ги сложим да се тракват под друг евент
React with white_check_mark
React with eyes
React with raised_hands
Add reaction…
Reply in thread
Forward message…
Save for later
More actions
Lukas Kovalik
Today at 11:31:05 AM
11:31 AM
здрасти, погледнах го и то си пуска общ event
React with white_check_mark
React with eyes
React with raised_hands
Add reaction…
Reply in thread
Forward message…
Save for later
More actions
ако е под
ако е под
Shift + Return to add a new line
Shift + Return
to add a new line
Channel
ActivityLateMoreSlackcalVIewMistonJiminny...y# engineering# general# jiminny-bg# platform-tickets# product launches# random# releases# soha-ofhce# support# thank-vous# the people of jimi..ó- Direct messagesGalya Dimitrova(3 Aneliva Angelova.2o Stoyan Tanev8 Stefka StovanovaVesAneliva Angelovaa Vasil Vasilev¿ James Grahame. Nikolay IvanovLukas Kovalik y...:::ADOS& ToastJira Cloudv D Jobsv Activity> M Dialoadv M imnortWindowhelpGalya Dimitrova• Messagese FilesUntitledTuesday. April 28th v in thehuddle tor 23m.Galva Dimitrova 6:27 PMПонеже си отпуска ако тояова и лovпможе ла ловъоши нешатаGallva Dimitrova 11:01 AMЗлравей, имам елин проблем3а Au kevorts нали в Dагаdод ги.направихме ла се логват кьм сьшия.евент кьм който и поугите и само типала е пазличецббаче така се оказа че се включват въввсичките гогфики кълето са и доугите иразвалят числата. Сега не мога да видяколко компании ползват елните и колкоnovrитe,сигуоно има някаква сложна формулакоято ла се измисли ама се цуля лали.няма ла е по лобое ла ги сложим ла сетоакват пол лоуг евентLukas Kovalik 11:31 AMзлрасти, поглелнах го и то си пуска дошіако e no+ Aa €@ CreateParticinants: 118C) DownloadTrack nhr 119(C) HudrateCrmDataRv.C) ImnortCall.ohrC) ImnortSyternalActiv 124C) ImnortTwilioVideoS 123Q IsActivityReadyForf 124C MatchCrmData nhn 1l23© UpdateCrmFieldDat© UpdateCustomCrmjectbetach.ong© ReportController.php X© AutomatedReportsCallbackService.php© RateLimitExc© ResponseException.php© OpportunitySyncTrait.phpC) HydrateCrmDataByExternalCallidJob.php(C) ConterenceCrmMatcherJob.phpateCrmDataResolver.ohpC) CachedCrmServiceDecorator.phoControlLerun V 12pesttu: JsonResponsee->isSuccess(Spayload)) {self::LOG_PREFIX . ' Error creating report', $payload):(['status' => 'ok']):ort immediately, if not leave it for the scheduler (automated-reports:send)etFrequency( === AutomatedReportsService::FREQUENCY_ONE_OFF) {ortatch(new SendReportJob(SreportUuid)):ort 1t 1t set and generatedrenortrodcast-›oetstatuso === Automatedrenortresult::sIAlUS GENERAIEU) *dispatch(new SendReportJob(reportUuid: SreportPodcast->getUuid0)):DaetRenorto:nlobatadoo SautomatedRenont, Srenont):>nushToDatadoa SautomatedRenort. SrenortPodcast)•Sthis->logger->info(self::LOG_PREFIX . ' Triggering Event for UserPilot tracking', ['report_uuid' => $automatedReport->getUuid,Inecul+ uuidi => Cnenontlluid1):Sthis->eventDispatcher->dispatch(new AutomatedReportGenerated(SautomatedReport)):} catch (ModelNotFoundException $exception) {Sthis->logger->error(self::L0G_PREFIX .• Report not found'. ['UU1d = sreporcuu1d'error' => Sexception->getMessage•1):return response@->isond'status" => 'error".'message' => 'Report not found']. status: 404):catch Uhrowable Sexcention) <Sthis->loqger->error(self::LOG_PREFIX . ' Failed to update report status'. [© ProviderRateLimiter.php X = custom.logElaravel.log4 SF jiminny@localhost]A HS_local jiminny@localhost]tiò accounts fimA console (PROD]# console [eu)A console [STAGING]use...class Providerratelimitenorotected RateLimiter SrateLimiter:public function __construct(RateLimiter $rateLimiter)(...}public function canMakeRequest(RateLimited Sprovider): bool/** Ovar RateLimitInterface $rateLimit */foreach (Sprovider->getRateLimits as $rateLimit) ‹$key = SrateLimit->getKeyO;if (Sthis->rateLimiter->tooManyAttempts($key, SrateLimit->getQuotaO)) ‹return true:pubLic tunction requestava1lableln kateLimited Sprovider: 1ntreturn Sprovider->getRateLimits(->isNotEmptv@Sproviden->getRateLimits@(RateLimitInterface SrateLimit): int => Sthis->rateLimiter->availableIn(SrateLnublic function incrementRequestCountRateLimited Sorovider)• void** Avan Ratel imitIntenface Sratel imit *^foreach (Sprovider->getRateLimits as $rateLimit){Sthicesnat.mit-›getKey, $rateLimit->getWindow0):suppont Dally • In sn z5m100% 12Fri 8 May 11:31:19CascadeHubspot Rate LimitingHubSpot Rate Limit H+0 ..The handler must be idemootent OR ordered to out the failina callltirste• phppublic function handle(): void {Sresult = ScrmService->matchByName(...):+ Move risky call FIRSNow only runs after successSthis->dispatchAnotherJob():Sthis->finalizeProcessing):This is the real fix — middleware alone doesn't solve duplicate side effectssummany mauiixListener (SyncQueued.lohlSvnc ControllerConcole(queued)Trom controler)Scenario 1: Rawx Wrong delayX HTTр 500Command failsSame as•HTTP 500job, no typedA Can try/catchif addedA Can try/catchSame asA Caller carUesOycxecouonCxceoloScenario 3:Correct delay.V If declaredScenario 2Conc usions1. Scenario 1 & 2 ic moctlv cosmetic for runtime hehavior The retrvAfter comnutation ic wasted without a concumer2. Scenario 2 → 3 is where actual behavior changes - but only for queued contexts2. Middleware is queue-onlv. Controllers console commands and non-queued listeners need exolicit trulcatch hlocks to benefifrom RateLimitException4. Side-effect duplication is orthogonal to all three scenarios. Middleware can actually make it worse by allowing unlimited retriesThe solution is handler idempotency or reorderina so failing calls run first.5. Best architectureClient throws tvned Ratel imitFycention with retrvAfter (Scenario 2's imorovement)•Queued iobs/listeners declare the middleware (Scenario 3 for queue contexts)• Sunchronous callers use exolicit try/catch to handle the tvoed excentionHandiers structure work so that side eftects come after the network call, or are idempotentAsk anvthina (*4L)Claude Onus 4.7 MediumAccent alliW Windsurf Toams 108.52UTF.Rfo 4 spaces...
|
Slack
|
Galya Dimitrova (DM) - Jiminny Inc - 5 new items - Galya Dimitrova (DM) - Jiminny Inc - 5 new items - Slack...
|
NULL
|
7168
|
|
7169
|
Project: faVsco.js, menu
master, menu
Start Listen Project: faVsco.js, menu
master, 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
<?php
declare(strict_types=1);
namespace Jiminny\Component\Utility\Service;
use Illuminate\Cache\RateLimiter;
use Jiminny\Contracts\Http\RateLimited;
use Jiminny\Contracts\Http\RateLimitInterface;
class ProviderRateLimiter
{
protected RateLimiter $rateLimiter;
public function __construct(RateLimiter $rateLimiter)
{
$this->rateLimiter = $rateLimiter;
}
public function canMakeRequest(RateLimited $provider): bool
{
/** @var RateLimitInterface $rateLimit */
foreach ($provider->getRateLimits() as $rateLimit) {
$key = $rateLimit->getKey();
if ($this->rateLimiter->tooManyAttempts($key, $rateLimit->getQuota())) {
return false;
}
}
return true;
}
public function requestAvailableIn(RateLimited $provider): int
{
return $provider->getRateLimits()->isNotEmpty()
? $provider->getRateLimits()
->map(fn (RateLimitInterface $rateLimit): int => $this->rateLimiter->availableIn($rateLimit->getKey()))
->max()
: 0
;
}
public function incrementRequestCount(RateLimited $provider): void
{
/** @var RateLimitInterface $rateLimit */
foreach ($provider->getRateLimits() as $rateLimit) {
$this->rateLimiter->hit($rateLimit->getKey(), $rateLimit->getWindow());
}
}
}
Code changed:
Hide
Sync Changes
Hide This Notification
<?php
declare(strict_types=1);
namespace Jiminny\Http\Controllers\Webhook;
use Carbon\Carbon;
use Illuminate\Contracts\Bus\Dispatcher as BusDispatcher;
use Illuminate\Contracts\Events\Dispatcher as EventDispatcher;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Jiminny\Events\AutomatedReports\AutomatedReportGenerated;
use Jiminny\Exceptions\ModelNotFoundException;
use Jiminny\Http\Controllers\AbstractController;
use Jiminny\Jobs\AutomatedReports\SendReportJob;
use Jiminny\Models\AutomatedReportResult;
use Jiminny\Services\Kiosk\AutomatedReports\AutomatedReportsCallbackService;
use Jiminny\Services\Kiosk\AutomatedReports\AutomatedReportsService;
use Psr\Log\LoggerInterface;
use Throwable;
class ReportController extends AbstractController
{
/**
* Log prefix for all log messages
*/
private const string LOG_PREFIX = '[Report Ready]';
public function __construct(
private readonly AutomatedReportsService $automatedReportsService,
private readonly BusDispatcher $dispatcher,
private readonly LoggerInterface $logger,
private readonly AutomatedReportsCallbackService $callbackService,
private readonly EventDispatcher $eventDispatcher,
) {
}
public function ready(Request $request): JsonResponse
{
$payload = $request->all();
$now = Carbon::now();
$this->logger->info(self::LOG_PREFIX . ' Webhook received', [
'payload' => $payload,
]);
// validate
$reportUuid = $this->callbackService->getResultUuid($payload);
if (empty($reportUuid)) {
return response()->json(['status' => 'error', 'message' => 'Request ID is empty'], status: 400);
}
try {
$report = $this->automatedReportsService->getReportResult($reportUuid);
// validate
if ($this->callbackService->isProcessed($report)) {
$this->logger->warning(self::LOG_PREFIX . ' Report has been already processed', [
'uuid' => $reportUuid,
'currentStatus' => $report->getStatusLabel(),
]);
return response()->json(['status' => 'already_processed']);
}
// always try to get a child podcast cause report configuration cannot be trusted
$reportPodcast = $this->automatedReportsService->findChildResult(
result: $report,
type: AutomatedReportsService::MEDIA_TYPE_PODCAST
);
// update results
$report->update([
'status' => $this->callbackService->getPrimaryStatus($report, $payload),
'response' => $payload,
'generated_at' => $now,
]);
// if a podcast is set, update it
$reportPodcast?->update([
'status' => $this->callbackService->getPodcastStatus($payload),
'response' => $payload,
'generated_at' => $now,
]);
$this->logger->info(self::LOG_PREFIX . ' Report has been processed', [
'uuid' => $reportUuid,
'child_uuid' => $reportPodcast?->getUuid(),
]);
if (! $this->callbackService->isSuccess($payload)) {
$this->logger->warning(self::LOG_PREFIX . ' Error creating report', $payload);
return response()->json(['status' => 'ok']);
}
// If one-off, send the report immediately, if not leave it for the scheduler (automated-reports:send)
if ($report->getReport()->getFrequency() === AutomatedReportsService::FREQUENCY_ONE_OFF) {
// send the primary report
$this->dispatcher->dispatch(new SendReportJob($reportUuid));
// send the podcast report if it set and generated
if ($reportPodcast && $reportPodcast->getStatus() === AutomatedReportResult::STATUS_GENERATED) {
$this->dispatcher->dispatch(new SendReportJob(reportUuid: $reportPodcast->getUuid()));
}
}
// Track Datadog metrics for automated reports
$automatedReport = $report->getReport();
$this->callbackService->pushToDatadog($automatedReport, $report);
if ($reportPodcast) {
$this->callbackService->pushToDatadog($automatedReport, $reportPodcast);
}
$this->logger->info(self::LOG_PREFIX . ' Triggering Event for UserPilot tracking', [
'report_uuid' => $automatedReport->getUuid(),
'result_uuid' => $reportUuid,
]);
$this->eventDispatcher->dispatch(new AutomatedReportGenerated($automatedReport));
} catch (ModelNotFoundException $exception) {
$this->logger->error(self::LOG_PREFIX . ' Report not found', [
'uuid' => $reportUuid,
'error' => $exception->getMessage(),
]);
return response()->json(['status' => 'error', 'message' => 'Report not found'], status: 404);
} catch (Throwable $exception) {
$this->logger->error(self::LOG_PREFIX . ' Failed to update report status', [
'uuid' => $reportUuid,
'error' => $exception->getMessage(),
]);
return response()->json(['status' => 'error', 'message' => 'Failed to update report status'], status: 500);
}
return response()->json(['status' => 'ok']);
}
}
Project
Project
New File or Directory…
Expand Selected
Collapse All
Options
Hide...
|
PhpStorm
|
faVsco.js – ReportController.php
|
NULL
|
7169
|
|
7170
|
Project: faVsco.js, menu
master, menu
Start Listen Project: faVsco.js, menu
master, 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
<?php
declare(strict_types=1);
namespace Jiminny\Component\Utility\Service;
use Illuminate\Cache\RateLimiter;
use Jiminny\Contracts\Http\RateLimited;
use Jiminny\Contracts\Http\RateLimitInterface;
class ProviderRateLimiter
{
protected RateLimiter $rateLimiter;
public function __construct(RateLimiter $rateLimiter)
{
$this->rateLimiter = $rateLimiter;
}
public function canMakeRequest(RateLimited $provider): bool
{
/** @var RateLimitInterface $rateLimit */
foreach ($provider->getRateLimits() as $rateLimit) {
$key = $rateLimit->getKey();
if ($this->rateLimiter->tooManyAttempts($key, $rateLimit->getQuota())) {
return false;
}
}
return true;
}
public function requestAvailableIn(RateLimited $provider): int
{
return $provider->getRateLimits()->isNotEmpty()
? $provider->getRateLimits()
->map(fn (RateLimitInterface $rateLimit): int => $this->rateLimiter->availableIn($rateLimit->getKey()))
->max()
: 0
;
}
public function incrementRequestCount(RateLimited $provider): void
{
/** @var RateLimitInterface $rateLimit */
foreach ($provider->getRateLimits() as $rateLimit) {
$this->rateLimiter->hit($rateLimit->getKey(), $rateLimit->getWindow());
}
}
}
Code changed:
Hide
Sync Changes
Hide This Notification
<?php
declare(strict_types=1);
namespace Jiminny\Http\Controllers\Webhook;
use Carbon\Carbon;
use Illuminate\Contracts\Bus\Dispatcher as BusDispatcher;
use Illuminate\Contracts\Events\Dispatcher as EventDispatcher;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Jiminny\Events\AutomatedReports\AutomatedReportGenerated;
use Jiminny\Exceptions\ModelNotFoundException;
use Jiminny\Http\Controllers\AbstractController;
use Jiminny\Jobs\AutomatedReports\SendReportJob;
use Jiminny\Models\AutomatedReportResult;
use Jiminny\Services\Kiosk\AutomatedReports\AutomatedReportsCallbackService;
use Jiminny\Services\Kiosk\AutomatedReports\AutomatedReportsService;
use Psr\Log\LoggerInterface;
use Throwable;
class ReportController extends AbstractController
{
/**
* Log prefix for all log messages
*/
private const string LOG_PREFIX = '[Report Ready]';
public function __construct(
private readonly AutomatedReportsService $automatedReportsService,
private readonly BusDispatcher $dispatcher,
private readonly LoggerInterface $logger,
private readonly AutomatedReportsCallbackService $callbackService,
private readonly EventDispatcher $eventDispatcher,
) {
}
public function ready(Request $request): JsonResponse
{
$payload = $request->all();
$now = Carbon::now();
$this->logger->info(self::LOG_PREFIX . ' Webhook received', [
'payload' => $payload,
]);
// validate
$reportUuid = $this->callbackService->getResultUuid($payload);
if (empty($reportUuid)) {
return response()->json(['status' => 'error', 'message' => 'Request ID is empty'], status: 400);
}
try {
$report = $this->automatedReportsService->getReportResult($reportUuid);
// validate
if ($this->callbackService->isProcessed($report)) {
$this->logger->warning(self::LOG_PREFIX . ' Report has been already processed', [
'uuid' => $reportUuid,
'currentStatus' => $report->getStatusLabel(),
]);
return response()->json(['status' => 'already_processed']);
}
// always try to get a child podcast cause report configuration cannot be trusted
$reportPodcast = $this->automatedReportsService->findChildResult(
result: $report,
type: AutomatedReportsService::MEDIA_TYPE_PODCAST
);
// update results
$report->update([
'status' => $this->callbackService->getPrimaryStatus($report, $payload),
'response' => $payload,
'generated_at' => $now,
]);
// if a podcast is set, update it
$reportPodcast?->update([
'status' => $this->callbackService->getPodcastStatus($payload),
'response' => $payload,
'generated_at' => $now,
]);
$this->logger->info(self::LOG_PREFIX . ' Report has been processed', [
'uuid' => $reportUuid,
'child_uuid' => $reportPodcast?->getUuid(),
]);
if (! $this->callbackService->isSuccess($payload)) {
$this->logger->warning(self::LOG_PREFIX . ' Error creating report', $payload);
return response()->json(['status' => 'ok']);
}
// If one-off, send the report immediately, if not leave it for the scheduler (automated-reports:send)
if ($report->getReport()->getFrequency() === AutomatedReportsService::FREQUENCY_ONE_OFF) {
// send the primary report
$this->dispatcher->dispatch(new SendReportJob($reportUuid));
// send the podcast report if it set and generated
if ($reportPodcast && $reportPodcast->getStatus() === AutomatedReportResult::STATUS_GENERATED) {
$this->dispatcher->dispatch(new SendReportJob(reportUuid: $reportPodcast->getUuid()));
}
}
// Track Datadog metrics for automated reports
$automatedReport = $report->getReport();
$this->callbackService->pushToDatadog($automatedReport, $report);
if ($reportPodcast) {
$this->callbackService->pushToDatadog($automatedReport, $reportPodcast);
}
$this->logger->info(self::LOG_PREFIX . ' Triggering Event for UserPilot tracking', [
'report_uuid' => $automatedReport->getUuid(),
'result_uuid' => $reportUuid,
]);
$this->eventDispatcher->dispatch(new AutomatedReportGenerated($automatedReport));
} catch (ModelNotFoundException $exception) {
$this->logger->error(self::LOG_PREFIX . ' Report not found', [
'uuid' => $reportUuid,
'error' => $exception->getMessage(),
]);
return response()->json(['status' => 'error', 'message' => 'Report not found'], status: 404);
} catch (Throwable $exception) {
$this->logger->error(self::LOG_PREFIX . ' Failed to update report status', [
'uuid' => $reportUuid,
'error' => $exception->getMessage(),
]);
return response()->json(['status' => 'error', 'message' => 'Failed to update report status'], status: 500);
}
return response()->json(['status' => 'ok']);
}
}
Project
Project
New File or Directory…
Expand Selected
Collapse All
Options
Hide...
|
PhpStorm
|
faVsco.js – ReportController.php
|
NULL
|
7170
|
|
7171
|
Project: faVsco.js, menu
master, menu
Start Listen Project: faVsco.js, menu
master, 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
<?php
declare(strict_types=1);
namespace Jiminny\Component\Utility\Service;
use Illuminate\Cache\RateLimiter;
use Jiminny\Contracts\Http\RateLimited;
use Jiminny\Contracts\Http\RateLimitInterface;
class ProviderRateLimiter
{
protected RateLimiter $rateLimiter;
public function __construct(RateLimiter $rateLimiter)
{
$this->rateLimiter = $rateLimiter;
}
public function canMakeRequest(RateLimited $provider): bool
{
/** @var RateLimitInterface $rateLimit */
foreach ($provider->getRateLimits() as $rateLimit) {
$key = $rateLimit->getKey();
if ($this->rateLimiter->tooManyAttempts($key, $rateLimit->getQuota())) {
return false;
}
}
return true;
}
public function requestAvailableIn(RateLimited $provider): int
{
return $provider->getRateLimits()->isNotEmpty()
? $provider->getRateLimits()
->map(fn (RateLimitInterface $rateLimit): int => $this->rateLimiter->availableIn($rateLimit->getKey()))
->max()
: 0
;
}
public function incrementRequestCount(RateLimited $provider): void
{
/** @var RateLimitInterface $rateLimit */
foreach ($provider->getRateLimits() as $rateLimit) {
$this->rateLimiter->hit($rateLimit->getKey(), $rateLimit->getWindow());
}
}
}
Code changed:
Hide
Sync Changes
Hide This Notification
<?php
declare(strict_types=1);
namespace Jiminny\Http\Controllers\Webhook;
use Carbon\Carbon;
use Illuminate\Contracts\Bus\Dispatcher as BusDispatcher;
use Illuminate\Contracts\Events\Dispatcher as EventDispatcher;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Jiminny\Events\AutomatedReports\AutomatedReportGenerated;
use Jiminny\Exceptions\ModelNotFoundException;
use Jiminny\Http\Controllers\AbstractController;
use Jiminny\Jobs\AutomatedReports\SendReportJob;
use Jiminny\Models\AutomatedReportResult;
use Jiminny\Services\Kiosk\AutomatedReports\AutomatedReportsCallbackService;
use Jiminny\Services\Kiosk\AutomatedReports\AutomatedReportsService;
use Psr\Log\LoggerInterface;
use Throwable;
class ReportController extends AbstractController
{
/**
* Log prefix for all log messages
*/
private const string LOG_PREFIX = '[Report Ready]';
public function __construct(
private readonly AutomatedReportsService $automatedReportsService,
private readonly BusDispatcher $dispatcher,
private readonly LoggerInterface $logger,
private readonly AutomatedReportsCallbackService $callbackService,
private readonly EventDispatcher $eventDispatcher,
) {
}
public function ready(Request $request): JsonResponse
{
$payload = $request->all();
$now = Carbon::now();
$this->logger->info(self::LOG_PREFIX . ' Webhook received', [
'payload' => $payload,
]);
// validate
$reportUuid = $this->callbackService->getResultUuid($payload);
if (empty($reportUuid)) {
return response()->json(['status' => 'error', 'message' => 'Request ID is empty'], status: 400);
}
try {
$report = $this->automatedReportsService->getReportResult($reportUuid);
// validate
if ($this->callbackService->isProcessed($report)) {
$this->logger->warning(self::LOG_PREFIX . ' Report has been already processed', [
'uuid' => $reportUuid,
'currentStatus' => $report->getStatusLabel(),
]);
return response()->json(['status' => 'already_processed']);
}
// always try to get a child podcast cause report configuration cannot be trusted
$reportPodcast = $this->automatedReportsService->findChildResult(
result: $report,
type: AutomatedReportsService::MEDIA_TYPE_PODCAST
);
// update results
$report->update([
'status' => $this->callbackService->getPrimaryStatus($report, $payload),
'response' => $payload,
'generated_at' => $now,
]);
// if a podcast is set, update it
$reportPodcast?->update([
'status' => $this->callbackService->getPodcastStatus($payload),
'response' => $payload,
'generated_at' => $now,
]);
$this->logger->info(self::LOG_PREFIX . ' Report has been processed', [
'uuid' => $reportUuid,
'child_uuid' => $reportPodcast?->getUuid(),
]);
if (! $this->callbackService->isSuccess($payload)) {
$this->logger->warning(self::LOG_PREFIX . ' Error creating report', $payload);
return response()->json(['status' => 'ok']);
}
// If one-off, send the report immediately, if not leave it for the scheduler (automated-reports:send)
if ($report->getReport()->getFrequency() === AutomatedReportsService::FREQUENCY_ONE_OFF) {
// send the primary report
$this->dispatcher->dispatch(new SendReportJob($reportUuid));
// send the podcast report if it set and generated
if ($reportPodcast && $reportPodcast->getStatus() === AutomatedReportResult::STATUS_GENERATED) {
$this->dispatcher->dispatch(new SendReportJob(reportUuid: $reportPodcast->getUuid()));
}
}
// Track Datadog metrics for automated reports
$automatedReport = $report->getReport();
$this->callbackService->pushToDatadog($automatedReport, $report);
if ($reportPodcast) {
$this->callbackService->pushToDatadog($automatedReport, $reportPodcast);
}
$this->logger->info(self::LOG_PREFIX . ' Triggering Event for UserPilot tracking', [
'report_uuid' => $automatedReport->getUuid(),
'result_uuid' => $reportUuid,
]);
$this->eventDispatcher->dispatch(new AutomatedReportGenerated($automatedReport));
} catch (ModelNotFoundException $exception) {
$this->logger->error(self::LOG_PREFIX . ' Report not found', [
'uuid' => $reportUuid,
'error' => $exception->getMessage(),
]);
return response()->json(['status' => 'error', 'message' => 'Report not found'], status: 404);
} catch (Throwable $exception) {
$this->logger->error(self::LOG_PREFIX . ' Failed to update report status', [
'uuid' => $reportUuid,
'error' => $exception->getMessage(),
]);
return response()->json(['status' => 'error', 'message' => 'Failed to update report status'], status: 500);
}
return response()->json(['status' => 'ok']);
}
}
Project
Project
New File or Directory…
Expand Selected
Collapse All
Options
Hide...
|
PhpStorm
|
faVsco.js – ReportController.php
|
NULL
|
7171
|
|
7172
|
Project: faVsco.js, menu
master, menu
Start Listen Project: faVsco.js, menu
master, 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
<?php
declare(strict_types=1);
namespace Jiminny\Component\Utility\Service;
use Illuminate\Cache\RateLimiter;
use Jiminny\Contracts\Http\RateLimited;
use Jiminny\Contracts\Http\RateLimitInterface;
class ProviderRateLimiter
{
protected RateLimiter $rateLimiter;
public function __construct(RateLimiter $rateLimiter)
{
$this->rateLimiter = $rateLimiter;
}
public function canMakeRequest(RateLimited $provider): bool
{
/** @var RateLimitInterface $rateLimit */
foreach ($provider->getRateLimits() as $rateLimit) {
$key = $rateLimit->getKey();
if ($this->rateLimiter->tooManyAttempts($key, $rateLimit->getQuota())) {
return false;
}
}
return true;
}
public function requestAvailableIn(RateLimited $provider): int
{
return $provider->getRateLimits()->isNotEmpty()
? $provider->getRateLimits()
->map(fn (RateLimitInterface $rateLimit): int => $this->rateLimiter->availableIn($rateLimit->getKey()))
->max()
: 0
;
}
public function incrementRequestCount(RateLimited $provider): void
{
/** @var RateLimitInterface $rateLimit */
foreach ($provider->getRateLimits() as $rateLimit) {
$this->rateLimiter->hit($rateLimit->getKey(), $rateLimit->getWindow());
}
}
}
Code changed:
Hide
Sync Changes
Hide This Notification
<?php
declare(strict_types=1);
namespace Jiminny\Http\Controllers\Webhook;
use Carbon\Carbon;
use Illuminate\Contracts\Bus\Dispatcher as BusDispatcher;
use Illuminate\Contracts\Events\Dispatcher as EventDispatcher;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Jiminny\Events\AutomatedReports\AutomatedReportGenerated;
use Jiminny\Exceptions\ModelNotFoundException;
use Jiminny\Http\Controllers\AbstractController;
use Jiminny\Jobs\AutomatedReports\SendReportJob;
use Jiminny\Models\AutomatedReportResult;
use Jiminny\Services\Kiosk\AutomatedReports\AutomatedReportsCallbackService;
use Jiminny\Services\Kiosk\AutomatedReports\AutomatedReportsService;
use Psr\Log\LoggerInterface;
use Throwable;
class ReportController extends AbstractController
{
/**
* Log prefix for all log messages
*/
private const string LOG_PREFIX = '[Report Ready]';
public function __construct(
private readonly AutomatedReportsService $automatedReportsService,
private readonly BusDispatcher $dispatcher,
private readonly LoggerInterface $logger,
private readonly AutomatedReportsCallbackService $callbackService,
private readonly EventDispatcher $eventDispatcher,
) {
}
public function ready(Request $request): JsonResponse
{
$payload = $request->all();
$now = Carbon::now();
$this->logger->info(self::LOG_PREFIX . ' Webhook received', [
'payload' => $payload,
]);
// validate
$reportUuid = $this->callbackService->getResultUuid($payload);
if (empty($reportUuid)) {
return response()->json(['status' => 'error', 'message' => 'Request ID is empty'], status: 400);
}
try {
$report = $this->automatedReportsService->getReportResult($reportUuid);
// validate
if ($this->callbackService->isProcessed($report)) {
$this->logger->warning(self::LOG_PREFIX . ' Report has been already processed', [
'uuid' => $reportUuid,
'currentStatus' => $report->getStatusLabel(),
]);
return response()->json(['status' => 'already_processed']);
}
// always try to get a child podcast cause report configuration cannot be trusted
$reportPodcast = $this->automatedReportsService->findChildResult(
result: $report,
type: AutomatedReportsService::MEDIA_TYPE_PODCAST
);
// update results
$report->update([
'status' => $this->callbackService->getPrimaryStatus($report, $payload),
'response' => $payload,
'generated_at' => $now,
]);
// if a podcast is set, update it
$reportPodcast?->update([
'status' => $this->callbackService->getPodcastStatus($payload),
'response' => $payload,
'generated_at' => $now,
]);
$this->logger->info(self::LOG_PREFIX . ' Report has been processed', [
'uuid' => $reportUuid,
'child_uuid' => $reportPodcast?->getUuid(),
]);
if (! $this->callbackService->isSuccess($payload)) {
$this->logger->warning(self::LOG_PREFIX . ' Error creating report', $payload);
return response()->json(['status' => 'ok']);
}
// If one-off, send the report immediately, if not leave it for the scheduler (automated-reports:send)
if ($report->getReport()->getFrequency() === AutomatedReportsService::FREQUENCY_ONE_OFF) {
// send the primary report
$this->dispatcher->dispatch(new SendReportJob($reportUuid));
// send the podcast report if it set and generated
if ($reportPodcast && $reportPodcast->getStatus() === AutomatedReportResult::STATUS_GENERATED) {
$this->dispatcher->dispatch(new SendReportJob(reportUuid: $reportPodcast->getUuid()));
}
}
// Track Datadog metrics for automated reports
$automatedReport = $report->getReport();
$this->callbackService->pushToDatadog($automatedReport, $report);
if ($reportPodcast) {
$this->callbackService->pushToDatadog($automatedReport, $reportPodcast);
}
$this->logger->info(self::LOG_PREFIX . ' Triggering Event for UserPilot tracking', [
'report_uuid' => $automatedReport->getUuid(),
'result_uuid' => $reportUuid,
]);
$this->eventDispatcher->dispatch(new AutomatedReportGenerated($automatedReport));
} catch (ModelNotFoundException $exception) {
$this->logger->error(self::LOG_PREFIX . ' Report not found', [
'uuid' => $reportUuid,
'error' => $exception->getMessage(),
]);
return response()->json(['status' => 'error', 'message' => 'Report not found'], status: 404);
} catch (Throwable $exception) {
$this->logger->error(self::LOG_PREFIX . ' Failed to update report status', [
'uuid' => $reportUuid,
'error' => $exception->getMessage(),
]);
return response()->json(['status' => 'error', 'message' => 'Failed to update report status'], status: 500);
}
return response()->json(['status' => 'ok']);
}
}
Project
Project
New File or Directory…
Expand Selected
Collapse All
Options
Hide...
|
PhpStorm
|
faVsco.js – ReportController.php
|
NULL
|
7172
|