|
45340
|
1625
|
4
|
2026-05-14T14:32:02.107358+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-14/1778 /Users/lukas/.screenpipe/data/data/2026-05-14/1778769122107_m1.jpg...
|
PhpStorm
|
faVsco.js – Close/Service.php
|
1
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
PhpStormFileEditViewNavigateCodeLaravelRefactorRun PhpStormFileEditViewNavigateCodeLaravelRefactorRunToolsGitWindowHelpabl100% C•FV faVsco.jsv* #12066 on JY-20725-handle-HS-search-rate-limit k vHandleHubspotRateLimitTestv* :8• Thu 14 May 17:32:01QProject v© ReportController.php© AutomatedReportGenerated.php=custom.logTrackAutomatedReportGeneratedEvent.phpPlaybackController.php© FieldDefinitions.phpUserAutomatedReportsController.phpPlanhatService.phpAutomatedReportResult.phpSendReportJob.php© FieldValueConverter.php• DeleteCrmEntityTrait.phpDeleteAccountJob.php© ImportActivityTypes.phpT WriteCrmTrait.php© DecorateActivity.php© Service.php© StandardFieldMetadata.phpSalesforce/Service.php© LogActivityTrait.php© Pipedrive/Service.phpClose/Service.php XPlainTextDecorateActivity.phpCopper© ActivityPlaybookTrait.phpCrmHelperRepository.php© AccountController.phpT IntegrationAppTrait.php= .env.staging18> D CrmObjectsv DecorateActivityE.envDetachActivityObject.phpRematchActivityOnCrmObjectDetach.php© MatchActivityCrmData.phpClient.php© BaseDecorateActivity.php© HubspotPaginationService.php© PlainTextDecorateActivity.pl© HandleHubspotRateLimit.phpclass Service extends BaseService implements> D Dummy52117298 A39 ×5v Helpers1 usageT ActivityPlaybookTrait.php© Arraylterator.php11731192private function buildTextMessagePayload(Activity $activity): Activity{...}ConnectionStateTrait.php3 usages© CrmHelperRepository.php1193private function generateActivityDescription(Activity $activity): string© FilterJoinedParticipants.php1194+ OpportunitySyncableFieldsT1195$description = '';v D Hubspot1196D AccountSyncStrategy1197> OJ Actions |1198• ContactSyncStrategy1199> ODTO1200> D Fields1201> 0 Journal1202switch (Sactivity->type) {case Activity: : TYPE_SOFTPHONE:case Activity:: TYPE_SOFTPHONE_INBOUND:case Activity::TYPE_CONFERENCE:if ($activity->hasActivityType()) {Sdescription = Sactivity->getActivityType()->getName() • PHP_EOL • PHP_EOL;• Metadata1203v C OpportunitySyncStrategy1204> D Concerns1205if ($activity->hasTitle()) {Sdescription .= $activity->getTitle() . PHP_EOL;© HubspotLastModifiedByF1206© HubspotLastModifiedCre1207© HubspotLastModifiedCre1208© HubspotLastModifiedOpe1209© HubspotLastModifiedSyn1210© HubspotSingleSyncStrate 1211© HubspotSyncStrategyBa:1212© HubspotWebhookBatchS1213if(Sactivitx->hasReasonCodeBotKickedO2n1$description .= 'Notetaker removed from this meeting' . PHP_EOL . PHP_EOL;// When we fix the state to be Activity::RECORDING_RECORDED as it should be this can chamg} elseif ($activity->hasReasonCodeNotCompliant)) {$description .= 'Notetaker did not join due to recording consent not being provided by att} elseif ($activity->canReviewActivityO) {v M DaninationWorkspace associated with branch 'JY-20725-handle-HS-search-rate-limit' has been restored // Rollback // Configure... (today 16:17)=laravel.logSF [jiminny@localhost]A HS_local [jiminny@localho:W4 console [QAI PROD] X4 console [PROD]A console (EU]DGo jiminny084143 ×4 л215•m_layout_id =2162,1661,66799,66— 217-218= 219.id = 33;220=221223=224225226=227228229230= 231id THEN •(ownhubspot":=233 v— 234= 11512582;W Windsurf Teams1201:35UTF-8Co 4 spaces...
|
NULL
|
2793437389811026541
|
NULL
|
click
|
ocr
|
NULL
|
PhpStormFileEditViewNavigateCodeLaravelRefactorRun PhpStormFileEditViewNavigateCodeLaravelRefactorRunToolsGitWindowHelpabl100% C•FV faVsco.jsv* #12066 on JY-20725-handle-HS-search-rate-limit k vHandleHubspotRateLimitTestv* :8• Thu 14 May 17:32:01QProject v© ReportController.php© AutomatedReportGenerated.php=custom.logTrackAutomatedReportGeneratedEvent.phpPlaybackController.php© FieldDefinitions.phpUserAutomatedReportsController.phpPlanhatService.phpAutomatedReportResult.phpSendReportJob.php© FieldValueConverter.php• DeleteCrmEntityTrait.phpDeleteAccountJob.php© ImportActivityTypes.phpT WriteCrmTrait.php© DecorateActivity.php© Service.php© StandardFieldMetadata.phpSalesforce/Service.php© LogActivityTrait.php© Pipedrive/Service.phpClose/Service.php XPlainTextDecorateActivity.phpCopper© ActivityPlaybookTrait.phpCrmHelperRepository.php© AccountController.phpT IntegrationAppTrait.php= .env.staging18> D CrmObjectsv DecorateActivityE.envDetachActivityObject.phpRematchActivityOnCrmObjectDetach.php© MatchActivityCrmData.phpClient.php© BaseDecorateActivity.php© HubspotPaginationService.php© PlainTextDecorateActivity.pl© HandleHubspotRateLimit.phpclass Service extends BaseService implements> D Dummy52117298 A39 ×5v Helpers1 usageT ActivityPlaybookTrait.php© Arraylterator.php11731192private function buildTextMessagePayload(Activity $activity): Activity{...}ConnectionStateTrait.php3 usages© CrmHelperRepository.php1193private function generateActivityDescription(Activity $activity): string© FilterJoinedParticipants.php1194+ OpportunitySyncableFieldsT1195$description = '';v D Hubspot1196D AccountSyncStrategy1197> OJ Actions |1198• ContactSyncStrategy1199> ODTO1200> D Fields1201> 0 Journal1202switch (Sactivity->type) {case Activity: : TYPE_SOFTPHONE:case Activity:: TYPE_SOFTPHONE_INBOUND:case Activity::TYPE_CONFERENCE:if ($activity->hasActivityType()) {Sdescription = Sactivity->getActivityType()->getName() • PHP_EOL • PHP_EOL;• Metadata1203v C OpportunitySyncStrategy1204> D Concerns1205if ($activity->hasTitle()) {Sdescription .= $activity->getTitle() . PHP_EOL;© HubspotLastModifiedByF1206© HubspotLastModifiedCre1207© HubspotLastModifiedCre1208© HubspotLastModifiedOpe1209© HubspotLastModifiedSyn1210© HubspotSingleSyncStrate 1211© HubspotSyncStrategyBa:1212© HubspotWebhookBatchS1213if(Sactivitx->hasReasonCodeBotKickedO2n1$description .= 'Notetaker removed from this meeting' . PHP_EOL . PHP_EOL;// When we fix the state to be Activity::RECORDING_RECORDED as it should be this can chamg} elseif ($activity->hasReasonCodeNotCompliant)) {$description .= 'Notetaker did not join due to recording consent not being provided by att} elseif ($activity->canReviewActivityO) {v M DaninationWorkspace associated with branch 'JY-20725-handle-HS-search-rate-limit' has been restored // Rollback // Configure... (today 16:17)=laravel.logSF [jiminny@localhost]A HS_local [jiminny@localho:W4 console [QAI PROD] X4 console [PROD]A console (EU]DGo jiminny084143 ×4 л215•m_layout_id =2162,1661,66799,66— 217-218= 219.id = 33;220=221223=224225226=227228229230= 231id THEN •(ownhubspot":=233 v— 234= 11512582;W Windsurf Teams1201:35UTF-8Co 4 spaces...
|
45339
|
NULL
|
NULL
|
NULL
|
|
45343
|
1625
|
5
|
2026-05-14T14:32:11.179051+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-14/1778 /Users/lukas/.screenpipe/data/data/2026-05-14/1778769131179_m1.jpg...
|
PhpStorm
|
faVsco.js – Close/Service.php
|
1
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
#12066 on JY-20725-handle Project: faVsco.js, menu
#12066 on JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
HandleHubspotRateLimitTest
Run 'HandleHubspotRateLimitTest'
Debug 'HandleHubspotRateLimitTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Code changed:
Hide...
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"bounds":{"left":0.054166667,"top":0.027777778,"width":0.08055556,"height":0.035555556},"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"#12066 on JY-20725-handle-HS-search-rate-limit, menu","depth":5,"bounds":{"left":0.13472222,"top":0.027777778,"width":0.25555557,"height":0.035555556},"on_screen":true,"help_text":"Pull request #12066 exists for current branch JY-20725-handle-HS-search-rate-limit, but local branch is out of sync with remote","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"bounds":{"left":0.6326389,"top":0.027777778,"width":0.023611112,"height":0.035555556},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"HandleHubspotRateLimitTest","depth":6,"bounds":{"left":0.6645833,"top":0.027777778,"width":0.15902779,"height":0.035555556},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'HandleHubspotRateLimitTest'","depth":6,"bounds":{"left":0.82361114,"top":0.027777778,"width":0.023611112,"height":0.035555556},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'HandleHubspotRateLimitTest'","depth":6,"bounds":{"left":0.8472222,"top":0.027777778,"width":0.023611112,"height":0.035555556},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"bounds":{"left":0.87083334,"top":0.027777778,"width":0.023611112,"height":0.035555556},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"bounds":{"left":0.9291667,"top":0.027777778,"width":0.023611112,"height":0.035555556},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"bounds":{"left":0.9527778,"top":0.027777778,"width":0.023611112,"height":0.035555556},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"IDE and Project Settings","depth":5,"bounds":{"left":0.9763889,"top":0.027777778,"width":0.023611112,"height":0.035555556},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.088194445,"height":0.027777778},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
4940759177423396031
|
-7051155909800514613
|
click
|
hybrid
|
NULL
|
Project: faVsco.js, menu
#12066 on JY-20725-handle Project: faVsco.js, menu
#12066 on JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
HandleHubspotRateLimitTest
Run 'HandleHubspotRateLimitTest'
Debug 'HandleHubspotRateLimitTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Code changed:
Hide
PhpStormFileEditViewNavigateCodeLaravelRefactorRunToolsGitWindowHelpabl100% C•FV faVsco.js* #12066 on JY-20725-handle-HS-search-rate-limit k vHandleHubspotRateLimitTestv8• Thu 14 May 17:32:10* :QProjectv© ReportController.php© AutomatedReportGenerated.php© PlaybackController.php:=custom.logTrackAutomatedReportGeneratedEvent.php© FieldDefinitions.phpUserAutomatedReportsController.phpPlanhatService.phpAutomatedReportResult.phpSendReportJob.php© FieldValueConverter.php• DeleteCrmEntityTrait.phpDeleteAccountJob.php© ImportActivityTypes.phpT WriteCrmTrait.php© DecorateActivity.php© Service.php© StandardFieldMetadata.php© Salesforce/Service.php© LogActivityTrait.phpPipedrive/Service.phpClose/Service.php XPlainTextDecorateActivity.php› D Copper© ActivityPlaybookTrait.phpCrmHelperRepository.php© AccountController.phpT IntegrationAppTrait.php= .env.staging18> D CrmObjectsv D DecorateActivityE.env© DetachActivityObject.phpRematchActivityOnCrmObjectDetach.phpMatchActivityCrmData.php© Client.php© BaseDecorateActivity.php© PlainTextDecorateActivity.pl> C Dummy521103© HubspotPaginationService.php© HandleHubspotRateLimit.phpclass Service extends BaseService implementsprivate function buildCallPayload(Activity $activity): ActivityA8 A39 ×5 ^v HelpersT ActivityPlaybookTrait.php1115© Arraylterator.php1116ConnectionStateTrait.php1117© CrmHelperRepository.php1118© FilterJoinedParticipants.php1119+ OpportunitySyncableFieldsT1120$this->logger->info('[Close CRM] Updating task', ['activity' => $activity->id,'erm_id' => $activity->crm_provider_id,'data' => $data,v C Hubspot11211);D AccountSyncStrategy1122> OJ Actions |1123$this->client->put( url: 'task/'• Sactivity->crm_provider_id, $data);• ContactSyncStrategy1124> ODTO1125› Fields1126> 0 Journal1127• Metadata1128v C OpportunitySyncStrategy1129› D Concerns1130© HubspotLastModifiedByF1131© HubspotLastModifiedCre1132© HubspotLastModifiedCre1133© HubspotLastModifiedOpe1134© HubspotLastModifiedSyn1135} else {// Just create an activity.$data = ['Lead_id' => Sactivity->account_id ? Sactivity-›account->crm_provider_id : null,'contact_id' => $activity->contact_id ? $activity->contact->crm_provider_id : null,'created_by' => $this->profile->crm_provider_id,'user_id' => $this->profile->crm_provider_id,'direction' => $activity->getCrmType() === Activity::TYPE_SOFTPHONE ? 'outbound' : 'inbau 231'status' => $this->mapStatus(Sactivity->getStatus()),'note' => $this->generateActivityDescription(Sactivity),223=224225226=227228I229• 230=232233V'duration' => $this->mapStatus($activity->getStatus()) !== 'cancel' ? $activity->duration 234'phone' => $activity->to ? $activity->to->phone_number : null,© HubspotSingleSyncStrate1136© HubspotSyncStrategyBa:1137© HubspotWebhookBatchS1138$clActivity = $this->client->post( url:'activity/call', $data);v M Danination1139Workspace associated with branch 'JY-20725-handle-HS-search-rate-limit' has been restored // Rollback // Configure... (today 16:17)=laravel.logSF [jiminny@localhost]A HS_local [jiminny@localho:W4 console [QAI PROD] X4 console [PROD]A console (EU]DGo jiminny084143 ×4 л215•m_layout_id =2162,1661,66799,66— 217-218= 219id = 33;220=221id THEN '(ownhubspot":= 11512582;W Windsurf Teams1133:43UTF-8Co 4 spaces...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
45342
|
1626
|
4
|
2026-05-14T14:32:11.193822+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-14/1778 /Users/lukas/.screenpipe/data/data/2026-05-14/1778769131193_m2.jpg...
|
PhpStorm
|
faVsco.js – Close/Service.php
|
1
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
#12066 on JY-20725-handle Project: faVsco.js, menu
#12066 on JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
HandleHubspotRateLimitTest
Run 'HandleHubspotRateLimitTest'
Debug 'HandleHubspotRateLimitTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Code changed:
Hide
Sync Changes
Hide This Notification
8
39
5
Previous Highlighted Error
Next Highlighted Error
<?php
namespace Jiminny\Services\Crm\Close;
use Cache;
use Carbon\Carbon;
use Exception;
use GuzzleHttp\Exception\ClientException;
use Illuminate\Support\Str;
use InvalidArgumentException;
use Jiminny\Contracts\Services\Crm\LayoutManagementInterface;
use Jiminny\Contracts\Services\Crm\MatchCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\Provider\CloseInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityLookupInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityManipulationInterface;
use Jiminny\Contracts\Services\Crm\SettingsInterface;
use Jiminny\Contracts\Services\Crm\SupportsObjectTypeParseInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmMetadataInterface;
use Jiminny\Contracts\Services\Crm\VerifyTaskExistsInterface;
use Jiminny\Exceptions\CrmException;
use Jiminny\Exceptions\HttpNotFoundException;
use Jiminny\Exceptions\ServiceUnavailableException;
use Jiminny\Exceptions\UnexpectedCallException;
use Jiminny\Models\Account;
use Jiminny\Models\Activity;
use Jiminny\Models\Contact;
use Jiminny\Models\Crm\Configuration;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\FieldData;
use Jiminny\Models\Crm\Profile;
use Jiminny\Models\Lead;
use Jiminny\Models\Opportunity;
use Jiminny\Models\Playbook;
use Jiminny\Models\SocialAccount;
use Jiminny\Models\Stage;
use Jiminny\Models\User;
use Jiminny\Repositories\Crm\FieldRepository;
use Jiminny\Repositories\Crm\ProfileRepository;
use Jiminny\Services\Avatar\ProspectPhotoPathService;
use Jiminny\Services\Crm\BaseService;
use Jiminny\Services\Crm\Close\Processor\AccountProcessor;
use Jiminny\Services\Crm\Close\Processor\MetadataProcessor;
use Jiminny\Services\Crm\Close\Processor\OpportunityProcessor;
use Jiminny\Services\Crm\Close\Processor\StageProcessor;
use Jiminny\Services\Crm\Helpers\FilterJoinedParticipants;
use Jiminny\Services\Crm\Metadata\ProfileMetadata;
use Jiminny\Services\Crm\OpportunitySyncStrategyResolver;
use Sentry;
use UnexpectedValueException;
class Service extends BaseService implements
CloseInterface,
SyncCrmEntitiesInterface,
SyncCrmMetadataInterface,
RemoteEntityManipulationInterface,
LayoutManagementInterface,
SettingsInterface,
MatchCrmEntitiesInterface,
RemoteEntityLookupInterface,
SupportsObjectTypeParseInterface,
VerifyTaskExistsInterface
{
private const int NOTE_BODY_MAX_LENGTH = 3000000;
private const int TASK_VERIFICATION_CACHE_TTL = 86400; // 1 day
private StandardFieldMetadata $standardFieldMetadata;
private MetadataProcessor $metadataProcessor;
private FieldValueConverter $fieldValueConverter;
private StageProcessor $stageProcessor;
private OpportunityProcessor $opportunityProcessor;
private AccountProcessor $accountProcessor;
public function __construct(
Client $client,
StandardFieldMetadata $standardFieldMetadata,
MetadataProcessor $metadataProcessor,
FieldValueConverter $fieldValueConverter,
StageProcessor $stageResolver,
OpportunityProcessor $opportunityProcessor,
AccountProcessor $accountProcessor,
private readonly ProspectPhotoPathService $prospectPhotoPathService,
) {
parent::__construct();
$this->client = $client;
$this->standardFieldMetadata = $standardFieldMetadata;
$this->metadataProcessor = $metadataProcessor;
$this->fieldValueConverter = $fieldValueConverter;
$this->stageProcessor = $stageResolver;
$this->opportunityProcessor = $opportunityProcessor;
$this->accountProcessor = $accountProcessor;
}
public function getDisplayName(): string
{
return 'Close';
}
public function setConfiguration(Configuration $config): void
{
parent::setConfiguration($config);
$this->metadataProcessor->setConfiguration($config);
$this->stageProcessor->setConfiguration($config);
$this->opportunityProcessor->setConfiguration($config);
$this->accountProcessor->setConfiguration($config);
}
protected function getOAuthAccount(User $user): ?SocialAccount
{
// In this case, the Account Owner is always the connection for any API operations.
return $user->getTeam()->getOwner()->getSocialAccount(SocialAccount::PROVIDER_CLOSE);
}
private function getClient(): Client
{
if (! $this->client instanceof Client) {
throw new UnexpectedCallException('Client not set');
}
return $this->client;
}
public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string
{
return $this->fieldValueConverter->convertFromCrm($fieldType, $fieldValue);
}
protected function getFieldTypes(): array
{
return [
parent::OBJECT_OPPORTUNITY,
parent::OBJECT_CONTACT,
parent::OBJECT_ACCOUNT,
];
}
protected function getFields(string $crmObject): array
{
// not used
return [];
}
/**
* @inheritdoc
*/
public function getDefaultFields(string $activityType): array
{
$fields = [];
if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {
$defaultFields = FieldDefinitions::defaultTaskFields();
// This lazy creates these fields if not already setup.
foreach ($defaultFields as $defaultField) {
$fields[] = $this->config->fields()->firstOrCreate($defaultField);
}
}
return $fields;
}
/**
* @inheritdoc
*/
public function getDefaultActivityField(string $activityType): Field
{
// Set up the activity field as the default Type.
/** @var Field $activityField */
$activityField = $this->config->fields()->where([
'crm_provider_id' => 'type',
'object_type' => $activityType,
])->first();
return $activityField;
}
/**
* @inheritdoc
*/
public function getSupportedPlaybookTypes(): array
{
return [Playbook::ACTIVITY_TYPE_TASK];
}
public function getDealInsightsFields(): array
{
return FieldDefinitions::dealInsightsFields();
}
protected function getDefaultFollowupLayoutFields(string $activityType): array
{
$fields = [];
$fieldRepo = app(FieldRepository::class);
$fieldData = FieldDefinitions::followupFieldsFilter();
foreach ($fieldData as $data) {
$field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $data);
// Only add the field if it is created, which it should be.
if ($field) {
$fields[] = $field;
}
}
return $fields;
}
public function syncFields(): void
{
$this->syncStandardFields();
$this->syncCustomFields();
}
/**
* @important Works only for custom fields
*/
public function syncField(Field $field): void
{
$resource = $this->convertObjectTypeToResource($field->getObjectType());
// We can only sync custom fields in this CRM.
if ($this->isCustomField($field->getCrmProviderId()) === false) {
return;
}
$crmField = $this->getClient()->fetchCustomFieldDefinition($resource, $field->getCrmProviderId());
$this->metadataProcessor->syncField($crmField);
}
private function isCustomField(string $fieldId): bool
{
return strpos($fieldId, 'cf_') === 0;
}
/**
* @inheritdoc
*/
public function importPicklistValues(Field $field): array
{
// handled in syncFields()
return [];
}
/**
* @important We only support stages on the opportunity object
*
* @param string[]|null $types
*/
public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage
{
if (! $missingStageName) {
// This is taken care of by syncOrganization()
return null;
}
$stage = $this->stageProcessor->resolveFromStageId($missingStageName);
if ($stage instanceof Stage) {
return $stage;
}
$stageMetadata = $this->getClient()->fetchStage($missingStageName);
if (! $stageMetadata) {
$this->logger->error('Stage does not exist', [
'stage' => $missingStageName,
]);
return null;
}
return $this->stageProcessor->importStage($stageMetadata);
}
/**
* @inheritdoc
*/
public function syncLeads(Carbon $since, ?Carbon $to = null, ?string $crmProfileId = null): int
{
// Even though Close.io has the concept of "leads", they fit more into our concept of accounts.
return 0;
}
/**
* @inheritdoc
*/
public function syncLead(string $crmId): ?Lead
{
// Not a supported entity.
return null;
}
/**
* @throws Exception
*/
public function syncAccounts(Carbon $since, ?Carbon $to = null): int
{
$syncCount = 0;
try {
foreach ($this->getClient()->listAccounts($since) as $clAccount) {
// Only sync if previously imported.
if ($this->hasAccount($clAccount->getId())) {
$this->importAccount($clAccount);
$syncCount++;
}
}
} catch (Exception $exception) {
$this->logger->error('Account sync failed', [
'error' => $exception->getMessage(),
]);
throw $exception;
}
return $syncCount;
}
public function syncAccount(string $crmId): ?Account
{
return $this->accountProcessor->syncAccount($crmId);
}
private function importAccount($crmData): Account
{
return $this->accountProcessor->importAccountMetadata($crmData);
}
/**
* @throws CloseException
*/
public function syncOpportunities(array $parameters, ?string $strategy = null): int
{
$strategyResolver = app(OpportunitySyncStrategyResolver::class, [
'client' => $this->client,
]);
$strategies = $strategyResolver->getStrategies($this->config, $strategy);
$syncCount = 0;
try {
$opportunities = [];
foreach ($strategies as $syncStrategy) {
$opportunitiesData = $syncStrategy->fetchOpportunities($parameters);
$opportunities[] = $opportunitiesData['data'];
if ($opportunitiesData['has_more']) {
$this->logger->info('[Close] Sync Opportunities - count warning', [
'team_id' => $this->config->getTeam()->getId(),
'total' => $opportunitiesData['total'],
'count' => $opportunitiesData['count'],
'skip' => $opportunitiesData['skip'],
'strategies_count' => count($strategies),
]);
}
}
$opportunities = array_merge(...$opportunities);
} catch (CrmException $exception) {
$this->logger->error('Fetching opportunity data failed', [
'team' => $this->getTeam()->getSlug(),
'error' => $exception->getMessage(),
]);
return 0;
}
foreach ($opportunities as $opportunityMetadata) {
try {
$this->importOpportunity($opportunityMetadata);
$syncCount++;
} catch (Exception $exception) {
$this->logger->warning('Opportunity sync failed', [
'opportunity' => $opportunityMetadata->getId(),
'error' => $exception->getMessage(),
]);
}
}
return $syncCount;
}
public function syncOpportunity(string $crmId): ?Opportunity
{
$strategyResolver = app(OpportunitySyncStrategyResolver::class, [
'client' => $this->client,
]);
$strategy = $strategyResolver->resolve(
$this->config,
OpportunitySyncStrategyResolver::SINGLE_SYNC_OPPORTUNITY_STRATEGY,
);
$parameters = ['crm_id' => $crmId];
$opportunity = $strategy->fetchOpportunities($parameters);
if (empty($opportunity['data'])) {
return null;
}
return $this->importOpportunity($opportunity['data']);
}
private function importOpportunity($crmData): ?Opportunity
{
if (! $crmData->getLeadId()) {
$this->logger->warning('Opportunity does not have a lead ID', [
'opportunity' => $crmData->getId(),
]);
return null;
}
$account = $this->getConfiguration()
->accounts()
->where('crm_provider_id', $crmData->getLeadId())
->first()
;
if ($account === null) {
$account = $this->accountProcessor->syncAccount($crmData->getLeadId());
}
$profile = $this->getConfiguration()
->profiles()
->where('crm_provider_id', $crmData->getUserId())
->first()
;
$stage = $this->getConfiguration()
->stages()
->where('crm_provider_id', $crmData->getStageId())
->where('type', Stage::TYPE_OPPORTUNITY)
->first()
;
if ($stage === null) {
$stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $crmData->getStageId());
}
return $this->opportunityProcessor->import($crmData, $account, $stage, $profile);
}
/**
* @param array<string,string> $crmData
* @param string[] $crmFields
*/
public function importOpportunityCrmFieldData(array $crmData, array $crmFields, int $opportunityId): void
{
// handled in importOpportunity
}
/**
* @inheritdoc
*/
public function syncContacts(Carbon $since, ?Carbon $to = null): int
{
$syncCount = 0;
try {
/** No way to sync today.
$clContacts = $this->client->get('lead', [
'date_updated__gte' => $since->toDateString(),
'_order_by' => '-date_updated',
]);
foreach ($clContacts as $clContact) {
// Only sync if previously imported.
if ($this->hasContact($clContact['id'])) {
$this->importContact($clContact);
$syncCount++;
}
}
**/
} catch (Exception $exception) {
// Do nothing for now.
throw $exception;
}
return $syncCount;
}
/**
* @inheritdoc
*/
public function syncContact(string $crmId): ?Contact
{
try {
$clContact = $this->client->get('contact/' . $crmId);
} catch (HttpNotFoundException $exception) {
return null;
}
return $this->importContact($clContact);
}
/**
* @inheritdoc
*/
private function importContact($crmData): Contact
{
$account = null;
if ($crmData['lead_id']) {
$account = $this->team
->accounts()
->where('crm_provider_id', $crmData['lead_id'])
->first();
if ($account === null) {
$account = $this->syncAccount($crmData['lead_id']);
}
}
$mobilePhone = $parsedNumber = null;
foreach ($crmData['phones'] as $phoneNumber) {
if ($phoneNumber['type'] === 'mobile') {
$mobilePhone = $this->buildContactMobilePhone(null, $phoneNumber['phone']);
} else {
$parsedNumber = $this->buildContactPhone(null, $phoneNumber['phone']);
}
}
$email = null;
if (empty($crmData['emails']) === false) {
$email = mb_strimwidth($crmData['emails'][0]['email'], 0, 80);
}
$profile = $this->config->profiles()->where('crm_provider_id', (string) $crmData['updated_by'])->first();
$data = [
'account_id' => $account->id ?? null,
'team_id' => $this->team->id,
'user_id' => $profile?->user_id,
'owner_id' => $crmData['updated_by'],
'name' => $crmData['name'] ?? 'Unknown',
'email' => $email,
'phone' => $parsedNumber['phone'] ?? null,
'mobile_phone' => $mobilePhone ?? null,
'ext' => $parsedNumber['ext'] ?? null,
'title' => mb_strimwidth($crmData['title'] ?? '', 0, 128),
'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(
crmConfiguration: $this->config,
crmProviderId: $crmData['id'],
modelType: Contact::class,
fileName: $crmData['id'],
avatarText: $crmData['name'] ?? 'Unknown'
),
'remotely_created_at' => Carbon::parse($crmData['date_created']),
];
/** @var Contact */
return $this->config->contacts()->updateOrCreate(['crm_provider_id' => (string) $crmData['id']], $data);
}
private function buildContactPhone(?string $countryCode, ?string $number): ?array
{
if ($number) {
// Trim to our width and attempt to parse it.
$number = mb_strimwidth($number, 0, 25);
$parsedNumber = parsePhoneNumber($countryCode, $number);
} else {
$parsedNumber = [];
}
return $parsedNumber;
}
private function buildContactMobilePhone(?string $countryCode, ?string $number): ?string
{
return $number ? mb_strimwidth(phone_e164($countryCode, $number), 0, 25) : null;
}
public function syncOrganization(): void
{
$organisation = $this->getClient()->fetchOrganisation();
$this->metadataProcessor->syncOrganisation($organisation);
foreach ($organisation->getPipelines() as $pipelineMetadata) {
$this->metadataProcessor->syncPipeline($pipelineMetadata);
}
}
private function syncStandardFields(): void
{
// Currently we sync only opportunity fields
$stages = $this->getClient()->listStages();
foreach ($this->standardFieldMetadata->opportunity($stages) as $fieldMetadata) {
$this->metadataProcessor->syncField($fieldMetadata);
}
$this->config->save();
}
private function syncCustomFields(): void
{
foreach ($this->getFieldTypes() as $fieldType) {
$objectType = $this->convertObjectTypeToResource($fieldType);
$currentFields = $this->getClient()->listCustomFieldDefinitions($objectType);
foreach ($currentFields as $fieldMetadata) {
$this->metadataProcessor->syncField($fieldMetadata);
}
}
$this->config->save();
}
public function syncProfiles(?User $userToSearch = null): ?Profile
{
/*
* Fetch the profile of the user from the database
* Then fetch the user metadata from Close and update it
* In case there's no profile for the user, proceed with syncing all users
*/
$foundUser = null;
if ($userToSearch) {
$profile = $userToSearch->getProfile();
if ($profile instanceof Profile) {
$crmProviderId = $profile->getCrmProviderId();
if ($crmProviderId) {
$profileMetadata = $this->getClient()->fetchUser($crmProviderId);
if (! $profileMetadata instanceof ProfileMetadata) {
return null;
}
return $this->metadataProcessor->syncProfile($profileMetadata);
}
}
}
foreach ($this->getClient()->listUsers() as $userMetadata) {
$userProfile = $this->metadataProcessor->syncProfile($userMetadata);
if (
$userToSearch instanceof User
&& $userProfile instanceof Profile
&& $userProfile->getUserId() === $userToSearch->getId()
) {
$foundUser = $userProfile;
}
}
return $foundUser;
}
public function syncProfileFields(): void
{
// Not used.
}
/**
* @inheritdoc
*/
public function find(string $name, array $scopes): array
{
$count = $this->limit ?? 20;
$offset = $this->offset ?? 0;
$data = Cache::remember($this->team->id . $name . $count . $offset, 300, function () use ($name, $count, $offset) {
$data = [];
try {
// If search phrase resembles phone number remove special symbols
if (preg_match('/^([0-9\s\-\+\(\)]*)$/', $name)) {
$name = '+' . preg_replace('/[\s\-\+\(\)]/', '', $name);
}
// Close do not provide a unified way to search, so we must hack our own.
$objects = $this->client->get('lead', [
'query' => 'sort:date_updated name:(' . $name . ') or email:(' . $name . ') or phone:(' . $name . ')',
'_limit' => $count, '_skip' => $offset,
]);
} catch (\GuzzleHttp\Exception\ServerException $exception) {
throw new ServiceUnavailableException($exception->getMessage());
}
foreach ($objects['data'] as $object) {
// We need a contact to dial it.
if (empty($object['contacts'])) {
continue;
}
foreach ($object['contacts'] as $contact) {
$record = [
'crmId' => $contact['id'],
'crmUrl' => $this->generateProviderUrl($object['id'], 'account'),
'name' => $contact['name'],
'industry' => null,
'title' => $contact['title'],
'organization' => $object['display_name'],
'prospectType' => 'contact',
'phoneNumbers' => [],
];
foreach ($contact['phones'] as $phone) {
if ($phone['type'] === 'mobile') {
$number = $this->buildContactMobilePhone(null, $phone['phone']);
$record['phoneNumbers'][] = [
'number' => $number,
'nationalFormat' => phone_national(null, $number),
'type' => 'mobile',
];
} else {
$parsedNumber = $this->buildContactPhone(null, $phone['phone']);
// Add phone number to record.
if (empty($parsedNumber['phone']) === false) {
$record['phoneNumbers'][] = [
'number' => $parsedNumber['phone'],
'nationalFormat' => phone_national(null, $parsedNumber['phone']),
'type' => 'phone',
];
}
}
}
$data[] = $record;
}
}
return $data;
});
return $data;
}
/**
* @inheritdoc
*/
public function findOpportunities(?string $crmAccountId, ?string $crmContactId, ?int $userId = null): array
{
$data = [];
$ownerData = [];
$ownerId = null;
$contact = null;
$account = null;
if ($crmAccountId) {
$account = $this->config->accounts()->where('crm_provider_id', $crmAccountId)->first();
if ($account === null) {
$account = $this->syncAccount($crmAccountId);
}
}
if ($crmContactId) {
$contact = $this->config->contacts()->where('crm_provider_id', $crmContactId)->first();
if ($contact === null) {
$contact = $this->syncContact($crmContactId);
}
}
if ($contact || $account) {
if ($contact && $account === null) {
$account = $contact->account;
}
if ($account === null) {
return [];
}
$params = [
'lead_id' => $account->crm_provider_id,
'_order_by' => '-date_updated',
];
$onlyOpen = true;
switch ($this->config->opportunity_assignment_rule) {
case Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_UPDATED:
$params['_order_by'] = '-date_updated';
break;
case Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_CREATED:
$params['_order_by'] = '-date_created';
break;
case Configuration::OPP_ASSIGNMENT_ALL_OPEN_OLDEST_CREATED:
$params['_order_by'] = 'date_created';
break;
case Configuration::OPP_ASSIGNMENT_ALL_RECENTLY_UPDATED:
$params['_order_by'] = '-date_updated';
$onlyOpen = false;
}
if ($onlyOpen) {
$params['status_type__in'] = 'active,won';
}
$clOpportunities = $this->client->get('opportunity', $params);
if ($userId) {
$profileRepository = app(ProfileRepository::class);
$profile = $profileRepository->findProfileByUserId($this->config, $userId);
$ownerId = $profile instanceof Profile ? $profile->getCrmProviderId() : null;
}
foreach ($clOpportunities['data'] as $clOpportunity) {
$stage = $this->config
->stages()
->where('crm_provider_id', $clOpportunity['status_id'])
->first();
if ($stage === null) {
// Import it.
$stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $clOpportunity['status_id']);
}
$record = [
'crmId' => $clOpportunity['id'],
'name' => $clOpportunity['note'],
'value' => currency_format($clOpportunity['value'], $clOpportunity['value_currency']),
'won' => $stage->probability === 100.00,
'closed' => $clOpportunity['status_type'] !== 'active',
'stage' => [
'id' => $stage->id_string,
'name' => $stage->name,
],
'recordType' => [],
];
if ($ownerId && isset($clOpportunity['user_id']) && $clOpportunity['user_id'] === $ownerId) {
$ownerData[] = $record;
}
$data[] = $record;
}
if (! empty($ownerData)) {
return $ownerData;
}
}
return $data;
}
/**
* @inheritdoc
*/
public function getTasks(string $objectType, string $objectId, ?string $opportunityId): array
{
$data = [];
$crmId = null;
if ($objectType === 'contact') {
$contact = $this->syncContact($objectId);
if ($contact && $contact->account_id) {
$crmId = $contact->account->crm_provider_id;
}
} else {
$crmId = $objectId;
}
if ($crmId) {
$clTasks = $this->client->get('task', [
'lead_id' => $crmId,
'_type' => 'lead',
'assigned_to' => $this->profile->crm_provider_id,
'is_complete' => 'false',
'_order_by' => 'date',
]);
foreach ($clTasks['data'] as $clTask) {
$data[] = [
'crmId' => $clTask['id'],
'subject' => $clTask['text'],
'due' => $clTask['date'] ?? null,
'type' => null,
];
}
}
return $data;
}
/**
* Try to find email address in CRM service
*
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
*}
*/
public function matchExactlyByEmail(string $email, ?int $userId = null): ?array
{
$clObject = $this->client->get('lead', [
'query' => 'sort:updated contact(email(email:"' . $email . '"))',
'_limit' => 1,
]);
if ($clObject['total_results'] > 0) {
foreach ($clObject['data'][0]['contacts'] as $clContact) {
foreach ($clContact['emails'] as $clEmail) {
if ($email === $clEmail['email']) {
$data = $this->convertCrmData($clContact['id'], $userId);
return ! empty(array_filter($data)) ? $data : null;
}
}
}
}
return null;
}
/**
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
*}
*/
public function matchByPhone(string $phone, ?string $rawPhoneNumber = null, ?int $userId = null): ?array
{
// Check if the user is internal.
$teamMember = $this->team->users()->where('phone', $phone)->exists();
// Skip the attendee if internal.
if ($teamMember === false) {
$clObject = $this->client->get('lead', [
'query' => 'sort:updated contact(' . $phone . ')',
'_limit' => 1,
]);
if ($clObject['total_results'] > 0) {
foreach ($clObject['data'][0]['contacts'] as $clContact) {
foreach ($clContact['phones'] as $clPhone) {
if ($phone === $clPhone['phone']) {
$data = $this->convertCrmData($clContact['id'], $userId);
return ! empty(array_filter($data)) ? $data : null;
}
}
}
}
}
return null;
}
/**
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchByName(string $name, ?int $userId = null): ?array
{
// Don't waste time searching for single character strings.
if (\strlen($name) <= 1) {
return null;
}
$cacheKey = $this->getCacheKey($name, $userId);
$result = Cache::remember($cacheKey, 60, function () use ($name, $userId) {
$clObject = $this->client->get('lead', [
'query' => 'sort:updated contact(name:"' . $name . '")',
'_limit' => 1,
]);
if ($clObject['total_results'] > 0) {
foreach ($clObject['data'][0]['contacts'] as $clContact) {
if ($clContact['name'] === $name || $clContact['display_name'] === $name) {
$data = $this->convertCrmData($clContact['id'], $userId);
return ! empty(array_filter($data)) ? $data : false;
}
}
}
return false;
});
return is_array($result) ? $result : null;
}
protected function getCacheKey(string $object, ?int $userId = null): ?string
{
$key = $this->team->getId() . $object;
$keySuffix = $this->getOwnerKeySuffix($userId);
return $key . $keySuffix;
}
private function getOwnerKeySuffix(?int $userId = null): string
{
return $userId === null ? '' : (string) $userId;
}
private function convertCrmData(string $crmId, ?int $userId = null): array
{
$lead = null;
$opportunity = null;
$account = null;
$stage = null;
$countryCode = null;
$contact = $this->syncContact($crmId);
if ($contact) {
$account = $contact->account;
if ($contact->country_code) {
$countryCode = $contact->country_code;
} elseif ($account) {
$countryCode = $account->country_code;
}
try {
$cpOpportunities = $this->findOpportunities(
$account?->crm_provider_id,
$contact->crm_provider_id,
$userId,
);
if (! empty($cpOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($cpOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
} catch (Exception) {
// Nothing to see here.
}
}
return [
$lead,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
public function saveActivity(Activity $activity): Activity
{
switch ($activity->type) {
case Activity::TYPE_CONFERENCE:
case Activity::TYPE_SOFTPHONE:
case Activity::TYPE_SOFTPHONE_INBOUND:
$activity = $this->buildCallPayload($activity);
break;
case Activity::TYPE_SMS_INBOUND:
case Activity::TYPE_SMS_OUTBOUND:
$activity = $this->buildTextMessagePayload($activity);
break;
}
return $activity;
}
private function mapStatus(string $status): string
{
switch ($status) {
case Activity::STATUS_COMPLETED:
case Activity::STATUS_IN_PROGRESS:
case Activity::STATUS_FAILED:
case Activity::STATUS_NO_ANSWER:
case Activity::STATUS_BUSY:
default:
return $status;
case Activity::STATUS_CANCELLED:
return 'cancel';
}
}
/**
* @throws CrmException
*/
private function buildCallPayload(Activity $activity): Activity
{
try {
if ($activity->crm_provider_id) {
// The activity should be logged under the existing Task (not Activity).
$data = [
'_type' => 'lead',
'lead_id' => $activity->account->crm_provider_id,
'assigned_to' => $this->profile->crm_provider_id,
'text' => $this->generateActivityDescription($activity),
'date' => $activity->getActualEndTime()->toDateString(),
'is_complete' => true,
];
$this->logger->info('[Close CRM] Updating task', [
'activity' => $activity->id,
'crm_id' => $activity->crm_provider_id,
'data' => $data,
]);
$this->client->put('task/' . $activity->crm_provider_id, $data);
} else {
// Just create an activity.
$data = [
'lead_id' => $activity->account_id ? $activity->account->crm_provider_id : null,
'contact_id' => $activity->contact_id ? $activity->contact->crm_provider_id : null,
'created_by' => $this->profile->crm_provider_id,
'user_id' => $this->profile->crm_provider_id,
'direction' => $activity->getCrmType() === Activity::TYPE_SOFTPHONE ? 'outbound' : 'inbound',
'status' => $this->mapStatus($activity->getStatus()),
'note' => $this->generateActivityDescription($activity),
'duration' => $this->mapStatus($activity->getStatus()) !== 'cancel' ? $activity->duration : 0,
'phone' => $activity->to ? $activity->to->phone_number : null,
];
$clActivity = $this->client->post('activity/call', $data);
$this->logger->info('[Close CRM] Creating activity', [
'activity' => $activity->id,
'crm_id' => $clActivity['id'],
'data' => $data,
'response' => $clActivity,
]);
$activity->crm_provider_id = $clActivity['id'];
$activity->save();
}
} catch (ClientException $exception) {
$response = $exception->getResponse();
if ($response === null) {
// Trying to debug weird cases where this is null.
Sentry::captureException($exception);
}
$responseBody = $response->getBody();
$message = $responseBody;
$errorCode = $response->getStatusCode();
$jsonResponse = json_decode($responseBody, true);
if (isset($jsonResponse[0]['message'])) {
$message = $jsonResponse[0]['message'];
}
throw new CrmException($message, $errorCode);
}
return $activity;
}
private function buildTextMessagePayload(Activity $activity): Activity
{
$clActivity = $this->client->post('activity/sms', [
'lead_id' => $activity->account_id ? $activity->account->crm_provider_id : null,
'contact_id' => $activity->contact_id ? $activity->contact->crm_provider_id : null,
'created_by' => $this->profile->crm_provider_id,
'user_id' => $this->profile->crm_provider_id,
'status' => $activity->getType() === Activity::TYPE_SMS_OUTBOUND ? 'sent' : 'inbox',
'text' => $this->generateActivityDescription($activity),
'remote_phone' => $activity->to ? $activity->to->phone_number : null,
'local_phone' => $activity->to ? $activity->to->phone_number : null,
'source' => 'Close.io',
]);
$activity->crm_provider_id = $clActivity['id'];
$activity->save();
return $activity;
}
private function generateActivityDescription(Activity $activity): string
{
$description = '';
switch ($activity->type) {
case Activity::TYPE_SOFTPHONE:
case Activity::TYPE_SOFTPHONE_INBOUND:
case Activity::TYPE_CONFERENCE:
if ($activity->hasActivityType()) {
$description = $activity->getActivityType()->getName() . PHP_EOL . PHP_EOL;
}
if ($activity->hasTitle()) {
$description .= $activity->getTitle() . PHP_EOL;
}
if ($activity->hasReasonCodeBotKicked()) {
$description .= 'Notetaker removed from this meeting' . PHP_EOL . PHP_EOL;
// When we fix the state to be Activity::RECORDING_RECORDED as it should be this can change.
} elseif ($activity->hasReasonCodeNotCompliant()) {
$description .= 'Notetaker did not join due to recording consent not being provided by attendees' . PHP_EOL . PHP_EOL;
} elseif ($activity->canReviewActivity()) {
$playbackUrl = $activity->user->team->partner->getPlaybackUrl($activity);
$description .= 'Review Activity:' . PHP_EOL . $playbackUrl . PHP_EOL . PHP_EOL;
}
if ($activity->type === Activity::TYPE_CONFERENCE) {
$description .= 'Attendees:'
. PHP_EOL
. (new FilterJoinedParticipants())->toString($activity);
}
if (\count($activity->notes) > 0) {
$description .= PHP_EOL . PHP_EOL . 'Notes: ' . PHP_EOL;
foreach ($activity->notes as $note) {
$time = ($note->time > 3600) ? gmdate('H:i:s', $note->time) : gmdate('i:s', $note->time);
$description .= $time . ' ' . $note->note . PHP_EOL;
}
}
// Get all private messages.
$messages = $activity->messages()
->where('is_private', 1)
->orderBy('created_at', 'asc');
if ($messages->count() > 0) {
$description .= PHP_EOL . PHP_EOL . 'Coaching Chat: ' . PHP_EOL;
foreach ($messages->get() as $message) {
$description .= $message->participant->name . ': ' . $message->message . PHP_EOL;
}
}
// Get all public messages.
$messages = $activity->messages()
->where('is_private', 0)
->orderBy('created_at', 'asc');
if ($messages->count() > 0) {
$description .= PHP_EOL . PHP_EOL . 'Customer Chat: ' . PHP_EOL;
foreach ($messages->get() as $message) {
$description .= $message->participant->name . ': ' . $message->message . PHP_EOL;
}
}
if ($activity->summary) {
$description .= PHP_EOL . PHP_EOL . 'Summary: ' . PHP_EOL . $activity->summary;
}
break;
case Activity::TYPE_SMS_INBOUND:
case Activity::TYPE_SMS_OUTBOUND:
$description = $activity->description;
break;
}
return $description;
}
public function saveFollowupActivity(Activity $activity, array $fields): ?string
{
// This is the user provided activity subject field.
if (empty($fields['name'])) {
return null;
}
$due = null;
if (empty($fields['due_date']) === false) {
$formatDue = Carbon::parse($fields['due_date']);
$due = $formatDue->toDateTimeString();
}
$clTask = $this->client->post('task', [
'_type' => 'lead',
'lead_id' => $activity->account->crm_provider_id,
'assigned_to' => $this->profile->crm_provider_id,
'text' => $fields['name'],
'date' => $due,
'is_complete' => false,
]);
// We don't actually create a corresponding activity object on our side yet.
return $clTask['id'];
}
/**
* Store transcripts as note.
*
* @throws Exception
*/
public function createTranscriptNotes(Activity $activity): void
{
if ($activity->account_id === null) {
// We can only log to accounts (leads).
return;
}
// Generate activity transcription.
$transcriptionData = $this->generateTranscription($activity);
// Truncate Notes with max notes length because transcription text could be very long.
$transcripts = mb_strimwidth($transcriptionData, 0, self::NOTE_BODY_MAX_LENGTH);
$clActivity = $this->client->post('activity/note', [
'lead_id' => $activity->account_id ? $activity->account->crm_provider_id : null,
'created_by' => $this->profile->crm_provider_id,
'user_id' => $this->profile->crm_provider_id,
'note' => $transcripts,
]);
// Store CRM Activity ID in transcription.
$transcription = $activity->getTranscription();
$transcription->crm_activity_id = $clActivity['id'];
$transcription->save();
}
public function parseObjectType(string $objectId): string
{
if (Str::startsWith($objectId, 'lead')) {
return 'account';
}
if (Str::startsWith($objectId, 'cont')) {
return 'contact';
}
if (Str::startsWith($objectId, 'oppo')) {
return 'opportunity';
}
throw new InvalidArgumentException('Unsupported Object Type');
}
/**
* @inheritdoc
*/
public function updateStage($crmObject, Stage $stage): void
{
if ($crmObject instanceof Lead) {
// This would never get invoked since we merge lead/accounts in Close.
$this->client->put('lead/' . $crmObject->crm_provider_id, [
'status' => $stage->crm_provider_id,
]);
} else {
$this->client->put('opportunity/' . $crmObject->crm_provider_id, [
'status_id' => $stage->crm_provider_id,
]);
}
}
public function updateRecord(string $objectType, string $objectId, array $data, array $headers = []): void
{
$this->getClient()->updateObject($this->convertObjectTypeToResource($objectType), $objectId, $data);
}
public function prepareValueForUpdate(array $params): array
{
$convertedValue = $this->fieldValueConverter->convertToCrm(
$this->config,
$params['fieldName'],
$params['fieldValue'],
);
if ($this->isCustomField($params['fieldName'])) {
$params['fieldName'] = 'custom.' . $params['fieldName'];
}
$params['fieldValue'] = $convertedValue;
return parent::prepareValueForUpdate($params);
}
public function getRecord(string $objectType, string $objectId, array $fields = []): array
{
return $this->client->get($this->convertObjectTypeToResource($objectType) . '/' . $objectId);
}
/**
*
* @throws UnexpectedValueException
*/
private function convertObjectTypeToResource(string $objectType): string
{
switch ($objectType) {
case FieldData::OBJECT_OPPORTUNITY:
return 'opportunity';
case FieldData::OBJECT_CONTACT:
return 'contact';
case FieldData::OBJECT_ACCOUNT:
return 'lead';
case FieldData::OBJECT_TASK:
return 'activity';
default:
throw new UnexpectedValueException('Unsupported object type "' . $objectType . '"');
}
}
public function generateProviderUrl(string $providerId, string $objectType): ?string
{
$baseUrl = 'https://app.close.com/';
$url = null;
switch ($objectType) {
case 'account':
$url = $baseUrl . 'lead/' . $providerId;
break;
case 'contact':
$contact = $this->config->contacts()->where('crm_provider_id', $providerId)->first();
if ($contact && $contact->account_id) {
$url = $baseUrl . 'lead/' . $contact->account->crm_provider_id;
}
break;
default:
// Sadly we can't deeplink to anything else in Close UI.
$url = null;
}
return $url;
}
/**
* Generate transcription for the activity.
*/
private function generateTranscription(Activity $activity): string
{
if (! $this->config->store_transcript) {
// If sending transcription to activity toggle is disabled
return '';
}
return $this->transcriptionService
->findTranscriptionByActivity($activity)
->map(static function (array $transcriptionSegment): string {
return $transcriptionSegment['formattedStartsAt'] . ' | ' . $transcriptionSegment['transcript'];
})
->implode(PHP_EOL);
}
public function verifyTaskExists(Activity $activity): bool
{
$crmProviderId = $activity->getCrmProviderId();
$cacheKey = "crm_task_exists:{$this->config->getId()}:$crmProviderId";
return Cache::remember($cacheKey, self::TASK_VERIFICATION_CACHE_TTL, function () use ($crmProviderId) {
try {
$client = $this->getClient();
$task = $client->get('task/' . $crmProviderId);
return ! empty($task);
} catch (HttpNotFoundException) {
// Task not found in CRM - this is expected and permanent
$this->logger->info('[Close] Task not found during verification', [
'task_id' => $crmProviderId,
'config_id' => $this->config->getId(),
]);
return false;
} catch (CloseException $e) {
// Handle 404 responses from Close API
if ($e->getResponseStatusCode() === 404) {
$this->logger->info('[Close] Task not found during verification (404)', [
'task_id' => $crmProviderId,
...
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"bounds":{"left":0.2962101,"top":1.0,"width":0.03856383,"height":-0.019952059},"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"#12066 on JY-20725-handle-HS-search-rate-limit, menu","depth":5,"bounds":{"left":0.33477393,"top":1.0,"width":0.122340426,"height":-0.019952059},"on_screen":true,"help_text":"Pull request #12066 exists for current branch JY-20725-handle-HS-search-rate-limit, but local branch is out of sync with remote","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"bounds":{"left":0.5731383,"top":1.0,"width":0.011303191,"height":-0.019952059},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"HandleHubspotRateLimitTest","depth":6,"bounds":{"left":0.5884308,"top":1.0,"width":0.076130316,"height":-0.019952059},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'HandleHubspotRateLimitTest'","depth":6,"bounds":{"left":0.66456115,"top":1.0,"width":0.011303191,"height":-0.019952059},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'HandleHubspotRateLimitTest'","depth":6,"bounds":{"left":0.67586434,"top":1.0,"width":0.011303191,"height":-0.019952059},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"bounds":{"left":0.6871675,"top":1.0,"width":0.011303191,"height":-0.019952059},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"bounds":{"left":0.7150931,"top":1.0,"width":0.011303191,"height":-0.019952059},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"bounds":{"left":0.72639626,"top":1.0,"width":0.011303191,"height":-0.019952059},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"IDE and Project Settings","depth":5,"bounds":{"left":0.73769945,"top":1.0,"width":0.011303191,"height":-0.019952059},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.042220745,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"8","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"39","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"5","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\nnamespace Jiminny\\Services\\Crm\\Close;\n\nuse Cache;\nuse Carbon\\Carbon;\nuse Exception;\nuse GuzzleHttp\\Exception\\ClientException;\nuse Illuminate\\Support\\Str;\nuse InvalidArgumentException;\nuse Jiminny\\Contracts\\Services\\Crm\\LayoutManagementInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\MatchCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\Provider\\CloseInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityLookupInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityManipulationInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SettingsInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SupportsObjectTypeParseInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmMetadataInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\VerifyTaskExistsInterface;\nuse Jiminny\\Exceptions\\CrmException;\nuse Jiminny\\Exceptions\\HttpNotFoundException;\nuse Jiminny\\Exceptions\\ServiceUnavailableException;\nuse Jiminny\\Exceptions\\UnexpectedCallException;\nuse Jiminny\\Models\\Account;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Contact;\nuse Jiminny\\Models\\Crm\\Configuration;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Models\\Crm\\FieldData;\nuse Jiminny\\Models\\Crm\\Profile;\nuse Jiminny\\Models\\Lead;\nuse Jiminny\\Models\\Opportunity;\nuse Jiminny\\Models\\Playbook;\nuse Jiminny\\Models\\SocialAccount;\nuse Jiminny\\Models\\Stage;\nuse Jiminny\\Models\\User;\nuse Jiminny\\Repositories\\Crm\\FieldRepository;\nuse Jiminny\\Repositories\\Crm\\ProfileRepository;\nuse Jiminny\\Services\\Avatar\\ProspectPhotoPathService;\nuse Jiminny\\Services\\Crm\\BaseService;\nuse Jiminny\\Services\\Crm\\Close\\Processor\\AccountProcessor;\nuse Jiminny\\Services\\Crm\\Close\\Processor\\MetadataProcessor;\nuse Jiminny\\Services\\Crm\\Close\\Processor\\OpportunityProcessor;\nuse Jiminny\\Services\\Crm\\Close\\Processor\\StageProcessor;\nuse Jiminny\\Services\\Crm\\Helpers\\FilterJoinedParticipants;\nuse Jiminny\\Services\\Crm\\Metadata\\ProfileMetadata;\nuse Jiminny\\Services\\Crm\\OpportunitySyncStrategyResolver;\nuse Sentry;\nuse UnexpectedValueException;\n\nclass Service extends BaseService implements\n CloseInterface,\n SyncCrmEntitiesInterface,\n SyncCrmMetadataInterface,\n RemoteEntityManipulationInterface,\n LayoutManagementInterface,\n SettingsInterface,\n MatchCrmEntitiesInterface,\n RemoteEntityLookupInterface,\n SupportsObjectTypeParseInterface,\n VerifyTaskExistsInterface\n{\n private const int NOTE_BODY_MAX_LENGTH = 3000000;\n\n private const int TASK_VERIFICATION_CACHE_TTL = 86400; // 1 day\n\n private StandardFieldMetadata $standardFieldMetadata;\n private MetadataProcessor $metadataProcessor;\n private FieldValueConverter $fieldValueConverter;\n private StageProcessor $stageProcessor;\n private OpportunityProcessor $opportunityProcessor;\n private AccountProcessor $accountProcessor;\n\n public function __construct(\n Client $client,\n StandardFieldMetadata $standardFieldMetadata,\n MetadataProcessor $metadataProcessor,\n FieldValueConverter $fieldValueConverter,\n StageProcessor $stageResolver,\n OpportunityProcessor $opportunityProcessor,\n AccountProcessor $accountProcessor,\n private readonly ProspectPhotoPathService $prospectPhotoPathService,\n ) {\n parent::__construct();\n\n $this->client = $client;\n $this->standardFieldMetadata = $standardFieldMetadata;\n $this->metadataProcessor = $metadataProcessor;\n $this->fieldValueConverter = $fieldValueConverter;\n $this->stageProcessor = $stageResolver;\n $this->opportunityProcessor = $opportunityProcessor;\n $this->accountProcessor = $accountProcessor;\n }\n\n public function getDisplayName(): string\n {\n return 'Close';\n }\n\n public function setConfiguration(Configuration $config): void\n {\n parent::setConfiguration($config);\n\n $this->metadataProcessor->setConfiguration($config);\n $this->stageProcessor->setConfiguration($config);\n $this->opportunityProcessor->setConfiguration($config);\n $this->accountProcessor->setConfiguration($config);\n }\n\n protected function getOAuthAccount(User $user): ?SocialAccount\n {\n // In this case, the Account Owner is always the connection for any API operations.\n return $user->getTeam()->getOwner()->getSocialAccount(SocialAccount::PROVIDER_CLOSE);\n }\n\n private function getClient(): Client\n {\n if (! $this->client instanceof Client) {\n throw new UnexpectedCallException('Client not set');\n }\n\n return $this->client;\n }\n\n public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string\n {\n return $this->fieldValueConverter->convertFromCrm($fieldType, $fieldValue);\n }\n\n protected function getFieldTypes(): array\n {\n return [\n parent::OBJECT_OPPORTUNITY,\n parent::OBJECT_CONTACT,\n parent::OBJECT_ACCOUNT,\n ];\n }\n\n protected function getFields(string $crmObject): array\n {\n // not used\n return [];\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultFields(string $activityType): array\n {\n $fields = [];\n\n if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {\n $defaultFields = FieldDefinitions::defaultTaskFields();\n\n // This lazy creates these fields if not already setup.\n foreach ($defaultFields as $defaultField) {\n $fields[] = $this->config->fields()->firstOrCreate($defaultField);\n }\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultActivityField(string $activityType): Field\n {\n // Set up the activity field as the default Type.\n /** @var Field $activityField */\n $activityField = $this->config->fields()->where([\n 'crm_provider_id' => 'type',\n 'object_type' => $activityType,\n ])->first();\n\n return $activityField;\n }\n\n /**\n * @inheritdoc\n */\n public function getSupportedPlaybookTypes(): array\n {\n return [Playbook::ACTIVITY_TYPE_TASK];\n }\n\n public function getDealInsightsFields(): array\n {\n return FieldDefinitions::dealInsightsFields();\n }\n\n protected function getDefaultFollowupLayoutFields(string $activityType): array\n {\n $fields = [];\n $fieldRepo = app(FieldRepository::class);\n\n $fieldData = FieldDefinitions::followupFieldsFilter();\n\n foreach ($fieldData as $data) {\n $field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $data);\n\n // Only add the field if it is created, which it should be.\n if ($field) {\n $fields[] = $field;\n }\n }\n\n return $fields;\n }\n\n public function syncFields(): void\n {\n $this->syncStandardFields();\n $this->syncCustomFields();\n }\n\n /**\n * @important Works only for custom fields\n */\n public function syncField(Field $field): void\n {\n $resource = $this->convertObjectTypeToResource($field->getObjectType());\n\n // We can only sync custom fields in this CRM.\n if ($this->isCustomField($field->getCrmProviderId()) === false) {\n return;\n }\n\n $crmField = $this->getClient()->fetchCustomFieldDefinition($resource, $field->getCrmProviderId());\n\n $this->metadataProcessor->syncField($crmField);\n }\n\n private function isCustomField(string $fieldId): bool\n {\n return strpos($fieldId, 'cf_') === 0;\n }\n\n /**\n * @inheritdoc\n */\n public function importPicklistValues(Field $field): array\n {\n // handled in syncFields()\n return [];\n }\n\n /**\n * @important We only support stages on the opportunity object\n *\n * @param string[]|null $types\n */\n public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage\n {\n if (! $missingStageName) {\n // This is taken care of by syncOrganization()\n return null;\n }\n\n $stage = $this->stageProcessor->resolveFromStageId($missingStageName);\n\n if ($stage instanceof Stage) {\n return $stage;\n }\n\n $stageMetadata = $this->getClient()->fetchStage($missingStageName);\n\n if (! $stageMetadata) {\n $this->logger->error('Stage does not exist', [\n 'stage' => $missingStageName,\n ]);\n\n return null;\n }\n\n\n return $this->stageProcessor->importStage($stageMetadata);\n }\n\n /**\n * @inheritdoc\n */\n public function syncLeads(Carbon $since, ?Carbon $to = null, ?string $crmProfileId = null): int\n {\n // Even though Close.io has the concept of \"leads\", they fit more into our concept of accounts.\n return 0;\n }\n\n /**\n * @inheritdoc\n */\n public function syncLead(string $crmId): ?Lead\n {\n // Not a supported entity.\n return null;\n }\n\n /**\n * @throws Exception\n */\n public function syncAccounts(Carbon $since, ?Carbon $to = null): int\n {\n $syncCount = 0;\n\n try {\n foreach ($this->getClient()->listAccounts($since) as $clAccount) {\n // Only sync if previously imported.\n if ($this->hasAccount($clAccount->getId())) {\n $this->importAccount($clAccount);\n $syncCount++;\n }\n }\n } catch (Exception $exception) {\n $this->logger->error('Account sync failed', [\n 'error' => $exception->getMessage(),\n ]);\n\n throw $exception;\n }\n\n return $syncCount;\n }\n\n public function syncAccount(string $crmId): ?Account\n {\n return $this->accountProcessor->syncAccount($crmId);\n }\n\n private function importAccount($crmData): Account\n {\n return $this->accountProcessor->importAccountMetadata($crmData);\n }\n\n /**\n * @throws CloseException\n */\n public function syncOpportunities(array $parameters, ?string $strategy = null): int\n {\n $strategyResolver = app(OpportunitySyncStrategyResolver::class, [\n 'client' => $this->client,\n ]);\n $strategies = $strategyResolver->getStrategies($this->config, $strategy);\n\n $syncCount = 0;\n\n try {\n $opportunities = [];\n foreach ($strategies as $syncStrategy) {\n $opportunitiesData = $syncStrategy->fetchOpportunities($parameters);\n $opportunities[] = $opportunitiesData['data'];\n\n if ($opportunitiesData['has_more']) {\n $this->logger->info('[Close] Sync Opportunities - count warning', [\n 'team_id' => $this->config->getTeam()->getId(),\n 'total' => $opportunitiesData['total'],\n 'count' => $opportunitiesData['count'],\n 'skip' => $opportunitiesData['skip'],\n 'strategies_count' => count($strategies),\n ]);\n }\n }\n\n $opportunities = array_merge(...$opportunities);\n } catch (CrmException $exception) {\n $this->logger->error('Fetching opportunity data failed', [\n 'team' => $this->getTeam()->getSlug(),\n 'error' => $exception->getMessage(),\n ]);\n\n return 0;\n }\n\n foreach ($opportunities as $opportunityMetadata) {\n try {\n $this->importOpportunity($opportunityMetadata);\n $syncCount++;\n } catch (Exception $exception) {\n $this->logger->warning('Opportunity sync failed', [\n 'opportunity' => $opportunityMetadata->getId(),\n 'error' => $exception->getMessage(),\n ]);\n }\n }\n\n return $syncCount;\n }\n\n public function syncOpportunity(string $crmId): ?Opportunity\n {\n $strategyResolver = app(OpportunitySyncStrategyResolver::class, [\n 'client' => $this->client,\n ]);\n\n $strategy = $strategyResolver->resolve(\n $this->config,\n OpportunitySyncStrategyResolver::SINGLE_SYNC_OPPORTUNITY_STRATEGY,\n );\n\n $parameters = ['crm_id' => $crmId];\n\n $opportunity = $strategy->fetchOpportunities($parameters);\n\n if (empty($opportunity['data'])) {\n return null;\n }\n\n return $this->importOpportunity($opportunity['data']);\n }\n\n private function importOpportunity($crmData): ?Opportunity\n {\n if (! $crmData->getLeadId()) {\n $this->logger->warning('Opportunity does not have a lead ID', [\n 'opportunity' => $crmData->getId(),\n ]);\n\n return null;\n }\n\n $account = $this->getConfiguration()\n ->accounts()\n ->where('crm_provider_id', $crmData->getLeadId())\n ->first()\n ;\n\n if ($account === null) {\n $account = $this->accountProcessor->syncAccount($crmData->getLeadId());\n }\n\n $profile = $this->getConfiguration()\n ->profiles()\n ->where('crm_provider_id', $crmData->getUserId())\n ->first()\n ;\n\n $stage = $this->getConfiguration()\n ->stages()\n ->where('crm_provider_id', $crmData->getStageId())\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->first()\n ;\n\n if ($stage === null) {\n $stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $crmData->getStageId());\n }\n\n return $this->opportunityProcessor->import($crmData, $account, $stage, $profile);\n }\n\n /**\n * @param array<string,string> $crmData\n * @param string[] $crmFields\n */\n public function importOpportunityCrmFieldData(array $crmData, array $crmFields, int $opportunityId): void\n {\n // handled in importOpportunity\n }\n\n /**\n * @inheritdoc\n */\n public function syncContacts(Carbon $since, ?Carbon $to = null): int\n {\n $syncCount = 0;\n\n try {\n /** No way to sync today.\n $clContacts = $this->client->get('lead', [\n 'date_updated__gte' => $since->toDateString(),\n '_order_by' => '-date_updated',\n ]);\n\n foreach ($clContacts as $clContact) {\n // Only sync if previously imported.\n if ($this->hasContact($clContact['id'])) {\n $this->importContact($clContact);\n $syncCount++;\n }\n }\n **/\n } catch (Exception $exception) {\n // Do nothing for now.\n throw $exception;\n }\n\n return $syncCount;\n }\n\n /**\n * @inheritdoc\n */\n public function syncContact(string $crmId): ?Contact\n {\n try {\n $clContact = $this->client->get('contact/' . $crmId);\n } catch (HttpNotFoundException $exception) {\n return null;\n }\n\n return $this->importContact($clContact);\n }\n\n /**\n * @inheritdoc\n */\n private function importContact($crmData): Contact\n {\n $account = null;\n if ($crmData['lead_id']) {\n $account = $this->team\n ->accounts()\n ->where('crm_provider_id', $crmData['lead_id'])\n ->first();\n\n if ($account === null) {\n $account = $this->syncAccount($crmData['lead_id']);\n }\n }\n\n $mobilePhone = $parsedNumber = null;\n foreach ($crmData['phones'] as $phoneNumber) {\n if ($phoneNumber['type'] === 'mobile') {\n $mobilePhone = $this->buildContactMobilePhone(null, $phoneNumber['phone']);\n } else {\n $parsedNumber = $this->buildContactPhone(null, $phoneNumber['phone']);\n }\n }\n\n $email = null;\n if (empty($crmData['emails']) === false) {\n $email = mb_strimwidth($crmData['emails'][0]['email'], 0, 80);\n }\n\n $profile = $this->config->profiles()->where('crm_provider_id', (string) $crmData['updated_by'])->first();\n\n $data = [\n 'account_id' => $account->id ?? null,\n 'team_id' => $this->team->id,\n 'user_id' => $profile?->user_id,\n 'owner_id' => $crmData['updated_by'],\n 'name' => $crmData['name'] ?? 'Unknown',\n 'email' => $email,\n 'phone' => $parsedNumber['phone'] ?? null,\n 'mobile_phone' => $mobilePhone ?? null,\n 'ext' => $parsedNumber['ext'] ?? null,\n 'title' => mb_strimwidth($crmData['title'] ?? '', 0, 128),\n 'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(\n crmConfiguration: $this->config,\n crmProviderId: $crmData['id'],\n modelType: Contact::class,\n fileName: $crmData['id'],\n avatarText: $crmData['name'] ?? 'Unknown'\n ),\n 'remotely_created_at' => Carbon::parse($crmData['date_created']),\n ];\n\n /** @var Contact */\n return $this->config->contacts()->updateOrCreate(['crm_provider_id' => (string) $crmData['id']], $data);\n }\n\n private function buildContactPhone(?string $countryCode, ?string $number): ?array\n {\n if ($number) {\n // Trim to our width and attempt to parse it.\n $number = mb_strimwidth($number, 0, 25);\n $parsedNumber = parsePhoneNumber($countryCode, $number);\n } else {\n $parsedNumber = [];\n }\n\n return $parsedNumber;\n }\n\n private function buildContactMobilePhone(?string $countryCode, ?string $number): ?string\n {\n return $number ? mb_strimwidth(phone_e164($countryCode, $number), 0, 25) : null;\n }\n\n public function syncOrganization(): void\n {\n $organisation = $this->getClient()->fetchOrganisation();\n\n $this->metadataProcessor->syncOrganisation($organisation);\n\n foreach ($organisation->getPipelines() as $pipelineMetadata) {\n $this->metadataProcessor->syncPipeline($pipelineMetadata);\n }\n }\n\n private function syncStandardFields(): void\n {\n // Currently we sync only opportunity fields\n $stages = $this->getClient()->listStages();\n foreach ($this->standardFieldMetadata->opportunity($stages) as $fieldMetadata) {\n $this->metadataProcessor->syncField($fieldMetadata);\n }\n\n $this->config->save();\n }\n\n private function syncCustomFields(): void\n {\n foreach ($this->getFieldTypes() as $fieldType) {\n $objectType = $this->convertObjectTypeToResource($fieldType);\n $currentFields = $this->getClient()->listCustomFieldDefinitions($objectType);\n\n foreach ($currentFields as $fieldMetadata) {\n $this->metadataProcessor->syncField($fieldMetadata);\n }\n }\n\n $this->config->save();\n }\n\n public function syncProfiles(?User $userToSearch = null): ?Profile\n {\n /*\n * Fetch the profile of the user from the database\n * Then fetch the user metadata from Close and update it\n * In case there's no profile for the user, proceed with syncing all users\n */\n $foundUser = null;\n\n if ($userToSearch) {\n $profile = $userToSearch->getProfile();\n\n if ($profile instanceof Profile) {\n $crmProviderId = $profile->getCrmProviderId();\n\n if ($crmProviderId) {\n $profileMetadata = $this->getClient()->fetchUser($crmProviderId);\n\n if (! $profileMetadata instanceof ProfileMetadata) {\n return null;\n }\n\n return $this->metadataProcessor->syncProfile($profileMetadata);\n }\n }\n }\n\n foreach ($this->getClient()->listUsers() as $userMetadata) {\n $userProfile = $this->metadataProcessor->syncProfile($userMetadata);\n\n if (\n $userToSearch instanceof User\n && $userProfile instanceof Profile\n && $userProfile->getUserId() === $userToSearch->getId()\n ) {\n $foundUser = $userProfile;\n }\n }\n\n return $foundUser;\n }\n\n public function syncProfileFields(): void\n {\n // Not used.\n }\n\n /**\n * @inheritdoc\n */\n public function find(string $name, array $scopes): array\n {\n $count = $this->limit ?? 20;\n $offset = $this->offset ?? 0;\n\n $data = Cache::remember($this->team->id . $name . $count . $offset, 300, function () use ($name, $count, $offset) {\n $data = [];\n\n try {\n // If search phrase resembles phone number remove special symbols\n if (preg_match('/^([0-9\\s\\-\\+\\(\\)]*)$/', $name)) {\n $name = '+' . preg_replace('/[\\s\\-\\+\\(\\)]/', '', $name);\n }\n\n // Close do not provide a unified way to search, so we must hack our own.\n $objects = $this->client->get('lead', [\n 'query' => 'sort:date_updated name:(' . $name . ') or email:(' . $name . ') or phone:(' . $name . ')',\n '_limit' => $count, '_skip' => $offset,\n ]);\n } catch (\\GuzzleHttp\\Exception\\ServerException $exception) {\n throw new ServiceUnavailableException($exception->getMessage());\n }\n\n foreach ($objects['data'] as $object) {\n // We need a contact to dial it.\n if (empty($object['contacts'])) {\n continue;\n }\n\n foreach ($object['contacts'] as $contact) {\n $record = [\n 'crmId' => $contact['id'],\n 'crmUrl' => $this->generateProviderUrl($object['id'], 'account'),\n 'name' => $contact['name'],\n 'industry' => null,\n 'title' => $contact['title'],\n 'organization' => $object['display_name'],\n 'prospectType' => 'contact',\n 'phoneNumbers' => [],\n ];\n\n foreach ($contact['phones'] as $phone) {\n if ($phone['type'] === 'mobile') {\n $number = $this->buildContactMobilePhone(null, $phone['phone']);\n\n $record['phoneNumbers'][] = [\n 'number' => $number,\n 'nationalFormat' => phone_national(null, $number),\n 'type' => 'mobile',\n ];\n } else {\n $parsedNumber = $this->buildContactPhone(null, $phone['phone']);\n\n // Add phone number to record.\n if (empty($parsedNumber['phone']) === false) {\n $record['phoneNumbers'][] = [\n 'number' => $parsedNumber['phone'],\n 'nationalFormat' => phone_national(null, $parsedNumber['phone']),\n 'type' => 'phone',\n ];\n }\n }\n }\n\n $data[] = $record;\n }\n }\n\n return $data;\n });\n\n return $data;\n }\n\n /**\n * @inheritdoc\n */\n public function findOpportunities(?string $crmAccountId, ?string $crmContactId, ?int $userId = null): array\n {\n $data = [];\n $ownerData = [];\n $ownerId = null;\n $contact = null;\n $account = null;\n\n if ($crmAccountId) {\n $account = $this->config->accounts()->where('crm_provider_id', $crmAccountId)->first();\n\n if ($account === null) {\n $account = $this->syncAccount($crmAccountId);\n }\n }\n\n if ($crmContactId) {\n $contact = $this->config->contacts()->where('crm_provider_id', $crmContactId)->first();\n\n if ($contact === null) {\n $contact = $this->syncContact($crmContactId);\n }\n }\n\n if ($contact || $account) {\n if ($contact && $account === null) {\n $account = $contact->account;\n }\n\n if ($account === null) {\n return [];\n }\n\n $params = [\n 'lead_id' => $account->crm_provider_id,\n '_order_by' => '-date_updated',\n ];\n\n $onlyOpen = true;\n switch ($this->config->opportunity_assignment_rule) {\n case Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_UPDATED:\n $params['_order_by'] = '-date_updated';\n\n break;\n\n case Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_CREATED:\n $params['_order_by'] = '-date_created';\n\n break;\n\n case Configuration::OPP_ASSIGNMENT_ALL_OPEN_OLDEST_CREATED:\n $params['_order_by'] = 'date_created';\n\n break;\n\n case Configuration::OPP_ASSIGNMENT_ALL_RECENTLY_UPDATED:\n $params['_order_by'] = '-date_updated';\n $onlyOpen = false;\n }\n\n if ($onlyOpen) {\n $params['status_type__in'] = 'active,won';\n }\n\n $clOpportunities = $this->client->get('opportunity', $params);\n\n if ($userId) {\n $profileRepository = app(ProfileRepository::class);\n $profile = $profileRepository->findProfileByUserId($this->config, $userId);\n\n $ownerId = $profile instanceof Profile ? $profile->getCrmProviderId() : null;\n }\n\n foreach ($clOpportunities['data'] as $clOpportunity) {\n $stage = $this->config\n ->stages()\n ->where('crm_provider_id', $clOpportunity['status_id'])\n ->first();\n\n if ($stage === null) {\n // Import it.\n $stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $clOpportunity['status_id']);\n }\n\n $record = [\n 'crmId' => $clOpportunity['id'],\n 'name' => $clOpportunity['note'],\n 'value' => currency_format($clOpportunity['value'], $clOpportunity['value_currency']),\n 'won' => $stage->probability === 100.00,\n 'closed' => $clOpportunity['status_type'] !== 'active',\n 'stage' => [\n 'id' => $stage->id_string,\n 'name' => $stage->name,\n ],\n 'recordType' => [],\n ];\n\n if ($ownerId && isset($clOpportunity['user_id']) && $clOpportunity['user_id'] === $ownerId) {\n $ownerData[] = $record;\n }\n\n $data[] = $record;\n }\n\n if (! empty($ownerData)) {\n return $ownerData;\n }\n }\n\n return $data;\n }\n\n /**\n * @inheritdoc\n */\n public function getTasks(string $objectType, string $objectId, ?string $opportunityId): array\n {\n $data = [];\n $crmId = null;\n\n if ($objectType === 'contact') {\n $contact = $this->syncContact($objectId);\n\n if ($contact && $contact->account_id) {\n $crmId = $contact->account->crm_provider_id;\n }\n } else {\n $crmId = $objectId;\n }\n\n if ($crmId) {\n $clTasks = $this->client->get('task', [\n 'lead_id' => $crmId,\n '_type' => 'lead',\n 'assigned_to' => $this->profile->crm_provider_id,\n 'is_complete' => 'false',\n '_order_by' => 'date',\n ]);\n\n foreach ($clTasks['data'] as $clTask) {\n $data[] = [\n 'crmId' => $clTask['id'],\n 'subject' => $clTask['text'],\n 'due' => $clTask['date'] ?? null,\n 'type' => null,\n ];\n }\n }\n\n return $data;\n }\n\n /**\n * Try to find email address in CRM service\n *\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n *}\n */\n public function matchExactlyByEmail(string $email, ?int $userId = null): ?array\n {\n $clObject = $this->client->get('lead', [\n 'query' => 'sort:updated contact(email(email:\"' . $email . '\"))',\n '_limit' => 1,\n ]);\n\n if ($clObject['total_results'] > 0) {\n foreach ($clObject['data'][0]['contacts'] as $clContact) {\n foreach ($clContact['emails'] as $clEmail) {\n if ($email === $clEmail['email']) {\n $data = $this->convertCrmData($clContact['id'], $userId);\n\n return ! empty(array_filter($data)) ? $data : null;\n }\n }\n }\n }\n\n return null;\n }\n\n /**\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n *}\n */\n public function matchByPhone(string $phone, ?string $rawPhoneNumber = null, ?int $userId = null): ?array\n {\n // Check if the user is internal.\n $teamMember = $this->team->users()->where('phone', $phone)->exists();\n\n // Skip the attendee if internal.\n if ($teamMember === false) {\n $clObject = $this->client->get('lead', [\n 'query' => 'sort:updated contact(' . $phone . ')',\n '_limit' => 1,\n ]);\n\n if ($clObject['total_results'] > 0) {\n foreach ($clObject['data'][0]['contacts'] as $clContact) {\n foreach ($clContact['phones'] as $clPhone) {\n if ($phone === $clPhone['phone']) {\n $data = $this->convertCrmData($clContact['id'], $userId);\n\n return ! empty(array_filter($data)) ? $data : null;\n }\n }\n }\n }\n }\n\n return null;\n }\n\n /**\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n public function matchByName(string $name, ?int $userId = null): ?array\n {\n // Don't waste time searching for single character strings.\n if (\\strlen($name) <= 1) {\n return null;\n }\n\n $cacheKey = $this->getCacheKey($name, $userId);\n\n $result = Cache::remember($cacheKey, 60, function () use ($name, $userId) {\n $clObject = $this->client->get('lead', [\n 'query' => 'sort:updated contact(name:\"' . $name . '\")',\n '_limit' => 1,\n ]);\n\n if ($clObject['total_results'] > 0) {\n foreach ($clObject['data'][0]['contacts'] as $clContact) {\n if ($clContact['name'] === $name || $clContact['display_name'] === $name) {\n $data = $this->convertCrmData($clContact['id'], $userId);\n\n return ! empty(array_filter($data)) ? $data : false;\n }\n }\n }\n\n return false;\n });\n\n return is_array($result) ? $result : null;\n }\n\n protected function getCacheKey(string $object, ?int $userId = null): ?string\n {\n $key = $this->team->getId() . $object;\n $keySuffix = $this->getOwnerKeySuffix($userId);\n\n return $key . $keySuffix;\n }\n\n private function getOwnerKeySuffix(?int $userId = null): string\n {\n return $userId === null ? '' : (string) $userId;\n }\n\n private function convertCrmData(string $crmId, ?int $userId = null): array\n {\n $lead = null;\n $opportunity = null;\n $account = null;\n $stage = null;\n $countryCode = null;\n\n $contact = $this->syncContact($crmId);\n if ($contact) {\n $account = $contact->account;\n\n if ($contact->country_code) {\n $countryCode = $contact->country_code;\n } elseif ($account) {\n $countryCode = $account->country_code;\n }\n\n try {\n $cpOpportunities = $this->findOpportunities(\n $account?->crm_provider_id,\n $contact->crm_provider_id,\n $userId,\n );\n\n if (! empty($cpOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($cpOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n } catch (Exception) {\n // Nothing to see here.\n }\n }\n\n return [\n $lead,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n }\n\n public function saveActivity(Activity $activity): Activity\n {\n switch ($activity->type) {\n case Activity::TYPE_CONFERENCE:\n case Activity::TYPE_SOFTPHONE:\n case Activity::TYPE_SOFTPHONE_INBOUND:\n $activity = $this->buildCallPayload($activity);\n\n break;\n case Activity::TYPE_SMS_INBOUND:\n case Activity::TYPE_SMS_OUTBOUND:\n $activity = $this->buildTextMessagePayload($activity);\n\n break;\n }\n\n return $activity;\n }\n\n private function mapStatus(string $status): string\n {\n switch ($status) {\n case Activity::STATUS_COMPLETED:\n case Activity::STATUS_IN_PROGRESS:\n case Activity::STATUS_FAILED:\n case Activity::STATUS_NO_ANSWER:\n case Activity::STATUS_BUSY:\n default:\n return $status;\n case Activity::STATUS_CANCELLED:\n return 'cancel';\n }\n }\n\n /**\n * @throws CrmException\n */\n private function buildCallPayload(Activity $activity): Activity\n {\n try {\n if ($activity->crm_provider_id) {\n // The activity should be logged under the existing Task (not Activity).\n $data = [\n '_type' => 'lead',\n 'lead_id' => $activity->account->crm_provider_id,\n 'assigned_to' => $this->profile->crm_provider_id,\n 'text' => $this->generateActivityDescription($activity),\n 'date' => $activity->getActualEndTime()->toDateString(),\n 'is_complete' => true,\n ];\n\n $this->logger->info('[Close CRM] Updating task', [\n 'activity' => $activity->id,\n 'crm_id' => $activity->crm_provider_id,\n 'data' => $data,\n ]);\n\n $this->client->put('task/' . $activity->crm_provider_id, $data);\n } else {\n // Just create an activity.\n $data = [\n 'lead_id' => $activity->account_id ? $activity->account->crm_provider_id : null,\n 'contact_id' => $activity->contact_id ? $activity->contact->crm_provider_id : null,\n 'created_by' => $this->profile->crm_provider_id,\n 'user_id' => $this->profile->crm_provider_id,\n 'direction' => $activity->getCrmType() === Activity::TYPE_SOFTPHONE ? 'outbound' : 'inbound',\n 'status' => $this->mapStatus($activity->getStatus()),\n 'note' => $this->generateActivityDescription($activity),\n 'duration' => $this->mapStatus($activity->getStatus()) !== 'cancel' ? $activity->duration : 0,\n 'phone' => $activity->to ? $activity->to->phone_number : null,\n ];\n\n $clActivity = $this->client->post('activity/call', $data);\n\n $this->logger->info('[Close CRM] Creating activity', [\n 'activity' => $activity->id,\n 'crm_id' => $clActivity['id'],\n 'data' => $data,\n 'response' => $clActivity,\n ]);\n\n $activity->crm_provider_id = $clActivity['id'];\n $activity->save();\n }\n } catch (ClientException $exception) {\n $response = $exception->getResponse();\n\n if ($response === null) {\n // Trying to debug weird cases where this is null.\n Sentry::captureException($exception);\n }\n\n $responseBody = $response->getBody();\n $message = $responseBody;\n $errorCode = $response->getStatusCode();\n\n $jsonResponse = json_decode($responseBody, true);\n if (isset($jsonResponse[0]['message'])) {\n $message = $jsonResponse[0]['message'];\n }\n\n throw new CrmException($message, $errorCode);\n }\n\n return $activity;\n }\n\n private function buildTextMessagePayload(Activity $activity): Activity\n {\n $clActivity = $this->client->post('activity/sms', [\n 'lead_id' => $activity->account_id ? $activity->account->crm_provider_id : null,\n 'contact_id' => $activity->contact_id ? $activity->contact->crm_provider_id : null,\n 'created_by' => $this->profile->crm_provider_id,\n 'user_id' => $this->profile->crm_provider_id,\n 'status' => $activity->getType() === Activity::TYPE_SMS_OUTBOUND ? 'sent' : 'inbox',\n 'text' => $this->generateActivityDescription($activity),\n 'remote_phone' => $activity->to ? $activity->to->phone_number : null,\n 'local_phone' => $activity->to ? $activity->to->phone_number : null,\n 'source' => 'Close.io',\n ]);\n\n $activity->crm_provider_id = $clActivity['id'];\n $activity->save();\n\n return $activity;\n }\n\n private function generateActivityDescription(Activity $activity): string\n {\n $description = '';\n\n switch ($activity->type) {\n case Activity::TYPE_SOFTPHONE:\n case Activity::TYPE_SOFTPHONE_INBOUND:\n case Activity::TYPE_CONFERENCE:\n if ($activity->hasActivityType()) {\n $description = $activity->getActivityType()->getName() . PHP_EOL . PHP_EOL;\n }\n if ($activity->hasTitle()) {\n $description .= $activity->getTitle() . PHP_EOL;\n }\n\n if ($activity->hasReasonCodeBotKicked()) {\n $description .= 'Notetaker removed from this meeting' . PHP_EOL . PHP_EOL;\n // When we fix the state to be Activity::RECORDING_RECORDED as it should be this can change.\n } elseif ($activity->hasReasonCodeNotCompliant()) {\n $description .= 'Notetaker did not join due to recording consent not being provided by attendees' . PHP_EOL . PHP_EOL;\n } elseif ($activity->canReviewActivity()) {\n $playbackUrl = $activity->user->team->partner->getPlaybackUrl($activity);\n $description .= 'Review Activity:' . PHP_EOL . $playbackUrl . PHP_EOL . PHP_EOL;\n }\n\n if ($activity->type === Activity::TYPE_CONFERENCE) {\n $description .= 'Attendees:'\n . PHP_EOL\n . (new FilterJoinedParticipants())->toString($activity);\n }\n\n if (\\count($activity->notes) > 0) {\n $description .= PHP_EOL . PHP_EOL . 'Notes: ' . PHP_EOL;\n\n foreach ($activity->notes as $note) {\n $time = ($note->time > 3600) ? gmdate('H:i:s', $note->time) : gmdate('i:s', $note->time);\n $description .= $time . ' ' . $note->note . PHP_EOL;\n }\n }\n\n // Get all private messages.\n $messages = $activity->messages()\n ->where('is_private', 1)\n ->orderBy('created_at', 'asc');\n\n if ($messages->count() > 0) {\n $description .= PHP_EOL . PHP_EOL . 'Coaching Chat: ' . PHP_EOL;\n\n foreach ($messages->get() as $message) {\n $description .= $message->participant->name . ': ' . $message->message . PHP_EOL;\n }\n }\n\n // Get all public messages.\n $messages = $activity->messages()\n ->where('is_private', 0)\n ->orderBy('created_at', 'asc');\n\n if ($messages->count() > 0) {\n $description .= PHP_EOL . PHP_EOL . 'Customer Chat: ' . PHP_EOL;\n\n foreach ($messages->get() as $message) {\n $description .= $message->participant->name . ': ' . $message->message . PHP_EOL;\n }\n }\n\n if ($activity->summary) {\n $description .= PHP_EOL . PHP_EOL . 'Summary: ' . PHP_EOL . $activity->summary;\n }\n\n break;\n\n case Activity::TYPE_SMS_INBOUND:\n case Activity::TYPE_SMS_OUTBOUND:\n $description = $activity->description;\n\n break;\n }\n\n return $description;\n }\n\n public function saveFollowupActivity(Activity $activity, array $fields): ?string\n {\n // This is the user provided activity subject field.\n if (empty($fields['name'])) {\n return null;\n }\n\n $due = null;\n if (empty($fields['due_date']) === false) {\n $formatDue = Carbon::parse($fields['due_date']);\n $due = $formatDue->toDateTimeString();\n }\n\n $clTask = $this->client->post('task', [\n '_type' => 'lead',\n 'lead_id' => $activity->account->crm_provider_id,\n 'assigned_to' => $this->profile->crm_provider_id,\n 'text' => $fields['name'],\n 'date' => $due,\n 'is_complete' => false,\n ]);\n\n // We don't actually create a corresponding activity object on our side yet.\n return $clTask['id'];\n }\n\n /**\n * Store transcripts as note.\n *\n * @throws Exception\n */\n public function createTranscriptNotes(Activity $activity): void\n {\n if ($activity->account_id === null) {\n // We can only log to accounts (leads).\n return;\n }\n\n // Generate activity transcription.\n $transcriptionData = $this->generateTranscription($activity);\n\n // Truncate Notes with max notes length because transcription text could be very long.\n $transcripts = mb_strimwidth($transcriptionData, 0, self::NOTE_BODY_MAX_LENGTH);\n\n $clActivity = $this->client->post('activity/note', [\n 'lead_id' => $activity->account_id ? $activity->account->crm_provider_id : null,\n 'created_by' => $this->profile->crm_provider_id,\n 'user_id' => $this->profile->crm_provider_id,\n 'note' => $transcripts,\n ]);\n\n // Store CRM Activity ID in transcription.\n $transcription = $activity->getTranscription();\n $transcription->crm_activity_id = $clActivity['id'];\n $transcription->save();\n }\n\n public function parseObjectType(string $objectId): string\n {\n if (Str::startsWith($objectId, 'lead')) {\n return 'account';\n }\n\n if (Str::startsWith($objectId, 'cont')) {\n return 'contact';\n }\n\n if (Str::startsWith($objectId, 'oppo')) {\n return 'opportunity';\n }\n\n throw new InvalidArgumentException('Unsupported Object Type');\n }\n\n /**\n * @inheritdoc\n */\n public function updateStage($crmObject, Stage $stage): void\n {\n if ($crmObject instanceof Lead) {\n // This would never get invoked since we merge lead/accounts in Close.\n $this->client->put('lead/' . $crmObject->crm_provider_id, [\n 'status' => $stage->crm_provider_id,\n ]);\n } else {\n $this->client->put('opportunity/' . $crmObject->crm_provider_id, [\n 'status_id' => $stage->crm_provider_id,\n ]);\n }\n }\n\n public function updateRecord(string $objectType, string $objectId, array $data, array $headers = []): void\n {\n $this->getClient()->updateObject($this->convertObjectTypeToResource($objectType), $objectId, $data);\n }\n\n public function prepareValueForUpdate(array $params): array\n {\n $convertedValue = $this->fieldValueConverter->convertToCrm(\n $this->config,\n $params['fieldName'],\n $params['fieldValue'],\n );\n\n if ($this->isCustomField($params['fieldName'])) {\n $params['fieldName'] = 'custom.' . $params['fieldName'];\n }\n\n $params['fieldValue'] = $convertedValue;\n\n return parent::prepareValueForUpdate($params);\n }\n\n public function getRecord(string $objectType, string $objectId, array $fields = []): array\n {\n return $this->client->get($this->convertObjectTypeToResource($objectType) . '/' . $objectId);\n }\n\n /**\n *\n * @throws UnexpectedValueException\n */\n private function convertObjectTypeToResource(string $objectType): string\n {\n switch ($objectType) {\n case FieldData::OBJECT_OPPORTUNITY:\n return 'opportunity';\n\n case FieldData::OBJECT_CONTACT:\n return 'contact';\n\n case FieldData::OBJECT_ACCOUNT:\n return 'lead';\n\n case FieldData::OBJECT_TASK:\n return 'activity';\n\n default:\n throw new UnexpectedValueException('Unsupported object type \"' . $objectType . '\"');\n }\n }\n\n public function generateProviderUrl(string $providerId, string $objectType): ?string\n {\n $baseUrl = 'https://app.close.com/';\n $url = null;\n\n switch ($objectType) {\n case 'account':\n $url = $baseUrl . 'lead/' . $providerId;\n\n break;\n\n case 'contact':\n $contact = $this->config->contacts()->where('crm_provider_id', $providerId)->first();\n if ($contact && $contact->account_id) {\n $url = $baseUrl . 'lead/' . $contact->account->crm_provider_id;\n }\n\n break;\n\n default:\n // Sadly we can't deeplink to anything else in Close UI.\n $url = null;\n }\n\n return $url;\n }\n\n /**\n * Generate transcription for the activity.\n */\n private function generateTranscription(Activity $activity): string\n {\n if (! $this->config->store_transcript) {\n // If sending transcription to activity toggle is disabled\n return '';\n }\n\n return $this->transcriptionService\n ->findTranscriptionByActivity($activity)\n ->map(static function (array $transcriptionSegment): string {\n return $transcriptionSegment['formattedStartsAt'] . ' | ' . $transcriptionSegment['transcript'];\n })\n ->implode(PHP_EOL);\n }\n\n public function verifyTaskExists(Activity $activity): bool\n {\n $crmProviderId = $activity->getCrmProviderId();\n $cacheKey = \"crm_task_exists:{$this->config->getId()}:$crmProviderId\";\n\n return Cache::remember($cacheKey, self::TASK_VERIFICATION_CACHE_TTL, function () use ($crmProviderId) {\n try {\n $client = $this->getClient();\n $task = $client->get('task/' . $crmProviderId);\n\n return ! empty($task);\n } catch (HttpNotFoundException) {\n // Task not found in CRM - this is expected and permanent\n $this->logger->info('[Close] Task not found during verification', [\n 'task_id' => $crmProviderId,\n 'config_id' => $this->config->getId(),\n ]);\n\n return false;\n } catch (CloseException $e) {\n // Handle 404 responses from Close API\n if ($e->getResponseStatusCode() === 404) {\n $this->logger->info('[Close] Task not found during verification (404)', [\n 'task_id' => $crmProviderId,\n 'config_id' => $this->config->getId(),\n ]);\n\n return false;\n }\n\n // Re-throw other Close exceptions for retry\n throw $e;\n }\n // Let other exceptions (network errors, rate limits, etc.) bubble up for retry\n });\n }\n}","depth":4,"on_screen":true,"value":"<?php\n\nnamespace Jiminny\\Services\\Crm\\Close;\n\nuse Cache;\nuse Carbon\\Carbon;\nuse Exception;\nuse GuzzleHttp\\Exception\\ClientException;\nuse Illuminate\\Support\\Str;\nuse InvalidArgumentException;\nuse Jiminny\\Contracts\\Services\\Crm\\LayoutManagementInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\MatchCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\Provider\\CloseInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityLookupInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityManipulationInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SettingsInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SupportsObjectTypeParseInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmMetadataInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\VerifyTaskExistsInterface;\nuse Jiminny\\Exceptions\\CrmException;\nuse Jiminny\\Exceptions\\HttpNotFoundException;\nuse Jiminny\\Exceptions\\ServiceUnavailableException;\nuse Jiminny\\Exceptions\\UnexpectedCallException;\nuse Jiminny\\Models\\Account;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Contact;\nuse Jiminny\\Models\\Crm\\Configuration;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Models\\Crm\\FieldData;\nuse Jiminny\\Models\\Crm\\Profile;\nuse Jiminny\\Models\\Lead;\nuse Jiminny\\Models\\Opportunity;\nuse Jiminny\\Models\\Playbook;\nuse Jiminny\\Models\\SocialAccount;\nuse Jiminny\\Models\\Stage;\nuse Jiminny\\Models\\User;\nuse Jiminny\\Repositories\\Crm\\FieldRepository;\nuse Jiminny\\Repositories\\Crm\\ProfileRepository;\nuse Jiminny\\Services\\Avatar\\ProspectPhotoPathService;\nuse Jiminny\\Services\\Crm\\BaseService;\nuse Jiminny\\Services\\Crm\\Close\\Processor\\AccountProcessor;\nuse Jiminny\\Services\\Crm\\Close\\Processor\\MetadataProcessor;\nuse Jiminny\\Services\\Crm\\Close\\Processor\\OpportunityProcessor;\nuse Jiminny\\Services\\Crm\\Close\\Processor\\StageProcessor;\nuse Jiminny\\Services\\Crm\\Helpers\\FilterJoinedParticipants;\nuse Jiminny\\Services\\Crm\\Metadata\\ProfileMetadata;\nuse Jiminny\\Services\\Crm\\OpportunitySyncStrategyResolver;\nuse Sentry;\nuse UnexpectedValueException;\n\nclass Service extends BaseService implements\n CloseInterface,\n SyncCrmEntitiesInterface,\n SyncCrmMetadataInterface,\n RemoteEntityManipulationInterface,\n LayoutManagementInterface,\n SettingsInterface,\n MatchCrmEntitiesInterface,\n RemoteEntityLookupInterface,\n SupportsObjectTypeParseInterface,\n VerifyTaskExistsInterface\n{\n private const int NOTE_BODY_MAX_LENGTH = 3000000;\n\n private const int TASK_VERIFICATION_CACHE_TTL = 86400; // 1 day\n\n private StandardFieldMetadata $standardFieldMetadata;\n private MetadataProcessor $metadataProcessor;\n private FieldValueConverter $fieldValueConverter;\n private StageProcessor $stageProcessor;\n private OpportunityProcessor $opportunityProcessor;\n private AccountProcessor $accountProcessor;\n\n public function __construct(\n Client $client,\n StandardFieldMetadata $standardFieldMetadata,\n MetadataProcessor $metadataProcessor,\n FieldValueConverter $fieldValueConverter,\n StageProcessor $stageResolver,\n OpportunityProcessor $opportunityProcessor,\n AccountProcessor $accountProcessor,\n private readonly ProspectPhotoPathService $prospectPhotoPathService,\n ) {\n parent::__construct();\n\n $this->client = $client;\n $this->standardFieldMetadata = $standardFieldMetadata;\n $this->metadataProcessor = $metadataProcessor;\n $this->fieldValueConverter = $fieldValueConverter;\n $this->stageProcessor = $stageResolver;\n $this->opportunityProcessor = $opportunityProcessor;\n $this->accountProcessor = $accountProcessor;\n }\n\n public function getDisplayName(): string\n {\n return 'Close';\n }\n\n public function setConfiguration(Configuration $config): void\n {\n parent::setConfiguration($config);\n\n $this->metadataProcessor->setConfiguration($config);\n $this->stageProcessor->setConfiguration($config);\n $this->opportunityProcessor->setConfiguration($config);\n $this->accountProcessor->setConfiguration($config);\n }\n\n protected function getOAuthAccount(User $user): ?SocialAccount\n {\n // In this case, the Account Owner is always the connection for any API operations.\n return $user->getTeam()->getOwner()->getSocialAccount(SocialAccount::PROVIDER_CLOSE);\n }\n\n private function getClient(): Client\n {\n if (! $this->client instanceof Client) {\n throw new UnexpectedCallException('Client not set');\n }\n\n return $this->client;\n }\n\n public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string\n {\n return $this->fieldValueConverter->convertFromCrm($fieldType, $fieldValue);\n }\n\n protected function getFieldTypes(): array\n {\n return [\n parent::OBJECT_OPPORTUNITY,\n parent::OBJECT_CONTACT,\n parent::OBJECT_ACCOUNT,\n ];\n }\n\n protected function getFields(string $crmObject): array\n {\n // not used\n return [];\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultFields(string $activityType): array\n {\n $fields = [];\n\n if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {\n $defaultFields = FieldDefinitions::defaultTaskFields();\n\n // This lazy creates these fields if not already setup.\n foreach ($defaultFields as $defaultField) {\n $fields[] = $this->config->fields()->firstOrCreate($defaultField);\n }\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultActivityField(string $activityType): Field\n {\n // Set up the activity field as the default Type.\n /** @var Field $activityField */\n $activityField = $this->config->fields()->where([\n 'crm_provider_id' => 'type',\n 'object_type' => $activityType,\n ])->first();\n\n return $activityField;\n }\n\n /**\n * @inheritdoc\n */\n public function getSupportedPlaybookTypes(): array\n {\n return [Playbook::ACTIVITY_TYPE_TASK];\n }\n\n public function getDealInsightsFields(): array\n {\n return FieldDefinitions::dealInsightsFields();\n }\n\n protected function getDefaultFollowupLayoutFields(string $activityType): array\n {\n $fields = [];\n $fieldRepo = app(FieldRepository::class);\n\n $fieldData = FieldDefinitions::followupFieldsFilter();\n\n foreach ($fieldData as $data) {\n $field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $data);\n\n // Only add the field if it is created, which it should be.\n if ($field) {\n $fields[] = $field;\n }\n }\n\n return $fields;\n }\n\n public function syncFields(): void\n {\n $this->syncStandardFields();\n $this->syncCustomFields();\n }\n\n /**\n * @important Works only for custom fields\n */\n public function syncField(Field $field): void\n {\n $resource = $this->convertObjectTypeToResource($field->getObjectType());\n\n // We can only sync custom fields in this CRM.\n if ($this->isCustomField($field->getCrmProviderId()) === false) {\n return;\n }\n\n $crmField = $this->getClient()->fetchCustomFieldDefinition($resource, $field->getCrmProviderId());\n\n $this->metadataProcessor->syncField($crmField);\n }\n\n private function isCustomField(string $fieldId): bool\n {\n return strpos($fieldId, 'cf_') === 0;\n }\n\n /**\n * @inheritdoc\n */\n public function importPicklistValues(Field $field): array\n {\n // handled in syncFields()\n return [];\n }\n\n /**\n * @important We only support stages on the opportunity object\n *\n * @param string[]|null $types\n */\n public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage\n {\n if (! $missingStageName) {\n // This is taken care of by syncOrganization()\n return null;\n }\n\n $stage = $this->stageProcessor->resolveFromStageId($missingStageName);\n\n if ($stage instanceof Stage) {\n return $stage;\n }\n\n $stageMetadata = $this->getClient()->fetchStage($missingStageName);\n\n if (! $stageMetadata) {\n $this->logger->error('Stage does not exist', [\n 'stage' => $missingStageName,\n ]);\n\n return null;\n }\n\n\n return $this->stageProcessor->importStage($stageMetadata);\n }\n\n /**\n * @inheritdoc\n */\n public function syncLeads(Carbon $since, ?Carbon $to = null, ?string $crmProfileId = null): int\n {\n // Even though Close.io has the concept of \"leads\", they fit more into our concept of accounts.\n return 0;\n }\n\n /**\n * @inheritdoc\n */\n public function syncLead(string $crmId): ?Lead\n {\n // Not a supported entity.\n return null;\n }\n\n /**\n * @throws Exception\n */\n public function syncAccounts(Carbon $since, ?Carbon $to = null): int\n {\n $syncCount = 0;\n\n try {\n foreach ($this->getClient()->listAccounts($since) as $clAccount) {\n // Only sync if previously imported.\n if ($this->hasAccount($clAccount->getId())) {\n $this->importAccount($clAccount);\n $syncCount++;\n }\n }\n } catch (Exception $exception) {\n $this->logger->error('Account sync failed', [\n 'error' => $exception->getMessage(),\n ]);\n\n throw $exception;\n }\n\n return $syncCount;\n }\n\n public function syncAccount(string $crmId): ?Account\n {\n return $this->accountProcessor->syncAccount($crmId);\n }\n\n private function importAccount($crmData): Account\n {\n return $this->accountProcessor->importAccountMetadata($crmData);\n }\n\n /**\n * @throws CloseException\n */\n public function syncOpportunities(array $parameters, ?string $strategy = null): int\n {\n $strategyResolver = app(OpportunitySyncStrategyResolver::class, [\n 'client' => $this->client,\n ]);\n $strategies = $strategyResolver->getStrategies($this->config, $strategy);\n\n $syncCount = 0;\n\n try {\n $opportunities = [];\n foreach ($strategies as $syncStrategy) {\n $opportunitiesData = $syncStrategy->fetchOpportunities($parameters);\n $opportunities[] = $opportunitiesData['data'];\n\n if ($opportunitiesData['has_more']) {\n $this->logger->info('[Close] Sync Opportunities - count warning', [\n 'team_id' => $this->config->getTeam()->getId(),\n 'total' => $opportunitiesData['total'],\n 'count' => $opportunitiesData['count'],\n 'skip' => $opportunitiesData['skip'],\n 'strategies_count' => count($strategies),\n ]);\n }\n }\n\n $opportunities = array_merge(...$opportunities);\n } catch (CrmException $exception) {\n $this->logger->error('Fetching opportunity data failed', [\n 'team' => $this->getTeam()->getSlug(),\n 'error' => $exception->getMessage(),\n ]);\n\n return 0;\n }\n\n foreach ($opportunities as $opportunityMetadata) {\n try {\n $this->importOpportunity($opportunityMetadata);\n $syncCount++;\n } catch (Exception $exception) {\n $this->logger->warning('Opportunity sync failed', [\n 'opportunity' => $opportunityMetadata->getId(),\n 'error' => $exception->getMessage(),\n ]);\n }\n }\n\n return $syncCount;\n }\n\n public function syncOpportunity(string $crmId): ?Opportunity\n {\n $strategyResolver = app(OpportunitySyncStrategyResolver::class, [\n 'client' => $this->client,\n ]);\n\n $strategy = $strategyResolver->resolve(\n $this->config,\n OpportunitySyncStrategyResolver::SINGLE_SYNC_OPPORTUNITY_STRATEGY,\n );\n\n $parameters = ['crm_id' => $crmId];\n\n $opportunity = $strategy->fetchOpportunities($parameters);\n\n if (empty($opportunity['data'])) {\n return null;\n }\n\n return $this->importOpportunity($opportunity['data']);\n }\n\n private function importOpportunity($crmData): ?Opportunity\n {\n if (! $crmData->getLeadId()) {\n $this->logger->warning('Opportunity does not have a lead ID', [\n 'opportunity' => $crmData->getId(),\n ]);\n\n return null;\n }\n\n $account = $this->getConfiguration()\n ->accounts()\n ->where('crm_provider_id', $crmData->getLeadId())\n ->first()\n ;\n\n if ($account === null) {\n $account = $this->accountProcessor->syncAccount($crmData->getLeadId());\n }\n\n $profile = $this->getConfiguration()\n ->profiles()\n ->where('crm_provider_id', $crmData->getUserId())\n ->first()\n ;\n\n $stage = $this->getConfiguration()\n ->stages()\n ->where('crm_provider_id', $crmData->getStageId())\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->first()\n ;\n\n if ($stage === null) {\n $stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $crmData->getStageId());\n }\n\n return $this->opportunityProcessor->import($crmData, $account, $stage, $profile);\n }\n\n /**\n * @param array<string,string> $crmData\n * @param string[] $crmFields\n */\n public function importOpportunityCrmFieldData(array $crmData, array $crmFields, int $opportunityId): void\n {\n // handled in importOpportunity\n }\n\n /**\n * @inheritdoc\n */\n public function syncContacts(Carbon $since, ?Carbon $to = null): int\n {\n $syncCount = 0;\n\n try {\n /** No way to sync today.\n $clContacts = $this->client->get('lead', [\n 'date_updated__gte' => $since->toDateString(),\n '_order_by' => '-date_updated',\n ]);\n\n foreach ($clContacts as $clContact) {\n // Only sync if previously imported.\n if ($this->hasContact($clContact['id'])) {\n $this->importContact($clContact);\n $syncCount++;\n }\n }\n **/\n } catch (Exception $exception) {\n // Do nothing for now.\n throw $exception;\n }\n\n return $syncCount;\n }\n\n /**\n * @inheritdoc\n */\n public function syncContact(string $crmId): ?Contact\n {\n try {\n $clContact = $this->client->get('contact/' . $crmId);\n } catch (HttpNotFoundException $exception) {\n return null;\n }\n\n return $this->importContact($clContact);\n }\n\n /**\n * @inheritdoc\n */\n private function importContact($crmData): Contact\n {\n $account = null;\n if ($crmData['lead_id']) {\n $account = $this->team\n ->accounts()\n ->where('crm_provider_id', $crmData['lead_id'])\n ->first();\n\n if ($account === null) {\n $account = $this->syncAccount($crmData['lead_id']);\n }\n }\n\n $mobilePhone = $parsedNumber = null;\n foreach ($crmData['phones'] as $phoneNumber) {\n if ($phoneNumber['type'] === 'mobile') {\n $mobilePhone = $this->buildContactMobilePhone(null, $phoneNumber['phone']);\n } else {\n $parsedNumber = $this->buildContactPhone(null, $phoneNumber['phone']);\n }\n }\n\n $email = null;\n if (empty($crmData['emails']) === false) {\n $email = mb_strimwidth($crmData['emails'][0]['email'], 0, 80);\n }\n\n $profile = $this->config->profiles()->where('crm_provider_id', (string) $crmData['updated_by'])->first();\n\n $data = [\n 'account_id' => $account->id ?? null,\n 'team_id' => $this->team->id,\n 'user_id' => $profile?->user_id,\n 'owner_id' => $crmData['updated_by'],\n 'name' => $crmData['name'] ?? 'Unknown',\n 'email' => $email,\n 'phone' => $parsedNumber['phone'] ?? null,\n 'mobile_phone' => $mobilePhone ?? null,\n 'ext' => $parsedNumber['ext'] ?? null,\n 'title' => mb_strimwidth($crmData['title'] ?? '', 0, 128),\n 'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(\n crmConfiguration: $this->config,\n crmProviderId: $crmData['id'],\n modelType: Contact::class,\n fileName: $crmData['id'],\n avatarText: $crmData['name'] ?? 'Unknown'\n ),\n 'remotely_created_at' => Carbon::parse($crmData['date_created']),\n ];\n\n /** @var Contact */\n return $this->config->contacts()->updateOrCreate(['crm_provider_id' => (string) $crmData['id']], $data);\n }\n\n private function buildContactPhone(?string $countryCode, ?string $number): ?array\n {\n if ($number) {\n // Trim to our width and attempt to parse it.\n $number = mb_strimwidth($number, 0, 25);\n $parsedNumber = parsePhoneNumber($countryCode, $number);\n } else {\n $parsedNumber = [];\n }\n\n return $parsedNumber;\n }\n\n private function buildContactMobilePhone(?string $countryCode, ?string $number): ?string\n {\n return $number ? mb_strimwidth(phone_e164($countryCode, $number), 0, 25) : null;\n }\n\n public function syncOrganization(): void\n {\n $organisation = $this->getClient()->fetchOrganisation();\n\n $this->metadataProcessor->syncOrganisation($organisation);\n\n foreach ($organisation->getPipelines() as $pipelineMetadata) {\n $this->metadataProcessor->syncPipeline($pipelineMetadata);\n }\n }\n\n private function syncStandardFields(): void\n {\n // Currently we sync only opportunity fields\n $stages = $this->getClient()->listStages();\n foreach ($this->standardFieldMetadata->opportunity($stages) as $fieldMetadata) {\n $this->metadataProcessor->syncField($fieldMetadata);\n }\n\n $this->config->save();\n }\n\n private function syncCustomFields(): void\n {\n foreach ($this->getFieldTypes() as $fieldType) {\n $objectType = $this->convertObjectTypeToResource($fieldType);\n $currentFields = $this->getClient()->listCustomFieldDefinitions($objectType);\n\n foreach ($currentFields as $fieldMetadata) {\n $this->metadataProcessor->syncField($fieldMetadata);\n }\n }\n\n $this->config->save();\n }\n\n public function syncProfiles(?User $userToSearch = null): ?Profile\n {\n /*\n * Fetch the profile of the user from the database\n * Then fetch the user metadata from Close and update it\n * In case there's no profile for the user, proceed with syncing all users\n */\n $foundUser = null;\n\n if ($userToSearch) {\n $profile = $userToSearch->getProfile();\n\n if ($profile instanceof Profile) {\n $crmProviderId = $profile->getCrmProviderId();\n\n if ($crmProviderId) {\n $profileMetadata = $this->getClient()->fetchUser($crmProviderId);\n\n if (! $profileMetadata instanceof ProfileMetadata) {\n return null;\n }\n\n return $this->metadataProcessor->syncProfile($profileMetadata);\n }\n }\n }\n\n foreach ($this->getClient()->listUsers() as $userMetadata) {\n $userProfile = $this->metadataProcessor->syncProfile($userMetadata);\n\n if (\n $userToSearch instanceof User\n && $userProfile instanceof Profile\n && $userProfile->getUserId() === $userToSearch->getId()\n ) {\n $foundUser = $userProfile;\n }\n }\n\n return $foundUser;\n }\n\n public function syncProfileFields(): void\n {\n // Not used.\n }\n\n /**\n * @inheritdoc\n */\n public function find(string $name, array $scopes): array\n {\n $count = $this->limit ?? 20;\n $offset = $this->offset ?? 0;\n\n $data = Cache::remember($this->team->id . $name . $count . $offset, 300, function () use ($name, $count, $offset) {\n $data = [];\n\n try {\n // If search phrase resembles phone number remove special symbols\n if (preg_match('/^([0-9\\s\\-\\+\\(\\)]*)$/', $name)) {\n $name = '+' . preg_replace('/[\\s\\-\\+\\(\\)]/', '', $name);\n }\n\n // Close do not provide a unified way to search, so we must hack our own.\n $objects = $this->client->get('lead', [\n 'query' => 'sort:date_updated name:(' . $name . ') or email:(' . $name . ') or phone:(' . $name . ')',\n '_limit' => $count, '_skip' => $offset,\n ]);\n } catch (\\GuzzleHttp\\Exception\\ServerException $exception) {\n throw new ServiceUnavailableException($exception->getMessage());\n }\n\n foreach ($objects['data'] as $object) {\n // We need a contact to dial it.\n if (empty($object['contacts'])) {\n continue;\n }\n\n foreach ($object['contacts'] as $contact) {\n $record = [\n 'crmId' => $contact['id'],\n 'crmUrl' => $this->generateProviderUrl($object['id'], 'account'),\n 'name' => $contact['name'],\n 'industry' => null,\n 'title' => $contact['title'],\n 'organization' => $object['display_name'],\n 'prospectType' => 'contact',\n 'phoneNumbers' => [],\n ];\n\n foreach ($contact['phones'] as $phone) {\n if ($phone['type'] === 'mobile') {\n $number = $this->buildContactMobilePhone(null, $phone['phone']);\n\n $record['phoneNumbers'][] = [\n 'number' => $number,\n 'nationalFormat' => phone_national(null, $number),\n 'type' => 'mobile',\n ];\n } else {\n $parsedNumber = $this->buildContactPhone(null, $phone['phone']);\n\n // Add phone number to record.\n if (empty($parsedNumber['phone']) === false) {\n $record['phoneNumbers'][] = [\n 'number' => $parsedNumber['phone'],\n 'nationalFormat' => phone_national(null, $parsedNumber['phone']),\n 'type' => 'phone',\n ];\n }\n }\n }\n\n $data[] = $record;\n }\n }\n\n return $data;\n });\n\n return $data;\n }\n\n /**\n * @inheritdoc\n */\n public function findOpportunities(?string $crmAccountId, ?string $crmContactId, ?int $userId = null): array\n {\n $data = [];\n $ownerData = [];\n $ownerId = null;\n $contact = null;\n $account = null;\n\n if ($crmAccountId) {\n $account = $this->config->accounts()->where('crm_provider_id', $crmAccountId)->first();\n\n if ($account === null) {\n $account = $this->syncAccount($crmAccountId);\n }\n }\n\n if ($crmContactId) {\n $contact = $this->config->contacts()->where('crm_provider_id', $crmContactId)->first();\n\n if ($contact === null) {\n $contact = $this->syncContact($crmContactId);\n }\n }\n\n if ($contact || $account) {\n if ($contact && $account === null) {\n $account = $contact->account;\n }\n\n if ($account === null) {\n return [];\n }\n\n $params = [\n 'lead_id' => $account->crm_provider_id,\n '_order_by' => '-date_updated',\n ];\n\n $onlyOpen = true;\n switch ($this->config->opportunity_assignment_rule) {\n case Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_UPDATED:\n $params['_order_by'] = '-date_updated';\n\n break;\n\n case Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_CREATED:\n $params['_order_by'] = '-date_created';\n\n break;\n\n case Configuration::OPP_ASSIGNMENT_ALL_OPEN_OLDEST_CREATED:\n $params['_order_by'] = 'date_created';\n\n break;\n\n case Configuration::OPP_ASSIGNMENT_ALL_RECENTLY_UPDATED:\n $params['_order_by'] = '-date_updated';\n $onlyOpen = false;\n }\n\n if ($onlyOpen) {\n $params['status_type__in'] = 'active,won';\n }\n\n $clOpportunities = $this->client->get('opportunity', $params);\n\n if ($userId) {\n $profileRepository = app(ProfileRepository::class);\n $profile = $profileRepository->findProfileByUserId($this->config, $userId);\n\n $ownerId = $profile instanceof Profile ? $profile->getCrmProviderId() : null;\n }\n\n foreach ($clOpportunities['data'] as $clOpportunity) {\n $stage = $this->config\n ->stages()\n ->where('crm_provider_id', $clOpportunity['status_id'])\n ->first();\n\n if ($stage === null) {\n // Import it.\n $stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $clOpportunity['status_id']);\n }\n\n $record = [\n 'crmId' => $clOpportunity['id'],\n 'name' => $clOpportunity['note'],\n 'value' => currency_format($clOpportunity['value'], $clOpportunity['value_currency']),\n 'won' => $stage->probability === 100.00,\n 'closed' => $clOpportunity['status_type'] !== 'active',\n 'stage' => [\n 'id' => $stage->id_string,\n 'name' => $stage->name,\n ],\n 'recordType' => [],\n ];\n\n if ($ownerId && isset($clOpportunity['user_id']) && $clOpportunity['user_id'] === $ownerId) {\n $ownerData[] = $record;\n }\n\n $data[] = $record;\n }\n\n if (! empty($ownerData)) {\n return $ownerData;\n }\n }\n\n return $data;\n }\n\n /**\n * @inheritdoc\n */\n public function getTasks(string $objectType, string $objectId, ?string $opportunityId): array\n {\n $data = [];\n $crmId = null;\n\n if ($objectType === 'contact') {\n $contact = $this->syncContact($objectId);\n\n if ($contact && $contact->account_id) {\n $crmId = $contact->account->crm_provider_id;\n }\n } else {\n $crmId = $objectId;\n }\n\n if ($crmId) {\n $clTasks = $this->client->get('task', [\n 'lead_id' => $crmId,\n '_type' => 'lead',\n 'assigned_to' => $this->profile->crm_provider_id,\n 'is_complete' => 'false',\n '_order_by' => 'date',\n ]);\n\n foreach ($clTasks['data'] as $clTask) {\n $data[] = [\n 'crmId' => $clTask['id'],\n 'subject' => $clTask['text'],\n 'due' => $clTask['date'] ?? null,\n 'type' => null,\n ];\n }\n }\n\n return $data;\n }\n\n /**\n * Try to find email address in CRM service\n *\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n *}\n */\n public function matchExactlyByEmail(string $email, ?int $userId = null): ?array\n {\n $clObject = $this->client->get('lead', [\n 'query' => 'sort:updated contact(email(email:\"' . $email . '\"))',\n '_limit' => 1,\n ]);\n\n if ($clObject['total_results'] > 0) {\n foreach ($clObject['data'][0]['contacts'] as $clContact) {\n foreach ($clContact['emails'] as $clEmail) {\n if ($email === $clEmail['email']) {\n $data = $this->convertCrmData($clContact['id'], $userId);\n\n return ! empty(array_filter($data)) ? $data : null;\n }\n }\n }\n }\n\n return null;\n }\n\n /**\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n *}\n */\n public function matchByPhone(string $phone, ?string $rawPhoneNumber = null, ?int $userId = null): ?array\n {\n // Check if the user is internal.\n $teamMember = $this->team->users()->where('phone', $phone)->exists();\n\n // Skip the attendee if internal.\n if ($teamMember === false) {\n $clObject = $this->client->get('lead', [\n 'query' => 'sort:updated contact(' . $phone . ')',\n '_limit' => 1,\n ]);\n\n if ($clObject['total_results'] > 0) {\n foreach ($clObject['data'][0]['contacts'] as $clContact) {\n foreach ($clContact['phones'] as $clPhone) {\n if ($phone === $clPhone['phone']) {\n $data = $this->convertCrmData($clContact['id'], $userId);\n\n return ! empty(array_filter($data)) ? $data : null;\n }\n }\n }\n }\n }\n\n return null;\n }\n\n /**\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n public function matchByName(string $name, ?int $userId = null): ?array\n {\n // Don't waste time searching for single character strings.\n if (\\strlen($name) <= 1) {\n return null;\n }\n\n $cacheKey = $this->getCacheKey($name, $userId);\n\n $result = Cache::remember($cacheKey, 60, function () use ($name, $userId) {\n $clObject = $this->client->get('lead', [\n 'query' => 'sort:updated contact(name:\"' . $name . '\")',\n '_limit' => 1,\n ]);\n\n if ($clObject['total_results'] > 0) {\n foreach ($clObject['data'][0]['contacts'] as $clContact) {\n if ($clContact['name'] === $name || $clContact['display_name'] === $name) {\n $data = $this->convertCrmData($clContact['id'], $userId);\n\n return ! empty(array_filter($data)) ? $data : false;\n }\n }\n }\n\n return false;\n });\n\n return is_array($result) ? $result : null;\n }\n\n protected function getCacheKey(string $object, ?int $userId = null): ?string\n {\n $key = $this->team->getId() . $object;\n $keySuffix = $this->getOwnerKeySuffix($userId);\n\n return $key . $keySuffix;\n }\n\n private function getOwnerKeySuffix(?int $userId = null): string\n {\n return $userId === null ? '' : (string) $userId;\n }\n\n private function convertCrmData(string $crmId, ?int $userId = null): array\n {\n $lead = null;\n $opportunity = null;\n $account = null;\n $stage = null;\n $countryCode = null;\n\n $contact = $this->syncContact($crmId);\n if ($contact) {\n $account = $contact->account;\n\n if ($contact->country_code) {\n $countryCode = $contact->country_code;\n } elseif ($account) {\n $countryCode = $account->country_code;\n }\n\n try {\n $cpOpportunities = $this->findOpportunities(\n $account?->crm_provider_id,\n $contact->crm_provider_id,\n $userId,\n );\n\n if (! empty($cpOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($cpOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n } catch (Exception) {\n // Nothing to see here.\n }\n }\n\n return [\n $lead,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n }\n\n public function saveActivity(Activity $activity): Activity\n {\n switch ($activity->type) {\n case Activity::TYPE_CONFERENCE:\n case Activity::TYPE_SOFTPHONE:\n case Activity::TYPE_SOFTPHONE_INBOUND:\n $activity = $this->buildCallPayload($activity);\n\n break;\n case Activity::TYPE_SMS_INBOUND:\n case Activity::TYPE_SMS_OUTBOUND:\n $activity = $this->buildTextMessagePayload($activity);\n\n break;\n }\n\n return $activity;\n }\n\n private function mapStatus(string $status): string\n {\n switch ($status) {\n case Activity::STATUS_COMPLETED:\n case Activity::STATUS_IN_PROGRESS:\n case Activity::STATUS_FAILED:\n case Activity::STATUS_NO_ANSWER:\n case Activity::STATUS_BUSY:\n default:\n return $status;\n case Activity::STATUS_CANCELLED:\n return 'cancel';\n }\n }\n\n /**\n * @throws CrmException\n */\n private function buildCallPayload(Activity $activity): Activity\n {\n try {\n if ($activity->crm_provider_id) {\n // The activity should be logged under the existing Task (not Activity).\n $data = [\n '_type' => 'lead',\n 'lead_id' => $activity->account->crm_provider_id,\n 'assigned_to' => $this->profile->crm_provider_id,\n 'text' => $this->generateActivityDescription($activity),\n 'date' => $activity->getActualEndTime()->toDateString(),\n 'is_complete' => true,\n ];\n\n $this->logger->info('[Close CRM] Updating task', [\n 'activity' => $activity->id,\n 'crm_id' => $activity->crm_provider_id,\n 'data' => $data,\n ]);\n\n $this->client->put('task/' . $activity->crm_provider_id, $data);\n } else {\n // Just create an activity.\n $data = [\n 'lead_id' => $activity->account_id ? $activity->account->crm_provider_id : null,\n 'contact_id' => $activity->contact_id ? $activity->contact->crm_provider_id : null,\n 'created_by' => $this->profile->crm_provider_id,\n 'user_id' => $this->profile->crm_provider_id,\n 'direction' => $activity->getCrmType() === Activity::TYPE_SOFTPHONE ? 'outbound' : 'inbound',\n 'status' => $this->mapStatus($activity->getStatus()),\n 'note' => $this->generateActivityDescription($activity),\n 'duration' => $this->mapStatus($activity->getStatus()) !== 'cancel' ? $activity->duration : 0,\n 'phone' => $activity->to ? $activity->to->phone_number : null,\n ];\n\n $clActivity = $this->client->post('activity/call', $data);\n\n $this->logger->info('[Close CRM] Creating activity', [\n 'activity' => $activity->id,\n 'crm_id' => $clActivity['id'],\n 'data' => $data,\n 'response' => $clActivity,\n ]);\n\n $activity->crm_provider_id = $clActivity['id'];\n $activity->save();\n }\n } catch (ClientException $exception) {\n $response = $exception->getResponse();\n\n if ($response === null) {\n // Trying to debug weird cases where this is null.\n Sentry::captureException($exception);\n }\n\n $responseBody = $response->getBody();\n $message = $responseBody;\n $errorCode = $response->getStatusCode();\n\n $jsonResponse = json_decode($responseBody, true);\n if (isset($jsonResponse[0]['message'])) {\n $message = $jsonResponse[0]['message'];\n }\n\n throw new CrmException($message, $errorCode);\n }\n\n return $activity;\n }\n\n private function buildTextMessagePayload(Activity $activity): Activity\n {\n $clActivity = $this->client->post('activity/sms', [\n 'lead_id' => $activity->account_id ? $activity->account->crm_provider_id : null,\n 'contact_id' => $activity->contact_id ? $activity->contact->crm_provider_id : null,\n 'created_by' => $this->profile->crm_provider_id,\n 'user_id' => $this->profile->crm_provider_id,\n 'status' => $activity->getType() === Activity::TYPE_SMS_OUTBOUND ? 'sent' : 'inbox',\n 'text' => $this->generateActivityDescription($activity),\n 'remote_phone' => $activity->to ? $activity->to->phone_number : null,\n 'local_phone' => $activity->to ? $activity->to->phone_number : null,\n 'source' => 'Close.io',\n ]);\n\n $activity->crm_provider_id = $clActivity['id'];\n $activity->save();\n\n return $activity;\n }\n\n private function generateActivityDescription(Activity $activity): string\n {\n $description = '';\n\n switch ($activity->type) {\n case Activity::TYPE_SOFTPHONE:\n case Activity::TYPE_SOFTPHONE_INBOUND:\n case Activity::TYPE_CONFERENCE:\n if ($activity->hasActivityType()) {\n $description = $activity->getActivityType()->getName() . PHP_EOL . PHP_EOL;\n }\n if ($activity->hasTitle()) {\n $description .= $activity->getTitle() . PHP_EOL;\n }\n\n if ($activity->hasReasonCodeBotKicked()) {\n $description .= 'Notetaker removed from this meeting' . PHP_EOL . PHP_EOL;\n // When we fix the state to be Activity::RECORDING_RECORDED as it should be this can change.\n } elseif ($activity->hasReasonCodeNotCompliant()) {\n $description .= 'Notetaker did not join due to recording consent not being provided by attendees' . PHP_EOL . PHP_EOL;\n } elseif ($activity->canReviewActivity()) {\n $playbackUrl = $activity->user->team->partner->getPlaybackUrl($activity);\n $description .= 'Review Activity:' . PHP_EOL . $playbackUrl . PHP_EOL . PHP_EOL;\n }\n\n if ($activity->type === Activity::TYPE_CONFERENCE) {\n $description .= 'Attendees:'\n . PHP_EOL\n . (new FilterJoinedParticipants())->toString($activity);\n }\n\n if (\\count($activity->notes) > 0) {\n $description .= PHP_EOL . PHP_EOL . 'Notes: ' . PHP_EOL;\n\n foreach ($activity->notes as $note) {\n $time = ($note->time > 3600) ? gmdate('H:i:s', $note->time) : gmdate('i:s', $note->time);\n $description .= $time . ' ' . $note->note . PHP_EOL;\n }\n }\n\n // Get all private messages.\n $messages = $activity->messages()\n ->where('is_private', 1)\n ->orderBy('created_at', 'asc');\n\n if ($messages->count() > 0) {\n $description .= PHP_EOL . PHP_EOL . 'Coaching Chat: ' . PHP_EOL;\n\n foreach ($messages->get() as $message) {\n $description .= $message->participant->name . ': ' . $message->message . PHP_EOL;\n }\n }\n\n // Get all public messages.\n $messages = $activity->messages()\n ->where('is_private', 0)\n ->orderBy('created_at', 'asc');\n\n if ($messages->count() > 0) {\n $description .= PHP_EOL . PHP_EOL . 'Customer Chat: ' . PHP_EOL;\n\n foreach ($messages->get() as $message) {\n $description .= $message->participant->name . ': ' . $message->message . PHP_EOL;\n }\n }\n\n if ($activity->summary) {\n $description .= PHP_EOL . PHP_EOL . 'Summary: ' . PHP_EOL . $activity->summary;\n }\n\n break;\n\n case Activity::TYPE_SMS_INBOUND:\n case Activity::TYPE_SMS_OUTBOUND:\n $description = $activity->description;\n\n break;\n }\n\n return $description;\n }\n\n public function saveFollowupActivity(Activity $activity, array $fields): ?string\n {\n // This is the user provided activity subject field.\n if (empty($fields['name'])) {\n return null;\n }\n\n $due = null;\n if (empty($fields['due_date']) === false) {\n $formatDue = Carbon::parse($fields['due_date']);\n $due = $formatDue->toDateTimeString();\n }\n\n $clTask = $this->client->post('task', [\n '_type' => 'lead',\n 'lead_id' => $activity->account->crm_provider_id,\n 'assigned_to' => $this->profile->crm_provider_id,\n 'text' => $fields['name'],\n 'date' => $due,\n 'is_complete' => false,\n ]);\n\n // We don't actually create a corresponding activity object on our side yet.\n return $clTask['id'];\n }\n\n /**\n * Store transcripts as note.\n *\n * @throws Exception\n */\n public function createTranscriptNotes(Activity $activity): void\n {\n if ($activity->account_id === null) {\n // We can only log to accounts (leads).\n return;\n }\n\n // Generate activity transcription.\n $transcriptionData = $this->generateTranscription($activity);\n\n // Truncate Notes with max notes length because transcription text could be very long.\n $transcripts = mb_strimwidth($transcriptionData, 0, self::NOTE_BODY_MAX_LENGTH);\n\n $clActivity = $this->client->post('activity/note', [\n 'lead_id' => $activity->account_id ? $activity->account->crm_provider_id : null,\n 'created_by' => $this->profile->crm_provider_id,\n 'user_id' => $this->profile->crm_provider_id,\n 'note' => $transcripts,\n ]);\n\n // Store CRM Activity ID in transcription.\n $transcription = $activity->getTranscription();\n $transcription->crm_activity_id = $clActivity['id'];\n $transcription->save();\n }\n\n public function parseObjectType(string $objectId): string\n {\n if (Str::startsWith($objectId, 'lead')) {\n return 'account';\n }\n\n if (Str::startsWith($objectId, 'cont')) {\n return 'contact';\n }\n\n if (Str::startsWith($objectId, 'oppo')) {\n return 'opportunity';\n }\n\n throw new InvalidArgumentException('Unsupported Object Type');\n }\n\n /**\n * @inheritdoc\n */\n public function updateStage($crmObject, Stage $stage): void\n {\n if ($crmObject instanceof Lead) {\n // This would never get invoked since we merge lead/accounts in Close.\n $this->client->put('lead/' . $crmObject->crm_provider_id, [\n 'status' => $stage->crm_provider_id,\n ]);\n } else {\n $this->client->put('opportunity/' . $crmObject->crm_provider_id, [\n 'status_id' => $stage->crm_provider_id,\n ]);\n }\n }\n\n public function updateRecord(string $objectType, string $objectId, array $data, array $headers = []): void\n {\n $this->getClient()->updateObject($this->convertObjectTypeToResource($objectType), $objectId, $data);\n }\n\n public function prepareValueForUpdate(array $params): array\n {\n $convertedValue = $this->fieldValueConverter->convertToCrm(\n $this->config,\n $params['fieldName'],\n $params['fieldValue'],\n );\n\n if ($this->isCustomField($params['fieldName'])) {\n $params['fieldName'] = 'custom.' . $params['fieldName'];\n }\n\n $params['fieldValue'] = $convertedValue;\n\n return parent::prepareValueForUpdate($params);\n }\n\n public function getRecord(string $objectType, string $objectId, array $fields = []): array\n {\n return $this->client->get($this->convertObjectTypeToResource($objectType) . '/' . $objectId);\n }\n\n /**\n *\n * @throws UnexpectedValueException\n */\n private function convertObjectTypeToResource(string $objectType): string\n {\n switch ($objectType) {\n case FieldData::OBJECT_OPPORTUNITY:\n return 'opportunity';\n\n case FieldData::OBJECT_CONTACT:\n return 'contact';\n\n case FieldData::OBJECT_ACCOUNT:\n return 'lead';\n\n case FieldData::OBJECT_TASK:\n return 'activity';\n\n default:\n throw new UnexpectedValueException('Unsupported object type \"' . $objectType . '\"');\n }\n }\n\n public function generateProviderUrl(string $providerId, string $objectType): ?string\n {\n $baseUrl = 'https://app.close.com/';\n $url = null;\n\n switch ($objectType) {\n case 'account':\n $url = $baseUrl . 'lead/' . $providerId;\n\n break;\n\n case 'contact':\n $contact = $this->config->contacts()->where('crm_provider_id', $providerId)->first();\n if ($contact && $contact->account_id) {\n $url = $baseUrl . 'lead/' . $contact->account->crm_provider_id;\n }\n\n break;\n\n default:\n // Sadly we can't deeplink to anything else in Close UI.\n $url = null;\n }\n\n return $url;\n }\n\n /**\n * Generate transcription for the activity.\n */\n private function generateTranscription(Activity $activity): string\n {\n if (! $this->config->store_transcript) {\n // If sending transcription to activity toggle is disabled\n return '';\n }\n\n return $this->transcriptionService\n ->findTranscriptionByActivity($activity)\n ->map(static function (array $transcriptionSegment): string {\n return $transcriptionSegment['formattedStartsAt'] . ' | ' . $transcriptionSegment['transcript'];\n })\n ->implode(PHP_EOL);\n }\n\n public function verifyTaskExists(Activity $activity): bool\n {\n $crmProviderId = $activity->getCrmProviderId();\n $cacheKey = \"crm_task_exists:{$this->config->getId()}:$crmProviderId\";\n\n return Cache::remember($cacheKey, self::TASK_VERIFICATION_CACHE_TTL, function () use ($crmProviderId) {\n try {\n $client = $this->getClient();\n $task = $client->get('task/' . $crmProviderId);\n\n return ! empty($task);\n } catch (HttpNotFoundException) {\n // Task not found in CRM - this is expected and permanent\n $this->logger->info('[Close] Task not found during verification', [\n 'task_id' => $crmProviderId,\n 'config_id' => $this->config->getId(),\n ]);\n\n return false;\n } catch (CloseException $e) {\n // Handle 404 responses from Close API\n if ($e->getResponseStatusCode() === 404) {\n $this->logger->info('[Close] Task not found during verification (404)', [\n 'task_id' => $crmProviderId,\n 'config_id' => $this->config->getId(),\n ]);\n\n return false;\n }\n\n // Re-throw other Close exceptions for retry\n throw $e;\n }\n // Let other exceptions (network errors, rate limits, etc.) bubble up for retry\n });\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":true,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Execute","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Explain Plan","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Browse Query History","depth":4,"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"View Parameters","depth":4,"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Open Query Execution Settings…","depth":4,"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"In-Editor Results","depth":4,"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Tx: Auto","depth":4,"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Cancel Running Statements","depth":4,"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
-3545036367931030470
|
-9030663327281178587
|
click
|
accessibility
|
NULL
|
Project: faVsco.js, menu
#12066 on JY-20725-handle Project: faVsco.js, menu
#12066 on JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
HandleHubspotRateLimitTest
Run 'HandleHubspotRateLimitTest'
Debug 'HandleHubspotRateLimitTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Code changed:
Hide
Sync Changes
Hide This Notification
8
39
5
Previous Highlighted Error
Next Highlighted Error
<?php
namespace Jiminny\Services\Crm\Close;
use Cache;
use Carbon\Carbon;
use Exception;
use GuzzleHttp\Exception\ClientException;
use Illuminate\Support\Str;
use InvalidArgumentException;
use Jiminny\Contracts\Services\Crm\LayoutManagementInterface;
use Jiminny\Contracts\Services\Crm\MatchCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\Provider\CloseInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityLookupInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityManipulationInterface;
use Jiminny\Contracts\Services\Crm\SettingsInterface;
use Jiminny\Contracts\Services\Crm\SupportsObjectTypeParseInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmMetadataInterface;
use Jiminny\Contracts\Services\Crm\VerifyTaskExistsInterface;
use Jiminny\Exceptions\CrmException;
use Jiminny\Exceptions\HttpNotFoundException;
use Jiminny\Exceptions\ServiceUnavailableException;
use Jiminny\Exceptions\UnexpectedCallException;
use Jiminny\Models\Account;
use Jiminny\Models\Activity;
use Jiminny\Models\Contact;
use Jiminny\Models\Crm\Configuration;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\FieldData;
use Jiminny\Models\Crm\Profile;
use Jiminny\Models\Lead;
use Jiminny\Models\Opportunity;
use Jiminny\Models\Playbook;
use Jiminny\Models\SocialAccount;
use Jiminny\Models\Stage;
use Jiminny\Models\User;
use Jiminny\Repositories\Crm\FieldRepository;
use Jiminny\Repositories\Crm\ProfileRepository;
use Jiminny\Services\Avatar\ProspectPhotoPathService;
use Jiminny\Services\Crm\BaseService;
use Jiminny\Services\Crm\Close\Processor\AccountProcessor;
use Jiminny\Services\Crm\Close\Processor\MetadataProcessor;
use Jiminny\Services\Crm\Close\Processor\OpportunityProcessor;
use Jiminny\Services\Crm\Close\Processor\StageProcessor;
use Jiminny\Services\Crm\Helpers\FilterJoinedParticipants;
use Jiminny\Services\Crm\Metadata\ProfileMetadata;
use Jiminny\Services\Crm\OpportunitySyncStrategyResolver;
use Sentry;
use UnexpectedValueException;
class Service extends BaseService implements
CloseInterface,
SyncCrmEntitiesInterface,
SyncCrmMetadataInterface,
RemoteEntityManipulationInterface,
LayoutManagementInterface,
SettingsInterface,
MatchCrmEntitiesInterface,
RemoteEntityLookupInterface,
SupportsObjectTypeParseInterface,
VerifyTaskExistsInterface
{
private const int NOTE_BODY_MAX_LENGTH = 3000000;
private const int TASK_VERIFICATION_CACHE_TTL = 86400; // 1 day
private StandardFieldMetadata $standardFieldMetadata;
private MetadataProcessor $metadataProcessor;
private FieldValueConverter $fieldValueConverter;
private StageProcessor $stageProcessor;
private OpportunityProcessor $opportunityProcessor;
private AccountProcessor $accountProcessor;
public function __construct(
Client $client,
StandardFieldMetadata $standardFieldMetadata,
MetadataProcessor $metadataProcessor,
FieldValueConverter $fieldValueConverter,
StageProcessor $stageResolver,
OpportunityProcessor $opportunityProcessor,
AccountProcessor $accountProcessor,
private readonly ProspectPhotoPathService $prospectPhotoPathService,
) {
parent::__construct();
$this->client = $client;
$this->standardFieldMetadata = $standardFieldMetadata;
$this->metadataProcessor = $metadataProcessor;
$this->fieldValueConverter = $fieldValueConverter;
$this->stageProcessor = $stageResolver;
$this->opportunityProcessor = $opportunityProcessor;
$this->accountProcessor = $accountProcessor;
}
public function getDisplayName(): string
{
return 'Close';
}
public function setConfiguration(Configuration $config): void
{
parent::setConfiguration($config);
$this->metadataProcessor->setConfiguration($config);
$this->stageProcessor->setConfiguration($config);
$this->opportunityProcessor->setConfiguration($config);
$this->accountProcessor->setConfiguration($config);
}
protected function getOAuthAccount(User $user): ?SocialAccount
{
// In this case, the Account Owner is always the connection for any API operations.
return $user->getTeam()->getOwner()->getSocialAccount(SocialAccount::PROVIDER_CLOSE);
}
private function getClient(): Client
{
if (! $this->client instanceof Client) {
throw new UnexpectedCallException('Client not set');
}
return $this->client;
}
public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string
{
return $this->fieldValueConverter->convertFromCrm($fieldType, $fieldValue);
}
protected function getFieldTypes(): array
{
return [
parent::OBJECT_OPPORTUNITY,
parent::OBJECT_CONTACT,
parent::OBJECT_ACCOUNT,
];
}
protected function getFields(string $crmObject): array
{
// not used
return [];
}
/**
* @inheritdoc
*/
public function getDefaultFields(string $activityType): array
{
$fields = [];
if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {
$defaultFields = FieldDefinitions::defaultTaskFields();
// This lazy creates these fields if not already setup.
foreach ($defaultFields as $defaultField) {
$fields[] = $this->config->fields()->firstOrCreate($defaultField);
}
}
return $fields;
}
/**
* @inheritdoc
*/
public function getDefaultActivityField(string $activityType): Field
{
// Set up the activity field as the default Type.
/** @var Field $activityField */
$activityField = $this->config->fields()->where([
'crm_provider_id' => 'type',
'object_type' => $activityType,
])->first();
return $activityField;
}
/**
* @inheritdoc
*/
public function getSupportedPlaybookTypes(): array
{
return [Playbook::ACTIVITY_TYPE_TASK];
}
public function getDealInsightsFields(): array
{
return FieldDefinitions::dealInsightsFields();
}
protected function getDefaultFollowupLayoutFields(string $activityType): array
{
$fields = [];
$fieldRepo = app(FieldRepository::class);
$fieldData = FieldDefinitions::followupFieldsFilter();
foreach ($fieldData as $data) {
$field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $data);
// Only add the field if it is created, which it should be.
if ($field) {
$fields[] = $field;
}
}
return $fields;
}
public function syncFields(): void
{
$this->syncStandardFields();
$this->syncCustomFields();
}
/**
* @important Works only for custom fields
*/
public function syncField(Field $field): void
{
$resource = $this->convertObjectTypeToResource($field->getObjectType());
// We can only sync custom fields in this CRM.
if ($this->isCustomField($field->getCrmProviderId()) === false) {
return;
}
$crmField = $this->getClient()->fetchCustomFieldDefinition($resource, $field->getCrmProviderId());
$this->metadataProcessor->syncField($crmField);
}
private function isCustomField(string $fieldId): bool
{
return strpos($fieldId, 'cf_') === 0;
}
/**
* @inheritdoc
*/
public function importPicklistValues(Field $field): array
{
// handled in syncFields()
return [];
}
/**
* @important We only support stages on the opportunity object
*
* @param string[]|null $types
*/
public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage
{
if (! $missingStageName) {
// This is taken care of by syncOrganization()
return null;
}
$stage = $this->stageProcessor->resolveFromStageId($missingStageName);
if ($stage instanceof Stage) {
return $stage;
}
$stageMetadata = $this->getClient()->fetchStage($missingStageName);
if (! $stageMetadata) {
$this->logger->error('Stage does not exist', [
'stage' => $missingStageName,
]);
return null;
}
return $this->stageProcessor->importStage($stageMetadata);
}
/**
* @inheritdoc
*/
public function syncLeads(Carbon $since, ?Carbon $to = null, ?string $crmProfileId = null): int
{
// Even though Close.io has the concept of "leads", they fit more into our concept of accounts.
return 0;
}
/**
* @inheritdoc
*/
public function syncLead(string $crmId): ?Lead
{
// Not a supported entity.
return null;
}
/**
* @throws Exception
*/
public function syncAccounts(Carbon $since, ?Carbon $to = null): int
{
$syncCount = 0;
try {
foreach ($this->getClient()->listAccounts($since) as $clAccount) {
// Only sync if previously imported.
if ($this->hasAccount($clAccount->getId())) {
$this->importAccount($clAccount);
$syncCount++;
}
}
} catch (Exception $exception) {
$this->logger->error('Account sync failed', [
'error' => $exception->getMessage(),
]);
throw $exception;
}
return $syncCount;
}
public function syncAccount(string $crmId): ?Account
{
return $this->accountProcessor->syncAccount($crmId);
}
private function importAccount($crmData): Account
{
return $this->accountProcessor->importAccountMetadata($crmData);
}
/**
* @throws CloseException
*/
public function syncOpportunities(array $parameters, ?string $strategy = null): int
{
$strategyResolver = app(OpportunitySyncStrategyResolver::class, [
'client' => $this->client,
]);
$strategies = $strategyResolver->getStrategies($this->config, $strategy);
$syncCount = 0;
try {
$opportunities = [];
foreach ($strategies as $syncStrategy) {
$opportunitiesData = $syncStrategy->fetchOpportunities($parameters);
$opportunities[] = $opportunitiesData['data'];
if ($opportunitiesData['has_more']) {
$this->logger->info('[Close] Sync Opportunities - count warning', [
'team_id' => $this->config->getTeam()->getId(),
'total' => $opportunitiesData['total'],
'count' => $opportunitiesData['count'],
'skip' => $opportunitiesData['skip'],
'strategies_count' => count($strategies),
]);
}
}
$opportunities = array_merge(...$opportunities);
} catch (CrmException $exception) {
$this->logger->error('Fetching opportunity data failed', [
'team' => $this->getTeam()->getSlug(),
'error' => $exception->getMessage(),
]);
return 0;
}
foreach ($opportunities as $opportunityMetadata) {
try {
$this->importOpportunity($opportunityMetadata);
$syncCount++;
} catch (Exception $exception) {
$this->logger->warning('Opportunity sync failed', [
'opportunity' => $opportunityMetadata->getId(),
'error' => $exception->getMessage(),
]);
}
}
return $syncCount;
}
public function syncOpportunity(string $crmId): ?Opportunity
{
$strategyResolver = app(OpportunitySyncStrategyResolver::class, [
'client' => $this->client,
]);
$strategy = $strategyResolver->resolve(
$this->config,
OpportunitySyncStrategyResolver::SINGLE_SYNC_OPPORTUNITY_STRATEGY,
);
$parameters = ['crm_id' => $crmId];
$opportunity = $strategy->fetchOpportunities($parameters);
if (empty($opportunity['data'])) {
return null;
}
return $this->importOpportunity($opportunity['data']);
}
private function importOpportunity($crmData): ?Opportunity
{
if (! $crmData->getLeadId()) {
$this->logger->warning('Opportunity does not have a lead ID', [
'opportunity' => $crmData->getId(),
]);
return null;
}
$account = $this->getConfiguration()
->accounts()
->where('crm_provider_id', $crmData->getLeadId())
->first()
;
if ($account === null) {
$account = $this->accountProcessor->syncAccount($crmData->getLeadId());
}
$profile = $this->getConfiguration()
->profiles()
->where('crm_provider_id', $crmData->getUserId())
->first()
;
$stage = $this->getConfiguration()
->stages()
->where('crm_provider_id', $crmData->getStageId())
->where('type', Stage::TYPE_OPPORTUNITY)
->first()
;
if ($stage === null) {
$stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $crmData->getStageId());
}
return $this->opportunityProcessor->import($crmData, $account, $stage, $profile);
}
/**
* @param array<string,string> $crmData
* @param string[] $crmFields
*/
public function importOpportunityCrmFieldData(array $crmData, array $crmFields, int $opportunityId): void
{
// handled in importOpportunity
}
/**
* @inheritdoc
*/
public function syncContacts(Carbon $since, ?Carbon $to = null): int
{
$syncCount = 0;
try {
/** No way to sync today.
$clContacts = $this->client->get('lead', [
'date_updated__gte' => $since->toDateString(),
'_order_by' => '-date_updated',
]);
foreach ($clContacts as $clContact) {
// Only sync if previously imported.
if ($this->hasContact($clContact['id'])) {
$this->importContact($clContact);
$syncCount++;
}
}
**/
} catch (Exception $exception) {
// Do nothing for now.
throw $exception;
}
return $syncCount;
}
/**
* @inheritdoc
*/
public function syncContact(string $crmId): ?Contact
{
try {
$clContact = $this->client->get('contact/' . $crmId);
} catch (HttpNotFoundException $exception) {
return null;
}
return $this->importContact($clContact);
}
/**
* @inheritdoc
*/
private function importContact($crmData): Contact
{
$account = null;
if ($crmData['lead_id']) {
$account = $this->team
->accounts()
->where('crm_provider_id', $crmData['lead_id'])
->first();
if ($account === null) {
$account = $this->syncAccount($crmData['lead_id']);
}
}
$mobilePhone = $parsedNumber = null;
foreach ($crmData['phones'] as $phoneNumber) {
if ($phoneNumber['type'] === 'mobile') {
$mobilePhone = $this->buildContactMobilePhone(null, $phoneNumber['phone']);
} else {
$parsedNumber = $this->buildContactPhone(null, $phoneNumber['phone']);
}
}
$email = null;
if (empty($crmData['emails']) === false) {
$email = mb_strimwidth($crmData['emails'][0]['email'], 0, 80);
}
$profile = $this->config->profiles()->where('crm_provider_id', (string) $crmData['updated_by'])->first();
$data = [
'account_id' => $account->id ?? null,
'team_id' => $this->team->id,
'user_id' => $profile?->user_id,
'owner_id' => $crmData['updated_by'],
'name' => $crmData['name'] ?? 'Unknown',
'email' => $email,
'phone' => $parsedNumber['phone'] ?? null,
'mobile_phone' => $mobilePhone ?? null,
'ext' => $parsedNumber['ext'] ?? null,
'title' => mb_strimwidth($crmData['title'] ?? '', 0, 128),
'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(
crmConfiguration: $this->config,
crmProviderId: $crmData['id'],
modelType: Contact::class,
fileName: $crmData['id'],
avatarText: $crmData['name'] ?? 'Unknown'
),
'remotely_created_at' => Carbon::parse($crmData['date_created']),
];
/** @var Contact */
return $this->config->contacts()->updateOrCreate(['crm_provider_id' => (string) $crmData['id']], $data);
}
private function buildContactPhone(?string $countryCode, ?string $number): ?array
{
if ($number) {
// Trim to our width and attempt to parse it.
$number = mb_strimwidth($number, 0, 25);
$parsedNumber = parsePhoneNumber($countryCode, $number);
} else {
$parsedNumber = [];
}
return $parsedNumber;
}
private function buildContactMobilePhone(?string $countryCode, ?string $number): ?string
{
return $number ? mb_strimwidth(phone_e164($countryCode, $number), 0, 25) : null;
}
public function syncOrganization(): void
{
$organisation = $this->getClient()->fetchOrganisation();
$this->metadataProcessor->syncOrganisation($organisation);
foreach ($organisation->getPipelines() as $pipelineMetadata) {
$this->metadataProcessor->syncPipeline($pipelineMetadata);
}
}
private function syncStandardFields(): void
{
// Currently we sync only opportunity fields
$stages = $this->getClient()->listStages();
foreach ($this->standardFieldMetadata->opportunity($stages) as $fieldMetadata) {
$this->metadataProcessor->syncField($fieldMetadata);
}
$this->config->save();
}
private function syncCustomFields(): void
{
foreach ($this->getFieldTypes() as $fieldType) {
$objectType = $this->convertObjectTypeToResource($fieldType);
$currentFields = $this->getClient()->listCustomFieldDefinitions($objectType);
foreach ($currentFields as $fieldMetadata) {
$this->metadataProcessor->syncField($fieldMetadata);
}
}
$this->config->save();
}
public function syncProfiles(?User $userToSearch = null): ?Profile
{
/*
* Fetch the profile of the user from the database
* Then fetch the user metadata from Close and update it
* In case there's no profile for the user, proceed with syncing all users
*/
$foundUser = null;
if ($userToSearch) {
$profile = $userToSearch->getProfile();
if ($profile instanceof Profile) {
$crmProviderId = $profile->getCrmProviderId();
if ($crmProviderId) {
$profileMetadata = $this->getClient()->fetchUser($crmProviderId);
if (! $profileMetadata instanceof ProfileMetadata) {
return null;
}
return $this->metadataProcessor->syncProfile($profileMetadata);
}
}
}
foreach ($this->getClient()->listUsers() as $userMetadata) {
$userProfile = $this->metadataProcessor->syncProfile($userMetadata);
if (
$userToSearch instanceof User
&& $userProfile instanceof Profile
&& $userProfile->getUserId() === $userToSearch->getId()
) {
$foundUser = $userProfile;
}
}
return $foundUser;
}
public function syncProfileFields(): void
{
// Not used.
}
/**
* @inheritdoc
*/
public function find(string $name, array $scopes): array
{
$count = $this->limit ?? 20;
$offset = $this->offset ?? 0;
$data = Cache::remember($this->team->id . $name . $count . $offset, 300, function () use ($name, $count, $offset) {
$data = [];
try {
// If search phrase resembles phone number remove special symbols
if (preg_match('/^([0-9\s\-\+\(\)]*)$/', $name)) {
$name = '+' . preg_replace('/[\s\-\+\(\)]/', '', $name);
}
// Close do not provide a unified way to search, so we must hack our own.
$objects = $this->client->get('lead', [
'query' => 'sort:date_updated name:(' . $name . ') or email:(' . $name . ') or phone:(' . $name . ')',
'_limit' => $count, '_skip' => $offset,
]);
} catch (\GuzzleHttp\Exception\ServerException $exception) {
throw new ServiceUnavailableException($exception->getMessage());
}
foreach ($objects['data'] as $object) {
// We need a contact to dial it.
if (empty($object['contacts'])) {
continue;
}
foreach ($object['contacts'] as $contact) {
$record = [
'crmId' => $contact['id'],
'crmUrl' => $this->generateProviderUrl($object['id'], 'account'),
'name' => $contact['name'],
'industry' => null,
'title' => $contact['title'],
'organization' => $object['display_name'],
'prospectType' => 'contact',
'phoneNumbers' => [],
];
foreach ($contact['phones'] as $phone) {
if ($phone['type'] === 'mobile') {
$number = $this->buildContactMobilePhone(null, $phone['phone']);
$record['phoneNumbers'][] = [
'number' => $number,
'nationalFormat' => phone_national(null, $number),
'type' => 'mobile',
];
} else {
$parsedNumber = $this->buildContactPhone(null, $phone['phone']);
// Add phone number to record.
if (empty($parsedNumber['phone']) === false) {
$record['phoneNumbers'][] = [
'number' => $parsedNumber['phone'],
'nationalFormat' => phone_national(null, $parsedNumber['phone']),
'type' => 'phone',
];
}
}
}
$data[] = $record;
}
}
return $data;
});
return $data;
}
/**
* @inheritdoc
*/
public function findOpportunities(?string $crmAccountId, ?string $crmContactId, ?int $userId = null): array
{
$data = [];
$ownerData = [];
$ownerId = null;
$contact = null;
$account = null;
if ($crmAccountId) {
$account = $this->config->accounts()->where('crm_provider_id', $crmAccountId)->first();
if ($account === null) {
$account = $this->syncAccount($crmAccountId);
}
}
if ($crmContactId) {
$contact = $this->config->contacts()->where('crm_provider_id', $crmContactId)->first();
if ($contact === null) {
$contact = $this->syncContact($crmContactId);
}
}
if ($contact || $account) {
if ($contact && $account === null) {
$account = $contact->account;
}
if ($account === null) {
return [];
}
$params = [
'lead_id' => $account->crm_provider_id,
'_order_by' => '-date_updated',
];
$onlyOpen = true;
switch ($this->config->opportunity_assignment_rule) {
case Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_UPDATED:
$params['_order_by'] = '-date_updated';
break;
case Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_CREATED:
$params['_order_by'] = '-date_created';
break;
case Configuration::OPP_ASSIGNMENT_ALL_OPEN_OLDEST_CREATED:
$params['_order_by'] = 'date_created';
break;
case Configuration::OPP_ASSIGNMENT_ALL_RECENTLY_UPDATED:
$params['_order_by'] = '-date_updated';
$onlyOpen = false;
}
if ($onlyOpen) {
$params['status_type__in'] = 'active,won';
}
$clOpportunities = $this->client->get('opportunity', $params);
if ($userId) {
$profileRepository = app(ProfileRepository::class);
$profile = $profileRepository->findProfileByUserId($this->config, $userId);
$ownerId = $profile instanceof Profile ? $profile->getCrmProviderId() : null;
}
foreach ($clOpportunities['data'] as $clOpportunity) {
$stage = $this->config
->stages()
->where('crm_provider_id', $clOpportunity['status_id'])
->first();
if ($stage === null) {
// Import it.
$stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $clOpportunity['status_id']);
}
$record = [
'crmId' => $clOpportunity['id'],
'name' => $clOpportunity['note'],
'value' => currency_format($clOpportunity['value'], $clOpportunity['value_currency']),
'won' => $stage->probability === 100.00,
'closed' => $clOpportunity['status_type'] !== 'active',
'stage' => [
'id' => $stage->id_string,
'name' => $stage->name,
],
'recordType' => [],
];
if ($ownerId && isset($clOpportunity['user_id']) && $clOpportunity['user_id'] === $ownerId) {
$ownerData[] = $record;
}
$data[] = $record;
}
if (! empty($ownerData)) {
return $ownerData;
}
}
return $data;
}
/**
* @inheritdoc
*/
public function getTasks(string $objectType, string $objectId, ?string $opportunityId): array
{
$data = [];
$crmId = null;
if ($objectType === 'contact') {
$contact = $this->syncContact($objectId);
if ($contact && $contact->account_id) {
$crmId = $contact->account->crm_provider_id;
}
} else {
$crmId = $objectId;
}
if ($crmId) {
$clTasks = $this->client->get('task', [
'lead_id' => $crmId,
'_type' => 'lead',
'assigned_to' => $this->profile->crm_provider_id,
'is_complete' => 'false',
'_order_by' => 'date',
]);
foreach ($clTasks['data'] as $clTask) {
$data[] = [
'crmId' => $clTask['id'],
'subject' => $clTask['text'],
'due' => $clTask['date'] ?? null,
'type' => null,
];
}
}
return $data;
}
/**
* Try to find email address in CRM service
*
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
*}
*/
public function matchExactlyByEmail(string $email, ?int $userId = null): ?array
{
$clObject = $this->client->get('lead', [
'query' => 'sort:updated contact(email(email:"' . $email . '"))',
'_limit' => 1,
]);
if ($clObject['total_results'] > 0) {
foreach ($clObject['data'][0]['contacts'] as $clContact) {
foreach ($clContact['emails'] as $clEmail) {
if ($email === $clEmail['email']) {
$data = $this->convertCrmData($clContact['id'], $userId);
return ! empty(array_filter($data)) ? $data : null;
}
}
}
}
return null;
}
/**
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
*}
*/
public function matchByPhone(string $phone, ?string $rawPhoneNumber = null, ?int $userId = null): ?array
{
// Check if the user is internal.
$teamMember = $this->team->users()->where('phone', $phone)->exists();
// Skip the attendee if internal.
if ($teamMember === false) {
$clObject = $this->client->get('lead', [
'query' => 'sort:updated contact(' . $phone . ')',
'_limit' => 1,
]);
if ($clObject['total_results'] > 0) {
foreach ($clObject['data'][0]['contacts'] as $clContact) {
foreach ($clContact['phones'] as $clPhone) {
if ($phone === $clPhone['phone']) {
$data = $this->convertCrmData($clContact['id'], $userId);
return ! empty(array_filter($data)) ? $data : null;
}
}
}
}
}
return null;
}
/**
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchByName(string $name, ?int $userId = null): ?array
{
// Don't waste time searching for single character strings.
if (\strlen($name) <= 1) {
return null;
}
$cacheKey = $this->getCacheKey($name, $userId);
$result = Cache::remember($cacheKey, 60, function () use ($name, $userId) {
$clObject = $this->client->get('lead', [
'query' => 'sort:updated contact(name:"' . $name . '")',
'_limit' => 1,
]);
if ($clObject['total_results'] > 0) {
foreach ($clObject['data'][0]['contacts'] as $clContact) {
if ($clContact['name'] === $name || $clContact['display_name'] === $name) {
$data = $this->convertCrmData($clContact['id'], $userId);
return ! empty(array_filter($data)) ? $data : false;
}
}
}
return false;
});
return is_array($result) ? $result : null;
}
protected function getCacheKey(string $object, ?int $userId = null): ?string
{
$key = $this->team->getId() . $object;
$keySuffix = $this->getOwnerKeySuffix($userId);
return $key . $keySuffix;
}
private function getOwnerKeySuffix(?int $userId = null): string
{
return $userId === null ? '' : (string) $userId;
}
private function convertCrmData(string $crmId, ?int $userId = null): array
{
$lead = null;
$opportunity = null;
$account = null;
$stage = null;
$countryCode = null;
$contact = $this->syncContact($crmId);
if ($contact) {
$account = $contact->account;
if ($contact->country_code) {
$countryCode = $contact->country_code;
} elseif ($account) {
$countryCode = $account->country_code;
}
try {
$cpOpportunities = $this->findOpportunities(
$account?->crm_provider_id,
$contact->crm_provider_id,
$userId,
);
if (! empty($cpOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($cpOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
} catch (Exception) {
// Nothing to see here.
}
}
return [
$lead,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
public function saveActivity(Activity $activity): Activity
{
switch ($activity->type) {
case Activity::TYPE_CONFERENCE:
case Activity::TYPE_SOFTPHONE:
case Activity::TYPE_SOFTPHONE_INBOUND:
$activity = $this->buildCallPayload($activity);
break;
case Activity::TYPE_SMS_INBOUND:
case Activity::TYPE_SMS_OUTBOUND:
$activity = $this->buildTextMessagePayload($activity);
break;
}
return $activity;
}
private function mapStatus(string $status): string
{
switch ($status) {
case Activity::STATUS_COMPLETED:
case Activity::STATUS_IN_PROGRESS:
case Activity::STATUS_FAILED:
case Activity::STATUS_NO_ANSWER:
case Activity::STATUS_BUSY:
default:
return $status;
case Activity::STATUS_CANCELLED:
return 'cancel';
}
}
/**
* @throws CrmException
*/
private function buildCallPayload(Activity $activity): Activity
{
try {
if ($activity->crm_provider_id) {
// The activity should be logged under the existing Task (not Activity).
$data = [
'_type' => 'lead',
'lead_id' => $activity->account->crm_provider_id,
'assigned_to' => $this->profile->crm_provider_id,
'text' => $this->generateActivityDescription($activity),
'date' => $activity->getActualEndTime()->toDateString(),
'is_complete' => true,
];
$this->logger->info('[Close CRM] Updating task', [
'activity' => $activity->id,
'crm_id' => $activity->crm_provider_id,
'data' => $data,
]);
$this->client->put('task/' . $activity->crm_provider_id, $data);
} else {
// Just create an activity.
$data = [
'lead_id' => $activity->account_id ? $activity->account->crm_provider_id : null,
'contact_id' => $activity->contact_id ? $activity->contact->crm_provider_id : null,
'created_by' => $this->profile->crm_provider_id,
'user_id' => $this->profile->crm_provider_id,
'direction' => $activity->getCrmType() === Activity::TYPE_SOFTPHONE ? 'outbound' : 'inbound',
'status' => $this->mapStatus($activity->getStatus()),
'note' => $this->generateActivityDescription($activity),
'duration' => $this->mapStatus($activity->getStatus()) !== 'cancel' ? $activity->duration : 0,
'phone' => $activity->to ? $activity->to->phone_number : null,
];
$clActivity = $this->client->post('activity/call', $data);
$this->logger->info('[Close CRM] Creating activity', [
'activity' => $activity->id,
'crm_id' => $clActivity['id'],
'data' => $data,
'response' => $clActivity,
]);
$activity->crm_provider_id = $clActivity['id'];
$activity->save();
}
} catch (ClientException $exception) {
$response = $exception->getResponse();
if ($response === null) {
// Trying to debug weird cases where this is null.
Sentry::captureException($exception);
}
$responseBody = $response->getBody();
$message = $responseBody;
$errorCode = $response->getStatusCode();
$jsonResponse = json_decode($responseBody, true);
if (isset($jsonResponse[0]['message'])) {
$message = $jsonResponse[0]['message'];
}
throw new CrmException($message, $errorCode);
}
return $activity;
}
private function buildTextMessagePayload(Activity $activity): Activity
{
$clActivity = $this->client->post('activity/sms', [
'lead_id' => $activity->account_id ? $activity->account->crm_provider_id : null,
'contact_id' => $activity->contact_id ? $activity->contact->crm_provider_id : null,
'created_by' => $this->profile->crm_provider_id,
'user_id' => $this->profile->crm_provider_id,
'status' => $activity->getType() === Activity::TYPE_SMS_OUTBOUND ? 'sent' : 'inbox',
'text' => $this->generateActivityDescription($activity),
'remote_phone' => $activity->to ? $activity->to->phone_number : null,
'local_phone' => $activity->to ? $activity->to->phone_number : null,
'source' => 'Close.io',
]);
$activity->crm_provider_id = $clActivity['id'];
$activity->save();
return $activity;
}
private function generateActivityDescription(Activity $activity): string
{
$description = '';
switch ($activity->type) {
case Activity::TYPE_SOFTPHONE:
case Activity::TYPE_SOFTPHONE_INBOUND:
case Activity::TYPE_CONFERENCE:
if ($activity->hasActivityType()) {
$description = $activity->getActivityType()->getName() . PHP_EOL . PHP_EOL;
}
if ($activity->hasTitle()) {
$description .= $activity->getTitle() . PHP_EOL;
}
if ($activity->hasReasonCodeBotKicked()) {
$description .= 'Notetaker removed from this meeting' . PHP_EOL . PHP_EOL;
// When we fix the state to be Activity::RECORDING_RECORDED as it should be this can change.
} elseif ($activity->hasReasonCodeNotCompliant()) {
$description .= 'Notetaker did not join due to recording consent not being provided by attendees' . PHP_EOL . PHP_EOL;
} elseif ($activity->canReviewActivity()) {
$playbackUrl = $activity->user->team->partner->getPlaybackUrl($activity);
$description .= 'Review Activity:' . PHP_EOL . $playbackUrl . PHP_EOL . PHP_EOL;
}
if ($activity->type === Activity::TYPE_CONFERENCE) {
$description .= 'Attendees:'
. PHP_EOL
. (new FilterJoinedParticipants())->toString($activity);
}
if (\count($activity->notes) > 0) {
$description .= PHP_EOL . PHP_EOL . 'Notes: ' . PHP_EOL;
foreach ($activity->notes as $note) {
$time = ($note->time > 3600) ? gmdate('H:i:s', $note->time) : gmdate('i:s', $note->time);
$description .= $time . ' ' . $note->note . PHP_EOL;
}
}
// Get all private messages.
$messages = $activity->messages()
->where('is_private', 1)
->orderBy('created_at', 'asc');
if ($messages->count() > 0) {
$description .= PHP_EOL . PHP_EOL . 'Coaching Chat: ' . PHP_EOL;
foreach ($messages->get() as $message) {
$description .= $message->participant->name . ': ' . $message->message . PHP_EOL;
}
}
// Get all public messages.
$messages = $activity->messages()
->where('is_private', 0)
->orderBy('created_at', 'asc');
if ($messages->count() > 0) {
$description .= PHP_EOL . PHP_EOL . 'Customer Chat: ' . PHP_EOL;
foreach ($messages->get() as $message) {
$description .= $message->participant->name . ': ' . $message->message . PHP_EOL;
}
}
if ($activity->summary) {
$description .= PHP_EOL . PHP_EOL . 'Summary: ' . PHP_EOL . $activity->summary;
}
break;
case Activity::TYPE_SMS_INBOUND:
case Activity::TYPE_SMS_OUTBOUND:
$description = $activity->description;
break;
}
return $description;
}
public function saveFollowupActivity(Activity $activity, array $fields): ?string
{
// This is the user provided activity subject field.
if (empty($fields['name'])) {
return null;
}
$due = null;
if (empty($fields['due_date']) === false) {
$formatDue = Carbon::parse($fields['due_date']);
$due = $formatDue->toDateTimeString();
}
$clTask = $this->client->post('task', [
'_type' => 'lead',
'lead_id' => $activity->account->crm_provider_id,
'assigned_to' => $this->profile->crm_provider_id,
'text' => $fields['name'],
'date' => $due,
'is_complete' => false,
]);
// We don't actually create a corresponding activity object on our side yet.
return $clTask['id'];
}
/**
* Store transcripts as note.
*
* @throws Exception
*/
public function createTranscriptNotes(Activity $activity): void
{
if ($activity->account_id === null) {
// We can only log to accounts (leads).
return;
}
// Generate activity transcription.
$transcriptionData = $this->generateTranscription($activity);
// Truncate Notes with max notes length because transcription text could be very long.
$transcripts = mb_strimwidth($transcriptionData, 0, self::NOTE_BODY_MAX_LENGTH);
$clActivity = $this->client->post('activity/note', [
'lead_id' => $activity->account_id ? $activity->account->crm_provider_id : null,
'created_by' => $this->profile->crm_provider_id,
'user_id' => $this->profile->crm_provider_id,
'note' => $transcripts,
]);
// Store CRM Activity ID in transcription.
$transcription = $activity->getTranscription();
$transcription->crm_activity_id = $clActivity['id'];
$transcription->save();
}
public function parseObjectType(string $objectId): string
{
if (Str::startsWith($objectId, 'lead')) {
return 'account';
}
if (Str::startsWith($objectId, 'cont')) {
return 'contact';
}
if (Str::startsWith($objectId, 'oppo')) {
return 'opportunity';
}
throw new InvalidArgumentException('Unsupported Object Type');
}
/**
* @inheritdoc
*/
public function updateStage($crmObject, Stage $stage): void
{
if ($crmObject instanceof Lead) {
// This would never get invoked since we merge lead/accounts in Close.
$this->client->put('lead/' . $crmObject->crm_provider_id, [
'status' => $stage->crm_provider_id,
]);
} else {
$this->client->put('opportunity/' . $crmObject->crm_provider_id, [
'status_id' => $stage->crm_provider_id,
]);
}
}
public function updateRecord(string $objectType, string $objectId, array $data, array $headers = []): void
{
$this->getClient()->updateObject($this->convertObjectTypeToResource($objectType), $objectId, $data);
}
public function prepareValueForUpdate(array $params): array
{
$convertedValue = $this->fieldValueConverter->convertToCrm(
$this->config,
$params['fieldName'],
$params['fieldValue'],
);
if ($this->isCustomField($params['fieldName'])) {
$params['fieldName'] = 'custom.' . $params['fieldName'];
}
$params['fieldValue'] = $convertedValue;
return parent::prepareValueForUpdate($params);
}
public function getRecord(string $objectType, string $objectId, array $fields = []): array
{
return $this->client->get($this->convertObjectTypeToResource($objectType) . '/' . $objectId);
}
/**
*
* @throws UnexpectedValueException
*/
private function convertObjectTypeToResource(string $objectType): string
{
switch ($objectType) {
case FieldData::OBJECT_OPPORTUNITY:
return 'opportunity';
case FieldData::OBJECT_CONTACT:
return 'contact';
case FieldData::OBJECT_ACCOUNT:
return 'lead';
case FieldData::OBJECT_TASK:
return 'activity';
default:
throw new UnexpectedValueException('Unsupported object type "' . $objectType . '"');
}
}
public function generateProviderUrl(string $providerId, string $objectType): ?string
{
$baseUrl = 'https://app.close.com/';
$url = null;
switch ($objectType) {
case 'account':
$url = $baseUrl . 'lead/' . $providerId;
break;
case 'contact':
$contact = $this->config->contacts()->where('crm_provider_id', $providerId)->first();
if ($contact && $contact->account_id) {
$url = $baseUrl . 'lead/' . $contact->account->crm_provider_id;
}
break;
default:
// Sadly we can't deeplink to anything else in Close UI.
$url = null;
}
return $url;
}
/**
* Generate transcription for the activity.
*/
private function generateTranscription(Activity $activity): string
{
if (! $this->config->store_transcript) {
// If sending transcription to activity toggle is disabled
return '';
}
return $this->transcriptionService
->findTranscriptionByActivity($activity)
->map(static function (array $transcriptionSegment): string {
return $transcriptionSegment['formattedStartsAt'] . ' | ' . $transcriptionSegment['transcript'];
})
->implode(PHP_EOL);
}
public function verifyTaskExists(Activity $activity): bool
{
$crmProviderId = $activity->getCrmProviderId();
$cacheKey = "crm_task_exists:{$this->config->getId()}:$crmProviderId";
return Cache::remember($cacheKey, self::TASK_VERIFICATION_CACHE_TTL, function () use ($crmProviderId) {
try {
$client = $this->getClient();
$task = $client->get('task/' . $crmProviderId);
return ! empty($task);
} catch (HttpNotFoundException) {
// Task not found in CRM - this is expected and permanent
$this->logger->info('[Close] Task not found during verification', [
'task_id' => $crmProviderId,
'config_id' => $this->config->getId(),
]);
return false;
} catch (CloseException $e) {
// Handle 404 responses from Close API
if ($e->getResponseStatusCode() === 404) {
$this->logger->info('[Close] Task not found during verification (404)', [
'task_id' => $crmProviderId,
...
|
45341
|
NULL
|
NULL
|
NULL
|
|
45344
|
1625
|
6
|
2026-05-14T14:32:13.643490+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-14/1778 /Users/lukas/.screenpipe/data/data/2026-05-14/1778769133643_m1.jpg...
|
PhpStorm
|
faVsco.js – Close/Service.php
|
1
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
#12066 on JY-20725-handle Project: faVsco.js, menu
#12066 on JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
HandleHubspotRateLimitTest
Run 'HandleHubspotRateLimitTest'
Debug 'HandleHubspotRateLimitTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Code changed:
Hide
Sync Changes
Hide This Notification
8
39
5
Previous Highlighted Error
Next Highlighted Error
<?php
namespace Jiminny\Services\Crm\Close;
use Cache;
use Carbon\Carbon;
use Exception;
use GuzzleHttp\Exception\ClientException;
use Illuminate\Support\Str;
use InvalidArgumentException;
use Jiminny\Contracts\Services\Crm\LayoutManagementInterface;
use Jiminny\Contracts\Services\Crm\MatchCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\Provider\CloseInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityLookupInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityManipulationInterface;
use Jiminny\Contracts\Services\Crm\SettingsInterface;
use Jiminny\Contracts\Services\Crm\SupportsObjectTypeParseInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmMetadataInterface;
use Jiminny\Contracts\Services\Crm\VerifyTaskExistsInterface;
use Jiminny\Exceptions\CrmException;
use Jiminny\Exceptions\HttpNotFoundException;
use Jiminny\Exceptions\ServiceUnavailableException;
use Jiminny\Exceptions\UnexpectedCallException;
use Jiminny\Models\Account;
use Jiminny\Models\Activity;
use Jiminny\Models\Contact;
use Jiminny\Models\Crm\Configuration;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\FieldData;
use Jiminny\Models\Crm\Profile;
use Jiminny\Models\Lead;
use Jiminny\Models\Opportunity;
use Jiminny\Models\Playbook;
use Jiminny\Models\SocialAccount;
use Jiminny\Models\Stage;
use Jiminny\Models\User;
use Jiminny\Repositories\Crm\FieldRepository;
use Jiminny\Repositories\Crm\ProfileRepository;
use Jiminny\Services\Avatar\ProspectPhotoPathService;
use Jiminny\Services\Crm\BaseService;
use Jiminny\Services\Crm\Close\Processor\AccountProcessor;
use Jiminny\Services\Crm\Close\Processor\MetadataProcessor;
use Jiminny\Services\Crm\Close\Processor\OpportunityProcessor;
use Jiminny\Services\Crm\Close\Processor\StageProcessor;
use Jiminny\Services\Crm\Helpers\FilterJoinedParticipants;
use Jiminny\Services\Crm\Metadata\ProfileMetadata;
use Jiminny\Services\Crm\OpportunitySyncStrategyResolver;
use Sentry;
use UnexpectedValueException;
class Service extends BaseService implements
CloseInterface,
SyncCrmEntitiesInterface,
SyncCrmMetadataInterface,
RemoteEntityManipulationInterface,
LayoutManagementInterface,
SettingsInterface,
MatchCrmEntitiesInterface,
RemoteEntityLookupInterface,
SupportsObjectTypeParseInterface,
VerifyTaskExistsInterface
{
private const int NOTE_BODY_MAX_LENGTH = 3000000;
private const int TASK_VERIFICATION_CACHE_TTL = 86400; // 1 day
private StandardFieldMetadata $standardFieldMetadata;
private MetadataProcessor $metadataProcessor;
private FieldValueConverter $fieldValueConverter;
private StageProcessor $stageProcessor;
private OpportunityProcessor $opportunityProcessor;
private AccountProcessor $accountProcessor;
public function __construct(
Client $client,
StandardFieldMetadata $standardFieldMetadata,
MetadataProcessor $metadataProcessor,
FieldValueConverter $fieldValueConverter,
StageProcessor $stageResolver,
OpportunityProcessor $opportunityProcessor,
AccountProcessor $accountProcessor,
private readonly ProspectPhotoPathService $prospectPhotoPathService,
) {
parent::__construct();
$this->client = $client;
$this->standardFieldMetadata = $standardFieldMetadata;
$this->metadataProcessor = $metadataProcessor;
$this->fieldValueConverter = $fieldValueConverter;
$this->stageProcessor = $stageResolver;
$this->opportunityProcessor = $opportunityProcessor;
$this->accountProcessor = $accountProcessor;
}
public function getDisplayName(): string
{
return 'Close';
}
public function setConfiguration(Configuration $config): void
{
parent::setConfiguration($config);
$this->metadataProcessor->setConfiguration($config);
$this->stageProcessor->setConfiguration($config);
$this->opportunityProcessor->setConfiguration($config);
$this->accountProcessor->setConfiguration($config);
}
protected function getOAuthAccount(User $user): ?SocialAccount
{
// In this case, the Account Owner is always the connection for any API operations.
return $user->getTeam()->getOwner()->getSocialAccount(SocialAccount::PROVIDER_CLOSE);
}
private function getClient(): Client
{
if (! $this->client instanceof Client) {
throw new UnexpectedCallException('Client not set');
}
return $this->client;
}
public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string
{
return $this->fieldValueConverter->convertFromCrm($fieldType, $fieldValue);
}
protected function getFieldTypes(): array
{
return [
parent::OBJECT_OPPORTUNITY,
parent::OBJECT_CONTACT,
parent::OBJECT_ACCOUNT,
];
}
protected function getFields(string $crmObject): array
{
// not used
return [];
}
/**
* @inheritdoc
*/
public function getDefaultFields(string $activityType): array
{
$fields = [];
if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {
$defaultFields = FieldDefinitions::defaultTaskFields();
// This lazy creates these fields if not already setup.
foreach ($defaultFields as $defaultField) {
$fields[] = $this->config->fields()->firstOrCreate($defaultField);
}
}
return $fields;
}
/**
* @inheritdoc
*/
public function getDefaultActivityField(string $activityType): Field
{
// Set up the activity field as the default Type.
/** @var Field $activityField */
$activityField = $this->config->fields()->where([
'crm_provider_id' => 'type',
'object_type' => $activityType,
])->first();
return $activityField;
}
/**
* @inheritdoc
*/
public function getSupportedPlaybookTypes(): array
{
return [Playbook::ACTIVITY_TYPE_TASK];
}
public function getDealInsightsFields(): array
{
return FieldDefinitions::dealInsightsFields();
}
protected function getDefaultFollowupLayoutFields(string $activityType): array
{
$fields = [];
$fieldRepo = app(FieldRepository::class);
$fieldData = FieldDefinitions::followupFieldsFilter();
foreach ($fieldData as $data) {
$field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $data);
// Only add the field if it is created, which it should be.
if ($field) {
$fields[] = $field;
}
}
return $fields;
}
public function syncFields(): void
{
$this->syncStandardFields();
$this->syncCustomFields();
}
/**
* @important Works only for custom fields
*/
public function syncField(Field $field): void
{
$resource = $this->convertObjectTypeToResource($field->getObjectType());
// We can only sync custom fields in this CRM.
if ($this->isCustomField($field->getCrmProviderId()) === false) {
return;
}
$crmField = $this->getClient()->fetchCustomFieldDefinition($resource, $field->getCrmProviderId());
$this->metadataProcessor->syncField($crmField);
}
private function isCustomField(string $fieldId): bool
{
return strpos($fieldId, 'cf_') === 0;
}
/**
* @inheritdoc
*/
public function importPicklistValues(Field $field): array
{
// handled in syncFields()
return [];
}
/**
* @important We only support stages on the opportunity object
*
* @param string[]|null $types
*/
public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage
{
if (! $missingStageName) {
// This is taken care of by syncOrganization()
return null;
}
$stage = $this->stageProcessor->resolveFromStageId($missingStageName);
if ($stage instanceof Stage) {
return $stage;
}
$stageMetadata = $this->getClient()->fetchStage($missingStageName);
if (! $stageMetadata) {
$this->logger->error('Stage does not exist', [
'stage' => $missingStageName,
]);
return null;
}
return $this->stageProcessor->importStage($stageMetadata);
}
/**
* @inheritdoc
*/
public function syncLeads(Carbon $since, ?Carbon $to = null, ?string $crmProfileId = null): int
{
// Even though Close.io has the concept of "leads", they fit more into our concept of accounts.
return 0;
}
/**
* @inheritdoc
*/
public function syncLead(string $crmId): ?Lead
{
// Not a supported entity.
return null;
}
/**
* @throws Exception
*/
public function syncAccounts(Carbon $since, ?Carbon $to = null): int
{
$syncCount = 0;
try {
foreach ($this->getClient()->listAccounts($since) as $clAccount) {
// Only sync if previously imported.
if ($this->hasAccount($clAccount->getId())) {
$this->importAccount($clAccount);
$syncCount++;
}
}
} catch (Exception $exception) {
$this->logger->error('Account sync failed', [
'error' => $exception->getMessage(),
]);
throw $exception;
}
return $syncCount;
}
public function syncAccount(string $crmId): ?Account
{
return $this->accountProcessor->syncAccount($crmId);
}
private function importAccount($crmData): Account
{
return $this->accountProcessor->importAccountMetadata($crmData);
}
/**
* @throws CloseException
*/
public function syncOpportunities(array $parameters, ?string $strategy = null): int
{
$strategyResolver = app(OpportunitySyncStrategyResolver::class, [
'client' => $this->client,
]);
$strategies = $strategyResolver->getStrategies($this->config, $strategy);
$syncCount = 0;
try {
$opportunities = [];
foreach ($strategies as $syncStrategy) {
$opportunitiesData = $syncStrategy->fetchOpportunities($parameters);
$opportunities[] = $opportunitiesData['data'];
if ($opportunitiesData['has_more']) {
$this->logger->info('[Close] Sync Opportunities - count warning', [
'team_id' => $this->config->getTeam()->getId(),
'total' => $opportunitiesData['total'],
'count' => $opportunitiesData['count'],
'skip' => $opportunitiesData['skip'],
'strategies_count' => count($strategies),
]);
}
}
$opportunities = array_merge(...$opportunities);
} catch (CrmException $exception) {
$this->logger->error('Fetching opportunity data failed', [
'team' => $this->getTeam()->getSlug(),
'error' => $exception->getMessage(),
]);
return 0;
}
foreach ($opportunities as $opportunityMetadata) {
try {
$this->importOpportunity($opportunityMetadata);
$syncCount++;
} catch (Exception $exception) {
$this->logger->warning('Opportunity sync failed', [
'opportunity' => $opportunityMetadata->getId(),
'error' => $exception->getMessage(),
]);
}
}
return $syncCount;
}
public function syncOpportunity(string $crmId): ?Opportunity
{
$strategyResolver = app(OpportunitySyncStrategyResolver::class, [
'client' => $this->client,
]);
$strategy = $strategyResolver->resolve(
$this->config,
OpportunitySyncStrategyResolver::SINGLE_SYNC_OPPORTUNITY_STRATEGY,
);
$parameters = ['crm_id' => $crmId];
$opportunity = $strategy->fetchOpportunities($parameters);
if (empty($opportunity['data'])) {
return null;
}
return $this->importOpportunity($opportunity['data']);
}
private function importOpportunity($crmData): ?Opportunity
{
if (! $crmData->getLeadId()) {
$this->logger->warning('Opportunity does not have a lead ID', [
'opportunity' => $crmData->getId(),
]);
return null;
}
$account = $this->getConfiguration()
->accounts()
->where('crm_provider_id', $crmData->getLeadId())
->first()
;
if ($account === null) {
$account = $this->accountProcessor->syncAccount($crmData->getLeadId());
}
$profile = $this->getConfiguration()
->profiles()
->where('crm_provider_id', $crmData->getUserId())
->first()
;
$stage = $this->getConfiguration()
->stages()
->where('crm_provider_id', $crmData->getStageId())
->where('type', Stage::TYPE_OPPORTUNITY)
->first()
;
if ($stage === null) {
$stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $crmData->getStageId());
}
return $this->opportunityProcessor->import($crmData, $account, $stage, $profile);
}
/**
* @param array<string,string> $crmData
* @param string[] $crmFields
*/
public function importOpportunityCrmFieldData(array $crmData, array $crmFields, int $opportunityId): void
{
// handled in importOpportunity
}
/**
* @inheritdoc
*/
public function syncContacts(Carbon $since, ?Carbon $to = null): int
{
$syncCount = 0;
try {
/** No way to sync today.
$clContacts = $this->client->get('lead', [
'date_updated__gte' => $since->toDateString(),
'_order_by' => '-date_updated',
]);
foreach ($clContacts as $clContact) {
// Only sync if previously imported.
if ($this->hasContact($clContact['id'])) {
$this->importContact($clContact);
$syncCount++;
}
}
**/
} catch (Exception $exception) {
// Do nothing for now.
throw $exception;
}
return $syncCount;
}
/**
* @inheritdoc
*/
public function syncContact(string $crmId): ?Contact
{
try {
$clContact = $this->client->get('contact/' . $crmId);
} catch (HttpNotFoundException $exception) {
return null;
}
return $this->importContact($clContact);
}
/**
* @inheritdoc
*/
private function importContact($crmData): Contact
{
$account = null;
if ($crmData['lead_id']) {
$account = $this->team
->accounts()
->where('crm_provider_id', $crmData['lead_id'])
->first();
if ($account === null) {
$account = $this->syncAccount($crmData['lead_id']);
}
}
$mobilePhone = $parsedNumber = null;
foreach ($crmData['phones'] as $phoneNumber) {
if ($phoneNumber['type'] === 'mobile') {
$mobilePhone = $this->buildContactMobilePhone(null, $phoneNumber['phone']);
} else {
$parsedNumber = $this->buildContactPhone(null, $phoneNumber['phone']);
}
}
$email = null;
if (empty($crmData['emails']) === false) {
$email = mb_strimwidth($crmData['emails'][0]['email'], 0, 80);
}
$profile = $this->config->profiles()->where('crm_provider_id', (string) $crmData['updated_by'])->first();
$data = [
'account_id' => $account->id ?? null,
'team_id' => $this->team->id,
'user_id' => $profile?->user_id,
'owner_id' => $crmData['updated_by'],
'name' => $crmData['name'] ?? 'Unknown',
'email' => $email,
'phone' => $parsedNumber['phone'] ?? null,
'mobile_phone' => $mobilePhone ?? null,
'ext' => $parsedNumber['ext'] ?? null,
'title' => mb_strimwidth($crmData['title'] ?? '', 0, 128),
'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(
crmConfiguration: $this->config,
crmProviderId: $crmData['id'],
modelType: Contact::class,
fileName: $crmData['id'],
avatarText: $crmData['name'] ?? 'Unknown'
),
'remotely_created_at' => Carbon::parse($crmData['date_created']),
];
/** @var Contact */
return $this->config->contacts()->updateOrCreate(['crm_provider_id' => (string) $crmData['id']], $data);
}
private function buildContactPhone(?string $countryCode, ?string $number): ?array
{
if ($number) {
// Trim to our width and attempt to parse it.
$number = mb_strimwidth($number, 0, 25);
$parsedNumber = parsePhoneNumber($countryCode, $number);
} else {
$parsedNumber = [];
}
return $parsedNumber;
}
private function buildContactMobilePhone(?string $countryCode, ?string $number): ?string
{
return $number ? mb_strimwidth(phone_e164($countryCode, $number), 0, 25) : null;
}
public function syncOrganization(): void
{
$organisation = $this->getClient()->fetchOrganisation();
$this->metadataProcessor->syncOrganisation($organisation);
foreach ($organisation->getPipelines() as $pipelineMetadata) {
$this->metadataProcessor->syncPipeline($pipelineMetadata);
}
}
private function syncStandardFields(): void
{
// Currently we sync only opportunity fields
$stages = $this->getClient()->listStages();
foreach ($this->standardFieldMetadata->opportunity($stages) as $fieldMetadata) {
$this->metadataProcessor->syncField($fieldMetadata);
}
$this->config->save();
}
private function syncCustomFields(): void
{
foreach ($this->getFieldTypes() as $fieldType) {
$objectType = $this->convertObjectTypeToResource($fieldType);
$currentFields = $this->getClient()->listCustomFieldDefinitions($objectType);
foreach ($currentFields as $fieldMetadata) {
$this->metadataProcessor->syncField($fieldMetadata);
}
}
$this->config->save();
}
public function syncProfiles(?User $userToSearch = null): ?Profile
{
/*
* Fetch the profile of the user from the database
* Then fetch the user metadata from Close and update it
* In case there's no profile for the user, proceed with syncing all users
*/
$foundUser = null;
if ($userToSearch) {
$profile = $userToSearch->getProfile();
if ($profile instanceof Profile) {
$crmProviderId = $profile->getCrmProviderId();
if ($crmProviderId) {
$profileMetadata = $this->getClient()->fetchUser($crmProviderId);
if (! $profileMetadata instanceof ProfileMetadata) {
return null;
}
return $this->metadataProcessor->syncProfile($profileMetadata);
}
}
}
foreach ($this->getClient()->listUsers() as $userMetadata) {
$userProfile = $this->metadataProcessor->syncProfile($userMetadata);
if (
$userToSearch instanceof User
&& $userProfile instanceof Profile
&& $userProfile->getUserId() === $userToSearch->getId()
) {
$foundUser = $userProfile;
}
}
return $foundUser;
}
public function syncProfileFields(): void
{
// Not used.
}
/**
* @inheritdoc
*/
public function find(string $name, array $scopes): array
{
$count = $this->limit ?? 20;
$offset = $this->offset ?? 0;
$data = Cache::remember($this->team->id . $name . $count . $offset, 300, function () use ($name, $count, $offset) {
$data = [];
try {
// If search phrase resembles phone number remove special symbols
if (preg_match('/^([0-9\s\-\+\(\)]*)$/', $name)) {
$name = '+' . preg_replace('/[\s\-\+\(\)]/', '', $name);
}
// Close do not provide a unified way to search, so we must hack our own.
$objects = $this->client->get('lead', [
'query' => 'sort:date_updated name:(' . $name . ') or email:(' . $name . ') or phone:(' . $name . ')',
'_limit' => $count, '_skip' => $offset,
]);
} catch (\GuzzleHttp\Exception\ServerException $exception) {
throw new ServiceUnavailableException($exception->getMessage());
}
foreach ($objects['data'] as $object) {
// We need a contact to dial it.
if (empty($object['contacts'])) {
continue;
}
foreach ($object['contacts'] as $contact) {
$record = [
'crmId' => $contact['id'],
'crmUrl' => $this->generateProviderUrl($object['id'], 'account'),
'name' => $contact['name'],
'industry' => null,
'title' => $contact['title'],
'organization' => $object['display_name'],
'prospectType' => 'contact',
'phoneNumbers' => [],
];
foreach ($contact['phones'] as $phone) {
if ($phone['type'] === 'mobile') {
$number = $this->buildContactMobilePhone(null, $phone['phone']);
$record['phoneNumbers'][] = [
'number' => $number,
'nationalFormat' => phone_national(null, $number),
'type' => 'mobile',
];
} else {
$parsedNumber = $this->buildContactPhone(null, $phone['phone']);
// Add phone number to record.
if (empty($parsedNumber['phone']) === false) {
$record['phoneNumbers'][] = [
'number' => $parsedNumber['phone'],
'nationalFormat' => phone_national(null, $parsedNumber['phone']),
'type' => 'phone',
];
}
}
}
$data[] = $record;
}
}
return $data;
});
return $data;
}
/**
* @inheritdoc
*/
public function findOpportunities(?string $crmAccountId, ?string $crmContactId, ?int $userId = null): array
{
$data = [];
$ownerData = [];
$ownerId = null;
$contact = null;
$account = null;
if ($crmAccountId) {
$account = $this->config->accounts()->where('crm_provider_id', $crmAccountId)->first();
if ($account === null) {
$account = $this->syncAccount($crmAccountId);
}
}
if ($crmContactId) {
$contact = $this->config->contacts()->where('crm_provider_id', $crmContactId)->first();
if ($contact === null) {
$contact = $this->syncContact($crmContactId);
}
}
if ($contact || $account) {
if ($contact && $account === null) {
$account = $contact->account;
}
if ($account === null) {
return [];
}
$params = [
'lead_id' => $account->crm_provider_id,
'_order_by' => '-date_updated',
];
$onlyOpen = true;
switch ($this->config->opportunity_assignment_rule) {
case Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_UPDATED:
$params['_order_by'] = '-date_updated';
break;
case Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_CREATED:
$params['_order_by'] = '-date_created';
break;
case Configuration::OPP_ASSIGNMENT_ALL_OPEN_OLDEST_CREATED:
$params['_order_by'] = 'date_created';
break;
case Configuration::OPP_ASSIGNMENT_ALL_RECENTLY_UPDATED:
$params['_order_by'] = '-date_updated';
$onlyOpen = false;
}
if ($onlyOpen) {
$params['status_type__in'] = 'active,won';
}
$clOpportunities = $this->client->get('opportunity', $params);
if ($userId) {
$profileRepository = app(ProfileRepository::class);
$profile = $profileRepository->findProfileByUserId($this->config, $userId);
$ownerId = $profile instanceof Profile ? $profile->getCrmProviderId() : null;
}
foreach ($clOpportunities['data'] as $clOpportunity) {
$stage = $this->config
->stages()
->where('crm_provider_id', $clOpportunity['status_id'])
->first();
if ($stage === null) {
// Import it.
$stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $clOpportunity['status_id']);
}
$record = [
'crmId' => $clOpportunity['id'],
'name' => $clOpportunity['note'],
'value' => currency_format($clOpportunity['value'], $clOpportunity['value_currency']),
'won' => $stage->probability === 100.00,
'closed' => $clOpportunity['status_type'] !== 'active',
'stage' => [
'id' => $stage->id_string,
'name' => $stage->name,
],
'recordType' => [],
];
if ($ownerId && isset($clOpportunity['user_id']) && $clOpportunity['user_id'] === $ownerId) {
$ownerData[] = $record;
}
$data[] = $record;
}
if (! empty($ownerData)) {
return $ownerData;
}
}
return $data;
}
/**
* @inheritdoc
*/
public function getTasks(string $objectType, string $objectId, ?string $opportunityId): array
{
$data = [];
$crmId = null;
if ($objectType === 'contact') {
$contact = $this->syncContact($objectId);
if ($contact && $contact->account_id) {
$crmId = $contact->account->crm_provider_id;
}
} else {
$crmId = $objectId;
}
if ($crmId) {
$clTasks = $this->client->get('task', [
'lead_id' => $crmId,
'_type' => 'lead',
'assigned_to' => $this->profile->crm_provider_id,
'is_complete' => 'false',
'_order_by' => 'date',
]);
foreach ($clTasks['data'] as $clTask) {
$data[] = [
'crmId' => $clTask['id'],
'subject' => $clTask['text'],
'due' => $clTask['date'] ?? null,
'type' => null,
];
}
}
return $data;
}
/**
* Try to find email address in CRM service
*
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
*}
*/
public function matchExactlyByEmail(string $email, ?int $userId = null): ?array
{
$clObject = $this->client->get('lead', [
'query' => 'sort:updated contact(email(email:"' . $email . '"))',
'_limit' => 1,
]);
if ($clObject['total_results'] > 0) {
foreach ($clObject['data'][0]['contacts'] as $clContact) {
foreach ($clContact['emails'] as $clEmail) {
if ($email === $clEmail['email']) {
$data = $this->convertCrmData($clContact['id'], $userId);
return ! empty(array_filter($data)) ? $data : null;
}
}
}
}
return null;
}
/**
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
*}
*/
public function matchByPhone(string $phone, ?string $rawPhoneNumber = null, ?int $userId = null): ?array
{
// Check if the user is internal.
$teamMember = $this->team->users()->where('phone', $phone)->exists();
// Skip the attendee if internal.
if ($teamMember === false) {
$clObject = $this->client->get('lead', [
'query' => 'sort:updated contact(' . $phone . ')',
'_limit' => 1,
]);
if ($clObject['total_results'] > 0) {
foreach ($clObject['data'][0]['contacts'] as $clContact) {
foreach ($clContact['phones'] as $clPhone) {
if ($phone === $clPhone['phone']) {
$data = $this->convertCrmData($clContact['id'], $userId);
return ! empty(array_filter($data)) ? $data : null;
}
}
}
}
}
return null;
}
/**
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchByName(string $name, ?int $userId = null): ?array
{
// Don't waste time searching for single character strings.
if (\strlen($name) <= 1) {
return null;
}
$cacheKey = $this->getCacheKey($name, $userId);
$result = Cache::remember($cacheKey, 60, function () use ($name, $userId) {
$clObject = $this->client->get('lead', [
'query' => 'sort:updated contact(name:"' . $name . '")',
'_limit' => 1,
]);
if ($clObject['total_results'] > 0) {
foreach ($clObject['data'][0]['contacts'] as $clContact) {
if ($clContact['name'] === $name || $clContact['display_name'] === $name) {
$data = $this->convertCrmData($clContact['id'], $userId);
return ! empty(array_filter($data)) ? $data : false;
}
}
}
return false;
});
return is_array($result) ? $result : null;
}
protected function getCacheKey(string $object, ?int $userId = null): ?string
{
$key = $this->team->getId() . $object;
$keySuffix = $this->getOwnerKeySuffix($userId);
return $key . $keySuffix;
}
private function getOwnerKeySuffix(?int $userId = null): string
{
return $userId === null ? '' : (string) $userId;
}
private function convertCrmData(string $crmId, ?int $userId = null): array
{
$lead = null;
$opportunity = null;
$account = null;
$stage = null;
$countryCode = null;
$contact = $this->syncContact($crmId);
if ($contact) {
$account = $contact->account;
if ($contact->country_code) {
$countryCode = $contact->country_code;
} elseif ($account) {
$countryCode = $account->country_code;
}
try {
$cpOpportunities = $this->findOpportunities(
$account?->crm_provider_id,
$contact->crm_provider_id,
$userId,
);
if (! empty($cpOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($cpOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
} catch (Exception) {
// Nothing to see here.
}
}
return [
$lead,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
public function saveActivity(Activity $activity): Activity
{
switch ($activity->type) {
case Activity::TYPE_CONFERENCE:
case Activity::TYPE_SOFTPHONE:
case Activity::TYPE_SOFTPHONE_INBOUND:
$activity = $this->buildCallPayload($activity);
break;
case Activity::TYPE_SMS_INBOUND:
case Activity::TYPE_SMS_OUTBOUND:
$activity = $this->buildTextMessagePayload($activity);
break;
}
return $activity;
}
private function mapStatus(string $status): string
{
switch ($status) {
case Activity::STATUS_COMPLETED:
case Activity::STATUS_IN_PROGRESS:
case Activity::STATUS_FAILED:
case Activity::STATUS_NO_ANSWER:
case Activity::STATUS_BUSY:
default:
return $status;
case Activity::STATUS_CANCELLED:
return 'cancel';
}
}
/**
* @throws CrmException
*/
private function buildCallPayload(Activity $activity): Activity
{
try {
if ($activity->crm_provider_id) {
// The activity should be logged under the existing Task (not Activity).
$data = [
'_type' => 'lead',
'lead_id' => $activity->account->crm_provider_id,
'assigned_to' => $this->profile->crm_provider_id,
'text' => $this->generateActivityDescription($activity),
'date' => $activity->getActualEndTime()->toDateString(),
'is_complete' => true,
];
$this->logger->info('[Close CRM] Updating task', [
'activity' => $activity->id,
'crm_id' => $activity->crm_provider_id,
'data' => $data,
]);
$this->client->put('task/' . $activity->crm_provider_id, $data);
} else {
// Just create an activity.
$data = [
'lead_id' => $activity->account_id ? $activity->account->crm_provider_id : null,
'contact_id' => $activity->contact_id ? $activity->contact->crm_provider_id : null,
'created_by' => $this->profile->crm_provider_id,
'user_id' => $this->profile->crm_provider_id,
'direction' => $activity->getCrmType() === Activity::TYPE_SOFTPHONE ? 'outbound' : 'inbound',
'status' => $this->mapStatus($activity->getStatus()),
'note' => $this->generateActivityDescription($activity),
'duration' => $this->mapStatus($activity->getStatus()) !== 'cancel' ? $activity->duration : 0,
'phone' => $activity->to ? $activity->to->phone_number : null,
];
$clActivity = $this->client->post('activity/call', $data);
$this->logger->info('[Close CRM] Creating activity', [
'activity' => $activity->id,
'crm_id' => $clActivity['id'],
'data' => $data,
'response' => $clActivity,
]);
$activity->crm_provider_id = $clActivity['id'];
$activity->save();
}
} catch (ClientException $exception) {
$response = $exception->getResponse();
if ($response === null) {
// Trying to debug weird cases where this is null.
Sentry::captureException($exception);
}
$responseBody = $response->getBody();
$message = $responseBody;
$errorCode = $response->getStatusCode();
$jsonResponse = json_decode($responseBody, true);
if (isset($jsonResponse[0]['message'])) {
$message = $jsonResponse[0]['message'];
}
throw new CrmException($message, $errorCode);
}
return $activity;
}
private function buildTextMessagePayload(Activity $activity): Activity
{
$clActivity = $this->client->post('activity/sms', [
'lead_id' => $activity->account_id ? $activity->account->crm_provider_id : null,
'contact_id' => $activity->contact_id ? $activity->contact->crm_provider_id : null,
'created_by' => $this->profile->crm_provider_id,
'user_id' => $this->profile->crm_provider_id,
'status' => $activity->getType() === Activity::TYPE_SMS_OUTBOUND ? 'sent' : 'inbox',
'text' => $this->generateActivityDescription($activity),
'remote_phone' => $activity->to ? $activity->to->phone_number : null,
'local_phone' => $activity->to ? $activity->to->phone_number : null,
'source' => 'Close.io',
]);
$activity->crm_provider_id = $clActivity['id'];
$activity->save();
return $activity;
}
private function generateActivityDescription(Activity $activity): string
{
$description = '';
switch ($activity->type) {
case Activity::TYPE_SOFTPHONE:
case Activity::TYPE_SOFTPHONE_INBOUND:
case Activity::TYPE_CONFERENCE:
if ($activity->hasActivityType()) {
$description = $activity->getActivityType()->getName() . PHP_EOL . PHP_EOL;
}
if ($activity->hasTitle()) {
$description .= $activity->getTitle() . PHP_EOL;
}
if ($activity->hasReasonCodeBotKicked()) {
$description .= 'Notetaker removed from this meeting' . PHP_EOL . PHP_EOL;
// When we fix the state to be Activity::RECORDING_RECORDED as it should be this can change.
} elseif ($activity->hasReasonCodeNotCompliant()) {
$description .= 'Notetaker did not join due to recording consent not being provided by attendees' . PHP_EOL . PHP_EOL;
} elseif ($activity->canReviewActivity()) {
$playbackUrl = $activity->user->team->partner->getPlaybackUrl($activity);
$description .= 'Review Activity:' . PHP_EOL . $playbackUrl . PHP_EOL . PHP_EOL;
}
if ($activity->type === Activity::TYPE_CONFERENCE) {
$description .= 'Attendees:'
. PHP_EOL
. (new FilterJoinedParticipants())->toString($activity);
}
if (\count($activity->notes) > 0) {
$description .= PHP_EOL . PHP_EOL . 'Notes: ' . PHP_EOL;
foreach ($activity->notes as $note) {
$time = ($note->time > 3600) ? gmdate('H:i:s', $note->time) : gmdate('i:s', $note->time);
$description .= $time . ' ' . $note->note . PHP_EOL;
}
}
// Get all private messages.
$messages = $activity->messages()
->where('is_private', 1)
->orderBy('created_at', 'asc');
if ($messages->count() > 0) {
$description .= PHP_EOL . PHP_EOL . 'Coaching Chat: ' . PHP_EOL;
foreach ($messages->get() as $message) {
$description .= $message->participant->name . ': ' . $message->message . PHP_EOL;
}
}
// Get all public messages.
$messages = $activity->messages()
->where('is_private', 0)
->orderBy('created_at', 'asc');
if ($messages->count() > 0) {
$description .= PHP_EOL . PHP_EOL . 'Customer Chat: ' . PHP_EOL;
foreach ($messages->get() as $message) {
$description .= $message->participant->name . ': ' . $message->message . PHP_EOL;
}
}
if ($activity->summary) {
$description .= PHP_EOL . PHP_EOL . 'Summary: ' . PHP_EOL . $activity->summary;
}
break;
case Activity::TYPE_SMS_INBOUND:
case Activity::TYPE_SMS_OUTBOUND:
$description = $activity->description;
break;
}
return $description;
}
public function saveFollowupActivity(Activity $activity, array $fields): ?string
{
// This is the user provided activity subject field.
if (empty($fields['name'])) {
return null;
}
$due = null;
if (empty($fields['due_date']) === false) {
$formatDue = Carbon::parse($fields['due_date']);
$due = $formatDue->toDateTimeString();
}
$clTask = $this->client->post('task', [
'_type' => 'lead',
'lead_id' => $activity->account->crm_provider_id,
'assigned_to' => $this->profile->crm_provider_id,
'text' => $fields['name'],
'date' => $due,
'is_complete' => false,
]);
// We don't actually create a corresponding activity object on our side yet.
return $clTask['id'];
}
/**
* Store transcripts as note.
*
* @throws Exception
*/
public function createTranscriptNotes(Activity $activity): void
{
if ($activity->account_id === null) {
// We can only log to accounts (leads).
return;
}
// Generate activity transcription.
$transcriptionData = $this->generateTranscription($activity);
// Truncate Notes with max notes length because transcription text could be very long.
$transcripts = mb_strimwidth($transcriptionData, 0, self::NOTE_BODY_MAX_LENGTH);
$clActivity = $this->client->post('activity/note', [
'lead_id' => $activity->account_id ? $activity->account->crm_provider_id : null,
'created_by' => $this->profile->crm_provider_id,
'user_id' => $this->profile->crm_provider_id,
'note' => $transcripts,
]);
// Store CRM Activity ID in transcription.
$transcription = $activity->getTranscription();
$transcription->crm_activity_id = $clActivity['id'];
$transcription->save();
}
public function parseObjectType(string $objectId): string
{
if (Str::startsWith($objectId, 'lead')) {
return 'account';
}
if (Str::startsWith($objectId, 'cont')) {
return 'contact';
}
if (Str::startsWith($objectId, 'oppo')) {
return 'opportunity';
}
throw new InvalidArgumentException('Unsupported Object Type');
}
/**
* @inheritdoc
*/
public function updateStage($crmObject, Stage $stage): void
{
if ($crmObject instanceof Lead) {
// This would never get invoked since we merge lead/accounts in Close.
$this->client->put('lead/' . $crmObject->crm_provider_id, [
'status' => $stage->crm_provider_id,
]);
} else {
$this->client->put('opportunity/' . $crmObject->crm_provider_id, [
'status_id' => $stage->crm_provider_id,
]);
}
}
public function updateRecord(string $objectType, string $objectId, array $data, array $headers = []): void
{
$this->getClient()->updateObject($this->convertObjectTypeToResource($objectType), $objectId, $data);
}
public function prepareValueForUpdate(array $params): array
{
$convertedValue = $this->fieldValueConverter->convertToCrm(
$this->config,
$params['fieldName'],
$params['fieldValue'],
);
if ($this->isCustomField($params['fieldName'])) {
$params['fieldName'] = 'custom.' . $params['fieldName'];
}
$params['fieldValue'] = $convertedValue;
return parent::prepareValueForUpdate($params);
}
public function getRecord(string $objectType, string $objectId, array $fields = []): array
{
return $this->client->get($this->convertObjectTypeToResource($objectType) . '/' . $objectId);
}
/**
*
* @throws UnexpectedValueException
*/
private function convertObjectTypeToResource(string $objectType): string
{
switch ($objectType) {
case FieldData::OBJECT_OPPORTUNITY:
return 'opportunity';
case FieldData::OBJECT_CONTACT:
return 'contact';
case FieldData::OBJECT_ACCOUNT:
return 'lead';
case FieldData::OBJECT_TASK:
return 'activity';
default:
throw new UnexpectedValueException('Unsupported object type "' . $objectType . '"');
}
}
public function generateProviderUrl(string $providerId, string $objectType): ?string
{
$baseUrl = 'https://app.close.com/';
$url = null;
switch ($objectType) {
case 'account':
$url = $baseUrl . 'lead/' . $providerId;
break;
case 'contact':
$contact = $this->config->contacts()->where('crm_provider_id', $providerId)->first();
if ($contact && $contact->account_id) {
$url = $baseUrl . 'lead/' . $contact->account->crm_provider_id;
}
break;
default:
// Sadly we can't deeplink to anything else in Close UI.
$url = null;
}
return $url;
}
/**
* Generate transcription for the activity.
*/
private function generateTranscription(Activity $activity): string
{
if (! $this->config->store_transcript) {
// If sending transcription to activity toggle is disabled
return '';
}
return $this->transcriptionService
->findTranscriptionByActivity($activity)
->map(static function (array $transcriptionSegment): string {
return $transcriptionSegment['formattedStartsAt'] . ' | ' . $transcriptionSegment['transcript'];
})
->implode(PHP_EOL);
}
public function verifyTaskExists(Activity $activity): bool
{
$crmProviderId = $activity->getCrmProviderId();
$cacheKey = "crm_task_exists:{$this->config->getId()}:$crmProviderId";
return Cache::remember($cacheKey, self::TASK_VERIFICATION_CACHE_TTL, function () use ($crmProviderId) {
try {
$client = $this->getClient();
$task = $client->get('task/' . $crmProviderId);
return ! empty($task);
} catch (HttpNotFoundException) {
// Task not found in CRM - this is expected and permanent
$this->logger->info('[Close] Task not found during verification', [
'task_id' => $crmProviderId,
'config_id' => $this->config->getId(),
]);
return false;
} catch (CloseException $e) {
// Handle 404 responses from Close API
if ($e->getResponseStatusCode() === 404) {
$this->logger->info('[Close] Task not found during verification (404)', [
'task_id' => $crmProviderId,
...
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"bounds":{"left":0.054166667,"top":0.027777778,"width":0.08055556,"height":0.035555556},"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"#12066 on JY-20725-handle-HS-search-rate-limit, menu","depth":5,"bounds":{"left":0.13472222,"top":0.027777778,"width":0.25555557,"height":0.035555556},"on_screen":true,"help_text":"Pull request #12066 exists for current branch JY-20725-handle-HS-search-rate-limit, but local branch is out of sync with remote","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"bounds":{"left":0.6326389,"top":0.027777778,"width":0.023611112,"height":0.035555556},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"HandleHubspotRateLimitTest","depth":6,"bounds":{"left":0.6645833,"top":0.027777778,"width":0.15902779,"height":0.035555556},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'HandleHubspotRateLimitTest'","depth":6,"bounds":{"left":0.82361114,"top":0.027777778,"width":0.023611112,"height":0.035555556},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'HandleHubspotRateLimitTest'","depth":6,"bounds":{"left":0.8472222,"top":0.027777778,"width":0.023611112,"height":0.035555556},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"bounds":{"left":0.87083334,"top":0.027777778,"width":0.023611112,"height":0.035555556},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"bounds":{"left":0.9291667,"top":0.027777778,"width":0.023611112,"height":0.035555556},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"bounds":{"left":0.9527778,"top":0.027777778,"width":0.023611112,"height":0.035555556},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"IDE and Project Settings","depth":5,"bounds":{"left":0.9763889,"top":0.027777778,"width":0.023611112,"height":0.035555556},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.088194445,"height":0.027777778},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"8","depth":4,"bounds":{"left":0.7548611,"top":0.3122222,"width":0.016666668,"height":0.02111111},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"39","depth":4,"bounds":{"left":0.77569443,"top":0.3122222,"width":0.021527778,"height":0.02111111},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"5","depth":4,"bounds":{"left":0.80138886,"top":0.3122222,"width":0.016666668,"height":0.02111111},"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"bounds":{"left":0.8215278,"top":0.31,"width":0.015277778,"height":0.025555555},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"bounds":{"left":0.8368056,"top":0.31,"width":0.014583333,"height":0.025555555},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\nnamespace Jiminny\\Services\\Crm\\Close;\n\nuse Cache;\nuse Carbon\\Carbon;\nuse Exception;\nuse GuzzleHttp\\Exception\\ClientException;\nuse Illuminate\\Support\\Str;\nuse InvalidArgumentException;\nuse Jiminny\\Contracts\\Services\\Crm\\LayoutManagementInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\MatchCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\Provider\\CloseInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityLookupInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityManipulationInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SettingsInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SupportsObjectTypeParseInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmMetadataInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\VerifyTaskExistsInterface;\nuse Jiminny\\Exceptions\\CrmException;\nuse Jiminny\\Exceptions\\HttpNotFoundException;\nuse Jiminny\\Exceptions\\ServiceUnavailableException;\nuse Jiminny\\Exceptions\\UnexpectedCallException;\nuse Jiminny\\Models\\Account;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Contact;\nuse Jiminny\\Models\\Crm\\Configuration;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Models\\Crm\\FieldData;\nuse Jiminny\\Models\\Crm\\Profile;\nuse Jiminny\\Models\\Lead;\nuse Jiminny\\Models\\Opportunity;\nuse Jiminny\\Models\\Playbook;\nuse Jiminny\\Models\\SocialAccount;\nuse Jiminny\\Models\\Stage;\nuse Jiminny\\Models\\User;\nuse Jiminny\\Repositories\\Crm\\FieldRepository;\nuse Jiminny\\Repositories\\Crm\\ProfileRepository;\nuse Jiminny\\Services\\Avatar\\ProspectPhotoPathService;\nuse Jiminny\\Services\\Crm\\BaseService;\nuse Jiminny\\Services\\Crm\\Close\\Processor\\AccountProcessor;\nuse Jiminny\\Services\\Crm\\Close\\Processor\\MetadataProcessor;\nuse Jiminny\\Services\\Crm\\Close\\Processor\\OpportunityProcessor;\nuse Jiminny\\Services\\Crm\\Close\\Processor\\StageProcessor;\nuse Jiminny\\Services\\Crm\\Helpers\\FilterJoinedParticipants;\nuse Jiminny\\Services\\Crm\\Metadata\\ProfileMetadata;\nuse Jiminny\\Services\\Crm\\OpportunitySyncStrategyResolver;\nuse Sentry;\nuse UnexpectedValueException;\n\nclass Service extends BaseService implements\n CloseInterface,\n SyncCrmEntitiesInterface,\n SyncCrmMetadataInterface,\n RemoteEntityManipulationInterface,\n LayoutManagementInterface,\n SettingsInterface,\n MatchCrmEntitiesInterface,\n RemoteEntityLookupInterface,\n SupportsObjectTypeParseInterface,\n VerifyTaskExistsInterface\n{\n private const int NOTE_BODY_MAX_LENGTH = 3000000;\n\n private const int TASK_VERIFICATION_CACHE_TTL = 86400; // 1 day\n\n private StandardFieldMetadata $standardFieldMetadata;\n private MetadataProcessor $metadataProcessor;\n private FieldValueConverter $fieldValueConverter;\n private StageProcessor $stageProcessor;\n private OpportunityProcessor $opportunityProcessor;\n private AccountProcessor $accountProcessor;\n\n public function __construct(\n Client $client,\n StandardFieldMetadata $standardFieldMetadata,\n MetadataProcessor $metadataProcessor,\n FieldValueConverter $fieldValueConverter,\n StageProcessor $stageResolver,\n OpportunityProcessor $opportunityProcessor,\n AccountProcessor $accountProcessor,\n private readonly ProspectPhotoPathService $prospectPhotoPathService,\n ) {\n parent::__construct();\n\n $this->client = $client;\n $this->standardFieldMetadata = $standardFieldMetadata;\n $this->metadataProcessor = $metadataProcessor;\n $this->fieldValueConverter = $fieldValueConverter;\n $this->stageProcessor = $stageResolver;\n $this->opportunityProcessor = $opportunityProcessor;\n $this->accountProcessor = $accountProcessor;\n }\n\n public function getDisplayName(): string\n {\n return 'Close';\n }\n\n public function setConfiguration(Configuration $config): void\n {\n parent::setConfiguration($config);\n\n $this->metadataProcessor->setConfiguration($config);\n $this->stageProcessor->setConfiguration($config);\n $this->opportunityProcessor->setConfiguration($config);\n $this->accountProcessor->setConfiguration($config);\n }\n\n protected function getOAuthAccount(User $user): ?SocialAccount\n {\n // In this case, the Account Owner is always the connection for any API operations.\n return $user->getTeam()->getOwner()->getSocialAccount(SocialAccount::PROVIDER_CLOSE);\n }\n\n private function getClient(): Client\n {\n if (! $this->client instanceof Client) {\n throw new UnexpectedCallException('Client not set');\n }\n\n return $this->client;\n }\n\n public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string\n {\n return $this->fieldValueConverter->convertFromCrm($fieldType, $fieldValue);\n }\n\n protected function getFieldTypes(): array\n {\n return [\n parent::OBJECT_OPPORTUNITY,\n parent::OBJECT_CONTACT,\n parent::OBJECT_ACCOUNT,\n ];\n }\n\n protected function getFields(string $crmObject): array\n {\n // not used\n return [];\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultFields(string $activityType): array\n {\n $fields = [];\n\n if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {\n $defaultFields = FieldDefinitions::defaultTaskFields();\n\n // This lazy creates these fields if not already setup.\n foreach ($defaultFields as $defaultField) {\n $fields[] = $this->config->fields()->firstOrCreate($defaultField);\n }\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultActivityField(string $activityType): Field\n {\n // Set up the activity field as the default Type.\n /** @var Field $activityField */\n $activityField = $this->config->fields()->where([\n 'crm_provider_id' => 'type',\n 'object_type' => $activityType,\n ])->first();\n\n return $activityField;\n }\n\n /**\n * @inheritdoc\n */\n public function getSupportedPlaybookTypes(): array\n {\n return [Playbook::ACTIVITY_TYPE_TASK];\n }\n\n public function getDealInsightsFields(): array\n {\n return FieldDefinitions::dealInsightsFields();\n }\n\n protected function getDefaultFollowupLayoutFields(string $activityType): array\n {\n $fields = [];\n $fieldRepo = app(FieldRepository::class);\n\n $fieldData = FieldDefinitions::followupFieldsFilter();\n\n foreach ($fieldData as $data) {\n $field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $data);\n\n // Only add the field if it is created, which it should be.\n if ($field) {\n $fields[] = $field;\n }\n }\n\n return $fields;\n }\n\n public function syncFields(): void\n {\n $this->syncStandardFields();\n $this->syncCustomFields();\n }\n\n /**\n * @important Works only for custom fields\n */\n public function syncField(Field $field): void\n {\n $resource = $this->convertObjectTypeToResource($field->getObjectType());\n\n // We can only sync custom fields in this CRM.\n if ($this->isCustomField($field->getCrmProviderId()) === false) {\n return;\n }\n\n $crmField = $this->getClient()->fetchCustomFieldDefinition($resource, $field->getCrmProviderId());\n\n $this->metadataProcessor->syncField($crmField);\n }\n\n private function isCustomField(string $fieldId): bool\n {\n return strpos($fieldId, 'cf_') === 0;\n }\n\n /**\n * @inheritdoc\n */\n public function importPicklistValues(Field $field): array\n {\n // handled in syncFields()\n return [];\n }\n\n /**\n * @important We only support stages on the opportunity object\n *\n * @param string[]|null $types\n */\n public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage\n {\n if (! $missingStageName) {\n // This is taken care of by syncOrganization()\n return null;\n }\n\n $stage = $this->stageProcessor->resolveFromStageId($missingStageName);\n\n if ($stage instanceof Stage) {\n return $stage;\n }\n\n $stageMetadata = $this->getClient()->fetchStage($missingStageName);\n\n if (! $stageMetadata) {\n $this->logger->error('Stage does not exist', [\n 'stage' => $missingStageName,\n ]);\n\n return null;\n }\n\n\n return $this->stageProcessor->importStage($stageMetadata);\n }\n\n /**\n * @inheritdoc\n */\n public function syncLeads(Carbon $since, ?Carbon $to = null, ?string $crmProfileId = null): int\n {\n // Even though Close.io has the concept of \"leads\", they fit more into our concept of accounts.\n return 0;\n }\n\n /**\n * @inheritdoc\n */\n public function syncLead(string $crmId): ?Lead\n {\n // Not a supported entity.\n return null;\n }\n\n /**\n * @throws Exception\n */\n public function syncAccounts(Carbon $since, ?Carbon $to = null): int\n {\n $syncCount = 0;\n\n try {\n foreach ($this->getClient()->listAccounts($since) as $clAccount) {\n // Only sync if previously imported.\n if ($this->hasAccount($clAccount->getId())) {\n $this->importAccount($clAccount);\n $syncCount++;\n }\n }\n } catch (Exception $exception) {\n $this->logger->error('Account sync failed', [\n 'error' => $exception->getMessage(),\n ]);\n\n throw $exception;\n }\n\n return $syncCount;\n }\n\n public function syncAccount(string $crmId): ?Account\n {\n return $this->accountProcessor->syncAccount($crmId);\n }\n\n private function importAccount($crmData): Account\n {\n return $this->accountProcessor->importAccountMetadata($crmData);\n }\n\n /**\n * @throws CloseException\n */\n public function syncOpportunities(array $parameters, ?string $strategy = null): int\n {\n $strategyResolver = app(OpportunitySyncStrategyResolver::class, [\n 'client' => $this->client,\n ]);\n $strategies = $strategyResolver->getStrategies($this->config, $strategy);\n\n $syncCount = 0;\n\n try {\n $opportunities = [];\n foreach ($strategies as $syncStrategy) {\n $opportunitiesData = $syncStrategy->fetchOpportunities($parameters);\n $opportunities[] = $opportunitiesData['data'];\n\n if ($opportunitiesData['has_more']) {\n $this->logger->info('[Close] Sync Opportunities - count warning', [\n 'team_id' => $this->config->getTeam()->getId(),\n 'total' => $opportunitiesData['total'],\n 'count' => $opportunitiesData['count'],\n 'skip' => $opportunitiesData['skip'],\n 'strategies_count' => count($strategies),\n ]);\n }\n }\n\n $opportunities = array_merge(...$opportunities);\n } catch (CrmException $exception) {\n $this->logger->error('Fetching opportunity data failed', [\n 'team' => $this->getTeam()->getSlug(),\n 'error' => $exception->getMessage(),\n ]);\n\n return 0;\n }\n\n foreach ($opportunities as $opportunityMetadata) {\n try {\n $this->importOpportunity($opportunityMetadata);\n $syncCount++;\n } catch (Exception $exception) {\n $this->logger->warning('Opportunity sync failed', [\n 'opportunity' => $opportunityMetadata->getId(),\n 'error' => $exception->getMessage(),\n ]);\n }\n }\n\n return $syncCount;\n }\n\n public function syncOpportunity(string $crmId): ?Opportunity\n {\n $strategyResolver = app(OpportunitySyncStrategyResolver::class, [\n 'client' => $this->client,\n ]);\n\n $strategy = $strategyResolver->resolve(\n $this->config,\n OpportunitySyncStrategyResolver::SINGLE_SYNC_OPPORTUNITY_STRATEGY,\n );\n\n $parameters = ['crm_id' => $crmId];\n\n $opportunity = $strategy->fetchOpportunities($parameters);\n\n if (empty($opportunity['data'])) {\n return null;\n }\n\n return $this->importOpportunity($opportunity['data']);\n }\n\n private function importOpportunity($crmData): ?Opportunity\n {\n if (! $crmData->getLeadId()) {\n $this->logger->warning('Opportunity does not have a lead ID', [\n 'opportunity' => $crmData->getId(),\n ]);\n\n return null;\n }\n\n $account = $this->getConfiguration()\n ->accounts()\n ->where('crm_provider_id', $crmData->getLeadId())\n ->first()\n ;\n\n if ($account === null) {\n $account = $this->accountProcessor->syncAccount($crmData->getLeadId());\n }\n\n $profile = $this->getConfiguration()\n ->profiles()\n ->where('crm_provider_id', $crmData->getUserId())\n ->first()\n ;\n\n $stage = $this->getConfiguration()\n ->stages()\n ->where('crm_provider_id', $crmData->getStageId())\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->first()\n ;\n\n if ($stage === null) {\n $stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $crmData->getStageId());\n }\n\n return $this->opportunityProcessor->import($crmData, $account, $stage, $profile);\n }\n\n /**\n * @param array<string,string> $crmData\n * @param string[] $crmFields\n */\n public function importOpportunityCrmFieldData(array $crmData, array $crmFields, int $opportunityId): void\n {\n // handled in importOpportunity\n }\n\n /**\n * @inheritdoc\n */\n public function syncContacts(Carbon $since, ?Carbon $to = null): int\n {\n $syncCount = 0;\n\n try {\n /** No way to sync today.\n $clContacts = $this->client->get('lead', [\n 'date_updated__gte' => $since->toDateString(),\n '_order_by' => '-date_updated',\n ]);\n\n foreach ($clContacts as $clContact) {\n // Only sync if previously imported.\n if ($this->hasContact($clContact['id'])) {\n $this->importContact($clContact);\n $syncCount++;\n }\n }\n **/\n } catch (Exception $exception) {\n // Do nothing for now.\n throw $exception;\n }\n\n return $syncCount;\n }\n\n /**\n * @inheritdoc\n */\n public function syncContact(string $crmId): ?Contact\n {\n try {\n $clContact = $this->client->get('contact/' . $crmId);\n } catch (HttpNotFoundException $exception) {\n return null;\n }\n\n return $this->importContact($clContact);\n }\n\n /**\n * @inheritdoc\n */\n private function importContact($crmData): Contact\n {\n $account = null;\n if ($crmData['lead_id']) {\n $account = $this->team\n ->accounts()\n ->where('crm_provider_id', $crmData['lead_id'])\n ->first();\n\n if ($account === null) {\n $account = $this->syncAccount($crmData['lead_id']);\n }\n }\n\n $mobilePhone = $parsedNumber = null;\n foreach ($crmData['phones'] as $phoneNumber) {\n if ($phoneNumber['type'] === 'mobile') {\n $mobilePhone = $this->buildContactMobilePhone(null, $phoneNumber['phone']);\n } else {\n $parsedNumber = $this->buildContactPhone(null, $phoneNumber['phone']);\n }\n }\n\n $email = null;\n if (empty($crmData['emails']) === false) {\n $email = mb_strimwidth($crmData['emails'][0]['email'], 0, 80);\n }\n\n $profile = $this->config->profiles()->where('crm_provider_id', (string) $crmData['updated_by'])->first();\n\n $data = [\n 'account_id' => $account->id ?? null,\n 'team_id' => $this->team->id,\n 'user_id' => $profile?->user_id,\n 'owner_id' => $crmData['updated_by'],\n 'name' => $crmData['name'] ?? 'Unknown',\n 'email' => $email,\n 'phone' => $parsedNumber['phone'] ?? null,\n 'mobile_phone' => $mobilePhone ?? null,\n 'ext' => $parsedNumber['ext'] ?? null,\n 'title' => mb_strimwidth($crmData['title'] ?? '', 0, 128),\n 'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(\n crmConfiguration: $this->config,\n crmProviderId: $crmData['id'],\n modelType: Contact::class,\n fileName: $crmData['id'],\n avatarText: $crmData['name'] ?? 'Unknown'\n ),\n 'remotely_created_at' => Carbon::parse($crmData['date_created']),\n ];\n\n /** @var Contact */\n return $this->config->contacts()->updateOrCreate(['crm_provider_id' => (string) $crmData['id']], $data);\n }\n\n private function buildContactPhone(?string $countryCode, ?string $number): ?array\n {\n if ($number) {\n // Trim to our width and attempt to parse it.\n $number = mb_strimwidth($number, 0, 25);\n $parsedNumber = parsePhoneNumber($countryCode, $number);\n } else {\n $parsedNumber = [];\n }\n\n return $parsedNumber;\n }\n\n private function buildContactMobilePhone(?string $countryCode, ?string $number): ?string\n {\n return $number ? mb_strimwidth(phone_e164($countryCode, $number), 0, 25) : null;\n }\n\n public function syncOrganization(): void\n {\n $organisation = $this->getClient()->fetchOrganisation();\n\n $this->metadataProcessor->syncOrganisation($organisation);\n\n foreach ($organisation->getPipelines() as $pipelineMetadata) {\n $this->metadataProcessor->syncPipeline($pipelineMetadata);\n }\n }\n\n private function syncStandardFields(): void\n {\n // Currently we sync only opportunity fields\n $stages = $this->getClient()->listStages();\n foreach ($this->standardFieldMetadata->opportunity($stages) as $fieldMetadata) {\n $this->metadataProcessor->syncField($fieldMetadata);\n }\n\n $this->config->save();\n }\n\n private function syncCustomFields(): void\n {\n foreach ($this->getFieldTypes() as $fieldType) {\n $objectType = $this->convertObjectTypeToResource($fieldType);\n $currentFields = $this->getClient()->listCustomFieldDefinitions($objectType);\n\n foreach ($currentFields as $fieldMetadata) {\n $this->metadataProcessor->syncField($fieldMetadata);\n }\n }\n\n $this->config->save();\n }\n\n public function syncProfiles(?User $userToSearch = null): ?Profile\n {\n /*\n * Fetch the profile of the user from the database\n * Then fetch the user metadata from Close and update it\n * In case there's no profile for the user, proceed with syncing all users\n */\n $foundUser = null;\n\n if ($userToSearch) {\n $profile = $userToSearch->getProfile();\n\n if ($profile instanceof Profile) {\n $crmProviderId = $profile->getCrmProviderId();\n\n if ($crmProviderId) {\n $profileMetadata = $this->getClient()->fetchUser($crmProviderId);\n\n if (! $profileMetadata instanceof ProfileMetadata) {\n return null;\n }\n\n return $this->metadataProcessor->syncProfile($profileMetadata);\n }\n }\n }\n\n foreach ($this->getClient()->listUsers() as $userMetadata) {\n $userProfile = $this->metadataProcessor->syncProfile($userMetadata);\n\n if (\n $userToSearch instanceof User\n && $userProfile instanceof Profile\n && $userProfile->getUserId() === $userToSearch->getId()\n ) {\n $foundUser = $userProfile;\n }\n }\n\n return $foundUser;\n }\n\n public function syncProfileFields(): void\n {\n // Not used.\n }\n\n /**\n * @inheritdoc\n */\n public function find(string $name, array $scopes): array\n {\n $count = $this->limit ?? 20;\n $offset = $this->offset ?? 0;\n\n $data = Cache::remember($this->team->id . $name . $count . $offset, 300, function () use ($name, $count, $offset) {\n $data = [];\n\n try {\n // If search phrase resembles phone number remove special symbols\n if (preg_match('/^([0-9\\s\\-\\+\\(\\)]*)$/', $name)) {\n $name = '+' . preg_replace('/[\\s\\-\\+\\(\\)]/', '', $name);\n }\n\n // Close do not provide a unified way to search, so we must hack our own.\n $objects = $this->client->get('lead', [\n 'query' => 'sort:date_updated name:(' . $name . ') or email:(' . $name . ') or phone:(' . $name . ')',\n '_limit' => $count, '_skip' => $offset,\n ]);\n } catch (\\GuzzleHttp\\Exception\\ServerException $exception) {\n throw new ServiceUnavailableException($exception->getMessage());\n }\n\n foreach ($objects['data'] as $object) {\n // We need a contact to dial it.\n if (empty($object['contacts'])) {\n continue;\n }\n\n foreach ($object['contacts'] as $contact) {\n $record = [\n 'crmId' => $contact['id'],\n 'crmUrl' => $this->generateProviderUrl($object['id'], 'account'),\n 'name' => $contact['name'],\n 'industry' => null,\n 'title' => $contact['title'],\n 'organization' => $object['display_name'],\n 'prospectType' => 'contact',\n 'phoneNumbers' => [],\n ];\n\n foreach ($contact['phones'] as $phone) {\n if ($phone['type'] === 'mobile') {\n $number = $this->buildContactMobilePhone(null, $phone['phone']);\n\n $record['phoneNumbers'][] = [\n 'number' => $number,\n 'nationalFormat' => phone_national(null, $number),\n 'type' => 'mobile',\n ];\n } else {\n $parsedNumber = $this->buildContactPhone(null, $phone['phone']);\n\n // Add phone number to record.\n if (empty($parsedNumber['phone']) === false) {\n $record['phoneNumbers'][] = [\n 'number' => $parsedNumber['phone'],\n 'nationalFormat' => phone_national(null, $parsedNumber['phone']),\n 'type' => 'phone',\n ];\n }\n }\n }\n\n $data[] = $record;\n }\n }\n\n return $data;\n });\n\n return $data;\n }\n\n /**\n * @inheritdoc\n */\n public function findOpportunities(?string $crmAccountId, ?string $crmContactId, ?int $userId = null): array\n {\n $data = [];\n $ownerData = [];\n $ownerId = null;\n $contact = null;\n $account = null;\n\n if ($crmAccountId) {\n $account = $this->config->accounts()->where('crm_provider_id', $crmAccountId)->first();\n\n if ($account === null) {\n $account = $this->syncAccount($crmAccountId);\n }\n }\n\n if ($crmContactId) {\n $contact = $this->config->contacts()->where('crm_provider_id', $crmContactId)->first();\n\n if ($contact === null) {\n $contact = $this->syncContact($crmContactId);\n }\n }\n\n if ($contact || $account) {\n if ($contact && $account === null) {\n $account = $contact->account;\n }\n\n if ($account === null) {\n return [];\n }\n\n $params = [\n 'lead_id' => $account->crm_provider_id,\n '_order_by' => '-date_updated',\n ];\n\n $onlyOpen = true;\n switch ($this->config->opportunity_assignment_rule) {\n case Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_UPDATED:\n $params['_order_by'] = '-date_updated';\n\n break;\n\n case Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_CREATED:\n $params['_order_by'] = '-date_created';\n\n break;\n\n case Configuration::OPP_ASSIGNMENT_ALL_OPEN_OLDEST_CREATED:\n $params['_order_by'] = 'date_created';\n\n break;\n\n case Configuration::OPP_ASSIGNMENT_ALL_RECENTLY_UPDATED:\n $params['_order_by'] = '-date_updated';\n $onlyOpen = false;\n }\n\n if ($onlyOpen) {\n $params['status_type__in'] = 'active,won';\n }\n\n $clOpportunities = $this->client->get('opportunity', $params);\n\n if ($userId) {\n $profileRepository = app(ProfileRepository::class);\n $profile = $profileRepository->findProfileByUserId($this->config, $userId);\n\n $ownerId = $profile instanceof Profile ? $profile->getCrmProviderId() : null;\n }\n\n foreach ($clOpportunities['data'] as $clOpportunity) {\n $stage = $this->config\n ->stages()\n ->where('crm_provider_id', $clOpportunity['status_id'])\n ->first();\n\n if ($stage === null) {\n // Import it.\n $stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $clOpportunity['status_id']);\n }\n\n $record = [\n 'crmId' => $clOpportunity['id'],\n 'name' => $clOpportunity['note'],\n 'value' => currency_format($clOpportunity['value'], $clOpportunity['value_currency']),\n 'won' => $stage->probability === 100.00,\n 'closed' => $clOpportunity['status_type'] !== 'active',\n 'stage' => [\n 'id' => $stage->id_string,\n 'name' => $stage->name,\n ],\n 'recordType' => [],\n ];\n\n if ($ownerId && isset($clOpportunity['user_id']) && $clOpportunity['user_id'] === $ownerId) {\n $ownerData[] = $record;\n }\n\n $data[] = $record;\n }\n\n if (! empty($ownerData)) {\n return $ownerData;\n }\n }\n\n return $data;\n }\n\n /**\n * @inheritdoc\n */\n public function getTasks(string $objectType, string $objectId, ?string $opportunityId): array\n {\n $data = [];\n $crmId = null;\n\n if ($objectType === 'contact') {\n $contact = $this->syncContact($objectId);\n\n if ($contact && $contact->account_id) {\n $crmId = $contact->account->crm_provider_id;\n }\n } else {\n $crmId = $objectId;\n }\n\n if ($crmId) {\n $clTasks = $this->client->get('task', [\n 'lead_id' => $crmId,\n '_type' => 'lead',\n 'assigned_to' => $this->profile->crm_provider_id,\n 'is_complete' => 'false',\n '_order_by' => 'date',\n ]);\n\n foreach ($clTasks['data'] as $clTask) {\n $data[] = [\n 'crmId' => $clTask['id'],\n 'subject' => $clTask['text'],\n 'due' => $clTask['date'] ?? null,\n 'type' => null,\n ];\n }\n }\n\n return $data;\n }\n\n /**\n * Try to find email address in CRM service\n *\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n *}\n */\n public function matchExactlyByEmail(string $email, ?int $userId = null): ?array\n {\n $clObject = $this->client->get('lead', [\n 'query' => 'sort:updated contact(email(email:\"' . $email . '\"))',\n '_limit' => 1,\n ]);\n\n if ($clObject['total_results'] > 0) {\n foreach ($clObject['data'][0]['contacts'] as $clContact) {\n foreach ($clContact['emails'] as $clEmail) {\n if ($email === $clEmail['email']) {\n $data = $this->convertCrmData($clContact['id'], $userId);\n\n return ! empty(array_filter($data)) ? $data : null;\n }\n }\n }\n }\n\n return null;\n }\n\n /**\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n *}\n */\n public function matchByPhone(string $phone, ?string $rawPhoneNumber = null, ?int $userId = null): ?array\n {\n // Check if the user is internal.\n $teamMember = $this->team->users()->where('phone', $phone)->exists();\n\n // Skip the attendee if internal.\n if ($teamMember === false) {\n $clObject = $this->client->get('lead', [\n 'query' => 'sort:updated contact(' . $phone . ')',\n '_limit' => 1,\n ]);\n\n if ($clObject['total_results'] > 0) {\n foreach ($clObject['data'][0]['contacts'] as $clContact) {\n foreach ($clContact['phones'] as $clPhone) {\n if ($phone === $clPhone['phone']) {\n $data = $this->convertCrmData($clContact['id'], $userId);\n\n return ! empty(array_filter($data)) ? $data : null;\n }\n }\n }\n }\n }\n\n return null;\n }\n\n /**\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n public function matchByName(string $name, ?int $userId = null): ?array\n {\n // Don't waste time searching for single character strings.\n if (\\strlen($name) <= 1) {\n return null;\n }\n\n $cacheKey = $this->getCacheKey($name, $userId);\n\n $result = Cache::remember($cacheKey, 60, function () use ($name, $userId) {\n $clObject = $this->client->get('lead', [\n 'query' => 'sort:updated contact(name:\"' . $name . '\")',\n '_limit' => 1,\n ]);\n\n if ($clObject['total_results'] > 0) {\n foreach ($clObject['data'][0]['contacts'] as $clContact) {\n if ($clContact['name'] === $name || $clContact['display_name'] === $name) {\n $data = $this->convertCrmData($clContact['id'], $userId);\n\n return ! empty(array_filter($data)) ? $data : false;\n }\n }\n }\n\n return false;\n });\n\n return is_array($result) ? $result : null;\n }\n\n protected function getCacheKey(string $object, ?int $userId = null): ?string\n {\n $key = $this->team->getId() . $object;\n $keySuffix = $this->getOwnerKeySuffix($userId);\n\n return $key . $keySuffix;\n }\n\n private function getOwnerKeySuffix(?int $userId = null): string\n {\n return $userId === null ? '' : (string) $userId;\n }\n\n private function convertCrmData(string $crmId, ?int $userId = null): array\n {\n $lead = null;\n $opportunity = null;\n $account = null;\n $stage = null;\n $countryCode = null;\n\n $contact = $this->syncContact($crmId);\n if ($contact) {\n $account = $contact->account;\n\n if ($contact->country_code) {\n $countryCode = $contact->country_code;\n } elseif ($account) {\n $countryCode = $account->country_code;\n }\n\n try {\n $cpOpportunities = $this->findOpportunities(\n $account?->crm_provider_id,\n $contact->crm_provider_id,\n $userId,\n );\n\n if (! empty($cpOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($cpOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n } catch (Exception) {\n // Nothing to see here.\n }\n }\n\n return [\n $lead,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n }\n\n public function saveActivity(Activity $activity): Activity\n {\n switch ($activity->type) {\n case Activity::TYPE_CONFERENCE:\n case Activity::TYPE_SOFTPHONE:\n case Activity::TYPE_SOFTPHONE_INBOUND:\n $activity = $this->buildCallPayload($activity);\n\n break;\n case Activity::TYPE_SMS_INBOUND:\n case Activity::TYPE_SMS_OUTBOUND:\n $activity = $this->buildTextMessagePayload($activity);\n\n break;\n }\n\n return $activity;\n }\n\n private function mapStatus(string $status): string\n {\n switch ($status) {\n case Activity::STATUS_COMPLETED:\n case Activity::STATUS_IN_PROGRESS:\n case Activity::STATUS_FAILED:\n case Activity::STATUS_NO_ANSWER:\n case Activity::STATUS_BUSY:\n default:\n return $status;\n case Activity::STATUS_CANCELLED:\n return 'cancel';\n }\n }\n\n /**\n * @throws CrmException\n */\n private function buildCallPayload(Activity $activity): Activity\n {\n try {\n if ($activity->crm_provider_id) {\n // The activity should be logged under the existing Task (not Activity).\n $data = [\n '_type' => 'lead',\n 'lead_id' => $activity->account->crm_provider_id,\n 'assigned_to' => $this->profile->crm_provider_id,\n 'text' => $this->generateActivityDescription($activity),\n 'date' => $activity->getActualEndTime()->toDateString(),\n 'is_complete' => true,\n ];\n\n $this->logger->info('[Close CRM] Updating task', [\n 'activity' => $activity->id,\n 'crm_id' => $activity->crm_provider_id,\n 'data' => $data,\n ]);\n\n $this->client->put('task/' . $activity->crm_provider_id, $data);\n } else {\n // Just create an activity.\n $data = [\n 'lead_id' => $activity->account_id ? $activity->account->crm_provider_id : null,\n 'contact_id' => $activity->contact_id ? $activity->contact->crm_provider_id : null,\n 'created_by' => $this->profile->crm_provider_id,\n 'user_id' => $this->profile->crm_provider_id,\n 'direction' => $activity->getCrmType() === Activity::TYPE_SOFTPHONE ? 'outbound' : 'inbound',\n 'status' => $this->mapStatus($activity->getStatus()),\n 'note' => $this->generateActivityDescription($activity),\n 'duration' => $this->mapStatus($activity->getStatus()) !== 'cancel' ? $activity->duration : 0,\n 'phone' => $activity->to ? $activity->to->phone_number : null,\n ];\n\n $clActivity = $this->client->post('activity/call', $data);\n\n $this->logger->info('[Close CRM] Creating activity', [\n 'activity' => $activity->id,\n 'crm_id' => $clActivity['id'],\n 'data' => $data,\n 'response' => $clActivity,\n ]);\n\n $activity->crm_provider_id = $clActivity['id'];\n $activity->save();\n }\n } catch (ClientException $exception) {\n $response = $exception->getResponse();\n\n if ($response === null) {\n // Trying to debug weird cases where this is null.\n Sentry::captureException($exception);\n }\n\n $responseBody = $response->getBody();\n $message = $responseBody;\n $errorCode = $response->getStatusCode();\n\n $jsonResponse = json_decode($responseBody, true);\n if (isset($jsonResponse[0]['message'])) {\n $message = $jsonResponse[0]['message'];\n }\n\n throw new CrmException($message, $errorCode);\n }\n\n return $activity;\n }\n\n private function buildTextMessagePayload(Activity $activity): Activity\n {\n $clActivity = $this->client->post('activity/sms', [\n 'lead_id' => $activity->account_id ? $activity->account->crm_provider_id : null,\n 'contact_id' => $activity->contact_id ? $activity->contact->crm_provider_id : null,\n 'created_by' => $this->profile->crm_provider_id,\n 'user_id' => $this->profile->crm_provider_id,\n 'status' => $activity->getType() === Activity::TYPE_SMS_OUTBOUND ? 'sent' : 'inbox',\n 'text' => $this->generateActivityDescription($activity),\n 'remote_phone' => $activity->to ? $activity->to->phone_number : null,\n 'local_phone' => $activity->to ? $activity->to->phone_number : null,\n 'source' => 'Close.io',\n ]);\n\n $activity->crm_provider_id = $clActivity['id'];\n $activity->save();\n\n return $activity;\n }\n\n private function generateActivityDescription(Activity $activity): string\n {\n $description = '';\n\n switch ($activity->type) {\n case Activity::TYPE_SOFTPHONE:\n case Activity::TYPE_SOFTPHONE_INBOUND:\n case Activity::TYPE_CONFERENCE:\n if ($activity->hasActivityType()) {\n $description = $activity->getActivityType()->getName() . PHP_EOL . PHP_EOL;\n }\n if ($activity->hasTitle()) {\n $description .= $activity->getTitle() . PHP_EOL;\n }\n\n if ($activity->hasReasonCodeBotKicked()) {\n $description .= 'Notetaker removed from this meeting' . PHP_EOL . PHP_EOL;\n // When we fix the state to be Activity::RECORDING_RECORDED as it should be this can change.\n } elseif ($activity->hasReasonCodeNotCompliant()) {\n $description .= 'Notetaker did not join due to recording consent not being provided by attendees' . PHP_EOL . PHP_EOL;\n } elseif ($activity->canReviewActivity()) {\n $playbackUrl = $activity->user->team->partner->getPlaybackUrl($activity);\n $description .= 'Review Activity:' . PHP_EOL . $playbackUrl . PHP_EOL . PHP_EOL;\n }\n\n if ($activity->type === Activity::TYPE_CONFERENCE) {\n $description .= 'Attendees:'\n . PHP_EOL\n . (new FilterJoinedParticipants())->toString($activity);\n }\n\n if (\\count($activity->notes) > 0) {\n $description .= PHP_EOL . PHP_EOL . 'Notes: ' . PHP_EOL;\n\n foreach ($activity->notes as $note) {\n $time = ($note->time > 3600) ? gmdate('H:i:s', $note->time) : gmdate('i:s', $note->time);\n $description .= $time . ' ' . $note->note . PHP_EOL;\n }\n }\n\n // Get all private messages.\n $messages = $activity->messages()\n ->where('is_private', 1)\n ->orderBy('created_at', 'asc');\n\n if ($messages->count() > 0) {\n $description .= PHP_EOL . PHP_EOL . 'Coaching Chat: ' . PHP_EOL;\n\n foreach ($messages->get() as $message) {\n $description .= $message->participant->name . ': ' . $message->message . PHP_EOL;\n }\n }\n\n // Get all public messages.\n $messages = $activity->messages()\n ->where('is_private', 0)\n ->orderBy('created_at', 'asc');\n\n if ($messages->count() > 0) {\n $description .= PHP_EOL . PHP_EOL . 'Customer Chat: ' . PHP_EOL;\n\n foreach ($messages->get() as $message) {\n $description .= $message->participant->name . ': ' . $message->message . PHP_EOL;\n }\n }\n\n if ($activity->summary) {\n $description .= PHP_EOL . PHP_EOL . 'Summary: ' . PHP_EOL . $activity->summary;\n }\n\n break;\n\n case Activity::TYPE_SMS_INBOUND:\n case Activity::TYPE_SMS_OUTBOUND:\n $description = $activity->description;\n\n break;\n }\n\n return $description;\n }\n\n public function saveFollowupActivity(Activity $activity, array $fields): ?string\n {\n // This is the user provided activity subject field.\n if (empty($fields['name'])) {\n return null;\n }\n\n $due = null;\n if (empty($fields['due_date']) === false) {\n $formatDue = Carbon::parse($fields['due_date']);\n $due = $formatDue->toDateTimeString();\n }\n\n $clTask = $this->client->post('task', [\n '_type' => 'lead',\n 'lead_id' => $activity->account->crm_provider_id,\n 'assigned_to' => $this->profile->crm_provider_id,\n 'text' => $fields['name'],\n 'date' => $due,\n 'is_complete' => false,\n ]);\n\n // We don't actually create a corresponding activity object on our side yet.\n return $clTask['id'];\n }\n\n /**\n * Store transcripts as note.\n *\n * @throws Exception\n */\n public function createTranscriptNotes(Activity $activity): void\n {\n if ($activity->account_id === null) {\n // We can only log to accounts (leads).\n return;\n }\n\n // Generate activity transcription.\n $transcriptionData = $this->generateTranscription($activity);\n\n // Truncate Notes with max notes length because transcription text could be very long.\n $transcripts = mb_strimwidth($transcriptionData, 0, self::NOTE_BODY_MAX_LENGTH);\n\n $clActivity = $this->client->post('activity/note', [\n 'lead_id' => $activity->account_id ? $activity->account->crm_provider_id : null,\n 'created_by' => $this->profile->crm_provider_id,\n 'user_id' => $this->profile->crm_provider_id,\n 'note' => $transcripts,\n ]);\n\n // Store CRM Activity ID in transcription.\n $transcription = $activity->getTranscription();\n $transcription->crm_activity_id = $clActivity['id'];\n $transcription->save();\n }\n\n public function parseObjectType(string $objectId): string\n {\n if (Str::startsWith($objectId, 'lead')) {\n return 'account';\n }\n\n if (Str::startsWith($objectId, 'cont')) {\n return 'contact';\n }\n\n if (Str::startsWith($objectId, 'oppo')) {\n return 'opportunity';\n }\n\n throw new InvalidArgumentException('Unsupported Object Type');\n }\n\n /**\n * @inheritdoc\n */\n public function updateStage($crmObject, Stage $stage): void\n {\n if ($crmObject instanceof Lead) {\n // This would never get invoked since we merge lead/accounts in Close.\n $this->client->put('lead/' . $crmObject->crm_provider_id, [\n 'status' => $stage->crm_provider_id,\n ]);\n } else {\n $this->client->put('opportunity/' . $crmObject->crm_provider_id, [\n 'status_id' => $stage->crm_provider_id,\n ]);\n }\n }\n\n public function updateRecord(string $objectType, string $objectId, array $data, array $headers = []): void\n {\n $this->getClient()->updateObject($this->convertObjectTypeToResource($objectType), $objectId, $data);\n }\n\n public function prepareValueForUpdate(array $params): array\n {\n $convertedValue = $this->fieldValueConverter->convertToCrm(\n $this->config,\n $params['fieldName'],\n $params['fieldValue'],\n );\n\n if ($this->isCustomField($params['fieldName'])) {\n $params['fieldName'] = 'custom.' . $params['fieldName'];\n }\n\n $params['fieldValue'] = $convertedValue;\n\n return parent::prepareValueForUpdate($params);\n }\n\n public function getRecord(string $objectType, string $objectId, array $fields = []): array\n {\n return $this->client->get($this->convertObjectTypeToResource($objectType) . '/' . $objectId);\n }\n\n /**\n *\n * @throws UnexpectedValueException\n */\n private function convertObjectTypeToResource(string $objectType): string\n {\n switch ($objectType) {\n case FieldData::OBJECT_OPPORTUNITY:\n return 'opportunity';\n\n case FieldData::OBJECT_CONTACT:\n return 'contact';\n\n case FieldData::OBJECT_ACCOUNT:\n return 'lead';\n\n case FieldData::OBJECT_TASK:\n return 'activity';\n\n default:\n throw new UnexpectedValueException('Unsupported object type \"' . $objectType . '\"');\n }\n }\n\n public function generateProviderUrl(string $providerId, string $objectType): ?string\n {\n $baseUrl = 'https://app.close.com/';\n $url = null;\n\n switch ($objectType) {\n case 'account':\n $url = $baseUrl . 'lead/' . $providerId;\n\n break;\n\n case 'contact':\n $contact = $this->config->contacts()->where('crm_provider_id', $providerId)->first();\n if ($contact && $contact->account_id) {\n $url = $baseUrl . 'lead/' . $contact->account->crm_provider_id;\n }\n\n break;\n\n default:\n // Sadly we can't deeplink to anything else in Close UI.\n $url = null;\n }\n\n return $url;\n }\n\n /**\n * Generate transcription for the activity.\n */\n private function generateTranscription(Activity $activity): string\n {\n if (! $this->config->store_transcript) {\n // If sending transcription to activity toggle is disabled\n return '';\n }\n\n return $this->transcriptionService\n ->findTranscriptionByActivity($activity)\n ->map(static function (array $transcriptionSegment): string {\n return $transcriptionSegment['formattedStartsAt'] . ' | ' . $transcriptionSegment['transcript'];\n })\n ->implode(PHP_EOL);\n }\n\n public function verifyTaskExists(Activity $activity): bool\n {\n $crmProviderId = $activity->getCrmProviderId();\n $cacheKey = \"crm_task_exists:{$this->config->getId()}:$crmProviderId\";\n\n return Cache::remember($cacheKey, self::TASK_VERIFICATION_CACHE_TTL, function () use ($crmProviderId) {\n try {\n $client = $this->getClient();\n $task = $client->get('task/' . $crmProviderId);\n\n return ! empty($task);\n } catch (HttpNotFoundException) {\n // Task not found in CRM - this is expected and permanent\n $this->logger->info('[Close] Task not found during verification', [\n 'task_id' => $crmProviderId,\n 'config_id' => $this->config->getId(),\n ]);\n\n return false;\n } catch (CloseException $e) {\n // Handle 404 responses from Close API\n if ($e->getResponseStatusCode() === 404) {\n $this->logger->info('[Close] Task not found during verification (404)', [\n 'task_id' => $crmProviderId,\n 'config_id' => $this->config->getId(),\n ]);\n\n return false;\n }\n\n // Re-throw other Close exceptions for retry\n throw $e;\n }\n // Let other exceptions (network errors, rate limits, etc.) bubble up for retry\n });\n }\n}","depth":4,"on_screen":true,"value":"<?php\n\nnamespace Jiminny\\Services\\Crm\\Close;\n\nuse Cache;\nuse Carbon\\Carbon;\nuse Exception;\nuse GuzzleHttp\\Exception\\ClientException;\nuse Illuminate\\Support\\Str;\nuse InvalidArgumentException;\nuse Jiminny\\Contracts\\Services\\Crm\\LayoutManagementInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\MatchCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\Provider\\CloseInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityLookupInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityManipulationInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SettingsInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SupportsObjectTypeParseInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmMetadataInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\VerifyTaskExistsInterface;\nuse Jiminny\\Exceptions\\CrmException;\nuse Jiminny\\Exceptions\\HttpNotFoundException;\nuse Jiminny\\Exceptions\\ServiceUnavailableException;\nuse Jiminny\\Exceptions\\UnexpectedCallException;\nuse Jiminny\\Models\\Account;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Contact;\nuse Jiminny\\Models\\Crm\\Configuration;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Models\\Crm\\FieldData;\nuse Jiminny\\Models\\Crm\\Profile;\nuse Jiminny\\Models\\Lead;\nuse Jiminny\\Models\\Opportunity;\nuse Jiminny\\Models\\Playbook;\nuse Jiminny\\Models\\SocialAccount;\nuse Jiminny\\Models\\Stage;\nuse Jiminny\\Models\\User;\nuse Jiminny\\Repositories\\Crm\\FieldRepository;\nuse Jiminny\\Repositories\\Crm\\ProfileRepository;\nuse Jiminny\\Services\\Avatar\\ProspectPhotoPathService;\nuse Jiminny\\Services\\Crm\\BaseService;\nuse Jiminny\\Services\\Crm\\Close\\Processor\\AccountProcessor;\nuse Jiminny\\Services\\Crm\\Close\\Processor\\MetadataProcessor;\nuse Jiminny\\Services\\Crm\\Close\\Processor\\OpportunityProcessor;\nuse Jiminny\\Services\\Crm\\Close\\Processor\\StageProcessor;\nuse Jiminny\\Services\\Crm\\Helpers\\FilterJoinedParticipants;\nuse Jiminny\\Services\\Crm\\Metadata\\ProfileMetadata;\nuse Jiminny\\Services\\Crm\\OpportunitySyncStrategyResolver;\nuse Sentry;\nuse UnexpectedValueException;\n\nclass Service extends BaseService implements\n CloseInterface,\n SyncCrmEntitiesInterface,\n SyncCrmMetadataInterface,\n RemoteEntityManipulationInterface,\n LayoutManagementInterface,\n SettingsInterface,\n MatchCrmEntitiesInterface,\n RemoteEntityLookupInterface,\n SupportsObjectTypeParseInterface,\n VerifyTaskExistsInterface\n{\n private const int NOTE_BODY_MAX_LENGTH = 3000000;\n\n private const int TASK_VERIFICATION_CACHE_TTL = 86400; // 1 day\n\n private StandardFieldMetadata $standardFieldMetadata;\n private MetadataProcessor $metadataProcessor;\n private FieldValueConverter $fieldValueConverter;\n private StageProcessor $stageProcessor;\n private OpportunityProcessor $opportunityProcessor;\n private AccountProcessor $accountProcessor;\n\n public function __construct(\n Client $client,\n StandardFieldMetadata $standardFieldMetadata,\n MetadataProcessor $metadataProcessor,\n FieldValueConverter $fieldValueConverter,\n StageProcessor $stageResolver,\n OpportunityProcessor $opportunityProcessor,\n AccountProcessor $accountProcessor,\n private readonly ProspectPhotoPathService $prospectPhotoPathService,\n ) {\n parent::__construct();\n\n $this->client = $client;\n $this->standardFieldMetadata = $standardFieldMetadata;\n $this->metadataProcessor = $metadataProcessor;\n $this->fieldValueConverter = $fieldValueConverter;\n $this->stageProcessor = $stageResolver;\n $this->opportunityProcessor = $opportunityProcessor;\n $this->accountProcessor = $accountProcessor;\n }\n\n public function getDisplayName(): string\n {\n return 'Close';\n }\n\n public function setConfiguration(Configuration $config): void\n {\n parent::setConfiguration($config);\n\n $this->metadataProcessor->setConfiguration($config);\n $this->stageProcessor->setConfiguration($config);\n $this->opportunityProcessor->setConfiguration($config);\n $this->accountProcessor->setConfiguration($config);\n }\n\n protected function getOAuthAccount(User $user): ?SocialAccount\n {\n // In this case, the Account Owner is always the connection for any API operations.\n return $user->getTeam()->getOwner()->getSocialAccount(SocialAccount::PROVIDER_CLOSE);\n }\n\n private function getClient(): Client\n {\n if (! $this->client instanceof Client) {\n throw new UnexpectedCallException('Client not set');\n }\n\n return $this->client;\n }\n\n public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string\n {\n return $this->fieldValueConverter->convertFromCrm($fieldType, $fieldValue);\n }\n\n protected function getFieldTypes(): array\n {\n return [\n parent::OBJECT_OPPORTUNITY,\n parent::OBJECT_CONTACT,\n parent::OBJECT_ACCOUNT,\n ];\n }\n\n protected function getFields(string $crmObject): array\n {\n // not used\n return [];\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultFields(string $activityType): array\n {\n $fields = [];\n\n if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {\n $defaultFields = FieldDefinitions::defaultTaskFields();\n\n // This lazy creates these fields if not already setup.\n foreach ($defaultFields as $defaultField) {\n $fields[] = $this->config->fields()->firstOrCreate($defaultField);\n }\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultActivityField(string $activityType): Field\n {\n // Set up the activity field as the default Type.\n /** @var Field $activityField */\n $activityField = $this->config->fields()->where([\n 'crm_provider_id' => 'type',\n 'object_type' => $activityType,\n ])->first();\n\n return $activityField;\n }\n\n /**\n * @inheritdoc\n */\n public function getSupportedPlaybookTypes(): array\n {\n return [Playbook::ACTIVITY_TYPE_TASK];\n }\n\n public function getDealInsightsFields(): array\n {\n return FieldDefinitions::dealInsightsFields();\n }\n\n protected function getDefaultFollowupLayoutFields(string $activityType): array\n {\n $fields = [];\n $fieldRepo = app(FieldRepository::class);\n\n $fieldData = FieldDefinitions::followupFieldsFilter();\n\n foreach ($fieldData as $data) {\n $field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $data);\n\n // Only add the field if it is created, which it should be.\n if ($field) {\n $fields[] = $field;\n }\n }\n\n return $fields;\n }\n\n public function syncFields(): void\n {\n $this->syncStandardFields();\n $this->syncCustomFields();\n }\n\n /**\n * @important Works only for custom fields\n */\n public function syncField(Field $field): void\n {\n $resource = $this->convertObjectTypeToResource($field->getObjectType());\n\n // We can only sync custom fields in this CRM.\n if ($this->isCustomField($field->getCrmProviderId()) === false) {\n return;\n }\n\n $crmField = $this->getClient()->fetchCustomFieldDefinition($resource, $field->getCrmProviderId());\n\n $this->metadataProcessor->syncField($crmField);\n }\n\n private function isCustomField(string $fieldId): bool\n {\n return strpos($fieldId, 'cf_') === 0;\n }\n\n /**\n * @inheritdoc\n */\n public function importPicklistValues(Field $field): array\n {\n // handled in syncFields()\n return [];\n }\n\n /**\n * @important We only support stages on the opportunity object\n *\n * @param string[]|null $types\n */\n public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage\n {\n if (! $missingStageName) {\n // This is taken care of by syncOrganization()\n return null;\n }\n\n $stage = $this->stageProcessor->resolveFromStageId($missingStageName);\n\n if ($stage instanceof Stage) {\n return $stage;\n }\n\n $stageMetadata = $this->getClient()->fetchStage($missingStageName);\n\n if (! $stageMetadata) {\n $this->logger->error('Stage does not exist', [\n 'stage' => $missingStageName,\n ]);\n\n return null;\n }\n\n\n return $this->stageProcessor->importStage($stageMetadata);\n }\n\n /**\n * @inheritdoc\n */\n public function syncLeads(Carbon $since, ?Carbon $to = null, ?string $crmProfileId = null): int\n {\n // Even though Close.io has the concept of \"leads\", they fit more into our concept of accounts.\n return 0;\n }\n\n /**\n * @inheritdoc\n */\n public function syncLead(string $crmId): ?Lead\n {\n // Not a supported entity.\n return null;\n }\n\n /**\n * @throws Exception\n */\n public function syncAccounts(Carbon $since, ?Carbon $to = null): int\n {\n $syncCount = 0;\n\n try {\n foreach ($this->getClient()->listAccounts($since) as $clAccount) {\n // Only sync if previously imported.\n if ($this->hasAccount($clAccount->getId())) {\n $this->importAccount($clAccount);\n $syncCount++;\n }\n }\n } catch (Exception $exception) {\n $this->logger->error('Account sync failed', [\n 'error' => $exception->getMessage(),\n ]);\n\n throw $exception;\n }\n\n return $syncCount;\n }\n\n public function syncAccount(string $crmId): ?Account\n {\n return $this->accountProcessor->syncAccount($crmId);\n }\n\n private function importAccount($crmData): Account\n {\n return $this->accountProcessor->importAccountMetadata($crmData);\n }\n\n /**\n * @throws CloseException\n */\n public function syncOpportunities(array $parameters, ?string $strategy = null): int\n {\n $strategyResolver = app(OpportunitySyncStrategyResolver::class, [\n 'client' => $this->client,\n ]);\n $strategies = $strategyResolver->getStrategies($this->config, $strategy);\n\n $syncCount = 0;\n\n try {\n $opportunities = [];\n foreach ($strategies as $syncStrategy) {\n $opportunitiesData = $syncStrategy->fetchOpportunities($parameters);\n $opportunities[] = $opportunitiesData['data'];\n\n if ($opportunitiesData['has_more']) {\n $this->logger->info('[Close] Sync Opportunities - count warning', [\n 'team_id' => $this->config->getTeam()->getId(),\n 'total' => $opportunitiesData['total'],\n 'count' => $opportunitiesData['count'],\n 'skip' => $opportunitiesData['skip'],\n 'strategies_count' => count($strategies),\n ]);\n }\n }\n\n $opportunities = array_merge(...$opportunities);\n } catch (CrmException $exception) {\n $this->logger->error('Fetching opportunity data failed', [\n 'team' => $this->getTeam()->getSlug(),\n 'error' => $exception->getMessage(),\n ]);\n\n return 0;\n }\n\n foreach ($opportunities as $opportunityMetadata) {\n try {\n $this->importOpportunity($opportunityMetadata);\n $syncCount++;\n } catch (Exception $exception) {\n $this->logger->warning('Opportunity sync failed', [\n 'opportunity' => $opportunityMetadata->getId(),\n 'error' => $exception->getMessage(),\n ]);\n }\n }\n\n return $syncCount;\n }\n\n public function syncOpportunity(string $crmId): ?Opportunity\n {\n $strategyResolver = app(OpportunitySyncStrategyResolver::class, [\n 'client' => $this->client,\n ]);\n\n $strategy = $strategyResolver->resolve(\n $this->config,\n OpportunitySyncStrategyResolver::SINGLE_SYNC_OPPORTUNITY_STRATEGY,\n );\n\n $parameters = ['crm_id' => $crmId];\n\n $opportunity = $strategy->fetchOpportunities($parameters);\n\n if (empty($opportunity['data'])) {\n return null;\n }\n\n return $this->importOpportunity($opportunity['data']);\n }\n\n private function importOpportunity($crmData): ?Opportunity\n {\n if (! $crmData->getLeadId()) {\n $this->logger->warning('Opportunity does not have a lead ID', [\n 'opportunity' => $crmData->getId(),\n ]);\n\n return null;\n }\n\n $account = $this->getConfiguration()\n ->accounts()\n ->where('crm_provider_id', $crmData->getLeadId())\n ->first()\n ;\n\n if ($account === null) {\n $account = $this->accountProcessor->syncAccount($crmData->getLeadId());\n }\n\n $profile = $this->getConfiguration()\n ->profiles()\n ->where('crm_provider_id', $crmData->getUserId())\n ->first()\n ;\n\n $stage = $this->getConfiguration()\n ->stages()\n ->where('crm_provider_id', $crmData->getStageId())\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->first()\n ;\n\n if ($stage === null) {\n $stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $crmData->getStageId());\n }\n\n return $this->opportunityProcessor->import($crmData, $account, $stage, $profile);\n }\n\n /**\n * @param array<string,string> $crmData\n * @param string[] $crmFields\n */\n public function importOpportunityCrmFieldData(array $crmData, array $crmFields, int $opportunityId): void\n {\n // handled in importOpportunity\n }\n\n /**\n * @inheritdoc\n */\n public function syncContacts(Carbon $since, ?Carbon $to = null): int\n {\n $syncCount = 0;\n\n try {\n /** No way to sync today.\n $clContacts = $this->client->get('lead', [\n 'date_updated__gte' => $since->toDateString(),\n '_order_by' => '-date_updated',\n ]);\n\n foreach ($clContacts as $clContact) {\n // Only sync if previously imported.\n if ($this->hasContact($clContact['id'])) {\n $this->importContact($clContact);\n $syncCount++;\n }\n }\n **/\n } catch (Exception $exception) {\n // Do nothing for now.\n throw $exception;\n }\n\n return $syncCount;\n }\n\n /**\n * @inheritdoc\n */\n public function syncContact(string $crmId): ?Contact\n {\n try {\n $clContact = $this->client->get('contact/' . $crmId);\n } catch (HttpNotFoundException $exception) {\n return null;\n }\n\n return $this->importContact($clContact);\n }\n\n /**\n * @inheritdoc\n */\n private function importContact($crmData): Contact\n {\n $account = null;\n if ($crmData['lead_id']) {\n $account = $this->team\n ->accounts()\n ->where('crm_provider_id', $crmData['lead_id'])\n ->first();\n\n if ($account === null) {\n $account = $this->syncAccount($crmData['lead_id']);\n }\n }\n\n $mobilePhone = $parsedNumber = null;\n foreach ($crmData['phones'] as $phoneNumber) {\n if ($phoneNumber['type'] === 'mobile') {\n $mobilePhone = $this->buildContactMobilePhone(null, $phoneNumber['phone']);\n } else {\n $parsedNumber = $this->buildContactPhone(null, $phoneNumber['phone']);\n }\n }\n\n $email = null;\n if (empty($crmData['emails']) === false) {\n $email = mb_strimwidth($crmData['emails'][0]['email'], 0, 80);\n }\n\n $profile = $this->config->profiles()->where('crm_provider_id', (string) $crmData['updated_by'])->first();\n\n $data = [\n 'account_id' => $account->id ?? null,\n 'team_id' => $this->team->id,\n 'user_id' => $profile?->user_id,\n 'owner_id' => $crmData['updated_by'],\n 'name' => $crmData['name'] ?? 'Unknown',\n 'email' => $email,\n 'phone' => $parsedNumber['phone'] ?? null,\n 'mobile_phone' => $mobilePhone ?? null,\n 'ext' => $parsedNumber['ext'] ?? null,\n 'title' => mb_strimwidth($crmData['title'] ?? '', 0, 128),\n 'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(\n crmConfiguration: $this->config,\n crmProviderId: $crmData['id'],\n modelType: Contact::class,\n fileName: $crmData['id'],\n avatarText: $crmData['name'] ?? 'Unknown'\n ),\n 'remotely_created_at' => Carbon::parse($crmData['date_created']),\n ];\n\n /** @var Contact */\n return $this->config->contacts()->updateOrCreate(['crm_provider_id' => (string) $crmData['id']], $data);\n }\n\n private function buildContactPhone(?string $countryCode, ?string $number): ?array\n {\n if ($number) {\n // Trim to our width and attempt to parse it.\n $number = mb_strimwidth($number, 0, 25);\n $parsedNumber = parsePhoneNumber($countryCode, $number);\n } else {\n $parsedNumber = [];\n }\n\n return $parsedNumber;\n }\n\n private function buildContactMobilePhone(?string $countryCode, ?string $number): ?string\n {\n return $number ? mb_strimwidth(phone_e164($countryCode, $number), 0, 25) : null;\n }\n\n public function syncOrganization(): void\n {\n $organisation = $this->getClient()->fetchOrganisation();\n\n $this->metadataProcessor->syncOrganisation($organisation);\n\n foreach ($organisation->getPipelines() as $pipelineMetadata) {\n $this->metadataProcessor->syncPipeline($pipelineMetadata);\n }\n }\n\n private function syncStandardFields(): void\n {\n // Currently we sync only opportunity fields\n $stages = $this->getClient()->listStages();\n foreach ($this->standardFieldMetadata->opportunity($stages) as $fieldMetadata) {\n $this->metadataProcessor->syncField($fieldMetadata);\n }\n\n $this->config->save();\n }\n\n private function syncCustomFields(): void\n {\n foreach ($this->getFieldTypes() as $fieldType) {\n $objectType = $this->convertObjectTypeToResource($fieldType);\n $currentFields = $this->getClient()->listCustomFieldDefinitions($objectType);\n\n foreach ($currentFields as $fieldMetadata) {\n $this->metadataProcessor->syncField($fieldMetadata);\n }\n }\n\n $this->config->save();\n }\n\n public function syncProfiles(?User $userToSearch = null): ?Profile\n {\n /*\n * Fetch the profile of the user from the database\n * Then fetch the user metadata from Close and update it\n * In case there's no profile for the user, proceed with syncing all users\n */\n $foundUser = null;\n\n if ($userToSearch) {\n $profile = $userToSearch->getProfile();\n\n if ($profile instanceof Profile) {\n $crmProviderId = $profile->getCrmProviderId();\n\n if ($crmProviderId) {\n $profileMetadata = $this->getClient()->fetchUser($crmProviderId);\n\n if (! $profileMetadata instanceof ProfileMetadata) {\n return null;\n }\n\n return $this->metadataProcessor->syncProfile($profileMetadata);\n }\n }\n }\n\n foreach ($this->getClient()->listUsers() as $userMetadata) {\n $userProfile = $this->metadataProcessor->syncProfile($userMetadata);\n\n if (\n $userToSearch instanceof User\n && $userProfile instanceof Profile\n && $userProfile->getUserId() === $userToSearch->getId()\n ) {\n $foundUser = $userProfile;\n }\n }\n\n return $foundUser;\n }\n\n public function syncProfileFields(): void\n {\n // Not used.\n }\n\n /**\n * @inheritdoc\n */\n public function find(string $name, array $scopes): array\n {\n $count = $this->limit ?? 20;\n $offset = $this->offset ?? 0;\n\n $data = Cache::remember($this->team->id . $name . $count . $offset, 300, function () use ($name, $count, $offset) {\n $data = [];\n\n try {\n // If search phrase resembles phone number remove special symbols\n if (preg_match('/^([0-9\\s\\-\\+\\(\\)]*)$/', $name)) {\n $name = '+' . preg_replace('/[\\s\\-\\+\\(\\)]/', '', $name);\n }\n\n // Close do not provide a unified way to search, so we must hack our own.\n $objects = $this->client->get('lead', [\n 'query' => 'sort:date_updated name:(' . $name . ') or email:(' . $name . ') or phone:(' . $name . ')',\n '_limit' => $count, '_skip' => $offset,\n ]);\n } catch (\\GuzzleHttp\\Exception\\ServerException $exception) {\n throw new ServiceUnavailableException($exception->getMessage());\n }\n\n foreach ($objects['data'] as $object) {\n // We need a contact to dial it.\n if (empty($object['contacts'])) {\n continue;\n }\n\n foreach ($object['contacts'] as $contact) {\n $record = [\n 'crmId' => $contact['id'],\n 'crmUrl' => $this->generateProviderUrl($object['id'], 'account'),\n 'name' => $contact['name'],\n 'industry' => null,\n 'title' => $contact['title'],\n 'organization' => $object['display_name'],\n 'prospectType' => 'contact',\n 'phoneNumbers' => [],\n ];\n\n foreach ($contact['phones'] as $phone) {\n if ($phone['type'] === 'mobile') {\n $number = $this->buildContactMobilePhone(null, $phone['phone']);\n\n $record['phoneNumbers'][] = [\n 'number' => $number,\n 'nationalFormat' => phone_national(null, $number),\n 'type' => 'mobile',\n ];\n } else {\n $parsedNumber = $this->buildContactPhone(null, $phone['phone']);\n\n // Add phone number to record.\n if (empty($parsedNumber['phone']) === false) {\n $record['phoneNumbers'][] = [\n 'number' => $parsedNumber['phone'],\n 'nationalFormat' => phone_national(null, $parsedNumber['phone']),\n 'type' => 'phone',\n ];\n }\n }\n }\n\n $data[] = $record;\n }\n }\n\n return $data;\n });\n\n return $data;\n }\n\n /**\n * @inheritdoc\n */\n public function findOpportunities(?string $crmAccountId, ?string $crmContactId, ?int $userId = null): array\n {\n $data = [];\n $ownerData = [];\n $ownerId = null;\n $contact = null;\n $account = null;\n\n if ($crmAccountId) {\n $account = $this->config->accounts()->where('crm_provider_id', $crmAccountId)->first();\n\n if ($account === null) {\n $account = $this->syncAccount($crmAccountId);\n }\n }\n\n if ($crmContactId) {\n $contact = $this->config->contacts()->where('crm_provider_id', $crmContactId)->first();\n\n if ($contact === null) {\n $contact = $this->syncContact($crmContactId);\n }\n }\n\n if ($contact || $account) {\n if ($contact && $account === null) {\n $account = $contact->account;\n }\n\n if ($account === null) {\n return [];\n }\n\n $params = [\n 'lead_id' => $account->crm_provider_id,\n '_order_by' => '-date_updated',\n ];\n\n $onlyOpen = true;\n switch ($this->config->opportunity_assignment_rule) {\n case Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_UPDATED:\n $params['_order_by'] = '-date_updated';\n\n break;\n\n case Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_CREATED:\n $params['_order_by'] = '-date_created';\n\n break;\n\n case Configuration::OPP_ASSIGNMENT_ALL_OPEN_OLDEST_CREATED:\n $params['_order_by'] = 'date_created';\n\n break;\n\n case Configuration::OPP_ASSIGNMENT_ALL_RECENTLY_UPDATED:\n $params['_order_by'] = '-date_updated';\n $onlyOpen = false;\n }\n\n if ($onlyOpen) {\n $params['status_type__in'] = 'active,won';\n }\n\n $clOpportunities = $this->client->get('opportunity', $params);\n\n if ($userId) {\n $profileRepository = app(ProfileRepository::class);\n $profile = $profileRepository->findProfileByUserId($this->config, $userId);\n\n $ownerId = $profile instanceof Profile ? $profile->getCrmProviderId() : null;\n }\n\n foreach ($clOpportunities['data'] as $clOpportunity) {\n $stage = $this->config\n ->stages()\n ->where('crm_provider_id', $clOpportunity['status_id'])\n ->first();\n\n if ($stage === null) {\n // Import it.\n $stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $clOpportunity['status_id']);\n }\n\n $record = [\n 'crmId' => $clOpportunity['id'],\n 'name' => $clOpportunity['note'],\n 'value' => currency_format($clOpportunity['value'], $clOpportunity['value_currency']),\n 'won' => $stage->probability === 100.00,\n 'closed' => $clOpportunity['status_type'] !== 'active',\n 'stage' => [\n 'id' => $stage->id_string,\n 'name' => $stage->name,\n ],\n 'recordType' => [],\n ];\n\n if ($ownerId && isset($clOpportunity['user_id']) && $clOpportunity['user_id'] === $ownerId) {\n $ownerData[] = $record;\n }\n\n $data[] = $record;\n }\n\n if (! empty($ownerData)) {\n return $ownerData;\n }\n }\n\n return $data;\n }\n\n /**\n * @inheritdoc\n */\n public function getTasks(string $objectType, string $objectId, ?string $opportunityId): array\n {\n $data = [];\n $crmId = null;\n\n if ($objectType === 'contact') {\n $contact = $this->syncContact($objectId);\n\n if ($contact && $contact->account_id) {\n $crmId = $contact->account->crm_provider_id;\n }\n } else {\n $crmId = $objectId;\n }\n\n if ($crmId) {\n $clTasks = $this->client->get('task', [\n 'lead_id' => $crmId,\n '_type' => 'lead',\n 'assigned_to' => $this->profile->crm_provider_id,\n 'is_complete' => 'false',\n '_order_by' => 'date',\n ]);\n\n foreach ($clTasks['data'] as $clTask) {\n $data[] = [\n 'crmId' => $clTask['id'],\n 'subject' => $clTask['text'],\n 'due' => $clTask['date'] ?? null,\n 'type' => null,\n ];\n }\n }\n\n return $data;\n }\n\n /**\n * Try to find email address in CRM service\n *\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n *}\n */\n public function matchExactlyByEmail(string $email, ?int $userId = null): ?array\n {\n $clObject = $this->client->get('lead', [\n 'query' => 'sort:updated contact(email(email:\"' . $email . '\"))',\n '_limit' => 1,\n ]);\n\n if ($clObject['total_results'] > 0) {\n foreach ($clObject['data'][0]['contacts'] as $clContact) {\n foreach ($clContact['emails'] as $clEmail) {\n if ($email === $clEmail['email']) {\n $data = $this->convertCrmData($clContact['id'], $userId);\n\n return ! empty(array_filter($data)) ? $data : null;\n }\n }\n }\n }\n\n return null;\n }\n\n /**\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n *}\n */\n public function matchByPhone(string $phone, ?string $rawPhoneNumber = null, ?int $userId = null): ?array\n {\n // Check if the user is internal.\n $teamMember = $this->team->users()->where('phone', $phone)->exists();\n\n // Skip the attendee if internal.\n if ($teamMember === false) {\n $clObject = $this->client->get('lead', [\n 'query' => 'sort:updated contact(' . $phone . ')',\n '_limit' => 1,\n ]);\n\n if ($clObject['total_results'] > 0) {\n foreach ($clObject['data'][0]['contacts'] as $clContact) {\n foreach ($clContact['phones'] as $clPhone) {\n if ($phone === $clPhone['phone']) {\n $data = $this->convertCrmData($clContact['id'], $userId);\n\n return ! empty(array_filter($data)) ? $data : null;\n }\n }\n }\n }\n }\n\n return null;\n }\n\n /**\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n public function matchByName(string $name, ?int $userId = null): ?array\n {\n // Don't waste time searching for single character strings.\n if (\\strlen($name) <= 1) {\n return null;\n }\n\n $cacheKey = $this->getCacheKey($name, $userId);\n\n $result = Cache::remember($cacheKey, 60, function () use ($name, $userId) {\n $clObject = $this->client->get('lead', [\n 'query' => 'sort:updated contact(name:\"' . $name . '\")',\n '_limit' => 1,\n ]);\n\n if ($clObject['total_results'] > 0) {\n foreach ($clObject['data'][0]['contacts'] as $clContact) {\n if ($clContact['name'] === $name || $clContact['display_name'] === $name) {\n $data = $this->convertCrmData($clContact['id'], $userId);\n\n return ! empty(array_filter($data)) ? $data : false;\n }\n }\n }\n\n return false;\n });\n\n return is_array($result) ? $result : null;\n }\n\n protected function getCacheKey(string $object, ?int $userId = null): ?string\n {\n $key = $this->team->getId() . $object;\n $keySuffix = $this->getOwnerKeySuffix($userId);\n\n return $key . $keySuffix;\n }\n\n private function getOwnerKeySuffix(?int $userId = null): string\n {\n return $userId === null ? '' : (string) $userId;\n }\n\n private function convertCrmData(string $crmId, ?int $userId = null): array\n {\n $lead = null;\n $opportunity = null;\n $account = null;\n $stage = null;\n $countryCode = null;\n\n $contact = $this->syncContact($crmId);\n if ($contact) {\n $account = $contact->account;\n\n if ($contact->country_code) {\n $countryCode = $contact->country_code;\n } elseif ($account) {\n $countryCode = $account->country_code;\n }\n\n try {\n $cpOpportunities = $this->findOpportunities(\n $account?->crm_provider_id,\n $contact->crm_provider_id,\n $userId,\n );\n\n if (! empty($cpOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($cpOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n } catch (Exception) {\n // Nothing to see here.\n }\n }\n\n return [\n $lead,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n }\n\n public function saveActivity(Activity $activity): Activity\n {\n switch ($activity->type) {\n case Activity::TYPE_CONFERENCE:\n case Activity::TYPE_SOFTPHONE:\n case Activity::TYPE_SOFTPHONE_INBOUND:\n $activity = $this->buildCallPayload($activity);\n\n break;\n case Activity::TYPE_SMS_INBOUND:\n case Activity::TYPE_SMS_OUTBOUND:\n $activity = $this->buildTextMessagePayload($activity);\n\n break;\n }\n\n return $activity;\n }\n\n private function mapStatus(string $status): string\n {\n switch ($status) {\n case Activity::STATUS_COMPLETED:\n case Activity::STATUS_IN_PROGRESS:\n case Activity::STATUS_FAILED:\n case Activity::STATUS_NO_ANSWER:\n case Activity::STATUS_BUSY:\n default:\n return $status;\n case Activity::STATUS_CANCELLED:\n return 'cancel';\n }\n }\n\n /**\n * @throws CrmException\n */\n private function buildCallPayload(Activity $activity): Activity\n {\n try {\n if ($activity->crm_provider_id) {\n // The activity should be logged under the existing Task (not Activity).\n $data = [\n '_type' => 'lead',\n 'lead_id' => $activity->account->crm_provider_id,\n 'assigned_to' => $this->profile->crm_provider_id,\n 'text' => $this->generateActivityDescription($activity),\n 'date' => $activity->getActualEndTime()->toDateString(),\n 'is_complete' => true,\n ];\n\n $this->logger->info('[Close CRM] Updating task', [\n 'activity' => $activity->id,\n 'crm_id' => $activity->crm_provider_id,\n 'data' => $data,\n ]);\n\n $this->client->put('task/' . $activity->crm_provider_id, $data);\n } else {\n // Just create an activity.\n $data = [\n 'lead_id' => $activity->account_id ? $activity->account->crm_provider_id : null,\n 'contact_id' => $activity->contact_id ? $activity->contact->crm_provider_id : null,\n 'created_by' => $this->profile->crm_provider_id,\n 'user_id' => $this->profile->crm_provider_id,\n 'direction' => $activity->getCrmType() === Activity::TYPE_SOFTPHONE ? 'outbound' : 'inbound',\n 'status' => $this->mapStatus($activity->getStatus()),\n 'note' => $this->generateActivityDescription($activity),\n 'duration' => $this->mapStatus($activity->getStatus()) !== 'cancel' ? $activity->duration : 0,\n 'phone' => $activity->to ? $activity->to->phone_number : null,\n ];\n\n $clActivity = $this->client->post('activity/call', $data);\n\n $this->logger->info('[Close CRM] Creating activity', [\n 'activity' => $activity->id,\n 'crm_id' => $clActivity['id'],\n 'data' => $data,\n 'response' => $clActivity,\n ]);\n\n $activity->crm_provider_id = $clActivity['id'];\n $activity->save();\n }\n } catch (ClientException $exception) {\n $response = $exception->getResponse();\n\n if ($response === null) {\n // Trying to debug weird cases where this is null.\n Sentry::captureException($exception);\n }\n\n $responseBody = $response->getBody();\n $message = $responseBody;\n $errorCode = $response->getStatusCode();\n\n $jsonResponse = json_decode($responseBody, true);\n if (isset($jsonResponse[0]['message'])) {\n $message = $jsonResponse[0]['message'];\n }\n\n throw new CrmException($message, $errorCode);\n }\n\n return $activity;\n }\n\n private function buildTextMessagePayload(Activity $activity): Activity\n {\n $clActivity = $this->client->post('activity/sms', [\n 'lead_id' => $activity->account_id ? $activity->account->crm_provider_id : null,\n 'contact_id' => $activity->contact_id ? $activity->contact->crm_provider_id : null,\n 'created_by' => $this->profile->crm_provider_id,\n 'user_id' => $this->profile->crm_provider_id,\n 'status' => $activity->getType() === Activity::TYPE_SMS_OUTBOUND ? 'sent' : 'inbox',\n 'text' => $this->generateActivityDescription($activity),\n 'remote_phone' => $activity->to ? $activity->to->phone_number : null,\n 'local_phone' => $activity->to ? $activity->to->phone_number : null,\n 'source' => 'Close.io',\n ]);\n\n $activity->crm_provider_id = $clActivity['id'];\n $activity->save();\n\n return $activity;\n }\n\n private function generateActivityDescription(Activity $activity): string\n {\n $description = '';\n\n switch ($activity->type) {\n case Activity::TYPE_SOFTPHONE:\n case Activity::TYPE_SOFTPHONE_INBOUND:\n case Activity::TYPE_CONFERENCE:\n if ($activity->hasActivityType()) {\n $description = $activity->getActivityType()->getName() . PHP_EOL . PHP_EOL;\n }\n if ($activity->hasTitle()) {\n $description .= $activity->getTitle() . PHP_EOL;\n }\n\n if ($activity->hasReasonCodeBotKicked()) {\n $description .= 'Notetaker removed from this meeting' . PHP_EOL . PHP_EOL;\n // When we fix the state to be Activity::RECORDING_RECORDED as it should be this can change.\n } elseif ($activity->hasReasonCodeNotCompliant()) {\n $description .= 'Notetaker did not join due to recording consent not being provided by attendees' . PHP_EOL . PHP_EOL;\n } elseif ($activity->canReviewActivity()) {\n $playbackUrl = $activity->user->team->partner->getPlaybackUrl($activity);\n $description .= 'Review Activity:' . PHP_EOL . $playbackUrl . PHP_EOL . PHP_EOL;\n }\n\n if ($activity->type === Activity::TYPE_CONFERENCE) {\n $description .= 'Attendees:'\n . PHP_EOL\n . (new FilterJoinedParticipants())->toString($activity);\n }\n\n if (\\count($activity->notes) > 0) {\n $description .= PHP_EOL . PHP_EOL . 'Notes: ' . PHP_EOL;\n\n foreach ($activity->notes as $note) {\n $time = ($note->time > 3600) ? gmdate('H:i:s', $note->time) : gmdate('i:s', $note->time);\n $description .= $time . ' ' . $note->note . PHP_EOL;\n }\n }\n\n // Get all private messages.\n $messages = $activity->messages()\n ->where('is_private', 1)\n ->orderBy('created_at', 'asc');\n\n if ($messages->count() > 0) {\n $description .= PHP_EOL . PHP_EOL . 'Coaching Chat: ' . PHP_EOL;\n\n foreach ($messages->get() as $message) {\n $description .= $message->participant->name . ': ' . $message->message . PHP_EOL;\n }\n }\n\n // Get all public messages.\n $messages = $activity->messages()\n ->where('is_private', 0)\n ->orderBy('created_at', 'asc');\n\n if ($messages->count() > 0) {\n $description .= PHP_EOL . PHP_EOL . 'Customer Chat: ' . PHP_EOL;\n\n foreach ($messages->get() as $message) {\n $description .= $message->participant->name . ': ' . $message->message . PHP_EOL;\n }\n }\n\n if ($activity->summary) {\n $description .= PHP_EOL . PHP_EOL . 'Summary: ' . PHP_EOL . $activity->summary;\n }\n\n break;\n\n case Activity::TYPE_SMS_INBOUND:\n case Activity::TYPE_SMS_OUTBOUND:\n $description = $activity->description;\n\n break;\n }\n\n return $description;\n }\n\n public function saveFollowupActivity(Activity $activity, array $fields): ?string\n {\n // This is the user provided activity subject field.\n if (empty($fields['name'])) {\n return null;\n }\n\n $due = null;\n if (empty($fields['due_date']) === false) {\n $formatDue = Carbon::parse($fields['due_date']);\n $due = $formatDue->toDateTimeString();\n }\n\n $clTask = $this->client->post('task', [\n '_type' => 'lead',\n 'lead_id' => $activity->account->crm_provider_id,\n 'assigned_to' => $this->profile->crm_provider_id,\n 'text' => $fields['name'],\n 'date' => $due,\n 'is_complete' => false,\n ]);\n\n // We don't actually create a corresponding activity object on our side yet.\n return $clTask['id'];\n }\n\n /**\n * Store transcripts as note.\n *\n * @throws Exception\n */\n public function createTranscriptNotes(Activity $activity): void\n {\n if ($activity->account_id === null) {\n // We can only log to accounts (leads).\n return;\n }\n\n // Generate activity transcription.\n $transcriptionData = $this->generateTranscription($activity);\n\n // Truncate Notes with max notes length because transcription text could be very long.\n $transcripts = mb_strimwidth($transcriptionData, 0, self::NOTE_BODY_MAX_LENGTH);\n\n $clActivity = $this->client->post('activity/note', [\n 'lead_id' => $activity->account_id ? $activity->account->crm_provider_id : null,\n 'created_by' => $this->profile->crm_provider_id,\n 'user_id' => $this->profile->crm_provider_id,\n 'note' => $transcripts,\n ]);\n\n // Store CRM Activity ID in transcription.\n $transcription = $activity->getTranscription();\n $transcription->crm_activity_id = $clActivity['id'];\n $transcription->save();\n }\n\n public function parseObjectType(string $objectId): string\n {\n if (Str::startsWith($objectId, 'lead')) {\n return 'account';\n }\n\n if (Str::startsWith($objectId, 'cont')) {\n return 'contact';\n }\n\n if (Str::startsWith($objectId, 'oppo')) {\n return 'opportunity';\n }\n\n throw new InvalidArgumentException('Unsupported Object Type');\n }\n\n /**\n * @inheritdoc\n */\n public function updateStage($crmObject, Stage $stage): void\n {\n if ($crmObject instanceof Lead) {\n // This would never get invoked since we merge lead/accounts in Close.\n $this->client->put('lead/' . $crmObject->crm_provider_id, [\n 'status' => $stage->crm_provider_id,\n ]);\n } else {\n $this->client->put('opportunity/' . $crmObject->crm_provider_id, [\n 'status_id' => $stage->crm_provider_id,\n ]);\n }\n }\n\n public function updateRecord(string $objectType, string $objectId, array $data, array $headers = []): void\n {\n $this->getClient()->updateObject($this->convertObjectTypeToResource($objectType), $objectId, $data);\n }\n\n public function prepareValueForUpdate(array $params): array\n {\n $convertedValue = $this->fieldValueConverter->convertToCrm(\n $this->config,\n $params['fieldName'],\n $params['fieldValue'],\n );\n\n if ($this->isCustomField($params['fieldName'])) {\n $params['fieldName'] = 'custom.' . $params['fieldName'];\n }\n\n $params['fieldValue'] = $convertedValue;\n\n return parent::prepareValueForUpdate($params);\n }\n\n public function getRecord(string $objectType, string $objectId, array $fields = []): array\n {\n return $this->client->get($this->convertObjectTypeToResource($objectType) . '/' . $objectId);\n }\n\n /**\n *\n * @throws UnexpectedValueException\n */\n private function convertObjectTypeToResource(string $objectType): string\n {\n switch ($objectType) {\n case FieldData::OBJECT_OPPORTUNITY:\n return 'opportunity';\n\n case FieldData::OBJECT_CONTACT:\n return 'contact';\n\n case FieldData::OBJECT_ACCOUNT:\n return 'lead';\n\n case FieldData::OBJECT_TASK:\n return 'activity';\n\n default:\n throw new UnexpectedValueException('Unsupported object type \"' . $objectType . '\"');\n }\n }\n\n public function generateProviderUrl(string $providerId, string $objectType): ?string\n {\n $baseUrl = 'https://app.close.com/';\n $url = null;\n\n switch ($objectType) {\n case 'account':\n $url = $baseUrl . 'lead/' . $providerId;\n\n break;\n\n case 'contact':\n $contact = $this->config->contacts()->where('crm_provider_id', $providerId)->first();\n if ($contact && $contact->account_id) {\n $url = $baseUrl . 'lead/' . $contact->account->crm_provider_id;\n }\n\n break;\n\n default:\n // Sadly we can't deeplink to anything else in Close UI.\n $url = null;\n }\n\n return $url;\n }\n\n /**\n * Generate transcription for the activity.\n */\n private function generateTranscription(Activity $activity): string\n {\n if (! $this->config->store_transcript) {\n // If sending transcription to activity toggle is disabled\n return '';\n }\n\n return $this->transcriptionService\n ->findTranscriptionByActivity($activity)\n ->map(static function (array $transcriptionSegment): string {\n return $transcriptionSegment['formattedStartsAt'] . ' | ' . $transcriptionSegment['transcript'];\n })\n ->implode(PHP_EOL);\n }\n\n public function verifyTaskExists(Activity $activity): bool\n {\n $crmProviderId = $activity->getCrmProviderId();\n $cacheKey = \"crm_task_exists:{$this->config->getId()}:$crmProviderId\";\n\n return Cache::remember($cacheKey, self::TASK_VERIFICATION_CACHE_TTL, function () use ($crmProviderId) {\n try {\n $client = $this->getClient();\n $task = $client->get('task/' . $crmProviderId);\n\n return ! empty($task);\n } catch (HttpNotFoundException) {\n // Task not found in CRM - this is expected and permanent\n $this->logger->info('[Close] Task not found during verification', [\n 'task_id' => $crmProviderId,\n 'config_id' => $this->config->getId(),\n ]);\n\n return false;\n } catch (CloseException $e) {\n // Handle 404 responses from Close API\n if ($e->getResponseStatusCode() === 404) {\n $this->logger->info('[Close] Task not found during verification (404)', [\n 'task_id' => $crmProviderId,\n 'config_id' => $this->config->getId(),\n ]);\n\n return false;\n }\n\n // Re-throw other Close exceptions for retry\n throw $e;\n }\n // Let other exceptions (network errors, rate limits, etc.) bubble up for retry\n });\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":true,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Execute","depth":4,"bounds":{"left":0.85486114,"top":0.31,"width":0.018055556,"height":0.026666667},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Explain Plan","depth":4,"bounds":{"left":0.87291664,"top":0.31,"width":0.018055556,"height":0.026666667},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Browse Query History","depth":4,"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"View Parameters","depth":4,"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Open Query Execution Settings…","depth":4,"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"In-Editor Results","depth":4,"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Tx: Auto","depth":4,"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Cancel Running Statements","depth":4,"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Playground","depth":4,"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"jiminny","depth":4,"bounds":{"left":0.9138889,"top":0.31,"width":0.059027776,"height":0.026666667},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.088194445,"height":0.027777778},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"8","depth":4,"bounds":{"left":0.8645833,"top":0.34444445,"width":0.016666668,"height":0.02111111},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"1","depth":4,"bounds":{"left":0.8854167,"top":0.34444445,"width":0.015277778,"height":0.02111111},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"3","depth":4,"bounds":{"left":0.9048611,"top":0.34444445,"width":0.016666668,"height":0.02111111},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"4","depth":4,"bounds":{"left":0.92569447,"top":0.34444445,"width":0.016666668,"height":0.02111111},"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"bounds":{"left":0.9458333,"top":0.3422222,"width":0.015277778,"height":0.025555555},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"bounds":{"left":0.9611111,"top":0.3422222,"width":0.014583333,"height":0.025555555},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"SELECT\n# DISTINCT\nopp.id as opp_id, opp.uuid, opp.name,\n# COUNT(dr.id) AS deal_risk_count,\n\n# dr.id,\n# cfd.value,\nusr.name AS owner_name,\nopp.value, opp.currency_code, opp.close_date, opp.remotely_created_at, opp.is_closed, opp.is_won,\nusr.uuid as owner_uuid,\nusr.photo_path as owner_photo,\nusr.id AS owner_id,\njt.name as owner_job,\nopp.stage_id, opp.stage_updated_at,\nacc.name AS acc_name, opp.stage_updated_at, acc.crm_provider_id AS acc_provider_id, opp.crm_provider_id AS opp_provider_id,\nrt.business_process_id AS pipeline_id\nFROM opportunities opp\nLEFT JOIN record_types rt ON opp.record_type_id = rt.id\nLEFT JOIN users usr ON opp.user_id = usr.id\nLEFT JOIN accounts acc ON opp.account_id = acc.id\nLEFT JOIN job_titles jt ON usr.job_title_id = jt.id\n\n# LEFT JOIN group_deal_risk_types gdrt ON gdrt.group_id = usr.group_id\n# LEFT JOIN deal_risks dr ON dr.opportunity_id = opp.id AND dr.group_deal_risk_type_id = gdrt.id\n\nLEFT JOIN crm_field_data cfd ON (cfd.object_id = opp.id and cfd.crm_field_id = 66814)\n\nWHERE opp.user_id IS NOT NULL\nAND opp.deleted_at IS NULL\nAND opp.is_closed = 1\nAND opp.is_won = 0\nAND opp.close_date >= '2024-01-01 00:00:00'\nAND opp.close_date <= '2024-12-31 23:59:59'\nAND usr.team_id = 1\n\n# and opp.id = 4823179\n\nGROUP BY opp.id, cfd.value\nORDER BY\n# cfd.value\n CAST(cfd.value AS UNSIGNED)\n# owner_name\n# (SELECT COUNT(dr.id)\n# FROM deal_risks dr\n# JOIN group_deal_risk_types gdrt ON dr.group_deal_risk_type_id = gdrt.id\n# WHERE dr.opportunity_id = opp.id\n# AND dr.is_active = 1\n# AND gdrt.is_enabled = 1)\nDESC\nLIMIT 25\n# OFFSET 0\n;\n\nselect * from crm_fields where crm_configuration_id = 1 and object_type = 'opportunity';\nselect * from crm_layout_entities where crm_layout_id = 1493;\n\nSELECT * FROM opportunities WHERE id = 4584045;\nSELECT * FROM crm_field_data WHERE object_id = 4584045;\n\nSELECT * FROM opportunities where team_id = 1 and crm_provider_id = '0061K00000lfb9IQAQ'\n\nSELECT\n opp.id as opportunity_id,\n opp.name,\n COUNT(dr.id) as risk_count\nFROM opportunities opp\nLEFT JOIN deal_risks dr ON dr.opportunity_id = opp.id\nWHERE opp.id IN (\n 6069574,8900938,9507638,9524799,9660490,9662824,9705675,9749758,4158460,4391812,6450439,4448422,5118945,5590675,4584045,7228149,9002408,9165534,9446720,9641778,9665149,9703344,9709280,5747948,4158491,6182565,6263970,5798120,5111315,4536978,6062352,6548383,6072095,6548225,4480986,5011422,6548381,8760540,8917554,9509986,9514392,9569009,9569011,9578490,9713604,5784397,7276612,7288405,8994421,9118219,9608148,9818911,4510535,6479693,5958049,6271674,6550448,4158331,4158483,6126571,6171615,6540943,4897466,5190896,5796182,5932762,8572433,8723698,8892697,9711001,9789264,4549188,6100831,6170064,6260799,6263653,6449936,6530871,6538978,7777651,8059269,8319918,8787049,8901150,9263153,9453207,9514738,9696700,9791062,5752018,6421452,7439134,7878923,9354763,9369285,9514396,9582506,9889949,9890216,4094311,4158495,4158496,6098128,5585661,3872564,6442149,5891604,6164746,6199593,6583474,6519684,9018490,9809006,4496897,5041324,5829430,6198319,6253504,6555763,7242914,7931055,8024125,8797814,8058559,8673347,8892695,8994420,9616219,9714970,9722004,9809439,9818918,6523177,8134147,9002915,9711422,9892713,9901719,9954210,9978435,5800810,6243518,6416114,6222251,6411974,6512456,5791953,6545606,9914780,5805540,6238986,6463838,6547680,9767049,9809437,9810885,9890855,6673493,9902036,4335521,6379871,6503799,6546077,8018765,9907556,9958433,9905855,9916179,9946741,9957877,5563344,6271838,6450815,7641128,7762567,7780592,8684810,8685786,8685787,8685788\n)\nGROUP BY opp.id;\n\nSELECT COUNT(dr.id)\n FROM deal_risks dr\n JOIN group_deal_risk_types gdrt ON dr.group_deal_risk_type_id = gdrt.id\n WHERE dr.opportunity_id = 5563344\n# AND gdrt.group_id = usr.group_id\n\n\n\nEXPLAIN SELECT\n opp.id as opp_id, opp.uuid, opp.name,\n\ncfd.value,\ncfv.sequence,\ncfv.value,\n# MAX(cfd.value) AS max_cfd_value,\nusr.name AS owner_name,\nopp.value, opp.currency_code, opp.close_date, opp.remotely_created_at, opp.is_closed, opp.is_won,\nusr.uuid as owner_uuid,\nusr.photo_path as owner_photo,\nusr.id AS owner_id,\njt.name as owner_job,\nopp.stage_id, opp.stage_updated_at,\nacc.name AS acc_name, opp.stage_updated_at, acc.crm_provider_id AS acc_provider_id, opp.crm_provider_id AS opp_provider_id,\nrt.business_process_id AS pipeline_id\nFROM opportunities opp\n\nLEFT JOIN record_types rt ON opp.record_type_id = rt.id\nLEFT JOIN users usr ON opp.user_id = usr.id\nLEFT JOIN accounts acc ON opp.account_id = acc.id\nLEFT JOIN job_titles jt ON usr.job_title_id = jt.id\n\nLEFT JOIN crm_field_data cfd ON cfd.id = (SELECT sub_cfd.id\n FROM crm_field_data sub_cfd\n WHERE sub_cfd.object_id = opp.id\n AND sub_cfd.crm_field_id = 66810\n ORDER BY sub_cfd.updated_at DESC\n LIMIT 1)\n\nLEFT JOIN crm_field_values cfv ON cfv.crm_field_id = 66810 AND cfv.value = cfd.value\n\nWHERE opp.user_id IS NOT NULL\nAND opp.deleted_at IS NULL\nAND opp.is_closed = 0\n# AND opp.is_closed = 1\n# AND opp.is_won = 0\nAND opp.close_date >= '2024-01-01 00:00:00'\nAND opp.close_date <= '2024-12-31 23:59:59'\nAND usr.team_id = 1\n\n# and opp.id = 4823179\n\n# GROUP BY opp.id\nORDER BY\n cfv.sequence DESC,\n# opp.name\n# owner_name\n cfd.value\n# CAST(cfd.value AS UNSIGNED)\n# CAST(MAX(cfd.value) AS UNSIGNED)\n# (SELECT COUNT(dr.id)\n# FROM deal_risks dr\n# JOIN group_deal_risk_types gdrt ON dr.group_deal_risk_type_id = gdrt.id\n# WHERE dr.opportunity_id = opp.id\n# AND dr.is_active = 1 AND gdrt.is_enabled = 1)\nDESC\n# LIMIT 75, 25\n;\n\n\n\nSELECT * FROM crm_field_values WHERE crm_field_id = 66810;\n\nSELECT f.id, f.crm_provider_id AS field_name, f.label, fd.object_id AS dealId, fd.value, fd.updated_at, fd.id, fd.created_at\nFROM crm_fields f\nINNER JOIN crm_field_data fd ON fd.crm_field_id = f.id\n WHERE (f.crm_configuration_id = 1)\n AND (f.object_type = 'opportunity')\n# AND (fd.object_id IN (4158331,4158483,4158495,4158496,6069574,6263970,6171615,6540943,6545606,5829430,6198319,6263653,6548381,7242914,8797814,7228149,7439134,7878923,8134147,9002915,9118219,9165534,9354763,9369285,9446720))\n AND (fd.object_id IN (4158460))\n AND (f.crm_provider_id IN ('ForecastCategoryName'))\n ORDER BY fd.object_id ASC, fd.updated_at DESC;\n\nselect * from crm_layouts where crm_configuration_id = 1;\nselect cf.* from crm_fields cf\njoin crm_layout_entities cle on cf.id = cle.crm_field_id\nwhere crm_layout_id = 1493;\n\nSELECT * FROM opportunities WHERE id = 4158460;\nSELECT * FROM crm_field_data WHERE object_id = 4158460;\n\nselect * from users where team_id = 1;\nSELECT * FROM users WHERE id = 7160;\nselect * from role_user where user_id = 3248;\n\nselect * from crm_field_values where crm_field_id = 66824;\n\nSELECT * FROM users WHERE id = 23470;\nselect * from crm_configurations;\n\n# ******************************************\nselect * from teams where id = 1038; # 23521, 966\nselect * from users where team_id = 1038;\nselect * from crm_configurations where id = 966;\nSELECT * FROM activities WHERE uuid_to_bin('e8dc7c34-31f6-4430-bd81-2d9d8f00dd07') = uuid; # 54965762\nSELECT * FROM activities WHERE uuid_to_bin('4b5727c0-69d1-428f-b8d9-b649055166e2') = uuid; # 54965764\nSELECT * FROM participants WHERE activity_id = 54965764;\n\nSELECT id, uuid, title, recording_state FROM activities WHERE uuid_to_bin('355f105d-5606-4379-b0c5-d91daf59f4ce') = uuid; # 54964075\nSELECT * FROM participants WHERE activity_id = 54964075;\nselect f.id, slug, title, tf.team_id from team_features tf JOIN features f on tf.feature_id = f.id\n where tf.team_id = 1038;\n\n\n# ****************************************** PD *************************\nselect * from teams where id = 1029;\nSELECT * FROM crm_configurations WHERE id = 957;\nSELECT * FROM activities WHERE uuid_to_bin('581a33cb-4343-44bd-ac0c-4a5cee71c5ec') = uuid; # 54963423\nSELECT * FROM activities WHERE uuid_to_bin('07238011-25fa-418e-838b-fb21e82b9ea2') = uuid; # 54963433\n\nSELECT * FROM activities WHERE uuid_to_bin('8b6bb2f7-e7a7-4aec-8806-fbfea7093ccd') = uuid; # 54963421\n\n# ****************************************** Close *************************\nselect * from teams where id = 1031;\nSELECT * FROM crm_configurations WHERE id = 959;\nSELECT * FROM activities WHERE uuid_to_bin('59c517e0-96c9-4dad-bfeb-b7bef72f8725') = uuid; # 54963517\n\n\nSELECT * FROM activities WHERE uuid_to_bin('07238011-25fa-418e-838b-fb21e82b9ea2') = uuid; # 54963433\n\nSELECT * FROM activities WHERE uuid_to_bin('d68311e3-ac34-49bd-bf04-88323a7e5352') = uuid; # 54966009\n\n# ****************************************** SF *************************\nSELECT * FROM activities WHERE uuid_to_bin('2bd4cfa3-4eb5-4c77-84a7-9ed46a21c988') = uuid;\n\nselect * from roles;\nselect * from permissions;\nselect * from permission_role where permission_id = 136;\n\nselect * from migrations order by id desc;\n\nselect * from teams where id IN (1, 1037);\nselect * from crm_layouts where crm_configuration_id = 1;\nSELECT * FROM crm_layout_entities WHERE crm_layout_id = 1493;\nSELECT * FROM crm_fields WHERE id IN (1652,1661,66799,66814,66821,66836,66843,66846,66864,84752,182306,323580,380378);\n\nselect * from features;\nselect * from team_features where feature_id = 33;\nselect * from opportunities;\n\nselect * from teams;\nSELECT\n CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,\n u.email,\n sa.*,\n t.owner_id FROM social_accounts sa\nJOIN users u on u.id = sa.sociable_id\nJOIN teams t on t.id = u.team_id\nWHERE u.team_id = 1052 and sa.provider = 'hubspot';\n\nSELECT * FROM accounts where id = 11512582;\nselect * from activities where account_id = 11512582;","depth":4,"on_screen":true,"value":"SELECT\n# DISTINCT\nopp.id as opp_id, opp.uuid, opp.name,\n# COUNT(dr.id) AS deal_risk_count,\n\n# dr.id,\n# cfd.value,\nusr.name AS owner_name,\nopp.value, opp.currency_code, opp.close_date, opp.remotely_created_at, opp.is_closed, opp.is_won,\nusr.uuid as owner_uuid,\nusr.photo_path as owner_photo,\nusr.id AS owner_id,\njt.name as owner_job,\nopp.stage_id, opp.stage_updated_at,\nacc.name AS acc_name, opp.stage_updated_at, acc.crm_provider_id AS acc_provider_id, opp.crm_provider_id AS opp_provider_id,\nrt.business_process_id AS pipeline_id\nFROM opportunities opp\nLEFT JOIN record_types rt ON opp.record_type_id = rt.id\nLEFT JOIN users usr ON opp.user_id = usr.id\nLEFT JOIN accounts acc ON opp.account_id = acc.id\nLEFT JOIN job_titles jt ON usr.job_title_id = jt.id\n\n# LEFT JOIN group_deal_risk_types gdrt ON gdrt.group_id = usr.group_id\n# LEFT JOIN deal_risks dr ON dr.opportunity_id = opp.id AND dr.group_deal_risk_type_id = gdrt.id\n\nLEFT JOIN crm_field_data cfd ON (cfd.object_id = opp.id and cfd.crm_field_id = 66814)\n\nWHERE opp.user_id IS NOT NULL\nAND opp.deleted_at IS NULL\nAND opp.is_closed = 1\nAND opp.is_won = 0\nAND opp.close_date >= '2024-01-01 00:00:00'\nAND opp.close_date <= '2024-12-31 23:59:59'\nAND usr.team_id = 1\n\n# and opp.id = 4823179\n\nGROUP BY opp.id, cfd.value\nORDER BY\n# cfd.value\n CAST(cfd.value AS UNSIGNED)\n# owner_name\n# (SELECT COUNT(dr.id)\n# FROM deal_risks dr\n# JOIN group_deal_risk_types gdrt ON dr.group_deal_risk_type_id = gdrt.id\n# WHERE dr.opportunity_id = opp.id\n# AND dr.is_active = 1\n# AND gdrt.is_enabled = 1)\nDESC\nLIMIT 25\n# OFFSET 0\n;\n\nselect * from crm_fields where crm_configuration_id = 1 and object_type = 'opportunity';\nselect * from crm_layout_entities where crm_layout_id = 1493;\n\nSELECT * FROM opportunities WHERE id = 4584045;\nSELECT * FROM crm_field_data WHERE object_id = 4584045;\n\nSELECT * FROM opportunities where team_id = 1 and crm_provider_id = '0061K00000lfb9IQAQ'\n\nSELECT\n opp.id as opportunity_id,\n opp.name,\n COUNT(dr.id) as risk_count\nFROM opportunities opp\nLEFT JOIN deal_risks dr ON dr.opportunity_id = opp.id\nWHERE opp.id IN (\n 6069574,8900938,9507638,9524799,9660490,9662824,9705675,9749758,4158460,4391812,6450439,4448422,5118945,5590675,4584045,7228149,9002408,9165534,9446720,9641778,9665149,9703344,9709280,5747948,4158491,6182565,6263970,5798120,5111315,4536978,6062352,6548383,6072095,6548225,4480986,5011422,6548381,8760540,8917554,9509986,9514392,9569009,9569011,9578490,9713604,5784397,7276612,7288405,8994421,9118219,9608148,9818911,4510535,6479693,5958049,6271674,6550448,4158331,4158483,6126571,6171615,6540943,4897466,5190896,5796182,5932762,8572433,8723698,8892697,9711001,9789264,4549188,6100831,6170064,6260799,6263653,6449936,6530871,6538978,7777651,8059269,8319918,8787049,8901150,9263153,9453207,9514738,9696700,9791062,5752018,6421452,7439134,7878923,9354763,9369285,9514396,9582506,9889949,9890216,4094311,4158495,4158496,6098128,5585661,3872564,6442149,5891604,6164746,6199593,6583474,6519684,9018490,9809006,4496897,5041324,5829430,6198319,6253504,6555763,7242914,7931055,8024125,8797814,8058559,8673347,8892695,8994420,9616219,9714970,9722004,9809439,9818918,6523177,8134147,9002915,9711422,9892713,9901719,9954210,9978435,5800810,6243518,6416114,6222251,6411974,6512456,5791953,6545606,9914780,5805540,6238986,6463838,6547680,9767049,9809437,9810885,9890855,6673493,9902036,4335521,6379871,6503799,6546077,8018765,9907556,9958433,9905855,9916179,9946741,9957877,5563344,6271838,6450815,7641128,7762567,7780592,8684810,8685786,8685787,8685788\n)\nGROUP BY opp.id;\n\nSELECT COUNT(dr.id)\n FROM deal_risks dr\n JOIN group_deal_risk_types gdrt ON dr.group_deal_risk_type_id = gdrt.id\n WHERE dr.opportunity_id = 5563344\n# AND gdrt.group_id = usr.group_id\n\n\n\nEXPLAIN SELECT\n opp.id as opp_id, opp.uuid, opp.name,\n\ncfd.value,\ncfv.sequence,\ncfv.value,\n# MAX(cfd.value) AS max_cfd_value,\nusr.name AS owner_name,\nopp.value, opp.currency_code, opp.close_date, opp.remotely_created_at, opp.is_closed, opp.is_won,\nusr.uuid as owner_uuid,\nusr.photo_path as owner_photo,\nusr.id AS owner_id,\njt.name as owner_job,\nopp.stage_id, opp.stage_updated_at,\nacc.name AS acc_name, opp.stage_updated_at, acc.crm_provider_id AS acc_provider_id, opp.crm_provider_id AS opp_provider_id,\nrt.business_process_id AS pipeline_id\nFROM opportunities opp\n\nLEFT JOIN record_types rt ON opp.record_type_id = rt.id\nLEFT JOIN users usr ON opp.user_id = usr.id\nLEFT JOIN accounts acc ON opp.account_id = acc.id\nLEFT JOIN job_titles jt ON usr.job_title_id = jt.id\n\nLEFT JOIN crm_field_data cfd ON cfd.id = (SELECT sub_cfd.id\n FROM crm_field_data sub_cfd\n WHERE sub_cfd.object_id = opp.id\n AND sub_cfd.crm_field_id = 66810\n ORDER BY sub_cfd.updated_at DESC\n LIMIT 1)\n\nLEFT JOIN crm_field_values cfv ON cfv.crm_field_id = 66810 AND cfv.value = cfd.value\n\nWHERE opp.user_id IS NOT NULL\nAND opp.deleted_at IS NULL\nAND opp.is_closed = 0\n# AND opp.is_closed = 1\n# AND opp.is_won = 0\nAND opp.close_date >= '2024-01-01 00:00:00'\nAND opp.close_date <= '2024-12-31 23:59:59'\nAND usr.team_id = 1\n\n# and opp.id = 4823179\n\n# GROUP BY opp.id\nORDER BY\n cfv.sequence DESC,\n# opp.name\n# owner_name\n cfd.value\n# CAST(cfd.value AS UNSIGNED)\n# CAST(MAX(cfd.value) AS UNSIGNED)\n# (SELECT COUNT(dr.id)\n# FROM deal_risks dr\n# JOIN group_deal_risk_types gdrt ON dr.group_deal_risk_type_id = gdrt.id\n# WHERE dr.opportunity_id = opp.id\n# AND dr.is_active = 1 AND gdrt.is_enabled = 1)\nDESC\n# LIMIT 75, 25\n;\n\n\n\nSELECT * FROM crm_field_values WHERE crm_field_id = 66810;\n\nSELECT f.id, f.crm_provider_id AS field_name, f.label, fd.object_id AS dealId, fd.value, fd.updated_at, fd.id, fd.created_at\nFROM crm_fields f\nINNER JOIN crm_field_data fd ON fd.crm_field_id = f.id\n WHERE (f.crm_configuration_id = 1)\n AND (f.object_type = 'opportunity')\n# AND (fd.object_id IN (4158331,4158483,4158495,4158496,6069574,6263970,6171615,6540943,6545606,5829430,6198319,6263653,6548381,7242914,8797814,7228149,7439134,7878923,8134147,9002915,9118219,9165534,9354763,9369285,9446720))\n AND (fd.object_id IN (4158460))\n AND (f.crm_provider_id IN ('ForecastCategoryName'))\n ORDER BY fd.object_id ASC, fd.updated_at DESC;\n\nselect * from crm_layouts where crm_configuration_id = 1;\nselect cf.* from crm_fields cf\njoin crm_layout_entities cle on cf.id = cle.crm_field_id\nwhere crm_layout_id = 1493;\n\nSELECT * FROM opportunities WHERE id = 4158460;\nSELECT * FROM crm_field_data WHERE object_id = 4158460;\n\nselect * from users where team_id = 1;\nSELECT * FROM users WHERE id = 7160;\nselect * from role_user where user_id = 3248;\n\nselect * from crm_field_values where crm_field_id = 66824;\n\nSELECT * FROM users WHERE id = 23470;\nselect * from crm_configurations;\n\n# ******************************************\nselect * from teams where id = 1038; # 23521, 966\nselect * from users where team_id = 1038;\nselect * from crm_configurations where id = 966;\nSELECT * FROM activities WHERE uuid_to_bin('e8dc7c34-31f6-4430-bd81-2d9d8f00dd07') = uuid; # 54965762\nSELECT * FROM activities WHERE uuid_to_bin('4b5727c0-69d1-428f-b8d9-b649055166e2') = uuid; # 54965764\nSELECT * FROM participants WHERE activity_id = 54965764;\n\nSELECT id, uuid, title, recording_state FROM activities WHERE uuid_to_bin('355f105d-5606-4379-b0c5-d91daf59f4ce') = uuid; # 54964075\nSELECT * FROM participants WHERE activity_id = 54964075;\nselect f.id, slug, title, tf.team_id from team_features tf JOIN features f on tf.feature_id = f.id\n where tf.team_id = 1038;\n\n\n# ****************************************** PD *************************\nselect * from teams where id = 1029;\nSELECT * FROM crm_configurations WHERE id = 957;\nSELECT * FROM activities WHERE uuid_to_bin('581a33cb-4343-44bd-ac0c-4a5cee71c5ec') = uuid; # 54963423\nSELECT * FROM activities WHERE uuid_to_bin('07238011-25fa-418e-838b-fb21e82b9ea2') = uuid; # 54963433\n\nSELECT * FROM activities WHERE uuid_to_bin('8b6bb2f7-e7a7-4aec-8806-fbfea7093ccd') = uuid; # 54963421\n\n# ****************************************** Close *************************\nselect * from teams where id = 1031;\nSELECT * FROM crm_configurations WHERE id = 959;\nSELECT * FROM activities WHERE uuid_to_bin('59c517e0-96c9-4dad-bfeb-b7bef72f8725') = uuid; # 54963517\n\n\nSELECT * FROM activities WHERE uuid_to_bin('07238011-25fa-418e-838b-fb21e82b9ea2') = uuid; # 54963433\n\nSELECT * FROM activities WHERE uuid_to_bin('d68311e3-ac34-49bd-bf04-88323a7e5352') = uuid; # 54966009\n\n# ****************************************** SF *************************\nSELECT * FROM activities WHERE uuid_to_bin('2bd4cfa3-4eb5-4c77-84a7-9ed46a21c988') = uuid;\n\nselect * from roles;\nselect * from permissions;\nselect * from permission_role where permission_id = 136;\n\nselect * from migrations order by id desc;\n\nselect * from teams where id IN (1, 1037);\nselect * from crm_layouts where crm_configuration_id = 1;\nSELECT * FROM crm_layout_entities WHERE crm_layout_id = 1493;\nSELECT * FROM crm_fields WHERE id IN (1652,1661,66799,66814,66821,66836,66843,66846,66864,84752,182306,323580,380378);\n\nselect * from features;\nselect * from team_features where feature_id = 33;\nselect * from opportunities;\n\nselect * from teams;\nSELECT\n CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,\n u.email,\n sa.*,\n t.owner_id FROM social_accounts sa\nJOIN users u on u.id = sa.sociable_id\nJOIN teams t on t.id = u.team_id\nWHERE u.team_id = 1052 and sa.provider = 'hubspot';\n\nSELECT * FROM accounts where id = 11512582;\nselect * from activities where account_id = 11512582;","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Project","depth":3,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Project","depth":3,"bounds":{"left":0.025,"top":0.06666667,"width":0.050694443,"height":0.034444444},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"New File or Directory…","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Expand Selected","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Collapse All","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Options","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
-3545036367931030470
|
-9030663327281178587
|
visual_change
|
accessibility
|
NULL
|
Project: faVsco.js, menu
#12066 on JY-20725-handle Project: faVsco.js, menu
#12066 on JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
HandleHubspotRateLimitTest
Run 'HandleHubspotRateLimitTest'
Debug 'HandleHubspotRateLimitTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Code changed:
Hide
Sync Changes
Hide This Notification
8
39
5
Previous Highlighted Error
Next Highlighted Error
<?php
namespace Jiminny\Services\Crm\Close;
use Cache;
use Carbon\Carbon;
use Exception;
use GuzzleHttp\Exception\ClientException;
use Illuminate\Support\Str;
use InvalidArgumentException;
use Jiminny\Contracts\Services\Crm\LayoutManagementInterface;
use Jiminny\Contracts\Services\Crm\MatchCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\Provider\CloseInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityLookupInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityManipulationInterface;
use Jiminny\Contracts\Services\Crm\SettingsInterface;
use Jiminny\Contracts\Services\Crm\SupportsObjectTypeParseInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmMetadataInterface;
use Jiminny\Contracts\Services\Crm\VerifyTaskExistsInterface;
use Jiminny\Exceptions\CrmException;
use Jiminny\Exceptions\HttpNotFoundException;
use Jiminny\Exceptions\ServiceUnavailableException;
use Jiminny\Exceptions\UnexpectedCallException;
use Jiminny\Models\Account;
use Jiminny\Models\Activity;
use Jiminny\Models\Contact;
use Jiminny\Models\Crm\Configuration;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\FieldData;
use Jiminny\Models\Crm\Profile;
use Jiminny\Models\Lead;
use Jiminny\Models\Opportunity;
use Jiminny\Models\Playbook;
use Jiminny\Models\SocialAccount;
use Jiminny\Models\Stage;
use Jiminny\Models\User;
use Jiminny\Repositories\Crm\FieldRepository;
use Jiminny\Repositories\Crm\ProfileRepository;
use Jiminny\Services\Avatar\ProspectPhotoPathService;
use Jiminny\Services\Crm\BaseService;
use Jiminny\Services\Crm\Close\Processor\AccountProcessor;
use Jiminny\Services\Crm\Close\Processor\MetadataProcessor;
use Jiminny\Services\Crm\Close\Processor\OpportunityProcessor;
use Jiminny\Services\Crm\Close\Processor\StageProcessor;
use Jiminny\Services\Crm\Helpers\FilterJoinedParticipants;
use Jiminny\Services\Crm\Metadata\ProfileMetadata;
use Jiminny\Services\Crm\OpportunitySyncStrategyResolver;
use Sentry;
use UnexpectedValueException;
class Service extends BaseService implements
CloseInterface,
SyncCrmEntitiesInterface,
SyncCrmMetadataInterface,
RemoteEntityManipulationInterface,
LayoutManagementInterface,
SettingsInterface,
MatchCrmEntitiesInterface,
RemoteEntityLookupInterface,
SupportsObjectTypeParseInterface,
VerifyTaskExistsInterface
{
private const int NOTE_BODY_MAX_LENGTH = 3000000;
private const int TASK_VERIFICATION_CACHE_TTL = 86400; // 1 day
private StandardFieldMetadata $standardFieldMetadata;
private MetadataProcessor $metadataProcessor;
private FieldValueConverter $fieldValueConverter;
private StageProcessor $stageProcessor;
private OpportunityProcessor $opportunityProcessor;
private AccountProcessor $accountProcessor;
public function __construct(
Client $client,
StandardFieldMetadata $standardFieldMetadata,
MetadataProcessor $metadataProcessor,
FieldValueConverter $fieldValueConverter,
StageProcessor $stageResolver,
OpportunityProcessor $opportunityProcessor,
AccountProcessor $accountProcessor,
private readonly ProspectPhotoPathService $prospectPhotoPathService,
) {
parent::__construct();
$this->client = $client;
$this->standardFieldMetadata = $standardFieldMetadata;
$this->metadataProcessor = $metadataProcessor;
$this->fieldValueConverter = $fieldValueConverter;
$this->stageProcessor = $stageResolver;
$this->opportunityProcessor = $opportunityProcessor;
$this->accountProcessor = $accountProcessor;
}
public function getDisplayName(): string
{
return 'Close';
}
public function setConfiguration(Configuration $config): void
{
parent::setConfiguration($config);
$this->metadataProcessor->setConfiguration($config);
$this->stageProcessor->setConfiguration($config);
$this->opportunityProcessor->setConfiguration($config);
$this->accountProcessor->setConfiguration($config);
}
protected function getOAuthAccount(User $user): ?SocialAccount
{
// In this case, the Account Owner is always the connection for any API operations.
return $user->getTeam()->getOwner()->getSocialAccount(SocialAccount::PROVIDER_CLOSE);
}
private function getClient(): Client
{
if (! $this->client instanceof Client) {
throw new UnexpectedCallException('Client not set');
}
return $this->client;
}
public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string
{
return $this->fieldValueConverter->convertFromCrm($fieldType, $fieldValue);
}
protected function getFieldTypes(): array
{
return [
parent::OBJECT_OPPORTUNITY,
parent::OBJECT_CONTACT,
parent::OBJECT_ACCOUNT,
];
}
protected function getFields(string $crmObject): array
{
// not used
return [];
}
/**
* @inheritdoc
*/
public function getDefaultFields(string $activityType): array
{
$fields = [];
if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {
$defaultFields = FieldDefinitions::defaultTaskFields();
// This lazy creates these fields if not already setup.
foreach ($defaultFields as $defaultField) {
$fields[] = $this->config->fields()->firstOrCreate($defaultField);
}
}
return $fields;
}
/**
* @inheritdoc
*/
public function getDefaultActivityField(string $activityType): Field
{
// Set up the activity field as the default Type.
/** @var Field $activityField */
$activityField = $this->config->fields()->where([
'crm_provider_id' => 'type',
'object_type' => $activityType,
])->first();
return $activityField;
}
/**
* @inheritdoc
*/
public function getSupportedPlaybookTypes(): array
{
return [Playbook::ACTIVITY_TYPE_TASK];
}
public function getDealInsightsFields(): array
{
return FieldDefinitions::dealInsightsFields();
}
protected function getDefaultFollowupLayoutFields(string $activityType): array
{
$fields = [];
$fieldRepo = app(FieldRepository::class);
$fieldData = FieldDefinitions::followupFieldsFilter();
foreach ($fieldData as $data) {
$field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $data);
// Only add the field if it is created, which it should be.
if ($field) {
$fields[] = $field;
}
}
return $fields;
}
public function syncFields(): void
{
$this->syncStandardFields();
$this->syncCustomFields();
}
/**
* @important Works only for custom fields
*/
public function syncField(Field $field): void
{
$resource = $this->convertObjectTypeToResource($field->getObjectType());
// We can only sync custom fields in this CRM.
if ($this->isCustomField($field->getCrmProviderId()) === false) {
return;
}
$crmField = $this->getClient()->fetchCustomFieldDefinition($resource, $field->getCrmProviderId());
$this->metadataProcessor->syncField($crmField);
}
private function isCustomField(string $fieldId): bool
{
return strpos($fieldId, 'cf_') === 0;
}
/**
* @inheritdoc
*/
public function importPicklistValues(Field $field): array
{
// handled in syncFields()
return [];
}
/**
* @important We only support stages on the opportunity object
*
* @param string[]|null $types
*/
public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage
{
if (! $missingStageName) {
// This is taken care of by syncOrganization()
return null;
}
$stage = $this->stageProcessor->resolveFromStageId($missingStageName);
if ($stage instanceof Stage) {
return $stage;
}
$stageMetadata = $this->getClient()->fetchStage($missingStageName);
if (! $stageMetadata) {
$this->logger->error('Stage does not exist', [
'stage' => $missingStageName,
]);
return null;
}
return $this->stageProcessor->importStage($stageMetadata);
}
/**
* @inheritdoc
*/
public function syncLeads(Carbon $since, ?Carbon $to = null, ?string $crmProfileId = null): int
{
// Even though Close.io has the concept of "leads", they fit more into our concept of accounts.
return 0;
}
/**
* @inheritdoc
*/
public function syncLead(string $crmId): ?Lead
{
// Not a supported entity.
return null;
}
/**
* @throws Exception
*/
public function syncAccounts(Carbon $since, ?Carbon $to = null): int
{
$syncCount = 0;
try {
foreach ($this->getClient()->listAccounts($since) as $clAccount) {
// Only sync if previously imported.
if ($this->hasAccount($clAccount->getId())) {
$this->importAccount($clAccount);
$syncCount++;
}
}
} catch (Exception $exception) {
$this->logger->error('Account sync failed', [
'error' => $exception->getMessage(),
]);
throw $exception;
}
return $syncCount;
}
public function syncAccount(string $crmId): ?Account
{
return $this->accountProcessor->syncAccount($crmId);
}
private function importAccount($crmData): Account
{
return $this->accountProcessor->importAccountMetadata($crmData);
}
/**
* @throws CloseException
*/
public function syncOpportunities(array $parameters, ?string $strategy = null): int
{
$strategyResolver = app(OpportunitySyncStrategyResolver::class, [
'client' => $this->client,
]);
$strategies = $strategyResolver->getStrategies($this->config, $strategy);
$syncCount = 0;
try {
$opportunities = [];
foreach ($strategies as $syncStrategy) {
$opportunitiesData = $syncStrategy->fetchOpportunities($parameters);
$opportunities[] = $opportunitiesData['data'];
if ($opportunitiesData['has_more']) {
$this->logger->info('[Close] Sync Opportunities - count warning', [
'team_id' => $this->config->getTeam()->getId(),
'total' => $opportunitiesData['total'],
'count' => $opportunitiesData['count'],
'skip' => $opportunitiesData['skip'],
'strategies_count' => count($strategies),
]);
}
}
$opportunities = array_merge(...$opportunities);
} catch (CrmException $exception) {
$this->logger->error('Fetching opportunity data failed', [
'team' => $this->getTeam()->getSlug(),
'error' => $exception->getMessage(),
]);
return 0;
}
foreach ($opportunities as $opportunityMetadata) {
try {
$this->importOpportunity($opportunityMetadata);
$syncCount++;
} catch (Exception $exception) {
$this->logger->warning('Opportunity sync failed', [
'opportunity' => $opportunityMetadata->getId(),
'error' => $exception->getMessage(),
]);
}
}
return $syncCount;
}
public function syncOpportunity(string $crmId): ?Opportunity
{
$strategyResolver = app(OpportunitySyncStrategyResolver::class, [
'client' => $this->client,
]);
$strategy = $strategyResolver->resolve(
$this->config,
OpportunitySyncStrategyResolver::SINGLE_SYNC_OPPORTUNITY_STRATEGY,
);
$parameters = ['crm_id' => $crmId];
$opportunity = $strategy->fetchOpportunities($parameters);
if (empty($opportunity['data'])) {
return null;
}
return $this->importOpportunity($opportunity['data']);
}
private function importOpportunity($crmData): ?Opportunity
{
if (! $crmData->getLeadId()) {
$this->logger->warning('Opportunity does not have a lead ID', [
'opportunity' => $crmData->getId(),
]);
return null;
}
$account = $this->getConfiguration()
->accounts()
->where('crm_provider_id', $crmData->getLeadId())
->first()
;
if ($account === null) {
$account = $this->accountProcessor->syncAccount($crmData->getLeadId());
}
$profile = $this->getConfiguration()
->profiles()
->where('crm_provider_id', $crmData->getUserId())
->first()
;
$stage = $this->getConfiguration()
->stages()
->where('crm_provider_id', $crmData->getStageId())
->where('type', Stage::TYPE_OPPORTUNITY)
->first()
;
if ($stage === null) {
$stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $crmData->getStageId());
}
return $this->opportunityProcessor->import($crmData, $account, $stage, $profile);
}
/**
* @param array<string,string> $crmData
* @param string[] $crmFields
*/
public function importOpportunityCrmFieldData(array $crmData, array $crmFields, int $opportunityId): void
{
// handled in importOpportunity
}
/**
* @inheritdoc
*/
public function syncContacts(Carbon $since, ?Carbon $to = null): int
{
$syncCount = 0;
try {
/** No way to sync today.
$clContacts = $this->client->get('lead', [
'date_updated__gte' => $since->toDateString(),
'_order_by' => '-date_updated',
]);
foreach ($clContacts as $clContact) {
// Only sync if previously imported.
if ($this->hasContact($clContact['id'])) {
$this->importContact($clContact);
$syncCount++;
}
}
**/
} catch (Exception $exception) {
// Do nothing for now.
throw $exception;
}
return $syncCount;
}
/**
* @inheritdoc
*/
public function syncContact(string $crmId): ?Contact
{
try {
$clContact = $this->client->get('contact/' . $crmId);
} catch (HttpNotFoundException $exception) {
return null;
}
return $this->importContact($clContact);
}
/**
* @inheritdoc
*/
private function importContact($crmData): Contact
{
$account = null;
if ($crmData['lead_id']) {
$account = $this->team
->accounts()
->where('crm_provider_id', $crmData['lead_id'])
->first();
if ($account === null) {
$account = $this->syncAccount($crmData['lead_id']);
}
}
$mobilePhone = $parsedNumber = null;
foreach ($crmData['phones'] as $phoneNumber) {
if ($phoneNumber['type'] === 'mobile') {
$mobilePhone = $this->buildContactMobilePhone(null, $phoneNumber['phone']);
} else {
$parsedNumber = $this->buildContactPhone(null, $phoneNumber['phone']);
}
}
$email = null;
if (empty($crmData['emails']) === false) {
$email = mb_strimwidth($crmData['emails'][0]['email'], 0, 80);
}
$profile = $this->config->profiles()->where('crm_provider_id', (string) $crmData['updated_by'])->first();
$data = [
'account_id' => $account->id ?? null,
'team_id' => $this->team->id,
'user_id' => $profile?->user_id,
'owner_id' => $crmData['updated_by'],
'name' => $crmData['name'] ?? 'Unknown',
'email' => $email,
'phone' => $parsedNumber['phone'] ?? null,
'mobile_phone' => $mobilePhone ?? null,
'ext' => $parsedNumber['ext'] ?? null,
'title' => mb_strimwidth($crmData['title'] ?? '', 0, 128),
'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(
crmConfiguration: $this->config,
crmProviderId: $crmData['id'],
modelType: Contact::class,
fileName: $crmData['id'],
avatarText: $crmData['name'] ?? 'Unknown'
),
'remotely_created_at' => Carbon::parse($crmData['date_created']),
];
/** @var Contact */
return $this->config->contacts()->updateOrCreate(['crm_provider_id' => (string) $crmData['id']], $data);
}
private function buildContactPhone(?string $countryCode, ?string $number): ?array
{
if ($number) {
// Trim to our width and attempt to parse it.
$number = mb_strimwidth($number, 0, 25);
$parsedNumber = parsePhoneNumber($countryCode, $number);
} else {
$parsedNumber = [];
}
return $parsedNumber;
}
private function buildContactMobilePhone(?string $countryCode, ?string $number): ?string
{
return $number ? mb_strimwidth(phone_e164($countryCode, $number), 0, 25) : null;
}
public function syncOrganization(): void
{
$organisation = $this->getClient()->fetchOrganisation();
$this->metadataProcessor->syncOrganisation($organisation);
foreach ($organisation->getPipelines() as $pipelineMetadata) {
$this->metadataProcessor->syncPipeline($pipelineMetadata);
}
}
private function syncStandardFields(): void
{
// Currently we sync only opportunity fields
$stages = $this->getClient()->listStages();
foreach ($this->standardFieldMetadata->opportunity($stages) as $fieldMetadata) {
$this->metadataProcessor->syncField($fieldMetadata);
}
$this->config->save();
}
private function syncCustomFields(): void
{
foreach ($this->getFieldTypes() as $fieldType) {
$objectType = $this->convertObjectTypeToResource($fieldType);
$currentFields = $this->getClient()->listCustomFieldDefinitions($objectType);
foreach ($currentFields as $fieldMetadata) {
$this->metadataProcessor->syncField($fieldMetadata);
}
}
$this->config->save();
}
public function syncProfiles(?User $userToSearch = null): ?Profile
{
/*
* Fetch the profile of the user from the database
* Then fetch the user metadata from Close and update it
* In case there's no profile for the user, proceed with syncing all users
*/
$foundUser = null;
if ($userToSearch) {
$profile = $userToSearch->getProfile();
if ($profile instanceof Profile) {
$crmProviderId = $profile->getCrmProviderId();
if ($crmProviderId) {
$profileMetadata = $this->getClient()->fetchUser($crmProviderId);
if (! $profileMetadata instanceof ProfileMetadata) {
return null;
}
return $this->metadataProcessor->syncProfile($profileMetadata);
}
}
}
foreach ($this->getClient()->listUsers() as $userMetadata) {
$userProfile = $this->metadataProcessor->syncProfile($userMetadata);
if (
$userToSearch instanceof User
&& $userProfile instanceof Profile
&& $userProfile->getUserId() === $userToSearch->getId()
) {
$foundUser = $userProfile;
}
}
return $foundUser;
}
public function syncProfileFields(): void
{
// Not used.
}
/**
* @inheritdoc
*/
public function find(string $name, array $scopes): array
{
$count = $this->limit ?? 20;
$offset = $this->offset ?? 0;
$data = Cache::remember($this->team->id . $name . $count . $offset, 300, function () use ($name, $count, $offset) {
$data = [];
try {
// If search phrase resembles phone number remove special symbols
if (preg_match('/^([0-9\s\-\+\(\)]*)$/', $name)) {
$name = '+' . preg_replace('/[\s\-\+\(\)]/', '', $name);
}
// Close do not provide a unified way to search, so we must hack our own.
$objects = $this->client->get('lead', [
'query' => 'sort:date_updated name:(' . $name . ') or email:(' . $name . ') or phone:(' . $name . ')',
'_limit' => $count, '_skip' => $offset,
]);
} catch (\GuzzleHttp\Exception\ServerException $exception) {
throw new ServiceUnavailableException($exception->getMessage());
}
foreach ($objects['data'] as $object) {
// We need a contact to dial it.
if (empty($object['contacts'])) {
continue;
}
foreach ($object['contacts'] as $contact) {
$record = [
'crmId' => $contact['id'],
'crmUrl' => $this->generateProviderUrl($object['id'], 'account'),
'name' => $contact['name'],
'industry' => null,
'title' => $contact['title'],
'organization' => $object['display_name'],
'prospectType' => 'contact',
'phoneNumbers' => [],
];
foreach ($contact['phones'] as $phone) {
if ($phone['type'] === 'mobile') {
$number = $this->buildContactMobilePhone(null, $phone['phone']);
$record['phoneNumbers'][] = [
'number' => $number,
'nationalFormat' => phone_national(null, $number),
'type' => 'mobile',
];
} else {
$parsedNumber = $this->buildContactPhone(null, $phone['phone']);
// Add phone number to record.
if (empty($parsedNumber['phone']) === false) {
$record['phoneNumbers'][] = [
'number' => $parsedNumber['phone'],
'nationalFormat' => phone_national(null, $parsedNumber['phone']),
'type' => 'phone',
];
}
}
}
$data[] = $record;
}
}
return $data;
});
return $data;
}
/**
* @inheritdoc
*/
public function findOpportunities(?string $crmAccountId, ?string $crmContactId, ?int $userId = null): array
{
$data = [];
$ownerData = [];
$ownerId = null;
$contact = null;
$account = null;
if ($crmAccountId) {
$account = $this->config->accounts()->where('crm_provider_id', $crmAccountId)->first();
if ($account === null) {
$account = $this->syncAccount($crmAccountId);
}
}
if ($crmContactId) {
$contact = $this->config->contacts()->where('crm_provider_id', $crmContactId)->first();
if ($contact === null) {
$contact = $this->syncContact($crmContactId);
}
}
if ($contact || $account) {
if ($contact && $account === null) {
$account = $contact->account;
}
if ($account === null) {
return [];
}
$params = [
'lead_id' => $account->crm_provider_id,
'_order_by' => '-date_updated',
];
$onlyOpen = true;
switch ($this->config->opportunity_assignment_rule) {
case Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_UPDATED:
$params['_order_by'] = '-date_updated';
break;
case Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_CREATED:
$params['_order_by'] = '-date_created';
break;
case Configuration::OPP_ASSIGNMENT_ALL_OPEN_OLDEST_CREATED:
$params['_order_by'] = 'date_created';
break;
case Configuration::OPP_ASSIGNMENT_ALL_RECENTLY_UPDATED:
$params['_order_by'] = '-date_updated';
$onlyOpen = false;
}
if ($onlyOpen) {
$params['status_type__in'] = 'active,won';
}
$clOpportunities = $this->client->get('opportunity', $params);
if ($userId) {
$profileRepository = app(ProfileRepository::class);
$profile = $profileRepository->findProfileByUserId($this->config, $userId);
$ownerId = $profile instanceof Profile ? $profile->getCrmProviderId() : null;
}
foreach ($clOpportunities['data'] as $clOpportunity) {
$stage = $this->config
->stages()
->where('crm_provider_id', $clOpportunity['status_id'])
->first();
if ($stage === null) {
// Import it.
$stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $clOpportunity['status_id']);
}
$record = [
'crmId' => $clOpportunity['id'],
'name' => $clOpportunity['note'],
'value' => currency_format($clOpportunity['value'], $clOpportunity['value_currency']),
'won' => $stage->probability === 100.00,
'closed' => $clOpportunity['status_type'] !== 'active',
'stage' => [
'id' => $stage->id_string,
'name' => $stage->name,
],
'recordType' => [],
];
if ($ownerId && isset($clOpportunity['user_id']) && $clOpportunity['user_id'] === $ownerId) {
$ownerData[] = $record;
}
$data[] = $record;
}
if (! empty($ownerData)) {
return $ownerData;
}
}
return $data;
}
/**
* @inheritdoc
*/
public function getTasks(string $objectType, string $objectId, ?string $opportunityId): array
{
$data = [];
$crmId = null;
if ($objectType === 'contact') {
$contact = $this->syncContact($objectId);
if ($contact && $contact->account_id) {
$crmId = $contact->account->crm_provider_id;
}
} else {
$crmId = $objectId;
}
if ($crmId) {
$clTasks = $this->client->get('task', [
'lead_id' => $crmId,
'_type' => 'lead',
'assigned_to' => $this->profile->crm_provider_id,
'is_complete' => 'false',
'_order_by' => 'date',
]);
foreach ($clTasks['data'] as $clTask) {
$data[] = [
'crmId' => $clTask['id'],
'subject' => $clTask['text'],
'due' => $clTask['date'] ?? null,
'type' => null,
];
}
}
return $data;
}
/**
* Try to find email address in CRM service
*
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
*}
*/
public function matchExactlyByEmail(string $email, ?int $userId = null): ?array
{
$clObject = $this->client->get('lead', [
'query' => 'sort:updated contact(email(email:"' . $email . '"))',
'_limit' => 1,
]);
if ($clObject['total_results'] > 0) {
foreach ($clObject['data'][0]['contacts'] as $clContact) {
foreach ($clContact['emails'] as $clEmail) {
if ($email === $clEmail['email']) {
$data = $this->convertCrmData($clContact['id'], $userId);
return ! empty(array_filter($data)) ? $data : null;
}
}
}
}
return null;
}
/**
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
*}
*/
public function matchByPhone(string $phone, ?string $rawPhoneNumber = null, ?int $userId = null): ?array
{
// Check if the user is internal.
$teamMember = $this->team->users()->where('phone', $phone)->exists();
// Skip the attendee if internal.
if ($teamMember === false) {
$clObject = $this->client->get('lead', [
'query' => 'sort:updated contact(' . $phone . ')',
'_limit' => 1,
]);
if ($clObject['total_results'] > 0) {
foreach ($clObject['data'][0]['contacts'] as $clContact) {
foreach ($clContact['phones'] as $clPhone) {
if ($phone === $clPhone['phone']) {
$data = $this->convertCrmData($clContact['id'], $userId);
return ! empty(array_filter($data)) ? $data : null;
}
}
}
}
}
return null;
}
/**
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchByName(string $name, ?int $userId = null): ?array
{
// Don't waste time searching for single character strings.
if (\strlen($name) <= 1) {
return null;
}
$cacheKey = $this->getCacheKey($name, $userId);
$result = Cache::remember($cacheKey, 60, function () use ($name, $userId) {
$clObject = $this->client->get('lead', [
'query' => 'sort:updated contact(name:"' . $name . '")',
'_limit' => 1,
]);
if ($clObject['total_results'] > 0) {
foreach ($clObject['data'][0]['contacts'] as $clContact) {
if ($clContact['name'] === $name || $clContact['display_name'] === $name) {
$data = $this->convertCrmData($clContact['id'], $userId);
return ! empty(array_filter($data)) ? $data : false;
}
}
}
return false;
});
return is_array($result) ? $result : null;
}
protected function getCacheKey(string $object, ?int $userId = null): ?string
{
$key = $this->team->getId() . $object;
$keySuffix = $this->getOwnerKeySuffix($userId);
return $key . $keySuffix;
}
private function getOwnerKeySuffix(?int $userId = null): string
{
return $userId === null ? '' : (string) $userId;
}
private function convertCrmData(string $crmId, ?int $userId = null): array
{
$lead = null;
$opportunity = null;
$account = null;
$stage = null;
$countryCode = null;
$contact = $this->syncContact($crmId);
if ($contact) {
$account = $contact->account;
if ($contact->country_code) {
$countryCode = $contact->country_code;
} elseif ($account) {
$countryCode = $account->country_code;
}
try {
$cpOpportunities = $this->findOpportunities(
$account?->crm_provider_id,
$contact->crm_provider_id,
$userId,
);
if (! empty($cpOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($cpOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
} catch (Exception) {
// Nothing to see here.
}
}
return [
$lead,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
public function saveActivity(Activity $activity): Activity
{
switch ($activity->type) {
case Activity::TYPE_CONFERENCE:
case Activity::TYPE_SOFTPHONE:
case Activity::TYPE_SOFTPHONE_INBOUND:
$activity = $this->buildCallPayload($activity);
break;
case Activity::TYPE_SMS_INBOUND:
case Activity::TYPE_SMS_OUTBOUND:
$activity = $this->buildTextMessagePayload($activity);
break;
}
return $activity;
}
private function mapStatus(string $status): string
{
switch ($status) {
case Activity::STATUS_COMPLETED:
case Activity::STATUS_IN_PROGRESS:
case Activity::STATUS_FAILED:
case Activity::STATUS_NO_ANSWER:
case Activity::STATUS_BUSY:
default:
return $status;
case Activity::STATUS_CANCELLED:
return 'cancel';
}
}
/**
* @throws CrmException
*/
private function buildCallPayload(Activity $activity): Activity
{
try {
if ($activity->crm_provider_id) {
// The activity should be logged under the existing Task (not Activity).
$data = [
'_type' => 'lead',
'lead_id' => $activity->account->crm_provider_id,
'assigned_to' => $this->profile->crm_provider_id,
'text' => $this->generateActivityDescription($activity),
'date' => $activity->getActualEndTime()->toDateString(),
'is_complete' => true,
];
$this->logger->info('[Close CRM] Updating task', [
'activity' => $activity->id,
'crm_id' => $activity->crm_provider_id,
'data' => $data,
]);
$this->client->put('task/' . $activity->crm_provider_id, $data);
} else {
// Just create an activity.
$data = [
'lead_id' => $activity->account_id ? $activity->account->crm_provider_id : null,
'contact_id' => $activity->contact_id ? $activity->contact->crm_provider_id : null,
'created_by' => $this->profile->crm_provider_id,
'user_id' => $this->profile->crm_provider_id,
'direction' => $activity->getCrmType() === Activity::TYPE_SOFTPHONE ? 'outbound' : 'inbound',
'status' => $this->mapStatus($activity->getStatus()),
'note' => $this->generateActivityDescription($activity),
'duration' => $this->mapStatus($activity->getStatus()) !== 'cancel' ? $activity->duration : 0,
'phone' => $activity->to ? $activity->to->phone_number : null,
];
$clActivity = $this->client->post('activity/call', $data);
$this->logger->info('[Close CRM] Creating activity', [
'activity' => $activity->id,
'crm_id' => $clActivity['id'],
'data' => $data,
'response' => $clActivity,
]);
$activity->crm_provider_id = $clActivity['id'];
$activity->save();
}
} catch (ClientException $exception) {
$response = $exception->getResponse();
if ($response === null) {
// Trying to debug weird cases where this is null.
Sentry::captureException($exception);
}
$responseBody = $response->getBody();
$message = $responseBody;
$errorCode = $response->getStatusCode();
$jsonResponse = json_decode($responseBody, true);
if (isset($jsonResponse[0]['message'])) {
$message = $jsonResponse[0]['message'];
}
throw new CrmException($message, $errorCode);
}
return $activity;
}
private function buildTextMessagePayload(Activity $activity): Activity
{
$clActivity = $this->client->post('activity/sms', [
'lead_id' => $activity->account_id ? $activity->account->crm_provider_id : null,
'contact_id' => $activity->contact_id ? $activity->contact->crm_provider_id : null,
'created_by' => $this->profile->crm_provider_id,
'user_id' => $this->profile->crm_provider_id,
'status' => $activity->getType() === Activity::TYPE_SMS_OUTBOUND ? 'sent' : 'inbox',
'text' => $this->generateActivityDescription($activity),
'remote_phone' => $activity->to ? $activity->to->phone_number : null,
'local_phone' => $activity->to ? $activity->to->phone_number : null,
'source' => 'Close.io',
]);
$activity->crm_provider_id = $clActivity['id'];
$activity->save();
return $activity;
}
private function generateActivityDescription(Activity $activity): string
{
$description = '';
switch ($activity->type) {
case Activity::TYPE_SOFTPHONE:
case Activity::TYPE_SOFTPHONE_INBOUND:
case Activity::TYPE_CONFERENCE:
if ($activity->hasActivityType()) {
$description = $activity->getActivityType()->getName() . PHP_EOL . PHP_EOL;
}
if ($activity->hasTitle()) {
$description .= $activity->getTitle() . PHP_EOL;
}
if ($activity->hasReasonCodeBotKicked()) {
$description .= 'Notetaker removed from this meeting' . PHP_EOL . PHP_EOL;
// When we fix the state to be Activity::RECORDING_RECORDED as it should be this can change.
} elseif ($activity->hasReasonCodeNotCompliant()) {
$description .= 'Notetaker did not join due to recording consent not being provided by attendees' . PHP_EOL . PHP_EOL;
} elseif ($activity->canReviewActivity()) {
$playbackUrl = $activity->user->team->partner->getPlaybackUrl($activity);
$description .= 'Review Activity:' . PHP_EOL . $playbackUrl . PHP_EOL . PHP_EOL;
}
if ($activity->type === Activity::TYPE_CONFERENCE) {
$description .= 'Attendees:'
. PHP_EOL
. (new FilterJoinedParticipants())->toString($activity);
}
if (\count($activity->notes) > 0) {
$description .= PHP_EOL . PHP_EOL . 'Notes: ' . PHP_EOL;
foreach ($activity->notes as $note) {
$time = ($note->time > 3600) ? gmdate('H:i:s', $note->time) : gmdate('i:s', $note->time);
$description .= $time . ' ' . $note->note . PHP_EOL;
}
}
// Get all private messages.
$messages = $activity->messages()
->where('is_private', 1)
->orderBy('created_at', 'asc');
if ($messages->count() > 0) {
$description .= PHP_EOL . PHP_EOL . 'Coaching Chat: ' . PHP_EOL;
foreach ($messages->get() as $message) {
$description .= $message->participant->name . ': ' . $message->message . PHP_EOL;
}
}
// Get all public messages.
$messages = $activity->messages()
->where('is_private', 0)
->orderBy('created_at', 'asc');
if ($messages->count() > 0) {
$description .= PHP_EOL . PHP_EOL . 'Customer Chat: ' . PHP_EOL;
foreach ($messages->get() as $message) {
$description .= $message->participant->name . ': ' . $message->message . PHP_EOL;
}
}
if ($activity->summary) {
$description .= PHP_EOL . PHP_EOL . 'Summary: ' . PHP_EOL . $activity->summary;
}
break;
case Activity::TYPE_SMS_INBOUND:
case Activity::TYPE_SMS_OUTBOUND:
$description = $activity->description;
break;
}
return $description;
}
public function saveFollowupActivity(Activity $activity, array $fields): ?string
{
// This is the user provided activity subject field.
if (empty($fields['name'])) {
return null;
}
$due = null;
if (empty($fields['due_date']) === false) {
$formatDue = Carbon::parse($fields['due_date']);
$due = $formatDue->toDateTimeString();
}
$clTask = $this->client->post('task', [
'_type' => 'lead',
'lead_id' => $activity->account->crm_provider_id,
'assigned_to' => $this->profile->crm_provider_id,
'text' => $fields['name'],
'date' => $due,
'is_complete' => false,
]);
// We don't actually create a corresponding activity object on our side yet.
return $clTask['id'];
}
/**
* Store transcripts as note.
*
* @throws Exception
*/
public function createTranscriptNotes(Activity $activity): void
{
if ($activity->account_id === null) {
// We can only log to accounts (leads).
return;
}
// Generate activity transcription.
$transcriptionData = $this->generateTranscription($activity);
// Truncate Notes with max notes length because transcription text could be very long.
$transcripts = mb_strimwidth($transcriptionData, 0, self::NOTE_BODY_MAX_LENGTH);
$clActivity = $this->client->post('activity/note', [
'lead_id' => $activity->account_id ? $activity->account->crm_provider_id : null,
'created_by' => $this->profile->crm_provider_id,
'user_id' => $this->profile->crm_provider_id,
'note' => $transcripts,
]);
// Store CRM Activity ID in transcription.
$transcription = $activity->getTranscription();
$transcription->crm_activity_id = $clActivity['id'];
$transcription->save();
}
public function parseObjectType(string $objectId): string
{
if (Str::startsWith($objectId, 'lead')) {
return 'account';
}
if (Str::startsWith($objectId, 'cont')) {
return 'contact';
}
if (Str::startsWith($objectId, 'oppo')) {
return 'opportunity';
}
throw new InvalidArgumentException('Unsupported Object Type');
}
/**
* @inheritdoc
*/
public function updateStage($crmObject, Stage $stage): void
{
if ($crmObject instanceof Lead) {
// This would never get invoked since we merge lead/accounts in Close.
$this->client->put('lead/' . $crmObject->crm_provider_id, [
'status' => $stage->crm_provider_id,
]);
} else {
$this->client->put('opportunity/' . $crmObject->crm_provider_id, [
'status_id' => $stage->crm_provider_id,
]);
}
}
public function updateRecord(string $objectType, string $objectId, array $data, array $headers = []): void
{
$this->getClient()->updateObject($this->convertObjectTypeToResource($objectType), $objectId, $data);
}
public function prepareValueForUpdate(array $params): array
{
$convertedValue = $this->fieldValueConverter->convertToCrm(
$this->config,
$params['fieldName'],
$params['fieldValue'],
);
if ($this->isCustomField($params['fieldName'])) {
$params['fieldName'] = 'custom.' . $params['fieldName'];
}
$params['fieldValue'] = $convertedValue;
return parent::prepareValueForUpdate($params);
}
public function getRecord(string $objectType, string $objectId, array $fields = []): array
{
return $this->client->get($this->convertObjectTypeToResource($objectType) . '/' . $objectId);
}
/**
*
* @throws UnexpectedValueException
*/
private function convertObjectTypeToResource(string $objectType): string
{
switch ($objectType) {
case FieldData::OBJECT_OPPORTUNITY:
return 'opportunity';
case FieldData::OBJECT_CONTACT:
return 'contact';
case FieldData::OBJECT_ACCOUNT:
return 'lead';
case FieldData::OBJECT_TASK:
return 'activity';
default:
throw new UnexpectedValueException('Unsupported object type "' . $objectType . '"');
}
}
public function generateProviderUrl(string $providerId, string $objectType): ?string
{
$baseUrl = 'https://app.close.com/';
$url = null;
switch ($objectType) {
case 'account':
$url = $baseUrl . 'lead/' . $providerId;
break;
case 'contact':
$contact = $this->config->contacts()->where('crm_provider_id', $providerId)->first();
if ($contact && $contact->account_id) {
$url = $baseUrl . 'lead/' . $contact->account->crm_provider_id;
}
break;
default:
// Sadly we can't deeplink to anything else in Close UI.
$url = null;
}
return $url;
}
/**
* Generate transcription for the activity.
*/
private function generateTranscription(Activity $activity): string
{
if (! $this->config->store_transcript) {
// If sending transcription to activity toggle is disabled
return '';
}
return $this->transcriptionService
->findTranscriptionByActivity($activity)
->map(static function (array $transcriptionSegment): string {
return $transcriptionSegment['formattedStartsAt'] . ' | ' . $transcriptionSegment['transcript'];
})
->implode(PHP_EOL);
}
public function verifyTaskExists(Activity $activity): bool
{
$crmProviderId = $activity->getCrmProviderId();
$cacheKey = "crm_task_exists:{$this->config->getId()}:$crmProviderId";
return Cache::remember($cacheKey, self::TASK_VERIFICATION_CACHE_TTL, function () use ($crmProviderId) {
try {
$client = $this->getClient();
$task = $client->get('task/' . $crmProviderId);
return ! empty($task);
} catch (HttpNotFoundException) {
// Task not found in CRM - this is expected and permanent
$this->logger->info('[Close] Task not found during verification', [
'task_id' => $crmProviderId,
'config_id' => $this->config->getId(),
]);
return false;
} catch (CloseException $e) {
// Handle 404 responses from Close API
if ($e->getResponseStatusCode() === 404) {
$this->logger->info('[Close] Task not found during verification (404)', [
'task_id' => $crmProviderId,
...
|
45343
|
NULL
|
NULL
|
NULL
|
|
45345
|
1625
|
7
|
2026-05-14T14:32:16.658328+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-14/1778 /Users/lukas/.screenpipe/data/data/2026-05-14/1778769136658_m1.jpg...
|
PhpStorm
|
|
1
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
Project Files
Preview
Filter
Open in Find Tool Win Project Files
Preview
Filter
Open in Find Tool Window
copper
Copper app/Services/Crm/Copper
Copper tests/Unit/Services/Crm/Copper
Copper build/coverage/Services/Crm/Copper
CopperException.php .../app/Services/Crm/Copper/Exception/CopperException.php, exception class
CopperInterface.php .../app/Contracts/Services/Crm/Provider/CopperInterface.php, interface
CopperDefaultStrategy.php .../app/.../Crm/Copper/ProspectSearchStrategy/CopperDefaultStrategy.php, class
CopperSingleSyncStrategy.php .../.../Copper/OpportunitySyncStrategy/CopperSingleSyncStrategy.php, class
CopperLastModifiedSyncStrategy.php .../CopperLastModifiedSyncStrategy.php, class
CopperLastModifiedSyncStrategyTest.php .../CopperLastModifiedSyncStrategyTest.php, class
CopperLastModifiedByProfileSyncStrategyTest.php .../tests/Unit/Services…ileSyncStrategyTest.php, class
CopperLastModifiedByProfileSyncStrategy.php .../CopperLastModifiedByProfileSyncStrategy.php, class
CopperException.php.html build/coverage/Services/Crm/Copper/Exception
CopperDefaultStrategy.php.html build/coverage/Services/Crm/Copper/ProspectSearchStrategy
CopperLastModifiedSyncStrategy.php.html build/coverage/.../Crm/Copper/OpportunitySyncStrategy
CopperSingleSyncStrategy.php.html build/coverage/Services/Crm/Copper/OpportunitySyncStrategy
… more
Copper app/Services/Crm/Copper
Copper tests/Unit/Services/Crm/Copper
Copper build/coverage/Services/Crm/Copper
CopperException.php .../app/Services/Crm/Copper/Exception/CopperException.php, exception class
CopperInterface.php .../app/Contracts/Services/Crm/Provider/CopperInterface.php, interface
CopperDefaultStrategy.php .../app/.../Crm/Copper/ProspectSearchStrategy/CopperDefaultStrategy.php, class
CopperSingleSyncStrategy.php .../.../Copper/OpportunitySyncStrategy/CopperSingleSyncStrategy.php, class
CopperLastModifiedSyncStrategy.php .../CopperLastModifiedSyncStrategy.php, class
CopperLastModifiedSyncStrategyTest.php .../CopperLastModifiedSyncStrategyTest.php, class
CopperLastModifiedByProfileSyncStrategyTest.php .../tests/Unit/Services…ileSyncStrategyTest.php, class
CopperLastModifiedByProfileSyncStrategy.php .../CopperLastModifiedByProfileSyncStrategy.php, class
CopperException.php.html build/coverage/Services/Crm/Copper/Exception
CopperDefaultStrategy.php.html build/coverage/Services/Crm/Copper/ProspectSearchStrategy
CopperLastModifiedSyncStrategy.php.html build/coverage/.../Crm/Copper/OpportunitySyncStrategy
CopperSingleSyncStrategy.php.html build/coverage/Services/Crm/Copper/OpportunitySyncStrategy
… more
Services/Crm/Copper
Open In Right Split...
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project Files","depth":2,"bounds":{"left":0.8298611,"top":0.25,"width":0.07430556,"height":0.026666667},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Preview","depth":2,"bounds":{"left":0.90416664,"top":0.25,"width":0.018055556,"height":0.026666667},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Filter","depth":2,"bounds":{"left":0.9222222,"top":0.25,"width":0.018055556,"height":0.026666667},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Open in Find Tool Window","depth":2,"bounds":{"left":0.94027776,"top":0.25,"width":0.018055556,"height":0.026666667},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextField","text":"copper","depth":1,"bounds":{"left":0.50555557,"top":0.29222223,"width":0.45277777,"height":0.032222223},"on_screen":true,"value":"copper","role_description":"text field","is_enabled":true,"is_focused":true,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Copper app/Services/Crm/Copper","depth":2,"bounds":{"left":0.49930555,"top":0.33666667,"width":0.4652778,"height":0.024444444},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Copper tests/Unit/Services/Crm/Copper","depth":2,"bounds":{"left":0.49930555,"top":0.3611111,"width":0.4652778,"height":0.024444444},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Copper build/coverage/Services/Crm/Copper","depth":2,"bounds":{"left":0.49930555,"top":0.38555557,"width":0.4652778,"height":0.024444444},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"CopperException.php .../app/Services/Crm/Copper/Exception/CopperException.php, exception class","depth":2,"bounds":{"left":0.49930555,"top":0.41,"width":0.4652778,"height":0.024444444},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"CopperInterface.php .../app/Contracts/Services/Crm/Provider/CopperInterface.php, interface","depth":2,"bounds":{"left":0.49930555,"top":0.43444446,"width":0.4652778,"height":0.024444444},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"CopperDefaultStrategy.php .../app/.../Crm/Copper/ProspectSearchStrategy/CopperDefaultStrategy.php, class","depth":2,"bounds":{"left":0.49930555,"top":0.4588889,"width":0.4652778,"height":0.024444444},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"CopperSingleSyncStrategy.php .../.../Copper/OpportunitySyncStrategy/CopperSingleSyncStrategy.php, class","depth":2,"bounds":{"left":0.49930555,"top":0.48333332,"width":0.4652778,"height":0.024444444},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"CopperLastModifiedSyncStrategy.php .../CopperLastModifiedSyncStrategy.php, class","depth":2,"bounds":{"left":0.49930555,"top":0.50777775,"width":0.4652778,"height":0.024444444},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"CopperLastModifiedSyncStrategyTest.php .../CopperLastModifiedSyncStrategyTest.php, class","depth":2,"bounds":{"left":0.49930555,"top":0.5322222,"width":0.4652778,"height":0.024444444},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"CopperLastModifiedByProfileSyncStrategyTest.php .../tests/Unit/Services…ileSyncStrategyTest.php, class","depth":2,"bounds":{"left":0.49930555,"top":0.5566667,"width":0.4652778,"height":0.024444444},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"CopperLastModifiedByProfileSyncStrategy.php .../CopperLastModifiedByProfileSyncStrategy.php, class","depth":2,"bounds":{"left":0.49930555,"top":0.58111113,"width":0.4652778,"height":0.024444444},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"CopperException.php.html build/coverage/Services/Crm/Copper/Exception","depth":2,"bounds":{"left":0.49930555,"top":0.60555553,"width":0.4652778,"height":0.024444444},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"CopperDefaultStrategy.php.html build/coverage/Services/Crm/Copper/ProspectSearchStrategy","depth":2,"bounds":{"left":0.49930555,"top":0.63,"width":0.4652778,"height":0.024444444},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"CopperLastModifiedSyncStrategy.php.html build/coverage/.../Crm/Copper/OpportunitySyncStrategy","depth":2,"bounds":{"left":0.49930555,"top":0.65444446,"width":0.4652778,"height":0.024444444},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"CopperSingleSyncStrategy.php.html build/coverage/Services/Crm/Copper/OpportunitySyncStrategy","depth":2,"bounds":{"left":0.49930555,"top":0.6788889,"width":0.4652778,"height":0.024444444},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"… more","depth":2,"bounds":{"left":0.49930555,"top":0.7033333,"width":0.4652778,"height":0.024444444},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Copper app/Services/Crm/Copper","depth":4,"bounds":{"left":0.49930555,"top":0.33666667,"width":0.4652778,"height":0.024444444},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Copper tests/Unit/Services/Crm/Copper","depth":4,"bounds":{"left":0.49930555,"top":0.3611111,"width":0.4652778,"height":0.024444444},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Copper build/coverage/Services/Crm/Copper","depth":4,"bounds":{"left":0.49930555,"top":0.38555557,"width":0.4652778,"height":0.024444444},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"CopperException.php .../app/Services/Crm/Copper/Exception/CopperException.php, exception class","depth":4,"bounds":{"left":0.49930555,"top":0.41,"width":0.4652778,"height":0.024444444},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"CopperInterface.php .../app/Contracts/Services/Crm/Provider/CopperInterface.php, interface","depth":4,"bounds":{"left":0.49930555,"top":0.43444446,"width":0.4652778,"height":0.024444444},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"CopperDefaultStrategy.php .../app/.../Crm/Copper/ProspectSearchStrategy/CopperDefaultStrategy.php, class","depth":4,"bounds":{"left":0.49930555,"top":0.4588889,"width":0.4652778,"height":0.024444444},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"CopperSingleSyncStrategy.php .../.../Copper/OpportunitySyncStrategy/CopperSingleSyncStrategy.php, class","depth":4,"bounds":{"left":0.49930555,"top":0.48333332,"width":0.4652778,"height":0.024444444},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"CopperLastModifiedSyncStrategy.php .../CopperLastModifiedSyncStrategy.php, class","depth":4,"bounds":{"left":0.49930555,"top":0.50777775,"width":0.4652778,"height":0.024444444},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"CopperLastModifiedSyncStrategyTest.php .../CopperLastModifiedSyncStrategyTest.php, class","depth":4,"bounds":{"left":0.49930555,"top":0.5322222,"width":0.4652778,"height":0.024444444},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"CopperLastModifiedByProfileSyncStrategyTest.php .../tests/Unit/Services…ileSyncStrategyTest.php, class","depth":4,"bounds":{"left":0.49930555,"top":0.5566667,"width":0.4652778,"height":0.024444444},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"CopperLastModifiedByProfileSyncStrategy.php .../CopperLastModifiedByProfileSyncStrategy.php, class","depth":4,"bounds":{"left":0.49930555,"top":0.58111113,"width":0.4652778,"height":0.024444444},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"CopperException.php.html build/coverage/Services/Crm/Copper/Exception","depth":4,"bounds":{"left":0.49930555,"top":0.60555553,"width":0.4652778,"height":0.024444444},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"CopperDefaultStrategy.php.html build/coverage/Services/Crm/Copper/ProspectSearchStrategy","depth":4,"bounds":{"left":0.49930555,"top":0.63,"width":0.4652778,"height":0.024444444},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"CopperLastModifiedSyncStrategy.php.html build/coverage/.../Crm/Copper/OpportunitySyncStrategy","depth":4,"bounds":{"left":0.49930555,"top":0.65444446,"width":0.4652778,"height":0.024444444},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"CopperSingleSyncStrategy.php.html build/coverage/Services/Crm/Copper/OpportunitySyncStrategy","depth":4,"bounds":{"left":0.49930555,"top":0.6788889,"width":0.4652778,"height":0.024444444},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"… more","depth":4,"bounds":{"left":0.49930555,"top":0.7033333,"width":0.4652778,"height":0.024444444},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Services/Crm/Copper","depth":1,"bounds":{"left":0.51319444,"top":0.74777776,"width":0.08472222,"height":0.018888889},"on_screen":true,"help_text":"Services/Crm/Copper","role_description":"text"},{"role":"AXLink","text":"Open In Right Split","depth":1,"bounds":{"left":0.8673611,"top":0.74777776,"width":0.08055556,"height":0.018888889},"on_screen":true,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
-6657031074750451911
|
-8450518281227135081
|
visual_change
|
accessibility
|
NULL
|
Project Files
Preview
Filter
Open in Find Tool Win Project Files
Preview
Filter
Open in Find Tool Window
copper
Copper app/Services/Crm/Copper
Copper tests/Unit/Services/Crm/Copper
Copper build/coverage/Services/Crm/Copper
CopperException.php .../app/Services/Crm/Copper/Exception/CopperException.php, exception class
CopperInterface.php .../app/Contracts/Services/Crm/Provider/CopperInterface.php, interface
CopperDefaultStrategy.php .../app/.../Crm/Copper/ProspectSearchStrategy/CopperDefaultStrategy.php, class
CopperSingleSyncStrategy.php .../.../Copper/OpportunitySyncStrategy/CopperSingleSyncStrategy.php, class
CopperLastModifiedSyncStrategy.php .../CopperLastModifiedSyncStrategy.php, class
CopperLastModifiedSyncStrategyTest.php .../CopperLastModifiedSyncStrategyTest.php, class
CopperLastModifiedByProfileSyncStrategyTest.php .../tests/Unit/Services…ileSyncStrategyTest.php, class
CopperLastModifiedByProfileSyncStrategy.php .../CopperLastModifiedByProfileSyncStrategy.php, class
CopperException.php.html build/coverage/Services/Crm/Copper/Exception
CopperDefaultStrategy.php.html build/coverage/Services/Crm/Copper/ProspectSearchStrategy
CopperLastModifiedSyncStrategy.php.html build/coverage/.../Crm/Copper/OpportunitySyncStrategy
CopperSingleSyncStrategy.php.html build/coverage/Services/Crm/Copper/OpportunitySyncStrategy
… more
Copper app/Services/Crm/Copper
Copper tests/Unit/Services/Crm/Copper
Copper build/coverage/Services/Crm/Copper
CopperException.php .../app/Services/Crm/Copper/Exception/CopperException.php, exception class
CopperInterface.php .../app/Contracts/Services/Crm/Provider/CopperInterface.php, interface
CopperDefaultStrategy.php .../app/.../Crm/Copper/ProspectSearchStrategy/CopperDefaultStrategy.php, class
CopperSingleSyncStrategy.php .../.../Copper/OpportunitySyncStrategy/CopperSingleSyncStrategy.php, class
CopperLastModifiedSyncStrategy.php .../CopperLastModifiedSyncStrategy.php, class
CopperLastModifiedSyncStrategyTest.php .../CopperLastModifiedSyncStrategyTest.php, class
CopperLastModifiedByProfileSyncStrategyTest.php .../tests/Unit/Services…ileSyncStrategyTest.php, class
CopperLastModifiedByProfileSyncStrategy.php .../CopperLastModifiedByProfileSyncStrategy.php, class
CopperException.php.html build/coverage/Services/Crm/Copper/Exception
CopperDefaultStrategy.php.html build/coverage/Services/Crm/Copper/ProspectSearchStrategy
CopperLastModifiedSyncStrategy.php.html build/coverage/.../Crm/Copper/OpportunitySyncStrategy
CopperSingleSyncStrategy.php.html build/coverage/Services/Crm/Copper/OpportunitySyncStrategy
… more
Services/Crm/Copper
Open In Right Split...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
45346
|
1625
|
8
|
2026-05-14T14:32:19.681124+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-14/1778 /Users/lukas/.screenpipe/data/data/2026-05-14/1778769139681_m1.jpg...
|
PhpStorm
|
faVsco.js – Close/Service.php
|
1
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
PhpStormFileEditViewNavigateCodeLaravelRefactorRun PhpStormFileEditViewNavigateCodeLaravelRefactorRunToolsGitWindowHelpabl100% C8• Thu 14 May 17:32:19* :QFV faVsco.jsv* #12066 on JY-20725-handle-HS-search-rate-limit k vHandleHubspotRateLimitTestvProjectv+,© ReportController.php© AutomatedReportGenerated.phpTrackAutomatedReportGeneratedEvent.php© PlaybackController.php:© FieldDefinitions.phpUserAutomatedReportsController.phpPlanhatService.phpAutomatedReportResult.phpSendReportJob.php© FieldValueConverter.php• DeleteCrmEntityTrait.phpDeleteAccountJob.php© ImportActivityTypes.phpT WriteCrmTrait.php© DecorateActivity.php© Service.php© StandardFieldMetadata.phpSalesforce/Service.php© LogActivityTrait.phpPipedrive/Service.phpClose/Service.php XPlainTextDecorateActivity.phpv 0 Copper© ActivityPlaybookTrait.phpCrmHelperRepository.php© AccountController.phpT IntegrationAppTrait.php= .env.staging18› DJ Api› D ExceptionE.envDetachActivityObject.phpRematchActivityOnCrmObjectDetach.phpMatchActivityCrmData.php© Client.php> C OpportunitySyncStrategy> D ProspectSearchStrategy> CJ Traits521103© HubspotPaginationService.php© HandleHubspotRateLimit.phpclass Service extends BaseService implementsprivate function buildCallPayload(Activity $activity): ActivityA8 A39 ×5^© FieldDefinitions.php© FieldValueConverter.php1115© Service.php1116> D CrmObjects1117v D DecorateActivity1118© BaseDecorateActivity.php1119© PlainTextDecorateActivity.pl1120$this->logger->info('[Close CRM] Updating task', ['activity' => $activity->id,'crm_id' => Sactivity->crm_provider_id,'data' => $data,> D Dummy11211):v Helpers1122T ActivityPlaybookTrait.php1123$this->client->put( url: 'task/'• Sactivity->crm_provider_id, $data);© Arraylterator.php1124T ConnectionStateTrait.php1125© CrmHelperRepository.php1126© FilterJoinedParticipants.php1127* OpportunitySyncableFieldsT1128v D Hubspot1129• AccountSyncStrategy1130> OJ Actions1131• ContactSyncStrategy1132> ODTO1133> D Fields1134> 0J Journal1135223} else {=224// Just create an activity.225$data = [226'lead_id' => $activity->account_id ? $activity->account->crm_provider_id : null,=227'contact_id' => $activity->contact_id ? $activity->contact->crm_provider_id : null,228'created_by' => $this->profile->crm_provider_id,229'user_id' => $this->profile->crm_provider_id,U 230'direction' => $activity->getCrmType() === Activity::TYPE_SOFTPHONE ? 'outbound":'inbour 231'status' => $this->mapStatus(Sactivity->getStatus()),— 232'note' => $this->generateActivityDescription(Sactivity),233V'duration' => $this->mapStatus($activity->getStatus()) !== 'cancel' ? $activity->duration 234'phone' => $activity->to ? $activity->to->phone_number : null,III1ID Metadata1136v D OpportunitySyncStrategy1137› D Concerns1138$clActivity = $this->client->post( url:'activity/call', $data);A Uuhenatl actlAndifinNDur 1139Workspace associated with branch 'JY-20725-handle-HS-search-rate-limit' has been restored // Rollback // Configure... (today 16:17)=custom.log=laravel.logSF [jiminny@localhost]A HS_local [jiminny@localho:W4 console [QAI PROD] X4 console [PROD]A console (EU]DGo jiminny084143 ×4 л215•m_layout_id =2162,1661,66799,66— 217-218= 219id = 33;220=221id THEN •(ownhubspat":= 11512582;W Windsurf Teams1123:70UTF-8P. 4 spaces...
|
NULL
|
8057434347006973852
|
NULL
|
visual_change
|
ocr
|
NULL
|
PhpStormFileEditViewNavigateCodeLaravelRefactorRun PhpStormFileEditViewNavigateCodeLaravelRefactorRunToolsGitWindowHelpabl100% C8• Thu 14 May 17:32:19* :QFV faVsco.jsv* #12066 on JY-20725-handle-HS-search-rate-limit k vHandleHubspotRateLimitTestvProjectv+,© ReportController.php© AutomatedReportGenerated.phpTrackAutomatedReportGeneratedEvent.php© PlaybackController.php:© FieldDefinitions.phpUserAutomatedReportsController.phpPlanhatService.phpAutomatedReportResult.phpSendReportJob.php© FieldValueConverter.php• DeleteCrmEntityTrait.phpDeleteAccountJob.php© ImportActivityTypes.phpT WriteCrmTrait.php© DecorateActivity.php© Service.php© StandardFieldMetadata.phpSalesforce/Service.php© LogActivityTrait.phpPipedrive/Service.phpClose/Service.php XPlainTextDecorateActivity.phpv 0 Copper© ActivityPlaybookTrait.phpCrmHelperRepository.php© AccountController.phpT IntegrationAppTrait.php= .env.staging18› DJ Api› D ExceptionE.envDetachActivityObject.phpRematchActivityOnCrmObjectDetach.phpMatchActivityCrmData.php© Client.php> C OpportunitySyncStrategy> D ProspectSearchStrategy> CJ Traits521103© HubspotPaginationService.php© HandleHubspotRateLimit.phpclass Service extends BaseService implementsprivate function buildCallPayload(Activity $activity): ActivityA8 A39 ×5^© FieldDefinitions.php© FieldValueConverter.php1115© Service.php1116> D CrmObjects1117v D DecorateActivity1118© BaseDecorateActivity.php1119© PlainTextDecorateActivity.pl1120$this->logger->info('[Close CRM] Updating task', ['activity' => $activity->id,'crm_id' => Sactivity->crm_provider_id,'data' => $data,> D Dummy11211):v Helpers1122T ActivityPlaybookTrait.php1123$this->client->put( url: 'task/'• Sactivity->crm_provider_id, $data);© Arraylterator.php1124T ConnectionStateTrait.php1125© CrmHelperRepository.php1126© FilterJoinedParticipants.php1127* OpportunitySyncableFieldsT1128v D Hubspot1129• AccountSyncStrategy1130> OJ Actions1131• ContactSyncStrategy1132> ODTO1133> D Fields1134> 0J Journal1135223} else {=224// Just create an activity.225$data = [226'lead_id' => $activity->account_id ? $activity->account->crm_provider_id : null,=227'contact_id' => $activity->contact_id ? $activity->contact->crm_provider_id : null,228'created_by' => $this->profile->crm_provider_id,229'user_id' => $this->profile->crm_provider_id,U 230'direction' => $activity->getCrmType() === Activity::TYPE_SOFTPHONE ? 'outbound":'inbour 231'status' => $this->mapStatus(Sactivity->getStatus()),— 232'note' => $this->generateActivityDescription(Sactivity),233V'duration' => $this->mapStatus($activity->getStatus()) !== 'cancel' ? $activity->duration 234'phone' => $activity->to ? $activity->to->phone_number : null,III1ID Metadata1136v D OpportunitySyncStrategy1137› D Concerns1138$clActivity = $this->client->post( url:'activity/call', $data);A Uuhenatl actlAndifinNDur 1139Workspace associated with branch 'JY-20725-handle-HS-search-rate-limit' has been restored // Rollback // Configure... (today 16:17)=custom.log=laravel.logSF [jiminny@localhost]A HS_local [jiminny@localho:W4 console [QAI PROD] X4 console [PROD]A console (EU]DGo jiminny084143 ×4 л215•m_layout_id =2162,1661,66799,66— 217-218= 219id = 33;220=221id THEN •(ownhubspat":= 11512582;W Windsurf Teams1123:70UTF-8P. 4 spaces...
|
45345
|
NULL
|
NULL
|
NULL
|
|
45348
|
1625
|
9
|
2026-05-14T14:32:21.857625+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-14/1778 /Users/lukas/.screenpipe/data/data/2026-05-14/1778769141857_m1.jpg...
|
PhpStorm
|
faVsco.js – Copper/Service.php
|
1
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
PhpStormFileEditViewNavigateCodeLaravelRefactorRun PhpStormFileEditViewNavigateCodeLaravelRefactorRunToolsGitWindowHelp•FV faVsco.jsv* #12066 on JY-20725-handle-HS-search-rate-limit k vabl19HandleHubspotRateLimitTestv100% C8• Thu 14 May 17:32:21* :QProject v+,.х:=AutomatedReportGenerated.phpTrackAutomatedReportGeneratedEvent.phpPlaybackController.php© FieldDefinitions.phpUserAutomatedReportsController.phpPlanhatService.phpAutomatedReportResult.phpSendReportJob.php© FieldValueConverter.php• DeleteCrmEntityTrait.phpDeleteAccountJob.php© ImportActivityTypes.phpT WriteCrmTrait.php© Service.php© DecorateActivity.php© StandardFieldMetadata.php© Salesforce/Service.php© LogActivityTrait.phpPipedrive/Service.phpClose/Service.phpService.php Xv D Copper© PlainTextDecorateActivity.phpT ActivityPlaybookTrait.php© CrmHelperRepository.phpAccountController.php18> D Api> D ExceptionT IntegrationAppTrait.php.env.stagingE.env© DetachActivityObject.phpRematchActivityOnCrmObjectDetach.php> C OpportunitySyncStrategy> D ProspectSearchStrategy© MatchActivityCrmData.php<?phpClient.php© HubspotPaginationService.php© HandleHubspotRateLimit.php1> D Traits© FieldDefinitions.php3namespace Jiminny (Services \Crm\Copper;© FieldValueConverter.php© Service.phpuse> D CrmObjectsv DecorateActivity51© BaseDecorateActivity.php© PlainTextDecorateActivity.pl> D Dummyv HelpersT ActivityPlaybookTrait.php© Arraylterator.php• ConnectionStateTrait.php© CrmHelperRepository.php© FilterJoinedParticipants.php* OpportunitySyncableFieldsTv D Hubspot• AccountSyncStrategy525354555657585960616263class Service extends BaseService implementsCopperInterface,SyncCrmEntitiesInterface,SyncCrmMetadataInterface,RemoteEntityManipulationInterface,LayoutManagementInterface,SettingsInterface,MatchCrmEntitiesInterface,RemoteEntityLookupInterface,VerifyTaskExistsInterfaceuse SearchesCrmEntities;> OJ Actions• ContactSyncStrategy> ODTO> D Fields6465private const int NOTE_BODY_MAX_LENGTH = 3000000;> • JournalD Metadata6667private const int TASK_VERIFICATION_CACHE_TTL = 86400; // 1 dayv D OpportunitySyncStrategy> D Concerns68pnivate const annay STANDARD_OPPORTUNITY_FTELDS =[A Lihonatl actlAndifioNDurWorkspace associated with branch 'JY-20725-handle-HS-search-rate-limit' has been restored // Rollback // Configure... (today 16:17)=custom.log=laravel.logSF [jiminny@localhost]A HS_local [jiminny@localho:4 console [QAI PROD] X4 console [PROD]A console (EU]D V234Go jiminny08 4143 ×4 ^•m_layout_id =2,1661,66799,66id = 33;id THEN •(ownhubspot':-= 11512582;W Windsurf Teams52:7UTF-8Ca 4 spaces...
|
NULL
|
-8418106964736389457
|
NULL
|
click
|
ocr
|
NULL
|
PhpStormFileEditViewNavigateCodeLaravelRefactorRun PhpStormFileEditViewNavigateCodeLaravelRefactorRunToolsGitWindowHelp•FV faVsco.jsv* #12066 on JY-20725-handle-HS-search-rate-limit k vabl19HandleHubspotRateLimitTestv100% C8• Thu 14 May 17:32:21* :QProject v+,.х:=AutomatedReportGenerated.phpTrackAutomatedReportGeneratedEvent.phpPlaybackController.php© FieldDefinitions.phpUserAutomatedReportsController.phpPlanhatService.phpAutomatedReportResult.phpSendReportJob.php© FieldValueConverter.php• DeleteCrmEntityTrait.phpDeleteAccountJob.php© ImportActivityTypes.phpT WriteCrmTrait.php© Service.php© DecorateActivity.php© StandardFieldMetadata.php© Salesforce/Service.php© LogActivityTrait.phpPipedrive/Service.phpClose/Service.phpService.php Xv D Copper© PlainTextDecorateActivity.phpT ActivityPlaybookTrait.php© CrmHelperRepository.phpAccountController.php18> D Api> D ExceptionT IntegrationAppTrait.php.env.stagingE.env© DetachActivityObject.phpRematchActivityOnCrmObjectDetach.php> C OpportunitySyncStrategy> D ProspectSearchStrategy© MatchActivityCrmData.php<?phpClient.php© HubspotPaginationService.php© HandleHubspotRateLimit.php1> D Traits© FieldDefinitions.php3namespace Jiminny (Services \Crm\Copper;© FieldValueConverter.php© Service.phpuse> D CrmObjectsv DecorateActivity51© BaseDecorateActivity.php© PlainTextDecorateActivity.pl> D Dummyv HelpersT ActivityPlaybookTrait.php© Arraylterator.php• ConnectionStateTrait.php© CrmHelperRepository.php© FilterJoinedParticipants.php* OpportunitySyncableFieldsTv D Hubspot• AccountSyncStrategy525354555657585960616263class Service extends BaseService implementsCopperInterface,SyncCrmEntitiesInterface,SyncCrmMetadataInterface,RemoteEntityManipulationInterface,LayoutManagementInterface,SettingsInterface,MatchCrmEntitiesInterface,RemoteEntityLookupInterface,VerifyTaskExistsInterfaceuse SearchesCrmEntities;> OJ Actions• ContactSyncStrategy> ODTO> D Fields6465private const int NOTE_BODY_MAX_LENGTH = 3000000;> • JournalD Metadata6667private const int TASK_VERIFICATION_CACHE_TTL = 86400; // 1 dayv D OpportunitySyncStrategy> D Concerns68pnivate const annay STANDARD_OPPORTUNITY_FTELDS =[A Lihonatl actlAndifioNDurWorkspace associated with branch 'JY-20725-handle-HS-search-rate-limit' has been restored // Rollback // Configure... (today 16:17)=custom.log=laravel.logSF [jiminny@localhost]A HS_local [jiminny@localho:4 console [QAI PROD] X4 console [PROD]A console (EU]D V234Go jiminny08 4143 ×4 ^•m_layout_id =2,1661,66799,66id = 33;id THEN •(ownhubspot':-= 11512582;W Windsurf Teams52:7UTF-8Ca 4 spaces...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
45347
|
1626
|
5
|
2026-05-14T14:32:22.121934+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-14/1778 /Users/lukas/.screenpipe/data/data/2026-05-14/1778769142121_m2.jpg...
|
PhpStorm
|
faVsco.js – Copper/Service.php
|
1
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
#12066 on JY-20725-handle Project: faVsco.js, menu
#12066 on JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
HandleHubspotRateLimitTest
Run 'HandleHubspotRateLimitTest'
Debug 'HandleHubspotRateLimitTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
Analyzing…
<?php
namespace Jiminny\Services\Crm\Copper;
use Carbon\Carbon;
use Exception;
use GuzzleHttp\Exception\ClientException;
use Illuminate\Support\Facades\Cache;
use Jiminny\Contracts\Services\Crm\LayoutManagementInterface;
use Jiminny\Contracts\Services\Crm\MatchCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\Provider\CopperInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityLookupInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityManipulationInterface;
use Jiminny\Contracts\Services\Crm\SettingsInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmMetadataInterface;
use Jiminny\Contracts\Services\Crm\VerifyTaskExistsInterface;
use Jiminny\Exceptions\CrmException;
use Jiminny\Exceptions\HttpNotFoundException;
use Jiminny\Models\Account;
use Jiminny\Models\Activity;
use Jiminny\Models\Contact;
use Jiminny\Models\Crm\BusinessProcess;
use Jiminny\Models\Crm\Configuration;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\FieldData;
use Jiminny\Models\Crm\FieldValue;
use Jiminny\Models\Crm\LayoutEntity;
use Jiminny\Models\Crm\Profile;
use Jiminny\Models\Lead;
use Jiminny\Models\Opportunity;
use Jiminny\Models\Playbook;
use Jiminny\Models\PlaybookCategory;
use Jiminny\Models\SocialAccount;
use Jiminny\Models\Stage;
use Jiminny\Models\User;
use Jiminny\Repositories\Crm\CrmEntityRepository;
use Jiminny\Repositories\Crm\FieldRepository;
use Jiminny\Repositories\Crm\ProfileRepository;
use Jiminny\Repositories\UserRepository;
use Jiminny\Services\Avatar\ProspectPhotoPathService;
use Jiminny\Services\Crm\BaseService;
use Jiminny\Services\Crm\Copper\Api\Client;
use Jiminny\Services\Crm\Copper\Api\ClientFactory;
use Jiminny\Services\Crm\Copper\Exception\RequestFailedException;
use Jiminny\Services\Crm\Helpers\FilterJoinedParticipants;
use Jiminny\Services\Crm\OpportunitySyncStrategyResolver;
use Jiminny\Services\Crm\Copper\Traits\SearchesCrmEntities;
use Jiminny\Utils\CurrencyFormatter;
use Jiminny\Utils\StringUtil;
class Service extends BaseService implements
CopperInterface,
SyncCrmEntitiesInterface,
SyncCrmMetadataInterface,
RemoteEntityManipulationInterface,
LayoutManagementInterface,
SettingsInterface,
MatchCrmEntitiesInterface,
RemoteEntityLookupInterface,
VerifyTaskExistsInterface
{
use SearchesCrmEntities;
private const int NOTE_BODY_MAX_LENGTH = 3000000;
private const int TASK_VERIFICATION_CACHE_TTL = 86400; // 1 day
private const array STANDARD_OPPORTUNITY_FIELDS = [
'monetary_value' => [
'label' => 'Value',
'available_on' => Field::OBJECT_OPPORTUNITY,
'type' => Field::TYPE_CURRENCY,
],
'pipeline_stage_id' => [
'label' => 'Stage',
'type' => Field::TYPE_PICKLIST,
'available_on' => Field::OBJECT_OPPORTUNITY,
],
'close_date' => [
'label' => 'Close date',
'type' => Field::TYPE_DATE,
'available_on' => Field::OBJECT_OPPORTUNITY,
],
'win_probability' => [
'label' => 'Win probability',
'type' => Field::TYPE_NUMBER,
'available_on' => Field::OBJECT_OPPORTUNITY,
],
];
private const string UNSUPPORTED_OBJECT_TYPE = 'unsupported';
private const string OPPORTUNITY_STATUS_OPEN = 'Open';
private const string OPPORTUNITY_IMPORT_FAILED_ERROR_MESSAGE = 'Opportunity import failed';
private const string RESOURCE_UPDATE_FAILED_ERROR_MESSAGE = 'Failed to update resource';
private FieldValueConverter $fieldValueConverter;
private ClientFactory $clientFactory;
private ?Client $copperClient = null;
public function __construct(
ClientFactory $clientFactory,
FieldValueConverter $fieldValueConverter,
private readonly UserRepository $userRepository,
private readonly ProspectPhotoPathService $prospectPhotoPathService,
private readonly CrmEntityRepository $crmEntityRepository
) {
parent::__construct();
$this->clientFactory = $clientFactory;
$this->fieldValueConverter = $fieldValueConverter;
}
public function getDisplayName(): string
{
return 'Copper';
}
/**
* Get the CRM entity repository (used by SearchesCrmEntities trait)
*/
protected function getCrmEntityRepository(): CrmEntityRepository
{
return $this->crmEntityRepository;
}
public function getMinimumApiVersion(): string
{
return Client::MIN_API_VERSION;
}
protected function getOAuthAccount(User $user): ?SocialAccount
{
// In this case, the Account Owner is always the connection for any API operations.
$owner = $user->team->owner;
return $owner->getSocialAccount(SocialAccount::PROVIDER_COPPER);
}
public function setUser(User $user): void
{
$this->configureSentryScope($user);
$this->team = $user->getTeam();
$this->teamService->setTeam($user->getTeam());
$this->setConfiguration($user->getTeam()->getCrmConfiguration());
// Reset state.
$this->profile = $user->getProfile();
if (! $this->profile instanceof Profile) {
// Try to fetch their profile, maybe it was not yet imported?
$this->profile = $this->getProfile($user);
}
}
public function setConfiguration(Configuration $configuration): void
{
$this->config = $configuration;
$this->copperClient = $this->clientFactory->create($configuration);
if ($this->team === null) {
$this->team = $configuration->getTeam();
}
}
public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string
{
return $this->fieldValueConverter->convertFromCrm($fieldType, $fieldValue);
}
private function mapObjectType(string $objectType): string
{
$objectType = strtolower($objectType);
switch ($objectType) {
case Field::OBJECT_TASK:
case Field::OBJECT_OPPORTUNITY:
case Field::OBJECT_LEAD:
$mappedType = $objectType;
break;
case Field::OBJECT_ACCOUNT:
$mappedType = 'company';
break;
case Field::OBJECT_CONTACT:
$mappedType = 'person';
break;
default:
$mappedType = self::UNSUPPORTED_OBJECT_TYPE;
break;
}
return $mappedType;
}
/**
* @param string $crmObject The name of the CRM object. i.e. Opportunity or Contact
*/
protected function getFields(string $crmObject): array
{
$mappedObjectType = $this->mapObjectType($crmObject);
if (! $this->isSupportedObjectType($mappedObjectType)) {
return [];
}
$customFields = $this->copperClient->fields()->all();
$filteredFields = array_filter(
$customFields,
static fn ($field) => in_array($mappedObjectType, $field['available_on'], true)
);
return array_map(
fn (array $property) => [
'name' => $property['id'],
'label' => $property['name'],
'type' => $this->convertFieldType($property['data_type']),
],
$filteredFields
);
}
/**
* @inheritdoc
*/
public function getDefaultFields(string $activityType): array
{
$fields = [];
if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {
$defaultFields = FieldDefinitions::defaultTaskFields();
// This lazy creates these fields if not already setup.
foreach ($defaultFields as $defaultField) {
$fields[] = $this->config->fields()->firstOrCreate($defaultField);
}
}
return $fields;
}
/**
* @inheritdoc
*/
public function getDefaultActivityField(string $activityType): Field
{
// Setup the activity field as the default Type.
$activityField = $this->config->fields()->where([
'crm_provider_id' => 'type',
'object_type' => $activityType,
])->first();
return $activityField;
}
/**
* @inheritdoc
*/
public function getSupportedPlaybookTypes(): array
{
return [Playbook::ACTIVITY_TYPE_TASK];
}
public function getDealInsightsFields(): array
{
return FieldDefinitions::dealInsightsFields();
}
protected function getDefaultFollowupLayoutFields(string $activityType): array
{
$fields = [];
$fieldRepo = app(FieldRepository::class);
$fieldFilters = FieldDefinitions::taskFollowupFieldsFilter();
foreach ($fieldFilters as $data) {
$field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $data);
// Only add the field if it is created, which it should be.
if ($field) {
$fields[] = $field;
}
}
return $fields;
}
public function syncFields(): void
{
$this->syncStandardFields();
$this->syncCustomFields();
}
public function syncField(Field $field): void
{
if ($field->getCrmProviderId() === 'type') {
/**
* This field is a special case, it is not a custom field and is not returned by the Copper API.
*
* The field is created manually during layout setup.
*
* @see getDefaultFields()
*
* There is a separate endpoint to get the activity types.
* @see importPicklistValues()
*/
return;
}
if ($field->getCrmProviderId() === 'pipeline_stage_id') {
// Not a custom type
return;
}
$crmField = $this->copperClient->fields()->find($field->getCrmProviderId());
$field->label = $crmField['name'];
$field->type = $this->convertFieldType($crmField['data_type']);
$field->save();
}
/**
* @param string $fieldType How the data should be input
*/
private function convertFieldType(string $fieldType): string
{
$fieldType = strtolower($fieldType);
switch ($fieldType) {
case 'dropdown':
$convertedType = Field::TYPE_PICKLIST;
break;
case 'string':
$convertedType = Field::TYPE_TEXT;
break;
case 'float':
case 'percentage':
$convertedType = Field::TYPE_NUMBER;
break;
case 'text':
$convertedType = Field::TYPE_TEXTAREA;
break;
case 'checkbox':
$convertedType = Field::TYPE_BOOLEAN;
break;
case Field::TYPE_DATE:
case Field::TYPE_CURRENCY:
case Field::TYPE_URL:
$convertedType = $fieldType;
break;
default:
$convertedType = Field::TYPE_UNSUPPORTED;
break;
}
return $convertedType;
}
/**
* @inheritdoc
*/
public function importPicklistValues(Field $field): array
{
$fieldValues = [];
if ($field->object_type === 'opportunity' && $field->crm_provider_id === 'pipeline_stage_id') {
return $this->importFieldValuesForDealInsights($field);
}
$values = [];
if ($field->object_type === Field::OBJECT_TASK && $field->crm_provider_id === 'type') {
$activityTypes = $this->copperClient->activityTypes()->all();
/**
* We ignore "system" activity types, disabled activity types and the "Note" activity type.
*/
$activityTypes = array_filter(
$activityTypes['user'],
fn (array $activityType) => ! $activityType['is_disabled'] && $activityType['id'] !== 0
);
// Import all activity values.
$i = 0;
foreach ($activityTypes as $activityType) {
$values[] = [
'value' => $activityType['id'],
'label' => mb_strimwidth($activityType['name'], 0, Field::LABEL_MAX_LENGTH),
'sequence' => $i++,
];
}
} else {
// We just check for the field in the custom definitions.
$crmField = $this->copperClient->fields()->find($field->crm_provider_id);
// Import all values.
foreach ($crmField['options'] as $option) {
$values[] = [
'value' => $option['id'],
'label' => mb_strimwidth($option['name'], 0, 191),
'sequence' => $option['rank'],
];
}
}
// Take the existing set of field values.
$fieldsToPurge = $field->values()->get()->pluck('value')->toArray();
foreach ($values as $value) {
// For all current values, upsert the field.
$value['value'] = substr($value['value'] ?? '', 0, 255);
$fieldValues[] = $field->values()->updateOrCreate([
'value' => $value['value'],
], $value);
// Remove this value from the ones we are going to purge.
$key = array_search($value['value'], $fieldsToPurge, false);
if ($key !== false) {
unset($fieldsToPurge[$key]);
}
}
// Delete the old values that are no longer used.
$field->values()->whereIn('value', $fieldsToPurge)->delete();
return $fieldValues;
}
/**
*
* @return FieldValue[]
*/
private function importFieldValuesForDealInsights(Field $field): array
{
$fieldValues = [];
$pipelines = $this->copperClient->pipelines()->all();
foreach ($pipelines as $pipeline) {
foreach ($pipeline['stages'] as $i => $dealStage) {
$fieldValues[] = $field->values()->updateOrCreate(
['value' => $dealStage['id']],
[
'label' => $dealStage['name'],
'sequence' => $i,
]
);
}
}
return $fieldValues;
}
/**
* @inheritdoc
*/
public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage
{
if ($types === null) {
$types = [Stage::TYPE_LEAD, Stage::TYPE_OPPORTUNITY];
}
$missingStage = null;
foreach ($types as $type) {
if ($type === Stage::TYPE_LEAD) {
$statuses = $this->requestWithExceptionHandling(
fn () => $this->copperClient->leadStatuses()->all()
);
if (count($statuses) > 0) {
$businessProcess = $this->config->businessProcesses()->updateOrCreate([
'crm_provider_id' => BusinessProcess::TYPE_LEAD,
], [
'team_id' => $this->team->id,
'name' => BusinessProcess::TYPE_LEAD,
'type' => BusinessProcess::TYPE_LEAD,
'is_selectable' => true,
]);
// A record type is really a clone of the business process, used to store which record uses which pipeline.
$this->config->recordTypes()->updateOrCreate([
'crm_provider_id' => BusinessProcess::TYPE_LEAD,
], [
'team_id' => $this->team->id,
'name' => BusinessProcess::TYPE_LEAD,
'is_selectable' => true,
'business_process_id' => $businessProcess->id ?? null,
]);
$leadStatuses = [];
foreach ($statuses as $status) {
// Upsert all statuses for the team.
$status = $this->config->stages()->updateOrCreate([
'crm_provider_id' => $status['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($status['name'], 0, 50),
'label' => mb_strimwidth($status['name'], 0, 191),
'type' => Stage::TYPE_LEAD,
'sequence' => $status['order'],
'is_selectable' => true,
]);
if ($missingStageName && $missingStageName === $status['id']) {
$missingStage = $status;
}
$leadStatuses[] = $status['id'];
}
$businessProcess->stages()->sync($leadStatuses);
}
} else {
// We create a business process to contain the pipeline, and later store all stages against it.
foreach ($this->copperClient->pipelines()->all() as $pipeline) {
$businessProcess = $this->config->businessProcesses()->updateOrCreate([
'crm_provider_id' => $pipeline['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($pipeline['name'], 0, 150),
'type' => BusinessProcess::TYPE_OPPORTUNITY,
'is_selectable' => true,
]);
// A record type is really a clone of the business process, used to store which record uses which pipeline.
$this->config->recordTypes()->updateOrCreate([
'crm_provider_id' => $pipeline['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($pipeline['name'], 0, 150),
'is_selectable' => true,
'business_process_id' => $businessProcess->id ?? null,
]);
$stages = [];
foreach ($pipeline['stages'] as $i => $dealStage) {
// Upsert all stages for the team.
$stage = $this->config->stages()->updateOrCreate([
'crm_provider_id' => $dealStage['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($dealStage['name'], 0, 50),
'label' => mb_strimwidth($dealStage['name'], 0, 191),
'type' => Stage::TYPE_OPPORTUNITY,
'sequence' => $i,
'is_selectable' => true,
'probability' => $dealStage['win_probability'],
]);
if ($missingStageName && $missingStageName === $dealStage['id']) {
$missingStage = $stage;
}
$stages[] = $stage['id'];
}
$businessProcess->stages()->sync($stages);
}
}
}
return $missingStage;
}
/**
* @inheritdoc
*/
public function syncLeads(Carbon $since, ?Carbon $to = null, ?string $crmProfileId = null): int
{
$syncCount = 0;
$cpLeads = $this->requestWithExceptionHandling(
fn () => $this->copperClient->leads()->paginate([
'minimum_modified_date' => $since->timestamp,
'sort_by' => 'date_modified',
])
);
foreach ($cpLeads as $cpLead) {
// Only sync if previously imported.
if ($this->hasLead($cpLead['id']) === false) {
$this->importLead($cpLead);
$syncCount++;
}
}
return $syncCount;
}
/**
* @inheritdoc
*/
public function syncLead(string $crmId): ?Lead
{
$cpLead = $this->requestWithExceptionHandling(
fn () => $this->copperClient->leads()->find($crmId)
);
if (\count($cpLead) === 0) {
return null;
}
return $this->importLead($cpLead);
}
/**
* @inheritdoc
*/
private function importLead($crmData): ?Lead
{
$countryCode = null;
if (! empty($crmData['address']) && isset($crmData['address']['country'])) {
$countryCode = $this->convertCountryNameToCode($crmData['address']['country']);
}
$mobilePhone = $parsedNumber = null;
if (! empty($crmData['phone_numbers'])) {
foreach ($crmData['phone_numbers'] as $phoneNumber) {
if ($phoneNumber['category'] === 'mobile') {
$mobilePhone = $this->buildContactMobilePhone($countryCode, $phoneNumber['number']);
} else {
$parsedNumber = $this->buildContactPhone($countryCode, $phoneNumber['number']);
}
}
}
$name = $crmData['name'];
if (empty($crmData['company_name'])) {
$company = 'Unknown';
} else {
$company = mb_strimwidth($crmData['company_name'], 0, 191);
}
$domain = null;
if (empty($crmData['websites']) === false) {
$domain = mb_strimwidth($crmData['websites'][0]['url'], 0, 191);
$domain = StringUtil::resolveDomain($domain);
}
$email = null;
if (empty($crmData['email']) === false) {
$email = mb_strimwidth($crmData['email']['email'], 0, 80);
}
$stage = $this->config
->stages()
->where('crm_provider_id', (string) $crmData['status_id'])
->where('type', Stage::TYPE_LEAD)
->first();
if ($stage === null) {
// Import it.
$stage = $this->importStages([Stage::TYPE_LEAD], $crmData['status_id']);
}
if (! $stage instanceof Stage) {
return null;
}
$profile = $this->config->profiles()->where('crm_provider_id', (string) $crmData['assignee_id'])->first();
$data = [
'team_id' => $this->team->id,
'user_id' => $profile ? $profile->user_id : null,
'owner_id' => $crmData['assignee_id'],
'name' => mb_strimwidth($name, 0, 191),
'company' => $company,
'domain' => $domain,
'title' => $crmData['title'] ? mb_strimwidth($crmData['title'], 0, 128) : null,
'email' => $email,
'phone' => $parsedNumber['phone'] ?? null,
'ext' => $parsedNumber['ext'] ?? null,
'mobile_phone' => $mobilePhone,
'stage_id' => $stage->id,
'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(
crmConfiguration: $this->config,
crmProviderId: $crmData['id'],
modelType: Lead::class,
fileName: $crmData['id'],
avatarText: $crmData['name']
),
'country_code' => $countryCode,
'remotely_created_at' => $crmData['date_created'],
];
return $this->config->leads()->updateOrCreate(['crm_provider_id' => (string) $crmData['id']], $data);
}
/**
* @inheritdoc
*/
public function syncAccounts(Carbon $since, ?Carbon $to = null): int
{
$syncCount = 0;
try {
$cpAccounts = $this->copperClient->accounts()->paginate([
'minimum_modified_date' => $since->timestamp,
'sort_by' => 'date_modified',
]);
foreach ($cpAccounts as $cpAccount) {
// Only sync if previously imported.
if ($this->hasAccount($cpAccount['id'])) {
$this->importAccount($cpAccount);
$syncCount++;
}
}
} catch (\Exception $exception) {
// Do nothing for now.
}
return $syncCount;
}
/**
* @inheritdoc
*/
public function syncAccount(string $crmId): ?Account
{
$cpAccount = $this->copperClient->accounts()->find($crmId);
if (empty($cpAccount)) {
return null;
}
return $this->importAccount($cpAccount);
}
/**
* @inheritdoc
*/
private function importAccount($crmData): Account
{
$countryCode = null;
if (empty($crmData['address']) === false && $crmData['address']['country']) {
$countryCode = $this->convertCountryNameToCode($crmData['address']['country']);
}
$name = $crmData['name'];
$parsedNumber = null;
foreach ($crmData['phone_numbers'] as $phoneNumber) {
$parsedNumber = parsePhoneNumber($countryCode, $phoneNumber['number']);
}
$domain = null;
if (empty($crmData['websites']) === false) {
$domain = mb_strimwidth($crmData['websites'][0]['url'], 0, 191);
$domain = StringUtil::resolveDomain($domain);
}
$profile = $this->config->profiles()->where('crm_provider_id', (string) $crmData['assignee_id'])->first();
$data = [
'team_id' => $this->team->id,
'user_id' => $profile ? $profile->user_id : null,
'owner_id' => $crmData['assignee_id'],
'name' => mb_strimwidth($name, 0, 191),
'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(
crmConfiguration: $this->config,
crmProviderId: $crmData['id'],
modelType: Account::class,
fileName: $crmData['id'],
avatarText: $crmData['name']
),
'country_code' => $countryCode,
'domain' => $domain,
'phone' => $parsedNumber['phone'] ?? null,
'ext' => $parsedNumber['ext'] ?? null,
'remotely_created_at' => $crmData['date_created'],
];
return $this->config->accounts()->updateOrCreate(['crm_provider_id' => (string) $crmData['id']], $data);
}
public function syncOpportunities(array $parameters, ?string $strategy = null): int
{
$strategyResolver = app(OpportunitySyncStrategyResolver::class, [
'client' => $this->copperClient,
]);
$strategies = $strategyResolver->getStrategies($this->config, $strategy);
$syncCount = 0;
try {
foreach ($strategies as $syncStrategy) {
foreach ($syncStrategy->fetchOpportunities($parameters) as $opportunityData) {
$this->importOpportunity($opportunityData);
$syncCount++;
}
}
} catch (\Exception $exception) {
// Do nothing for now.
$this->logger->error(self::OPPORTUNITY_IMPORT_FAILED_ERROR_MESSAGE, [
'error' => $exception->getMessage(),
]);
}
return $syncCount;
}
public function syncOpportunity(string $crmId): ?Opportunity
{
$strategyResolver = app(OpportunitySyncStrategyResolver::class, [
'client' => $this->copperClient,
]);
$strategy = $strategyResolver->resolve(
$this->config,
OpportunitySyncStrategyResolver::SINGLE_SYNC_OPPORTUNITY_STRATEGY
);
$parameters['crm_id'] = $crmId;
$cpOpportunity = $strategy->fetchOpportunities($parameters);
return $this->importOpportunity($cpOpportunity);
}
private function importOpportunity($crmData): ?Opportunity
{
$companyId = (string) $crmData['company_id'];
if (empty($companyId)) {
$this->logger->error(self::OPPORTUNITY_IMPORT_FAILED_ERROR_MESSAGE, [
'error' => 'No company id',
]);
return null;
}
$account = $this->config->accounts()->where('crm_provider_id', $companyId)->first();
if ($account === null) {
$account = $this->syncAccount($companyId);
}
$profile = $this->config->profiles()->where('crm_provider_id', (string) $crmData['assignee_id'])->first();
$businessProcess = $this->config
->businessProcesses()
->where('crm_provider_id', (string) $crmData['pipeline_id'])
->first();
if ($businessProcess === null) {
// It is possible for some deals to not be in a pipeline.
$this->logger->warning(self::OPPORTUNITY_IMPORT_FAILED_ERROR_MESSAGE, [
'error' => 'Missing pipeline',
]);
return null;
}
$stage = $businessProcess
->stages()
->where('crm_provider_id', (string) $crmData['pipeline_stage_id'])
->where('type', Stage::TYPE_OPPORTUNITY)
->first();
if ($stage === null) {
$this->logger->info('Stage not found, importing', [
'pipeline_id' => $crmData['pipeline_id'],
'stage_id' => $crmData['pipeline_stage_id'],
]);
$stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $crmData['pipeline_stage_id']);
}
$recordType = $businessProcess->recordTypes()->first();
$closeDate = null;
$isClosed = $crmData['status'] !== self::OPPORTUNITY_STATUS_OPEN;
if ($crmData['close_date']) {
$closeDate = Carbon::parse($crmData['close_date'])->format('Y-m-d');
}
$data = [
'team_id' => $this->team->getId(),
'account_id' => $account->id,
'user_id' => $profile->user_id ?? null,
'owner_id' => $crmData['assignee_id'],
'name' => mb_strimwidth($crmData['name'], 0, 128),
'value' => $crmData['monetary_value'],
'currency_code' => CurrencyFormatter::formatCode($crmData['monetary_unit']),
'close_date' => $closeDate,
'is_closed' => $isClosed,
'is_won' => $crmData['status'] === 'Won',
'stage_id' => $stage->id,
'record_type_id' => $recordType->id ?? null,
'remotely_created_at' => $crmData['date_created'],
];
/** @var Opportunity $opportunity */
$opportunity = $this->config
->opportunities()
->updateOrCreate(['crm_provider_id' => (string) $crmData['id']], $data)
;
// import external fields into crm_field_data if present
$dealInsightsFields = $this->getOpportunitySyncableFields();
$this->importOpportunityCrmFieldData($crmData, $dealInsightsFields, $opportunity->id);
return $opportunity;
}
/**
* @param array<string, mixed> $opportunityData
* @param string[] $dealInsightsFields
*/
public function importOpportunityCrmFieldData(
array $opportunityData,
array $dealInsightsFields,
int $opportunityId
): void {
foreach ($dealInsightsFields as $fieldName) {
$field = $this->config->fields()->with('entities')
->where('crm_provider_id', $fieldName)
->where('object_type', Field::OBJECT_OPPORTUNITY)
->first();
if (! $field instanceof Field) {
$this->logger->warning('Field not found', [
'field' => $fieldName,
'opportunity_id' => $opportunityId,
]);
continue;
}
$entity = $field->getEntities()->first();
if (! $entity instanceof LayoutEntity) {
continue;
}
$fieldValue = $this->extractFieldValue($opportunityData, $fieldName);
$normalizedValue = $this->normalizeValue($field->getType(), (string) $fieldValue, true);
$field->data()
->updateOrCreate([
'crm_layout_entity_id' => $entity->id,
'crm_field_id' => $field->id,
'object_type' => 'opportunity',
'object_id' => $opportunityId,
], ['value' => $normalizedValue])
;
}
}
/**
* @inheritdoc
*/
public function syncContacts(Carbon $since, ?Carbon $to = null): int
{
$syncCount = 0;
try {
$cpContacts = $this->copperClient->contacts()->paginate([
'minimum_interaction_date' => $since->timestamp,
'sort_by' => 'date_modified',
]);
foreach ($cpContacts as $cpContact) {
// Only sync if previously imported.
if ($this->hasContact($cpContact['id'])) {
$this->importContact($cpContact);
$syncCount++;
}
}
} catch (\Exception $exception) {
// Do nothing for now.
throw $exception;
}
return $syncCount;
}
/**
* @inheritdoc
*/
public function syncContact(string $crmId): ?Contact
{
$cpContact = $this->copperClient->contacts()->find($crmId);
if (empty($cpContact)) {
return null;
}
return $this->importContact($cpContact);
}
/**
* @inheritdoc
*/
private function importContact($crmData): Contact
{
$account = null;
if ($crmData['company_id']) {
$account = $this->team
->crm
->accounts()
->where('crm_provider_id', (string) $crmData['company_id'])
->first();
if ($account === null) {
$account = $this->syncAccount($crmData['company_id']);
}
}
$countryCode = null;
if (empty($crmData['address']) === false && $crmData['address']['country']) {
$countryCode = $this->convertCountryNameToCode($crmData['address']['country']);
}
$mobilePhone = $parsedNumber = null;
foreach ($crmData['phone_numbers'] as $phoneNumber) {
if ($phoneNumber['category'] === 'mobile') {
$mobilePhone = $this->buildContactMobilePhone($countryCode, $phoneNumber['number']);
} else {
$parsedNumber = $this->buildContactPhone($countryCode, $phoneNumber['number']);
}
}
$email = null;
if (empty($crmData['emails']) === false) {
$email = mb_strimwidth($crmData['emails'][0]['email'], 0, 80);
}
$profile = $this->config->profiles()->where('crm_provider_id', (string) $crmData['assignee_id'])->first();
$data = [
'team_id' => $this->team->id,
'account_id' => $account->id ?? null,
'user_id' => $profile ? $profile->user_id : null,
'owner_id' => $crmData['assignee_id'],
'name' => $crmData['name'],
'email' => $email,
'phone' => $parsedNumber['phone'] ?? null,
'mobile_phone' => $mobilePhone ?? null,
'ext' => $parsedNumber['ext'] ?? null,
'title' => $crmData['title'],
'country_code' => $countryCode,
'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(
crmConfiguration: $this->config,
crmProviderId: $crmData['id'],
modelType: Contact::class,
fileName: $crmData['id'],
avatarText: $crmData['name']
),
'remotely_created_at' => $crmData['date_created'],
];
return $this->config->contacts()->updateOrCreate(['crm_provider_id' => (string) $crmData['id']], $data);
}
private function buildContactPhone(?string $countryCode, ?string $number): ?array
{
if ($number) {
// Trim to our width and attempt to parse it.
$number = mb_strimwidth($number, 0, 25);
$parsedNumber = parsePhoneNumber($countryCode, $number);
} else {
$parsedNumber = [];
}
return $parsedNumber;
}
private function buildContactMobilePhone(?string $countryCode, ?string $number): ?string
{
return $number ? mb_strimwidth(phone_e164($countryCode, $number), 0, 25) : null;
}
/**
* @inheritdoc
*/
public function syncOrganization(): void
{
$organization = $this->copperClient->organisation()->all();
if ($this->config->crm_provider_id !== $organization['id']) {
$this->config->update(['crm_provider_id' => $organization['id']]);
}
}
private function syncCustomFields(): void
{
foreach ($this->getFieldTypes() as $fieldType) {
$currentFields = $this->getFields($fieldType);
foreach ($currentFields as $field) {
/** @var Field $field */
$field = $this->config->fields()->updateOrCreate([
'crm_provider_id' => $field['name'],
'object_type' => $fieldType,
], [
'label' => mb_strimwidth($field['label'], 0, Field::LABEL_MAX_LENGTH),
'type' => $field['type'],
]);
if ($field->getType() === Field::TYPE_PICKLIST) {
$this->importPicklistValues($field);
}
}
}
$this->config->save();
}
protected function getFieldTypes(): array
{
return [
parent::OBJECT_TASK,
parent::OBJECT_LEAD,
parent::OBJECT_ACCOUNT,
parent::OBJECT_CONTACT,
parent::OBJECT_OPPORTUNITY,
];
}
/**
* @inheritdoc
*/
public function syncProfiles(?User $userToSearch = null): ?Profile
{
$profiles = $this->copperClient->users()->all();
foreach ($profiles as $profile) {
$user = $this->userRepository->findByTeamAndEmailAddress($this->team, $profile['email'], onlyActive: true);
// Only import profiles for users we have locally.
if ($user) {
$profile = $user->crmProfile()->updateOrCreate([
'crm_configuration_id' => $this->config->id,
'crm_provider_id' => $profile['id'],
]);
if ($userToSearch && $userToSearch->id === $user->id) {
return $profile;
}
}
}
return null;
}
/**
* @inheritdoc
*/
public function syncProfileFields(): void
{
$fieldTypes = [
'task',
'lead',
'account',
'contact',
'opportunity',
];
foreach ($fieldTypes as $fieldType) {
try {
$currentFields = $this->getFields(ucfirst($fieldType));
$newFields = array_column($currentFields, 'name');
$changedFields = array_merge(
array_diff($this->profile->getFieldsAsArray($fieldType), $newFields),
array_diff($newFields, $this->profile->getFieldsAsArray($fieldType))
);
if (\count($changedFields) > 0) {
$fields = implode(',', $newFields);
$this->logger->debug(
ucfirst($fieldType)
. ' fields changed from '
. $this->profile->{$fieldType . '_fields'}
. ' to: '
. $fields
);
$this->profile->{$fieldType . '_fields'} = $fields;
}
} catch (\Exception $exception) {
$this->logger->error('No access to ' . $fieldType . ' object, skipping...');
continue;
}
}
$this->profile->save();
}
/**
* @inheritdoc
*/
public function find(string $name, array $scopes): array
{
$count = $this->limit ?? 20;
$offset = $this->offset ?? 0;
$scopeValues = array_map(fn ($scope) => is_string($scope) ? $scope : $scope->value, $scopes);
$cacheKey = sprintf('copper-search-%d-%s-%s-%d-%d', $this->team->id, $name, implode('-', $scopeValues), $count, $offset);
return Cache::remember($cacheKey, 300, function () use ($name, $scopes, $count, $offset) {
// Try local search first
$localResults = $this->searchLocally($name, $scopes, $count, $offset);
// If no local results, fall back to remote API search
if (empty($localResults)) {
return $this->searchRemotely($name, $scopes, $count, $offset);
}
return $localResults;
});
}
/**
* @inheritdoc
*/
public function findOpportunities(?string $crmAccountId, ?string $crmContactId, ?int $userId = null): array
{
$data = [];
$ownerData = [];
$ownerId = null;
$contact = null;
$account = null;
if ($crmAccountId) {
$account = $this->config->accounts()->where('crm_provider_id', $crmAccountId)->first();
if ($account === null) {
$account = $this->syncAccount($crmAccountId);
}
}
if ($crmContactId) {
$contact = $this->config->contacts()->where('crm_provider_id', $crmContactId)->first();
if ($contact === null) {
$contact = $this->syncContact($crmContactId);
}
}
if ($contact || $account) {
if ($contact && $account === null) {
$account = $contact->account;
}
if ($account === null) {
return [];
}
$params = [
'company_ids' => [$account->crm_provider_id],
'status_ids' => [0], // The integer values are 0, 1, 2, 3, for "Open", "Won", "Lost", and "Abandoned".
'sort_by' => 'date_modified',
'sort_direction' => 'desc',
];
switch ($this->config->opportunity_assignment_rule) {
case Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_UPDATED:
$params['sort_by'] = 'date_modified';
$params['sort_direction'] = 'desc';
break;
case Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_CREATED:
$params['sort_by'] = 'date_created';
$params['sort_direction'] = 'desc';
break;
case Configuration::OPP_ASSIGNMENT_ALL_OPEN_OLDEST_CREATED:
$params['sort_by'] = 'date_created';
$params['sort_direction'] = 'asc';
break;
case Configuration::OPP_ASSIGNMENT_ALL_RECENTLY_UPDATED:
$params['sort_by'] = 'date_modified';
$params['sort_direction'] = 'desc';
$params['status_ids'] = [0, 1, 2, 3];
}
$cpOpportunities = $this->copperClient->opportunities()->search($params);
if ($userId) {
$profileRepository = app(ProfileRepository::class);
$profile = $profileRepository->findProfileByUserId($this->config, $userId);
$ownerId = $profile instanceof Profile ? $profile->getCrmProviderId() : null;
}
foreach ($cpOpportunities as $cpOpportunity) {
$businessProcess = $this->config
->businessProcesses()
->where('crm_provider_id', (string) $cpOpportunity['pipeline_id'])
->first();
if ($businessProcess === null) {
// Import it.
$businessProcess = $this->importStages(
[Stage::TYPE_OPPORTUNITY],
$cpOpportunity['pipeline_stage_id']
);
}
$stage = $businessProcess
->stages()
->where('crm_provider_id', (string) $cpOpportunity['pipeline_stage_id'])
->where('type', Stage::TYPE_OPPORTUNITY)
->first();
if ($stage === null) {
// Import it.
$stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $cpOpportunity['pipeline_stage_id']);
}
$recordType = $businessProcess->recordTypes()->first();
$record = [
'crmId' => $cpOpportunity['id'],
'name' => $cpOpportunity['name'],
'value' => currency_format($cpOpportunity['monetary_value'], $cpOpportunity['monetary_unit']),
'won' => $cpOpportunity['status'] === 'Won',
'stage' => [
'id' => $stage->id_string,
'name' => $stage->name,
],
'recordType' => [
'id' => $recordType->id_string,
'name' => $recordType->name,
],
];
if ($ownerId && isset($cpOpportunity['assignee_id']) && $cpOpportunity['assignee_id'] === $ownerId) {
$ownerData[] = $record;
}
$data[] = $record;
}
}
if (! empty($ownerData)) {
return $ownerData;
}
return $data;
}
/**
* @inheritdoc
*/
public function getTasks(string $objectType, string $objectId, ?string $opportunityId): array
{
$data = $cpTasks = [];
switch ($objectType) {
case 'lead':
$cpTasks = $this->requestWithExceptionHandling(
fn () => $this->copperClient->leads()->relatedTasks($objectId)
);
break;
case 'account':
$cpTasks = $this->copperClient->accounts()->relatedTasks($objectId);
break;
case 'contact':
if ($opportunityId) {
$cpTasks = $this->copperClient->opportunities()->relatedTasks($opportunityId);
} elseif ($objectType === 'contact') {
$cpTasks = $this->copperClient->contacts()->relatedTasks($objectId);
}
break;
}
foreach ($cpTasks as $cpTask) {
// We only get the ID itself returned, so pull out the full object.
try {
$cpTask = $this->copperClient->tasks()->find($cpTask['id']);
} catch (Exception $exception) {
$this->logger->error('[Copper] Error fetching task', [
'resource_id' => $cpTask['id'],
'error' => $exception->getMessage(),
]);
continue;
}
// Only allow open tasks.
if ($cpTask['status'] === 'Open') {
$data[] = [
'crmId' => $cpTask['id'],
'subject' => $cpTask['name'],
'due' => $cpTask['due_date']
? Carbon::createFromTimestamp($cpTask['due_date'])->format('Y-m-d\TH:i:sO')
: null,
'type' => null,
];
}
}
return $data;
}
/**
* Try to find email address in CRM service
*
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
*}
*/
public function matchExactlyByEmail(string $email, ?int $userId = null): ?array
{
$cpObjectType = 'contact';
$cpObjects = $this->copperClient->contacts()->search([
'emails' => $email,
'sort_by' => 'date_modified',
'sort_direction' => 'desc',
]);
if (empty($cpObjects)) {
$cpObjectType = 'lead';
$cpObjects = $this->requestWithExceptionHandling(
fn () => $this->copperClient->leads()->search([
'emails' => $email,
'sort_by' => 'date_...
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"bounds":{"left":0.2962101,"top":1.0,"width":0.03856383,"height":-0.019952059},"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"#12066 on JY-20725-handle-HS-search-rate-limit, menu","depth":5,"bounds":{"left":0.33477393,"top":1.0,"width":0.122340426,"height":-0.019952059},"on_screen":true,"help_text":"Pull request #12066 exists for current branch JY-20725-handle-HS-search-rate-limit, but local branch is out of sync with remote","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"bounds":{"left":0.5731383,"top":1.0,"width":0.011303191,"height":-0.019952059},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"HandleHubspotRateLimitTest","depth":6,"bounds":{"left":0.5884308,"top":1.0,"width":0.076130316,"height":-0.019952059},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'HandleHubspotRateLimitTest'","depth":6,"bounds":{"left":0.66456115,"top":1.0,"width":0.011303191,"height":-0.019952059},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'HandleHubspotRateLimitTest'","depth":6,"bounds":{"left":0.67586434,"top":1.0,"width":0.011303191,"height":-0.019952059},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"bounds":{"left":0.6871675,"top":1.0,"width":0.011303191,"height":-0.019952059},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"bounds":{"left":0.7150931,"top":1.0,"width":0.011303191,"height":-0.019952059},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"bounds":{"left":0.72639626,"top":1.0,"width":0.011303191,"height":-0.019952059},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"IDE and Project Settings","depth":5,"bounds":{"left":0.73769945,"top":1.0,"width":0.011303191,"height":-0.019952059},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.042220745,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Analyzing…","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXTextArea","text":"<?php\n\nnamespace Jiminny\\Services\\Crm\\Copper;\n\nuse Carbon\\Carbon;\nuse Exception;\nuse GuzzleHttp\\Exception\\ClientException;\nuse Illuminate\\Support\\Facades\\Cache;\nuse Jiminny\\Contracts\\Services\\Crm\\LayoutManagementInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\MatchCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\Provider\\CopperInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityLookupInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityManipulationInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SettingsInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmMetadataInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\VerifyTaskExistsInterface;\nuse Jiminny\\Exceptions\\CrmException;\nuse Jiminny\\Exceptions\\HttpNotFoundException;\nuse Jiminny\\Models\\Account;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Contact;\nuse Jiminny\\Models\\Crm\\BusinessProcess;\nuse Jiminny\\Models\\Crm\\Configuration;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Models\\Crm\\FieldData;\nuse Jiminny\\Models\\Crm\\FieldValue;\nuse Jiminny\\Models\\Crm\\LayoutEntity;\nuse Jiminny\\Models\\Crm\\Profile;\nuse Jiminny\\Models\\Lead;\nuse Jiminny\\Models\\Opportunity;\nuse Jiminny\\Models\\Playbook;\nuse Jiminny\\Models\\PlaybookCategory;\nuse Jiminny\\Models\\SocialAccount;\nuse Jiminny\\Models\\Stage;\nuse Jiminny\\Models\\User;\nuse Jiminny\\Repositories\\Crm\\CrmEntityRepository;\nuse Jiminny\\Repositories\\Crm\\FieldRepository;\nuse Jiminny\\Repositories\\Crm\\ProfileRepository;\nuse Jiminny\\Repositories\\UserRepository;\nuse Jiminny\\Services\\Avatar\\ProspectPhotoPathService;\nuse Jiminny\\Services\\Crm\\BaseService;\nuse Jiminny\\Services\\Crm\\Copper\\Api\\Client;\nuse Jiminny\\Services\\Crm\\Copper\\Api\\ClientFactory;\nuse Jiminny\\Services\\Crm\\Copper\\Exception\\RequestFailedException;\nuse Jiminny\\Services\\Crm\\Helpers\\FilterJoinedParticipants;\nuse Jiminny\\Services\\Crm\\OpportunitySyncStrategyResolver;\nuse Jiminny\\Services\\Crm\\Copper\\Traits\\SearchesCrmEntities;\nuse Jiminny\\Utils\\CurrencyFormatter;\nuse Jiminny\\Utils\\StringUtil;\n\nclass Service extends BaseService implements\n CopperInterface,\n SyncCrmEntitiesInterface,\n SyncCrmMetadataInterface,\n RemoteEntityManipulationInterface,\n LayoutManagementInterface,\n SettingsInterface,\n MatchCrmEntitiesInterface,\n RemoteEntityLookupInterface,\n VerifyTaskExistsInterface\n{\n use SearchesCrmEntities;\n private const int NOTE_BODY_MAX_LENGTH = 3000000;\n\n private const int TASK_VERIFICATION_CACHE_TTL = 86400; // 1 day\n\n private const array STANDARD_OPPORTUNITY_FIELDS = [\n 'monetary_value' => [\n 'label' => 'Value',\n 'available_on' => Field::OBJECT_OPPORTUNITY,\n 'type' => Field::TYPE_CURRENCY,\n ],\n 'pipeline_stage_id' => [\n 'label' => 'Stage',\n 'type' => Field::TYPE_PICKLIST,\n 'available_on' => Field::OBJECT_OPPORTUNITY,\n ],\n 'close_date' => [\n 'label' => 'Close date',\n 'type' => Field::TYPE_DATE,\n 'available_on' => Field::OBJECT_OPPORTUNITY,\n ],\n 'win_probability' => [\n 'label' => 'Win probability',\n 'type' => Field::TYPE_NUMBER,\n 'available_on' => Field::OBJECT_OPPORTUNITY,\n ],\n ];\n private const string UNSUPPORTED_OBJECT_TYPE = 'unsupported';\n private const string OPPORTUNITY_STATUS_OPEN = 'Open';\n private const string OPPORTUNITY_IMPORT_FAILED_ERROR_MESSAGE = 'Opportunity import failed';\n private const string RESOURCE_UPDATE_FAILED_ERROR_MESSAGE = 'Failed to update resource';\n\n private FieldValueConverter $fieldValueConverter;\n private ClientFactory $clientFactory;\n private ?Client $copperClient = null;\n\n public function __construct(\n ClientFactory $clientFactory,\n FieldValueConverter $fieldValueConverter,\n private readonly UserRepository $userRepository,\n private readonly ProspectPhotoPathService $prospectPhotoPathService,\n private readonly CrmEntityRepository $crmEntityRepository\n ) {\n parent::__construct();\n\n $this->clientFactory = $clientFactory;\n $this->fieldValueConverter = $fieldValueConverter;\n }\n\n public function getDisplayName(): string\n {\n return 'Copper';\n }\n\n /**\n * Get the CRM entity repository (used by SearchesCrmEntities trait)\n */\n protected function getCrmEntityRepository(): CrmEntityRepository\n {\n return $this->crmEntityRepository;\n }\n\n public function getMinimumApiVersion(): string\n {\n return Client::MIN_API_VERSION;\n }\n\n protected function getOAuthAccount(User $user): ?SocialAccount\n {\n // In this case, the Account Owner is always the connection for any API operations.\n $owner = $user->team->owner;\n\n return $owner->getSocialAccount(SocialAccount::PROVIDER_COPPER);\n }\n\n public function setUser(User $user): void\n {\n $this->configureSentryScope($user);\n\n $this->team = $user->getTeam();\n $this->teamService->setTeam($user->getTeam());\n $this->setConfiguration($user->getTeam()->getCrmConfiguration());\n\n // Reset state.\n $this->profile = $user->getProfile();\n\n if (! $this->profile instanceof Profile) {\n // Try to fetch their profile, maybe it was not yet imported?\n $this->profile = $this->getProfile($user);\n }\n }\n\n public function setConfiguration(Configuration $configuration): void\n {\n $this->config = $configuration;\n $this->copperClient = $this->clientFactory->create($configuration);\n\n if ($this->team === null) {\n $this->team = $configuration->getTeam();\n }\n }\n\n public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string\n {\n return $this->fieldValueConverter->convertFromCrm($fieldType, $fieldValue);\n }\n\n private function mapObjectType(string $objectType): string\n {\n $objectType = strtolower($objectType);\n\n switch ($objectType) {\n case Field::OBJECT_TASK:\n case Field::OBJECT_OPPORTUNITY:\n case Field::OBJECT_LEAD:\n $mappedType = $objectType;\n\n break;\n case Field::OBJECT_ACCOUNT:\n $mappedType = 'company';\n\n break;\n case Field::OBJECT_CONTACT:\n $mappedType = 'person';\n\n break;\n default:\n $mappedType = self::UNSUPPORTED_OBJECT_TYPE;\n\n break;\n }\n\n return $mappedType;\n }\n\n /**\n * @param string $crmObject The name of the CRM object. i.e. Opportunity or Contact\n */\n protected function getFields(string $crmObject): array\n {\n $mappedObjectType = $this->mapObjectType($crmObject);\n\n if (! $this->isSupportedObjectType($mappedObjectType)) {\n return [];\n }\n\n $customFields = $this->copperClient->fields()->all();\n\n $filteredFields = array_filter(\n $customFields,\n static fn ($field) => in_array($mappedObjectType, $field['available_on'], true)\n );\n\n return array_map(\n fn (array $property) => [\n 'name' => $property['id'],\n 'label' => $property['name'],\n 'type' => $this->convertFieldType($property['data_type']),\n ],\n $filteredFields\n );\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultFields(string $activityType): array\n {\n $fields = [];\n\n if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {\n $defaultFields = FieldDefinitions::defaultTaskFields();\n\n // This lazy creates these fields if not already setup.\n foreach ($defaultFields as $defaultField) {\n $fields[] = $this->config->fields()->firstOrCreate($defaultField);\n }\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultActivityField(string $activityType): Field\n {\n // Setup the activity field as the default Type.\n $activityField = $this->config->fields()->where([\n 'crm_provider_id' => 'type',\n 'object_type' => $activityType,\n ])->first();\n\n return $activityField;\n }\n\n /**\n * @inheritdoc\n */\n public function getSupportedPlaybookTypes(): array\n {\n return [Playbook::ACTIVITY_TYPE_TASK];\n }\n\n public function getDealInsightsFields(): array\n {\n return FieldDefinitions::dealInsightsFields();\n }\n\n protected function getDefaultFollowupLayoutFields(string $activityType): array\n {\n $fields = [];\n $fieldRepo = app(FieldRepository::class);\n $fieldFilters = FieldDefinitions::taskFollowupFieldsFilter();\n\n foreach ($fieldFilters as $data) {\n $field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $data);\n\n // Only add the field if it is created, which it should be.\n if ($field) {\n $fields[] = $field;\n }\n }\n\n return $fields;\n }\n\n public function syncFields(): void\n {\n $this->syncStandardFields();\n $this->syncCustomFields();\n }\n\n public function syncField(Field $field): void\n {\n if ($field->getCrmProviderId() === 'type') {\n /**\n * This field is a special case, it is not a custom field and is not returned by the Copper API.\n *\n * The field is created manually during layout setup.\n *\n * @see getDefaultFields()\n *\n * There is a separate endpoint to get the activity types.\n * @see importPicklistValues()\n */\n return;\n }\n\n if ($field->getCrmProviderId() === 'pipeline_stage_id') {\n // Not a custom type\n return;\n }\n\n $crmField = $this->copperClient->fields()->find($field->getCrmProviderId());\n $field->label = $crmField['name'];\n $field->type = $this->convertFieldType($crmField['data_type']);\n\n $field->save();\n }\n\n /**\n * @param string $fieldType How the data should be input\n */\n private function convertFieldType(string $fieldType): string\n {\n $fieldType = strtolower($fieldType);\n\n switch ($fieldType) {\n case 'dropdown':\n $convertedType = Field::TYPE_PICKLIST;\n\n break;\n case 'string':\n $convertedType = Field::TYPE_TEXT;\n\n break;\n case 'float':\n case 'percentage':\n $convertedType = Field::TYPE_NUMBER;\n\n break;\n case 'text':\n $convertedType = Field::TYPE_TEXTAREA;\n\n break;\n case 'checkbox':\n $convertedType = Field::TYPE_BOOLEAN;\n\n break;\n case Field::TYPE_DATE:\n case Field::TYPE_CURRENCY:\n case Field::TYPE_URL:\n $convertedType = $fieldType;\n\n break;\n default:\n $convertedType = Field::TYPE_UNSUPPORTED;\n\n break;\n }\n\n return $convertedType;\n }\n\n /**\n * @inheritdoc\n */\n public function importPicklistValues(Field $field): array\n {\n $fieldValues = [];\n\n if ($field->object_type === 'opportunity' && $field->crm_provider_id === 'pipeline_stage_id') {\n return $this->importFieldValuesForDealInsights($field);\n }\n\n $values = [];\n\n if ($field->object_type === Field::OBJECT_TASK && $field->crm_provider_id === 'type') {\n $activityTypes = $this->copperClient->activityTypes()->all();\n\n /**\n * We ignore \"system\" activity types, disabled activity types and the \"Note\" activity type.\n */\n $activityTypes = array_filter(\n $activityTypes['user'],\n fn (array $activityType) => ! $activityType['is_disabled'] && $activityType['id'] !== 0\n );\n\n // Import all activity values.\n $i = 0;\n foreach ($activityTypes as $activityType) {\n $values[] = [\n 'value' => $activityType['id'],\n 'label' => mb_strimwidth($activityType['name'], 0, Field::LABEL_MAX_LENGTH),\n 'sequence' => $i++,\n ];\n }\n } else {\n // We just check for the field in the custom definitions.\n $crmField = $this->copperClient->fields()->find($field->crm_provider_id);\n\n // Import all values.\n foreach ($crmField['options'] as $option) {\n $values[] = [\n 'value' => $option['id'],\n 'label' => mb_strimwidth($option['name'], 0, 191),\n 'sequence' => $option['rank'],\n ];\n }\n }\n\n // Take the existing set of field values.\n $fieldsToPurge = $field->values()->get()->pluck('value')->toArray();\n\n foreach ($values as $value) {\n // For all current values, upsert the field.\n $value['value'] = substr($value['value'] ?? '', 0, 255);\n $fieldValues[] = $field->values()->updateOrCreate([\n 'value' => $value['value'],\n ], $value);\n\n // Remove this value from the ones we are going to purge.\n $key = array_search($value['value'], $fieldsToPurge, false);\n if ($key !== false) {\n unset($fieldsToPurge[$key]);\n }\n }\n\n // Delete the old values that are no longer used.\n $field->values()->whereIn('value', $fieldsToPurge)->delete();\n\n return $fieldValues;\n }\n\n /**\n *\n * @return FieldValue[]\n */\n private function importFieldValuesForDealInsights(Field $field): array\n {\n $fieldValues = [];\n $pipelines = $this->copperClient->pipelines()->all();\n\n foreach ($pipelines as $pipeline) {\n foreach ($pipeline['stages'] as $i => $dealStage) {\n $fieldValues[] = $field->values()->updateOrCreate(\n ['value' => $dealStage['id']],\n [\n 'label' => $dealStage['name'],\n 'sequence' => $i,\n ]\n );\n }\n }\n\n return $fieldValues;\n }\n\n /**\n * @inheritdoc\n */\n public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage\n {\n if ($types === null) {\n $types = [Stage::TYPE_LEAD, Stage::TYPE_OPPORTUNITY];\n }\n $missingStage = null;\n\n foreach ($types as $type) {\n if ($type === Stage::TYPE_LEAD) {\n $statuses = $this->requestWithExceptionHandling(\n fn () => $this->copperClient->leadStatuses()->all()\n );\n\n if (count($statuses) > 0) {\n $businessProcess = $this->config->businessProcesses()->updateOrCreate([\n 'crm_provider_id' => BusinessProcess::TYPE_LEAD,\n ], [\n 'team_id' => $this->team->id,\n 'name' => BusinessProcess::TYPE_LEAD,\n 'type' => BusinessProcess::TYPE_LEAD,\n 'is_selectable' => true,\n ]);\n\n // A record type is really a clone of the business process, used to store which record uses which pipeline.\n $this->config->recordTypes()->updateOrCreate([\n 'crm_provider_id' => BusinessProcess::TYPE_LEAD,\n ], [\n 'team_id' => $this->team->id,\n 'name' => BusinessProcess::TYPE_LEAD,\n 'is_selectable' => true,\n 'business_process_id' => $businessProcess->id ?? null,\n ]);\n\n $leadStatuses = [];\n foreach ($statuses as $status) {\n // Upsert all statuses for the team.\n $status = $this->config->stages()->updateOrCreate([\n 'crm_provider_id' => $status['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($status['name'], 0, 50),\n 'label' => mb_strimwidth($status['name'], 0, 191),\n 'type' => Stage::TYPE_LEAD,\n 'sequence' => $status['order'],\n 'is_selectable' => true,\n ]);\n\n if ($missingStageName && $missingStageName === $status['id']) {\n $missingStage = $status;\n }\n\n $leadStatuses[] = $status['id'];\n }\n\n $businessProcess->stages()->sync($leadStatuses);\n }\n } else {\n // We create a business process to contain the pipeline, and later store all stages against it.\n foreach ($this->copperClient->pipelines()->all() as $pipeline) {\n $businessProcess = $this->config->businessProcesses()->updateOrCreate([\n 'crm_provider_id' => $pipeline['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($pipeline['name'], 0, 150),\n 'type' => BusinessProcess::TYPE_OPPORTUNITY,\n 'is_selectable' => true,\n ]);\n\n // A record type is really a clone of the business process, used to store which record uses which pipeline.\n $this->config->recordTypes()->updateOrCreate([\n 'crm_provider_id' => $pipeline['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($pipeline['name'], 0, 150),\n 'is_selectable' => true,\n 'business_process_id' => $businessProcess->id ?? null,\n ]);\n\n $stages = [];\n\n foreach ($pipeline['stages'] as $i => $dealStage) {\n // Upsert all stages for the team.\n $stage = $this->config->stages()->updateOrCreate([\n 'crm_provider_id' => $dealStage['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($dealStage['name'], 0, 50),\n 'label' => mb_strimwidth($dealStage['name'], 0, 191),\n 'type' => Stage::TYPE_OPPORTUNITY,\n 'sequence' => $i,\n 'is_selectable' => true,\n 'probability' => $dealStage['win_probability'],\n ]);\n\n if ($missingStageName && $missingStageName === $dealStage['id']) {\n $missingStage = $stage;\n }\n\n $stages[] = $stage['id'];\n }\n\n $businessProcess->stages()->sync($stages);\n }\n }\n }\n\n return $missingStage;\n }\n\n /**\n * @inheritdoc\n */\n public function syncLeads(Carbon $since, ?Carbon $to = null, ?string $crmProfileId = null): int\n {\n $syncCount = 0;\n\n $cpLeads = $this->requestWithExceptionHandling(\n fn () => $this->copperClient->leads()->paginate([\n 'minimum_modified_date' => $since->timestamp,\n 'sort_by' => 'date_modified',\n ])\n );\n\n foreach ($cpLeads as $cpLead) {\n // Only sync if previously imported.\n if ($this->hasLead($cpLead['id']) === false) {\n $this->importLead($cpLead);\n $syncCount++;\n }\n }\n\n return $syncCount;\n }\n\n /**\n * @inheritdoc\n */\n public function syncLead(string $crmId): ?Lead\n {\n $cpLead = $this->requestWithExceptionHandling(\n fn () => $this->copperClient->leads()->find($crmId)\n );\n\n if (\\count($cpLead) === 0) {\n return null;\n }\n\n return $this->importLead($cpLead);\n }\n\n /**\n * @inheritdoc\n */\n private function importLead($crmData): ?Lead\n {\n $countryCode = null;\n if (! empty($crmData['address']) && isset($crmData['address']['country'])) {\n $countryCode = $this->convertCountryNameToCode($crmData['address']['country']);\n }\n\n $mobilePhone = $parsedNumber = null;\n if (! empty($crmData['phone_numbers'])) {\n foreach ($crmData['phone_numbers'] as $phoneNumber) {\n if ($phoneNumber['category'] === 'mobile') {\n $mobilePhone = $this->buildContactMobilePhone($countryCode, $phoneNumber['number']);\n } else {\n $parsedNumber = $this->buildContactPhone($countryCode, $phoneNumber['number']);\n }\n }\n }\n\n $name = $crmData['name'];\n\n if (empty($crmData['company_name'])) {\n $company = 'Unknown';\n } else {\n $company = mb_strimwidth($crmData['company_name'], 0, 191);\n }\n\n $domain = null;\n if (empty($crmData['websites']) === false) {\n $domain = mb_strimwidth($crmData['websites'][0]['url'], 0, 191);\n $domain = StringUtil::resolveDomain($domain);\n }\n\n $email = null;\n if (empty($crmData['email']) === false) {\n $email = mb_strimwidth($crmData['email']['email'], 0, 80);\n }\n\n $stage = $this->config\n ->stages()\n ->where('crm_provider_id', (string) $crmData['status_id'])\n ->where('type', Stage::TYPE_LEAD)\n ->first();\n\n if ($stage === null) {\n // Import it.\n $stage = $this->importStages([Stage::TYPE_LEAD], $crmData['status_id']);\n }\n\n if (! $stage instanceof Stage) {\n return null;\n }\n\n $profile = $this->config->profiles()->where('crm_provider_id', (string) $crmData['assignee_id'])->first();\n\n $data = [\n 'team_id' => $this->team->id,\n 'user_id' => $profile ? $profile->user_id : null,\n 'owner_id' => $crmData['assignee_id'],\n 'name' => mb_strimwidth($name, 0, 191),\n 'company' => $company,\n 'domain' => $domain,\n 'title' => $crmData['title'] ? mb_strimwidth($crmData['title'], 0, 128) : null,\n 'email' => $email,\n 'phone' => $parsedNumber['phone'] ?? null,\n 'ext' => $parsedNumber['ext'] ?? null,\n 'mobile_phone' => $mobilePhone,\n 'stage_id' => $stage->id,\n 'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(\n crmConfiguration: $this->config,\n crmProviderId: $crmData['id'],\n modelType: Lead::class,\n fileName: $crmData['id'],\n avatarText: $crmData['name']\n ),\n 'country_code' => $countryCode,\n 'remotely_created_at' => $crmData['date_created'],\n ];\n\n return $this->config->leads()->updateOrCreate(['crm_provider_id' => (string) $crmData['id']], $data);\n }\n\n /**\n * @inheritdoc\n */\n public function syncAccounts(Carbon $since, ?Carbon $to = null): int\n {\n $syncCount = 0;\n\n try {\n $cpAccounts = $this->copperClient->accounts()->paginate([\n 'minimum_modified_date' => $since->timestamp,\n 'sort_by' => 'date_modified',\n ]);\n\n foreach ($cpAccounts as $cpAccount) {\n // Only sync if previously imported.\n if ($this->hasAccount($cpAccount['id'])) {\n $this->importAccount($cpAccount);\n $syncCount++;\n }\n }\n } catch (\\Exception $exception) {\n // Do nothing for now.\n }\n\n return $syncCount;\n }\n\n /**\n * @inheritdoc\n */\n public function syncAccount(string $crmId): ?Account\n {\n $cpAccount = $this->copperClient->accounts()->find($crmId);\n\n if (empty($cpAccount)) {\n return null;\n }\n\n return $this->importAccount($cpAccount);\n }\n\n /**\n * @inheritdoc\n */\n private function importAccount($crmData): Account\n {\n $countryCode = null;\n if (empty($crmData['address']) === false && $crmData['address']['country']) {\n $countryCode = $this->convertCountryNameToCode($crmData['address']['country']);\n }\n\n $name = $crmData['name'];\n\n $parsedNumber = null;\n foreach ($crmData['phone_numbers'] as $phoneNumber) {\n $parsedNumber = parsePhoneNumber($countryCode, $phoneNumber['number']);\n }\n\n $domain = null;\n if (empty($crmData['websites']) === false) {\n $domain = mb_strimwidth($crmData['websites'][0]['url'], 0, 191);\n $domain = StringUtil::resolveDomain($domain);\n }\n\n $profile = $this->config->profiles()->where('crm_provider_id', (string) $crmData['assignee_id'])->first();\n\n $data = [\n 'team_id' => $this->team->id,\n 'user_id' => $profile ? $profile->user_id : null,\n 'owner_id' => $crmData['assignee_id'],\n 'name' => mb_strimwidth($name, 0, 191),\n 'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(\n crmConfiguration: $this->config,\n crmProviderId: $crmData['id'],\n modelType: Account::class,\n fileName: $crmData['id'],\n avatarText: $crmData['name']\n ),\n 'country_code' => $countryCode,\n 'domain' => $domain,\n 'phone' => $parsedNumber['phone'] ?? null,\n 'ext' => $parsedNumber['ext'] ?? null,\n 'remotely_created_at' => $crmData['date_created'],\n ];\n\n return $this->config->accounts()->updateOrCreate(['crm_provider_id' => (string) $crmData['id']], $data);\n }\n\n public function syncOpportunities(array $parameters, ?string $strategy = null): int\n {\n $strategyResolver = app(OpportunitySyncStrategyResolver::class, [\n 'client' => $this->copperClient,\n ]);\n $strategies = $strategyResolver->getStrategies($this->config, $strategy);\n\n $syncCount = 0;\n\n try {\n foreach ($strategies as $syncStrategy) {\n foreach ($syncStrategy->fetchOpportunities($parameters) as $opportunityData) {\n $this->importOpportunity($opportunityData);\n $syncCount++;\n }\n }\n } catch (\\Exception $exception) {\n // Do nothing for now.\n $this->logger->error(self::OPPORTUNITY_IMPORT_FAILED_ERROR_MESSAGE, [\n 'error' => $exception->getMessage(),\n ]);\n }\n\n return $syncCount;\n }\n\n public function syncOpportunity(string $crmId): ?Opportunity\n {\n $strategyResolver = app(OpportunitySyncStrategyResolver::class, [\n 'client' => $this->copperClient,\n ]);\n $strategy = $strategyResolver->resolve(\n $this->config,\n OpportunitySyncStrategyResolver::SINGLE_SYNC_OPPORTUNITY_STRATEGY\n );\n\n $parameters['crm_id'] = $crmId;\n\n $cpOpportunity = $strategy->fetchOpportunities($parameters);\n\n return $this->importOpportunity($cpOpportunity);\n }\n\n private function importOpportunity($crmData): ?Opportunity\n {\n $companyId = (string) $crmData['company_id'];\n\n if (empty($companyId)) {\n $this->logger->error(self::OPPORTUNITY_IMPORT_FAILED_ERROR_MESSAGE, [\n 'error' => 'No company id',\n ]);\n\n return null;\n }\n\n $account = $this->config->accounts()->where('crm_provider_id', $companyId)->first();\n\n if ($account === null) {\n $account = $this->syncAccount($companyId);\n }\n\n $profile = $this->config->profiles()->where('crm_provider_id', (string) $crmData['assignee_id'])->first();\n\n $businessProcess = $this->config\n ->businessProcesses()\n ->where('crm_provider_id', (string) $crmData['pipeline_id'])\n ->first();\n\n if ($businessProcess === null) {\n // It is possible for some deals to not be in a pipeline.\n $this->logger->warning(self::OPPORTUNITY_IMPORT_FAILED_ERROR_MESSAGE, [\n 'error' => 'Missing pipeline',\n ]);\n\n return null;\n }\n\n $stage = $businessProcess\n ->stages()\n ->where('crm_provider_id', (string) $crmData['pipeline_stage_id'])\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->first();\n\n if ($stage === null) {\n $this->logger->info('Stage not found, importing', [\n 'pipeline_id' => $crmData['pipeline_id'],\n 'stage_id' => $crmData['pipeline_stage_id'],\n ]);\n\n $stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $crmData['pipeline_stage_id']);\n }\n\n $recordType = $businessProcess->recordTypes()->first();\n $closeDate = null;\n $isClosed = $crmData['status'] !== self::OPPORTUNITY_STATUS_OPEN;\n\n if ($crmData['close_date']) {\n $closeDate = Carbon::parse($crmData['close_date'])->format('Y-m-d');\n }\n\n $data = [\n 'team_id' => $this->team->getId(),\n 'account_id' => $account->id,\n 'user_id' => $profile->user_id ?? null,\n 'owner_id' => $crmData['assignee_id'],\n 'name' => mb_strimwidth($crmData['name'], 0, 128),\n 'value' => $crmData['monetary_value'],\n 'currency_code' => CurrencyFormatter::formatCode($crmData['monetary_unit']),\n 'close_date' => $closeDate,\n 'is_closed' => $isClosed,\n 'is_won' => $crmData['status'] === 'Won',\n 'stage_id' => $stage->id,\n 'record_type_id' => $recordType->id ?? null,\n 'remotely_created_at' => $crmData['date_created'],\n ];\n\n /** @var Opportunity $opportunity */\n $opportunity = $this->config\n ->opportunities()\n ->updateOrCreate(['crm_provider_id' => (string) $crmData['id']], $data)\n ;\n\n // import external fields into crm_field_data if present\n $dealInsightsFields = $this->getOpportunitySyncableFields();\n $this->importOpportunityCrmFieldData($crmData, $dealInsightsFields, $opportunity->id);\n\n return $opportunity;\n }\n\n /**\n * @param array<string, mixed> $opportunityData\n * @param string[] $dealInsightsFields\n */\n public function importOpportunityCrmFieldData(\n array $opportunityData,\n array $dealInsightsFields,\n int $opportunityId\n ): void {\n foreach ($dealInsightsFields as $fieldName) {\n $field = $this->config->fields()->with('entities')\n ->where('crm_provider_id', $fieldName)\n ->where('object_type', Field::OBJECT_OPPORTUNITY)\n ->first();\n\n if (! $field instanceof Field) {\n $this->logger->warning('Field not found', [\n 'field' => $fieldName,\n 'opportunity_id' => $opportunityId,\n ]);\n\n continue;\n }\n\n $entity = $field->getEntities()->first();\n\n if (! $entity instanceof LayoutEntity) {\n continue;\n }\n\n $fieldValue = $this->extractFieldValue($opportunityData, $fieldName);\n $normalizedValue = $this->normalizeValue($field->getType(), (string) $fieldValue, true);\n\n $field->data()\n ->updateOrCreate([\n 'crm_layout_entity_id' => $entity->id,\n 'crm_field_id' => $field->id,\n 'object_type' => 'opportunity',\n 'object_id' => $opportunityId,\n ], ['value' => $normalizedValue])\n ;\n }\n }\n\n /**\n * @inheritdoc\n */\n public function syncContacts(Carbon $since, ?Carbon $to = null): int\n {\n $syncCount = 0;\n\n try {\n $cpContacts = $this->copperClient->contacts()->paginate([\n 'minimum_interaction_date' => $since->timestamp,\n 'sort_by' => 'date_modified',\n ]);\n\n foreach ($cpContacts as $cpContact) {\n // Only sync if previously imported.\n if ($this->hasContact($cpContact['id'])) {\n $this->importContact($cpContact);\n $syncCount++;\n }\n }\n } catch (\\Exception $exception) {\n // Do nothing for now.\n throw $exception;\n }\n\n return $syncCount;\n }\n\n /**\n * @inheritdoc\n */\n public function syncContact(string $crmId): ?Contact\n {\n $cpContact = $this->copperClient->contacts()->find($crmId);\n\n if (empty($cpContact)) {\n return null;\n }\n\n return $this->importContact($cpContact);\n }\n\n /**\n * @inheritdoc\n */\n private function importContact($crmData): Contact\n {\n $account = null;\n if ($crmData['company_id']) {\n $account = $this->team\n ->crm\n ->accounts()\n ->where('crm_provider_id', (string) $crmData['company_id'])\n ->first();\n\n if ($account === null) {\n $account = $this->syncAccount($crmData['company_id']);\n }\n }\n\n $countryCode = null;\n if (empty($crmData['address']) === false && $crmData['address']['country']) {\n $countryCode = $this->convertCountryNameToCode($crmData['address']['country']);\n }\n\n $mobilePhone = $parsedNumber = null;\n foreach ($crmData['phone_numbers'] as $phoneNumber) {\n if ($phoneNumber['category'] === 'mobile') {\n $mobilePhone = $this->buildContactMobilePhone($countryCode, $phoneNumber['number']);\n } else {\n $parsedNumber = $this->buildContactPhone($countryCode, $phoneNumber['number']);\n }\n }\n\n $email = null;\n if (empty($crmData['emails']) === false) {\n $email = mb_strimwidth($crmData['emails'][0]['email'], 0, 80);\n }\n\n $profile = $this->config->profiles()->where('crm_provider_id', (string) $crmData['assignee_id'])->first();\n\n $data = [\n 'team_id' => $this->team->id,\n 'account_id' => $account->id ?? null,\n 'user_id' => $profile ? $profile->user_id : null,\n 'owner_id' => $crmData['assignee_id'],\n 'name' => $crmData['name'],\n 'email' => $email,\n 'phone' => $parsedNumber['phone'] ?? null,\n 'mobile_phone' => $mobilePhone ?? null,\n 'ext' => $parsedNumber['ext'] ?? null,\n 'title' => $crmData['title'],\n 'country_code' => $countryCode,\n 'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(\n crmConfiguration: $this->config,\n crmProviderId: $crmData['id'],\n modelType: Contact::class,\n fileName: $crmData['id'],\n avatarText: $crmData['name']\n ),\n 'remotely_created_at' => $crmData['date_created'],\n ];\n\n return $this->config->contacts()->updateOrCreate(['crm_provider_id' => (string) $crmData['id']], $data);\n }\n\n private function buildContactPhone(?string $countryCode, ?string $number): ?array\n {\n if ($number) {\n // Trim to our width and attempt to parse it.\n $number = mb_strimwidth($number, 0, 25);\n $parsedNumber = parsePhoneNumber($countryCode, $number);\n } else {\n $parsedNumber = [];\n }\n\n return $parsedNumber;\n }\n\n private function buildContactMobilePhone(?string $countryCode, ?string $number): ?string\n {\n return $number ? mb_strimwidth(phone_e164($countryCode, $number), 0, 25) : null;\n }\n\n /**\n * @inheritdoc\n */\n public function syncOrganization(): void\n {\n $organization = $this->copperClient->organisation()->all();\n\n if ($this->config->crm_provider_id !== $organization['id']) {\n $this->config->update(['crm_provider_id' => $organization['id']]);\n }\n }\n\n private function syncCustomFields(): void\n {\n foreach ($this->getFieldTypes() as $fieldType) {\n $currentFields = $this->getFields($fieldType);\n\n foreach ($currentFields as $field) {\n /** @var Field $field */\n $field = $this->config->fields()->updateOrCreate([\n 'crm_provider_id' => $field['name'],\n 'object_type' => $fieldType,\n ], [\n 'label' => mb_strimwidth($field['label'], 0, Field::LABEL_MAX_LENGTH),\n 'type' => $field['type'],\n ]);\n\n if ($field->getType() === Field::TYPE_PICKLIST) {\n $this->importPicklistValues($field);\n }\n }\n }\n\n $this->config->save();\n }\n\n protected function getFieldTypes(): array\n {\n return [\n parent::OBJECT_TASK,\n parent::OBJECT_LEAD,\n parent::OBJECT_ACCOUNT,\n parent::OBJECT_CONTACT,\n parent::OBJECT_OPPORTUNITY,\n ];\n }\n\n /**\n * @inheritdoc\n */\n public function syncProfiles(?User $userToSearch = null): ?Profile\n {\n $profiles = $this->copperClient->users()->all();\n\n foreach ($profiles as $profile) {\n $user = $this->userRepository->findByTeamAndEmailAddress($this->team, $profile['email'], onlyActive: true);\n\n // Only import profiles for users we have locally.\n if ($user) {\n $profile = $user->crmProfile()->updateOrCreate([\n 'crm_configuration_id' => $this->config->id,\n 'crm_provider_id' => $profile['id'],\n ]);\n\n if ($userToSearch && $userToSearch->id === $user->id) {\n return $profile;\n }\n }\n }\n\n return null;\n }\n\n /**\n * @inheritdoc\n */\n public function syncProfileFields(): void\n {\n $fieldTypes = [\n 'task',\n 'lead',\n 'account',\n 'contact',\n 'opportunity',\n ];\n\n foreach ($fieldTypes as $fieldType) {\n try {\n $currentFields = $this->getFields(ucfirst($fieldType));\n\n $newFields = array_column($currentFields, 'name');\n $changedFields = array_merge(\n array_diff($this->profile->getFieldsAsArray($fieldType), $newFields),\n array_diff($newFields, $this->profile->getFieldsAsArray($fieldType))\n );\n\n if (\\count($changedFields) > 0) {\n $fields = implode(',', $newFields);\n $this->logger->debug(\n ucfirst($fieldType)\n . ' fields changed from '\n . $this->profile->{$fieldType . '_fields'}\n . ' to: '\n . $fields\n );\n\n $this->profile->{$fieldType . '_fields'} = $fields;\n }\n } catch (\\Exception $exception) {\n $this->logger->error('No access to ' . $fieldType . ' object, skipping...');\n\n continue;\n }\n }\n\n $this->profile->save();\n }\n\n /**\n * @inheritdoc\n */\n public function find(string $name, array $scopes): array\n {\n $count = $this->limit ?? 20;\n $offset = $this->offset ?? 0;\n\n $scopeValues = array_map(fn ($scope) => is_string($scope) ? $scope : $scope->value, $scopes);\n $cacheKey = sprintf('copper-search-%d-%s-%s-%d-%d', $this->team->id, $name, implode('-', $scopeValues), $count, $offset);\n\n return Cache::remember($cacheKey, 300, function () use ($name, $scopes, $count, $offset) {\n // Try local search first\n $localResults = $this->searchLocally($name, $scopes, $count, $offset);\n\n // If no local results, fall back to remote API search\n if (empty($localResults)) {\n return $this->searchRemotely($name, $scopes, $count, $offset);\n }\n\n return $localResults;\n });\n }\n\n /**\n * @inheritdoc\n */\n public function findOpportunities(?string $crmAccountId, ?string $crmContactId, ?int $userId = null): array\n {\n $data = [];\n $ownerData = [];\n $ownerId = null;\n $contact = null;\n $account = null;\n\n if ($crmAccountId) {\n $account = $this->config->accounts()->where('crm_provider_id', $crmAccountId)->first();\n\n if ($account === null) {\n $account = $this->syncAccount($crmAccountId);\n }\n }\n\n if ($crmContactId) {\n $contact = $this->config->contacts()->where('crm_provider_id', $crmContactId)->first();\n\n if ($contact === null) {\n $contact = $this->syncContact($crmContactId);\n }\n }\n\n if ($contact || $account) {\n if ($contact && $account === null) {\n $account = $contact->account;\n }\n\n if ($account === null) {\n return [];\n }\n\n $params = [\n 'company_ids' => [$account->crm_provider_id],\n 'status_ids' => [0], // The integer values are 0, 1, 2, 3, for \"Open\", \"Won\", \"Lost\", and \"Abandoned\".\n 'sort_by' => 'date_modified',\n 'sort_direction' => 'desc',\n ];\n\n switch ($this->config->opportunity_assignment_rule) {\n case Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_UPDATED:\n $params['sort_by'] = 'date_modified';\n $params['sort_direction'] = 'desc';\n\n break;\n\n case Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_CREATED:\n $params['sort_by'] = 'date_created';\n $params['sort_direction'] = 'desc';\n\n break;\n\n case Configuration::OPP_ASSIGNMENT_ALL_OPEN_OLDEST_CREATED:\n $params['sort_by'] = 'date_created';\n $params['sort_direction'] = 'asc';\n\n break;\n\n case Configuration::OPP_ASSIGNMENT_ALL_RECENTLY_UPDATED:\n $params['sort_by'] = 'date_modified';\n $params['sort_direction'] = 'desc';\n $params['status_ids'] = [0, 1, 2, 3];\n }\n\n $cpOpportunities = $this->copperClient->opportunities()->search($params);\n\n if ($userId) {\n $profileRepository = app(ProfileRepository::class);\n $profile = $profileRepository->findProfileByUserId($this->config, $userId);\n\n $ownerId = $profile instanceof Profile ? $profile->getCrmProviderId() : null;\n }\n\n foreach ($cpOpportunities as $cpOpportunity) {\n $businessProcess = $this->config\n ->businessProcesses()\n ->where('crm_provider_id', (string) $cpOpportunity['pipeline_id'])\n ->first();\n\n if ($businessProcess === null) {\n // Import it.\n $businessProcess = $this->importStages(\n [Stage::TYPE_OPPORTUNITY],\n $cpOpportunity['pipeline_stage_id']\n );\n }\n\n $stage = $businessProcess\n ->stages()\n ->where('crm_provider_id', (string) $cpOpportunity['pipeline_stage_id'])\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->first();\n\n if ($stage === null) {\n // Import it.\n $stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $cpOpportunity['pipeline_stage_id']);\n }\n\n $recordType = $businessProcess->recordTypes()->first();\n\n $record = [\n 'crmId' => $cpOpportunity['id'],\n 'name' => $cpOpportunity['name'],\n 'value' => currency_format($cpOpportunity['monetary_value'], $cpOpportunity['monetary_unit']),\n 'won' => $cpOpportunity['status'] === 'Won',\n 'stage' => [\n 'id' => $stage->id_string,\n 'name' => $stage->name,\n ],\n 'recordType' => [\n 'id' => $recordType->id_string,\n 'name' => $recordType->name,\n ],\n ];\n\n if ($ownerId && isset($cpOpportunity['assignee_id']) && $cpOpportunity['assignee_id'] === $ownerId) {\n $ownerData[] = $record;\n }\n\n $data[] = $record;\n }\n }\n\n if (! empty($ownerData)) {\n return $ownerData;\n }\n\n return $data;\n }\n\n /**\n * @inheritdoc\n */\n public function getTasks(string $objectType, string $objectId, ?string $opportunityId): array\n {\n $data = $cpTasks = [];\n\n switch ($objectType) {\n case 'lead':\n $cpTasks = $this->requestWithExceptionHandling(\n fn () => $this->copperClient->leads()->relatedTasks($objectId)\n );\n\n break;\n\n case 'account':\n $cpTasks = $this->copperClient->accounts()->relatedTasks($objectId);\n\n break;\n case 'contact':\n if ($opportunityId) {\n $cpTasks = $this->copperClient->opportunities()->relatedTasks($opportunityId);\n } elseif ($objectType === 'contact') {\n $cpTasks = $this->copperClient->contacts()->relatedTasks($objectId);\n }\n\n break;\n }\n\n foreach ($cpTasks as $cpTask) {\n // We only get the ID itself returned, so pull out the full object.\n try {\n $cpTask = $this->copperClient->tasks()->find($cpTask['id']);\n } catch (Exception $exception) {\n $this->logger->error('[Copper] Error fetching task', [\n 'resource_id' => $cpTask['id'],\n 'error' => $exception->getMessage(),\n ]);\n\n continue;\n }\n\n // Only allow open tasks.\n if ($cpTask['status'] === 'Open') {\n $data[] = [\n 'crmId' => $cpTask['id'],\n 'subject' => $cpTask['name'],\n 'due' => $cpTask['due_date']\n ? Carbon::createFromTimestamp($cpTask['due_date'])->format('Y-m-d\\TH:i:sO')\n : null,\n 'type' => null,\n ];\n }\n }\n\n return $data;\n }\n\n /**\n * Try to find email address in CRM service\n *\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n *}\n */\n public function matchExactlyByEmail(string $email, ?int $userId = null): ?array\n {\n $cpObjectType = 'contact';\n\n $cpObjects = $this->copperClient->contacts()->search([\n 'emails' => $email,\n 'sort_by' => 'date_modified',\n 'sort_direction' => 'desc',\n ]);\n\n if (empty($cpObjects)) {\n $cpObjectType = 'lead';\n $cpObjects = $this->requestWithExceptionHandling(\n fn () => $this->copperClient->leads()->search([\n 'emails' => $email,\n 'sort_by' => 'date_modified',\n 'sort_direction' => 'desc',\n ])\n );\n }\n\n if (empty($cpObjects)) {\n return null;\n }\n\n // Search returns an array of results, but we only want the first\n $cpObject = reset($cpObjects);\n $data = $this->convertCrmData($cpObject, $cpObjectType, $userId);\n\n return ! empty(array_filter($data)) ? $data : null;\n }\n\n /**\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n public function matchByPhone(string $phone, ?string $rawPhoneNumber = null, ?int $userId = null): ?array\n {\n // Check if the user is internal.\n $teamMember = $this->team->users()->where('phone', $phone)->exists();\n\n // Skip the attendee if internal.\n if ($teamMember === false) {\n $cpObjectType = 'contact';\n $cpObject = $this->copperClient->contacts()->search([\n 'phone_number' => $phone,\n 'sort_by' => 'date_modified',\n 'sort_direction' => 'desc',\n ]);\n\n if (\\count($cpObject) === 0) {\n $cpObjectType = 'lead';\n $cpObject = $this->requestWithExceptionHandling(\n fn () => $this->copperClient->leads()->search([\n 'phone_number' => $phone,\n 'sort_by' => 'date_modified',\n 'sort_direction' => 'desc',\n ])\n );\n }\n\n if (empty($cpObject)) {\n return null;\n }\n\n $cpObject = reset($cpObject);\n $data = $this->convertCrmData($cpObject, $cpObjectType, $userId);\n\n return ! empty(array_filter($data)) ? $data : null;\n }\n\n return null;\n }\n\n /**\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n public function matchByName(string $name, ?int $userId = null): ?array\n {\n // Don't waste time searching for single character strings.\n if (\\strlen($name) <= 1) {\n return null;\n }\n\n $result = \\Cache::remember($this->team->id . $name, 60, function () use ($name, $userId) {\n $cpObjectType = 'contact';\n $cpObject = $this->copperClient->contacts()->search([\n 'name' => $name,\n 'sort_by' => 'date_modified',\n 'sort_direction' => 'desc',\n ]);\n\n if (\\count($cpObject) === 0) {\n $cpObjectType = 'lead';\n $cpObject = $this->requestWithExceptionHandling(\n fn () => $this->copperClient->leads()->search([\n 'name' => $name,\n 'sort_by' => 'date_modified',\n 'sort_direction' => 'desc',\n ])\n );\n }\n\n if (! empty($cpObject)) {\n $cpObject = reset($cpObject);\n }\n\n $data = $this->convertCrmData($cpObject, $cpObjectType, $userId);\n\n return ! empty(array_filter($data)) ? $data : false;\n });\n\n return is_array($result) ? $result : null;\n }\n\n private function convertCrmData(array $object, string $objectType, ?int $userId = null): array\n {\n $lead = null;\n $contact = null;\n $opportunity = null;\n $account = null;\n $stage = null;\n $countryCode = null;\n\n if ($objectType === 'lead') {\n $lead = $this->importLead($object);\n\n // Lead might not be imported if the Stage is null for example.\n if ($lead) {\n $countryCode = $lead->country_code;\n $stage = $lead->stage;\n }\n } else {\n $contact = $this->importContact($object);\n $account = $contact->account;\n\n if ($contact->country_code) {\n $countryCode = $contact->country_code;\n } elseif ($account) {\n $countryCode = $account->country_code;\n }\n\n try {\n $cpOpportunities = $this->findOpportunities(\n $account?->crm_provider_id,\n $contact->crm_provider_id,\n $userId,\n );\n\n if (! empty($cpOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($cpOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n } catch (\\Exception $e) {\n // Nothing to see here.\n }\n }\n\n return [\n $lead,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n }\n\n public function saveActivity(Activity $activity): Activity\n {\n switch ($activity->type) {\n case Activity::TYPE_CONFERENCE:\n case Activity::TYPE_SOFTPHONE:\n case Activity::TYPE_SOFTPHONE_INBOUND:\n $activity = $this->buildCallPayload($activity);\n\n break;\n case Activity::TYPE_SMS_INBOUND:\n case Activity::TYPE_SMS_OUTBOUND:\n $activity = $this->buildTextMessagePayload($activity);\n\n break;\n }\n\n return $activity;\n }\n\n private function convertActivityAssociations(Activity $activity): array\n {\n if ($activity->lead) {\n $type = 'lead';\n $id = $activity->lead->crm_provider_id;\n } elseif ($activity->contact) {\n $type = 'person';\n $id = $activity->contact->crm_provider_id;\n } elseif ($activity->opportunity) {\n $type = 'opportunity';\n $id = $activity->opportunity->crm_provider_id;\n } else {\n $type = 'company';\n $id = $activity->account->crm_provider_id;\n }\n\n return [\n 'type' => $type,\n 'id' => $id,\n ];\n }\n\n private function convertActivityType(PlaybookCategory $activityType): array\n {\n return [\n 'category' => 'user',\n 'id' => $this->getCrmType($activityType)->value,\n ];\n }\n\n /**\n * @throws CrmException\n */\n private function buildCallPayload(Activity $activity): Activity\n {\n try {\n if ($activity->crm_provider_id) {\n // The activity should be logged under the existing Task (not Activity).\n $newActivityType = $this->getCrmType($activity->category);\n if ($newActivityType === null) {\n $message = sprintf('[Copper] invalid CRM Custom Activity Type: %s', $activity->category);\n\n throw new CrmException($message, 400);\n }\n $this->copperClient->tasks()->update($activity->crm_provider_id, [\n 'status' => 'Completed',\n 'custom_activity_type_id' => $newActivityType->value,\n 'details' => $this->generateActivityDescription($activity),\n ]);\n } else {\n if ($this->getCrmType($activity->category) === null) {\n $message = sprintf('[Copper] Invalid CRM Activity Type: %s', $activity->category->getName());\n\n throw new CrmException($message, 400);\n }\n\n if ($this->getCrmType($activity->category)->value === null) {\n $message = sprintf('[Copper] Invalid CRM Activity Type: %s value', $activity->category->getName());\n\n throw new CrmException($message, 400);\n }\n // Just create an activity.\n $cpActivity = $this->copperClient->activities()->create([\n 'user_id' => $this->profile->crm_provider_id,\n 'activity_date' => $activity->scheduled_start_time->timestamp,\n 'parent' => $this->convertActivityAssociations($activity),\n 'type' => $this->convertActivityType($activity->category),\n 'details' => $this->generateActivityDescription($activity),\n ]);\n\n $activity->crm_provider_id = $cpActivity['id'];\n $activity->save();\n }\n } catch (ClientException $exception) {\n //Log all errors to Sentry\n \\Sentry::captureException($exception);\n\n $response = $exception->getResponse();\n\n $responseBody = $response->getBody();\n $message = $responseBody;\n $errorCode = $response->getStatusCode();\n\n $jsonResponse = json_decode($responseBody, true);\n if (isset($jsonResponse[0]['message'])) {\n $message = $jsonResponse[0]['message'];\n }\n\n throw new CrmException($message, $errorCode);\n }\n\n return $activity;\n }\n\n private function buildTextMessagePayload(Activity $activity): Activity\n {\n if ($this->getCrmType($activity->category) === null) {\n $message = sprintf('[Copper] Invalid CRM Activity Type: %s', $activity->category->getName());\n\n throw new CrmException($message, 400);\n }\n\n if ($this->getCrmType($activity->category)->value === null) {\n $message = sprintf('[Copper] Invalid CRM Activity Type: %s value', $activity->category->getName());\n\n throw new CrmException($message, 400);\n }\n\n $cpActivity = $this->copperClient->activities()->create([\n 'user_id' => $this->profile->crm_provider_id,\n 'activity_date' => $activity->created_at->timestamp,\n 'parent' => $this->convertActivityAssociations($activity),\n 'type' => $this->convertActivityType($activity->category),\n 'details' => $this->generateActivityDescription($activity),\n ]);\n\n $activity->crm_provider_id = $cpActivity['id'];\n $activity->save();\n\n return $activity;\n }\n\n private function generateActivityDescription(Activity $activity): string\n {\n $description = '';\n\n switch ($activity->type) {\n case Activity::TYPE_SOFTPHONE:\n case Activity::TYPE_SOFTPHONE_INBOUND:\n case Activity::TYPE_CONFERENCE:\n\n $description = 'Jiminny Call Summary' . PHP_EOL . PHP_EOL;\n $description .= 'Call Duration: ' . $activity->duration_for_humans . PHP_EOL . PHP_EOL;\n\n // Tell them which number was dialed.\n if ($activity->to) {\n $description .= 'Dialed: ' . $activity->to->phone_number . PHP_EOL . PHP_EOL;\n }\n\n if ($activity->hasReasonCodeBotKicked()) {\n $description .= 'Notetaker removed from this meeting' . PHP_EOL . PHP_EOL;\n // When we fix the state to be Activity::RECORDING_RECORDED as it should be this can change.\n } elseif ($activity->hasReasonCodeNotCompliant()) {\n $description .= 'Notetaker did not join due to recording consent not being provided by attendees' . PHP_EOL . PHP_EOL;\n } elseif ($activity->canReviewActivity()) {\n $playbackUrl = $activity->user->team->partner->getPlaybackUrl($activity);\n $description .= 'Review Activity:' . PHP_EOL . $playbackUrl . PHP_EOL . PHP_EOL;\n }\n\n if ($activity->getCrmType() === Activity::TYPE_CONFERENCE) {\n $description .= 'Attendees:'\n . PHP_EOL\n . (new FilterJoinedParticipants())->toString($activity);\n }\n\n if (\\count($activity->notes) > 0) {\n $description .= PHP_EOL . PHP_EOL . 'Notes: ' . PHP_EOL;\n\n foreach ($activity->notes as $note) {\n $nTime = (int) ceil($note->time);\n $time = ($nTime > 3600) ? gmdate('H:i:s', $nTime) : gmdate('i:s', $nTime);\n $description .= $time . ' ' . $note->note . PHP_EOL;\n }\n }\n\n // Get all private messages.\n $messages = $activity->messages()\n ->where('is_private', 1)\n ->orderBy('created_at', 'asc');\n\n if ($messages->count() > 0) {\n $description .= PHP_EOL . PHP_EOL . 'Coaching Chat: ' . PHP_EOL;\n\n foreach ($messages->get() as $message) {\n $description .= $message->participant->name . ': ' . $message->message . PHP_EOL;\n }\n }\n\n // Get all public messages.\n $messages = $activity->messages()\n ->where('is_private', 0)\n ->orderBy('created_at', 'asc');\n\n if ($messages->count() > 0) {\n $description .= PHP_EOL . PHP_EOL . 'Customer Chat: ' . PHP_EOL;\n\n foreach ($messages->get() as $message) {\n $description .= $message->participant->name . ': ' . $message->message . PHP_EOL;\n }\n }\n\n if ($activity->summary) {\n $description .= PHP_EOL . PHP_EOL . 'Summary: ' . PHP_EOL . $activity->summary;\n }\n\n break;\n\n case Activity::TYPE_SMS_INBOUND:\n case Activity::TYPE_SMS_OUTBOUND:\n $description = $activity->description;\n\n break;\n }\n\n return $description;\n }\n\n public function saveFollowupActivity(Activity $activity, array $fields): ?string\n {\n // This is the user provided activity subject field.\n if (empty($fields['type'])) {\n return null;\n }\n\n $reminder = null;\n if (empty($fields['reminder_date']) === false) {\n $formatReminder = Carbon::createFromFormat('Y-m-d\\TH:i:s\\Z', $fields['reminder_date']);\n $reminder = $formatReminder->getTimestamp();\n }\n\n $due = null;\n if (empty($fields['due_date']) === false) {\n $formatDue = Carbon::createFromFormat('Y-m-d\\TH:i:s\\Z', $fields['due_date']);\n $due = $formatDue->getTimestamp();\n }\n\n $data = [\n 'custom_activity_type_id' => $fields['type'],\n 'status' => 'Open',\n 'assignee_id' => $this->profile->crm_provider_id,\n 'due_date' => $due,\n 'details' => $fields['details'] ?? '',\n 'reminder_date' => $reminder,\n 'name' => $fields['name'] ?? 'Follow-up',\n 'related_resource' => $this->convertActivityAssociations($activity),\n 'custom_fields' => $this->fetchCustomFieldData($activity, Field::OBJECT_TASK),\n ];\n\n // We don't actually create a corresponding activity object on our side yet.\n return $this->copperClient->tasks()->create($data)['id'];\n }\n\n /**\n * Store transcripts as note.\n *\n * @throws \\Exception\n */\n public function createTranscriptNotes(Activity $activity): void\n {\n // Generate activity transcription.\n $transcriptionData = $this->generateTranscription($activity);\n\n // Truncate Notes with max notes length because transcription text could be very long.\n $transcripts = mb_strimwidth($transcriptionData, 0, static::NOTE_BODY_MAX_LENGTH);\n\n $data = [\n 'user_id' => $this->profile->crm_provider_id,\n 'type' => [\n 'category' => 'user',\n 'id' => 0, // This creates a note.\n ],\n 'activity_date' => time(),\n 'details' => $transcripts,\n 'parent' => $this->convertActivityAssociations($activity),\n ];\n\n $cpActivity = $this->copperClient->activities()->create($data);\n\n // Store CRM Activity ID in transcription.\n $transcription = $activity->getTranscription();\n $transcription->crm_activity_id = $cpActivity['id'];\n $transcription->save();\n }\n\n /**\n * @inheritdoc\n */\n public function updateStage($crmObject, Stage $stage): void\n {\n if ($crmObject instanceof Lead) {\n $this->requestWithExceptionHandling(\n fn () => $this->copperClient->leads()->update($crmObject->crm_provider_id, [\n 'status_id' => $stage->crm_provider_id,\n ])\n );\n } else {\n $this->copperClient->opportunities()->update($crmObject->crm_provider_id, [\n 'pipeline_stage_id' => $stage->crm_provider_id,\n ]);\n }\n }\n\n public function prepareValueForUpdate(array $params): array\n {\n return [$params['fieldName'] => $params['fieldValue']];\n }\n\n /*\n * @inheritdoc\n */\n public function updateRecord(string $objectType, string $objectId, array $data, array $headers = []): void\n {\n $preparedData = $data;\n\n if ($objectType === Field::OBJECT_OPPORTUNITY) {\n $preparedData = $this->prepareOpportunityFieldsForUpdate($data);\n }\n\n try {\n $this->copperClient\n ->createResourceFromObjectType($objectType)\n ->update($objectId, $preparedData)\n ;\n } catch (Exception $e) {\n $this->logger->error(self::RESOURCE_UPDATE_FAILED_ERROR_MESSAGE, [\n 'resource_id' => $objectId,\n 'data' => $preparedData,\n 'error' => $e->getMessage(),\n ]);\n\n throw new CrmException(self::RESOURCE_UPDATE_FAILED_ERROR_MESSAGE, $e->getCode(), $e);\n }\n }\n\n /**\n * @param array<string,string> $fieldData\n *\n * @throws CrmException\n *\n * @return array<string,array<int,array<string,bool|float|int|string|null>>|bool|float|int|string|null>\n */\n private function prepareOpportunityFieldsForUpdate(array $fieldData): array\n {\n $preparedData = [];\n $customFields = [];\n\n foreach ($fieldData as $fieldName => $fieldValue) {\n $field = $this->config\n ->fields()\n ->where('crm_provider_id', $fieldName)\n ->first();\n\n if (! $field instanceof Field) {\n throw new CrmException('Field not found');\n }\n\n $convertedValue = $this->fieldValueConverter->convertToCrm($field->getType(), $fieldValue);\n\n if ($this->isStandardField($fieldName)) {\n $preparedData[$fieldName] = $convertedValue;\n\n continue;\n }\n\n $customFields[] = [\n 'custom_field_definition_id' => $fieldName,\n 'value' => $convertedValue,\n ];\n }\n\n if (! empty($customFields)) {\n $preparedData['custom_fields'] = $customFields;\n }\n\n return $preparedData;\n }\n\n /*\n * @inheritdoc\n */\n public function getRecord(string $objectType, string $objectId, array $fields = []): array\n {\n return $this->copperClient->createResourceFromObjectType($objectType)->find($objectId);\n }\n\n private function fetchCustomFieldData(Activity $activity, string $objectType): array\n {\n $payload = [];\n\n $fieldData = FieldData::where([\n 'object_id' => $activity->id,\n ])->whereHas('field', function ($query) use ($objectType) {\n $query->where('object_type', $objectType);\n })->get();\n\n foreach ($fieldData as $data) {\n // Add the field and value to the payload.\n $payload += [\n 'custom_field_definition_id' => $data->field->crm_provider_id,\n 'value' => $data->value,\n ];\n }\n\n return $payload;\n }\n\n public function generateProviderUrl(string $providerId, string $objectType): ?string\n {\n $baseUrl = 'https://app.prosperworks.com/companies/' . $this->config->crm_provider_id . '/app#/';\n switch ($objectType) {\n case 'lead':\n $url = $baseUrl . 'leads/' . $providerId;\n\n break;\n\n case 'account':\n $url = $baseUrl . 'organization/' . $providerId;\n\n break;\n\n case 'contact':\n $url = $baseUrl . 'contact/' . $providerId;\n\n break;\n\n case 'opportunity':\n $url = $baseUrl . 'deal/' . $providerId;\n\n break;\n\n default:\n case 'task':\n case 'activity':\n // Sadly we can't deeplink to these in Copper UI.\n $url = null;\n }\n\n return $url;\n }\n\n /**\n * Generate transcription for the activity.\n */\n private function generateTranscription(Activity $activity): string\n {\n if (! ($this->config->store_transcript)) {\n // If sending transcription to activity toggle is disabled\n return '';\n }\n\n return $this->transcriptionService\n ->findTranscriptionByActivity($activity)\n ->map(static function (array $transcriptionSegment): string {\n return $transcriptionSegment['formattedStartsAt'] . ' | ' . $transcriptionSegment['transcript'];\n })\n ->implode(PHP_EOL);\n }\n\n /**\n * @param array<string, mixed> $opportunityData\n *\n * @return int|string|bool|float|null\n */\n private function extractFieldValue(array $opportunityData, string $fieldName)\n {\n $fieldValue = $this->isStandardField($fieldName)\n ? $opportunityData[$fieldName]\n : $this->resolveCustomFieldFromOpportunityData($opportunityData, $fieldName);\n\n if (is_array($fieldValue)) {\n $fieldValue = implode(', ', $fieldValue);\n }\n\n return $fieldValue;\n }\n\n private function isStandardField(string $fieldName): bool\n {\n return array_key_exists($fieldName, self::STANDARD_OPPORTUNITY_FIELDS);\n }\n\n /**\n * @param array<string, mixed> $opportunityData\n *\n * @return string|int|float|bool|null|array<string, mixed>\n */\n private function resolveCustomFieldFromOpportunityData(array $opportunityData, string $fieldName)\n {\n $fieldData = array_filter(\n $opportunityData['custom_fields'],\n static fn ($field) => (string) $field['custom_field_definition_id'] === $fieldName\n );\n\n return array_pop($fieldData)['value'] ?? '';\n }\n\n private function isSupportedObjectType(string $objectType): bool\n {\n return $objectType !== self::UNSUPPORTED_OBJECT_TYPE;\n }\n\n private function syncStandardFields(): void\n {\n foreach (self::STANDARD_OPPORTUNITY_FIELDS as $fieldId => $fieldData) {\n $this->config->fields()->updateOrCreate([\n 'crm_provider_id' => mb_strimwidth($fieldId, 0, 128),\n 'object_type' => Field::OBJECT_OPPORTUNITY,\n ], [\n 'label' => mb_strimwidth($fieldData['label'], 0, Field::LABEL_MAX_LENGTH),\n 'type' => $fieldData['type'],\n ]);\n }\n\n $this->importStages([Stage::TYPE_OPPORTUNITY]);\n }\n\n private function requestWithExceptionHandling(\\Closure $apiRequest): iterable\n {\n try {\n return $apiRequest();\n } catch (\\Exception $exception) {\n if ($exception->getCode() === 403) {\n // Object might not be supported\n return [];\n }\n\n \\Sentry::captureException($exception);\n\n return [];\n }\n }\n\n public function verifyTaskExists(Activity $activity): bool\n {\n $crmProviderId = $activity->getCrmProviderId();\n $cacheKey = \"crm_task_exists:{$this->config->getId()}:$crmProviderId\";\n\n return Cache::remember($cacheKey, self::TASK_VERIFICATION_CACHE_TTL, function () use ($crmProviderId) {\n try {\n if ($this->copperClient === null) {\n return false;\n }\n\n $task = $this->copperClient->tasks()->find($crmProviderId);\n\n return ! empty($task);\n } catch (HttpNotFoundException) {\n // Task not found in CRM - this is expected and permanent\n $this->logger->info('[Copper] Task not found during verification', [\n 'task_id' => $crmProviderId,\n 'config_id' => $this->config->getId(),\n ]);\n\n return false;\n } catch (RequestFailedException $e) {\n // Handle 404 responses from Copper API\n if ($e->getCode() === 404) {\n $this->logger->info('[Copper] Task not found during verification (404)', [\n 'task_id' => $crmProviderId,\n 'config_id' => $this->config->getId(),\n ]);\n\n return false;\n }\n\n // Re-throw other Copper exceptions for retry\n throw $e;\n }\n // Let other exceptions (network errors, rate limits, etc.) bubble up for retry\n });\n }\n}","depth":4,"on_screen":true,"value":"<?php\n\nnamespace Jiminny\\Services\\Crm\\Copper;\n\nuse Carbon\\Carbon;\nuse Exception;\nuse GuzzleHttp\\Exception\\ClientException;\nuse Illuminate\\Support\\Facades\\Cache;\nuse Jiminny\\Contracts\\Services\\Crm\\LayoutManagementInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\MatchCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\Provider\\CopperInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityLookupInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityManipulationInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SettingsInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmMetadataInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\VerifyTaskExistsInterface;\nuse Jiminny\\Exceptions\\CrmException;\nuse Jiminny\\Exceptions\\HttpNotFoundException;\nuse Jiminny\\Models\\Account;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Contact;\nuse Jiminny\\Models\\Crm\\BusinessProcess;\nuse Jiminny\\Models\\Crm\\Configuration;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Models\\Crm\\FieldData;\nuse Jiminny\\Models\\Crm\\FieldValue;\nuse Jiminny\\Models\\Crm\\LayoutEntity;\nuse Jiminny\\Models\\Crm\\Profile;\nuse Jiminny\\Models\\Lead;\nuse Jiminny\\Models\\Opportunity;\nuse Jiminny\\Models\\Playbook;\nuse Jiminny\\Models\\PlaybookCategory;\nuse Jiminny\\Models\\SocialAccount;\nuse Jiminny\\Models\\Stage;\nuse Jiminny\\Models\\User;\nuse Jiminny\\Repositories\\Crm\\CrmEntityRepository;\nuse Jiminny\\Repositories\\Crm\\FieldRepository;\nuse Jiminny\\Repositories\\Crm\\ProfileRepository;\nuse Jiminny\\Repositories\\UserRepository;\nuse Jiminny\\Services\\Avatar\\ProspectPhotoPathService;\nuse Jiminny\\Services\\Crm\\BaseService;\nuse Jiminny\\Services\\Crm\\Copper\\Api\\Client;\nuse Jiminny\\Services\\Crm\\Copper\\Api\\ClientFactory;\nuse Jiminny\\Services\\Crm\\Copper\\Exception\\RequestFailedException;\nuse Jiminny\\Services\\Crm\\Helpers\\FilterJoinedParticipants;\nuse Jiminny\\Services\\Crm\\OpportunitySyncStrategyResolver;\nuse Jiminny\\Services\\Crm\\Copper\\Traits\\SearchesCrmEntities;\nuse Jiminny\\Utils\\CurrencyFormatter;\nuse Jiminny\\Utils\\StringUtil;\n\nclass Service extends BaseService implements\n CopperInterface,\n SyncCrmEntitiesInterface,\n SyncCrmMetadataInterface,\n RemoteEntityManipulationInterface,\n LayoutManagementInterface,\n SettingsInterface,\n MatchCrmEntitiesInterface,\n RemoteEntityLookupInterface,\n VerifyTaskExistsInterface\n{\n use SearchesCrmEntities;\n private const int NOTE_BODY_MAX_LENGTH = 3000000;\n\n private const int TASK_VERIFICATION_CACHE_TTL = 86400; // 1 day\n\n private const array STANDARD_OPPORTUNITY_FIELDS = [\n 'monetary_value' => [\n 'label' => 'Value',\n 'available_on' => Field::OBJECT_OPPORTUNITY,\n 'type' => Field::TYPE_CURRENCY,\n ],\n 'pipeline_stage_id' => [\n 'label' => 'Stage',\n 'type' => Field::TYPE_PICKLIST,\n 'available_on' => Field::OBJECT_OPPORTUNITY,\n ],\n 'close_date' => [\n 'label' => 'Close date',\n 'type' => Field::TYPE_DATE,\n 'available_on' => Field::OBJECT_OPPORTUNITY,\n ],\n 'win_probability' => [\n 'label' => 'Win probability',\n 'type' => Field::TYPE_NUMBER,\n 'available_on' => Field::OBJECT_OPPORTUNITY,\n ],\n ];\n private const string UNSUPPORTED_OBJECT_TYPE = 'unsupported';\n private const string OPPORTUNITY_STATUS_OPEN = 'Open';\n private const string OPPORTUNITY_IMPORT_FAILED_ERROR_MESSAGE = 'Opportunity import failed';\n private const string RESOURCE_UPDATE_FAILED_ERROR_MESSAGE = 'Failed to update resource';\n\n private FieldValueConverter $fieldValueConverter;\n private ClientFactory $clientFactory;\n private ?Client $copperClient = null;\n\n public function __construct(\n ClientFactory $clientFactory,\n FieldValueConverter $fieldValueConverter,\n private readonly UserRepository $userRepository,\n private readonly ProspectPhotoPathService $prospectPhotoPathService,\n private readonly CrmEntityRepository $crmEntityRepository\n ) {\n parent::__construct();\n\n $this->clientFactory = $clientFactory;\n $this->fieldValueConverter = $fieldValueConverter;\n }\n\n public function getDisplayName(): string\n {\n return 'Copper';\n }\n\n /**\n * Get the CRM entity repository (used by SearchesCrmEntities trait)\n */\n protected function getCrmEntityRepository(): CrmEntityRepository\n {\n return $this->crmEntityRepository;\n }\n\n public function getMinimumApiVersion(): string\n {\n return Client::MIN_API_VERSION;\n }\n\n protected function getOAuthAccount(User $user): ?SocialAccount\n {\n // In this case, the Account Owner is always the connection for any API operations.\n $owner = $user->team->owner;\n\n return $owner->getSocialAccount(SocialAccount::PROVIDER_COPPER);\n }\n\n public function setUser(User $user): void\n {\n $this->configureSentryScope($user);\n\n $this->team = $user->getTeam();\n $this->teamService->setTeam($user->getTeam());\n $this->setConfiguration($user->getTeam()->getCrmConfiguration());\n\n // Reset state.\n $this->profile = $user->getProfile();\n\n if (! $this->profile instanceof Profile) {\n // Try to fetch their profile, maybe it was not yet imported?\n $this->profile = $this->getProfile($user);\n }\n }\n\n public function setConfiguration(Configuration $configuration): void\n {\n $this->config = $configuration;\n $this->copperClient = $this->clientFactory->create($configuration);\n\n if ($this->team === null) {\n $this->team = $configuration->getTeam();\n }\n }\n\n public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string\n {\n return $this->fieldValueConverter->convertFromCrm($fieldType, $fieldValue);\n }\n\n private function mapObjectType(string $objectType): string\n {\n $objectType = strtolower($objectType);\n\n switch ($objectType) {\n case Field::OBJECT_TASK:\n case Field::OBJECT_OPPORTUNITY:\n case Field::OBJECT_LEAD:\n $mappedType = $objectType;\n\n break;\n case Field::OBJECT_ACCOUNT:\n $mappedType = 'company';\n\n break;\n case Field::OBJECT_CONTACT:\n $mappedType = 'person';\n\n break;\n default:\n $mappedType = self::UNSUPPORTED_OBJECT_TYPE;\n\n break;\n }\n\n return $mappedType;\n }\n\n /**\n * @param string $crmObject The name of the CRM object. i.e. Opportunity or Contact\n */\n protected function getFields(string $crmObject): array\n {\n $mappedObjectType = $this->mapObjectType($crmObject);\n\n if (! $this->isSupportedObjectType($mappedObjectType)) {\n return [];\n }\n\n $customFields = $this->copperClient->fields()->all();\n\n $filteredFields = array_filter(\n $customFields,\n static fn ($field) => in_array($mappedObjectType, $field['available_on'], true)\n );\n\n return array_map(\n fn (array $property) => [\n 'name' => $property['id'],\n 'label' => $property['name'],\n 'type' => $this->convertFieldType($property['data_type']),\n ],\n $filteredFields\n );\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultFields(string $activityType): array\n {\n $fields = [];\n\n if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {\n $defaultFields = FieldDefinitions::defaultTaskFields();\n\n // This lazy creates these fields if not already setup.\n foreach ($defaultFields as $defaultField) {\n $fields[] = $this->config->fields()->firstOrCreate($defaultField);\n }\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultActivityField(string $activityType): Field\n {\n // Setup the activity field as the default Type.\n $activityField = $this->config->fields()->where([\n 'crm_provider_id' => 'type',\n 'object_type' => $activityType,\n ])->first();\n\n return $activityField;\n }\n\n /**\n * @inheritdoc\n */\n public function getSupportedPlaybookTypes(): array\n {\n return [Playbook::ACTIVITY_TYPE_TASK];\n }\n\n public function getDealInsightsFields(): array\n {\n return FieldDefinitions::dealInsightsFields();\n }\n\n protected function getDefaultFollowupLayoutFields(string $activityType): array\n {\n $fields = [];\n $fieldRepo = app(FieldRepository::class);\n $fieldFilters = FieldDefinitions::taskFollowupFieldsFilter();\n\n foreach ($fieldFilters as $data) {\n $field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $data);\n\n // Only add the field if it is created, which it should be.\n if ($field) {\n $fields[] = $field;\n }\n }\n\n return $fields;\n }\n\n public function syncFields(): void\n {\n $this->syncStandardFields();\n $this->syncCustomFields();\n }\n\n public function syncField(Field $field): void\n {\n if ($field->getCrmProviderId() === 'type') {\n /**\n * This field is a special case, it is not a custom field and is not returned by the Copper API.\n *\n * The field is created manually during layout setup.\n *\n * @see getDefaultFields()\n *\n * There is a separate endpoint to get the activity types.\n * @see importPicklistValues()\n */\n return;\n }\n\n if ($field->getCrmProviderId() === 'pipeline_stage_id') {\n // Not a custom type\n return;\n }\n\n $crmField = $this->copperClient->fields()->find($field->getCrmProviderId());\n $field->label = $crmField['name'];\n $field->type = $this->convertFieldType($crmField['data_type']);\n\n $field->save();\n }\n\n /**\n * @param string $fieldType How the data should be input\n */\n private function convertFieldType(string $fieldType): string\n {\n $fieldType = strtolower($fieldType);\n\n switch ($fieldType) {\n case 'dropdown':\n $convertedType = Field::TYPE_PICKLIST;\n\n break;\n case 'string':\n $convertedType = Field::TYPE_TEXT;\n\n break;\n case 'float':\n case 'percentage':\n $convertedType = Field::TYPE_NUMBER;\n\n break;\n case 'text':\n $convertedType = Field::TYPE_TEXTAREA;\n\n break;\n case 'checkbox':\n $convertedType = Field::TYPE_BOOLEAN;\n\n break;\n case Field::TYPE_DATE:\n case Field::TYPE_CURRENCY:\n case Field::TYPE_URL:\n $convertedType = $fieldType;\n\n break;\n default:\n $convertedType = Field::TYPE_UNSUPPORTED;\n\n break;\n }\n\n return $convertedType;\n }\n\n /**\n * @inheritdoc\n */\n public function importPicklistValues(Field $field): array\n {\n $fieldValues = [];\n\n if ($field->object_type === 'opportunity' && $field->crm_provider_id === 'pipeline_stage_id') {\n return $this->importFieldValuesForDealInsights($field);\n }\n\n $values = [];\n\n if ($field->object_type === Field::OBJECT_TASK && $field->crm_provider_id === 'type') {\n $activityTypes = $this->copperClient->activityTypes()->all();\n\n /**\n * We ignore \"system\" activity types, disabled activity types and the \"Note\" activity type.\n */\n $activityTypes = array_filter(\n $activityTypes['user'],\n fn (array $activityType) => ! $activityType['is_disabled'] && $activityType['id'] !== 0\n );\n\n // Import all activity values.\n $i = 0;\n foreach ($activityTypes as $activityType) {\n $values[] = [\n 'value' => $activityType['id'],\n 'label' => mb_strimwidth($activityType['name'], 0, Field::LABEL_MAX_LENGTH),\n 'sequence' => $i++,\n ];\n }\n } else {\n // We just check for the field in the custom definitions.\n $crmField = $this->copperClient->fields()->find($field->crm_provider_id);\n\n // Import all values.\n foreach ($crmField['options'] as $option) {\n $values[] = [\n 'value' => $option['id'],\n 'label' => mb_strimwidth($option['name'], 0, 191),\n 'sequence' => $option['rank'],\n ];\n }\n }\n\n // Take the existing set of field values.\n $fieldsToPurge = $field->values()->get()->pluck('value')->toArray();\n\n foreach ($values as $value) {\n // For all current values, upsert the field.\n $value['value'] = substr($value['value'] ?? '', 0, 255);\n $fieldValues[] = $field->values()->updateOrCreate([\n 'value' => $value['value'],\n ], $value);\n\n // Remove this value from the ones we are going to purge.\n $key = array_search($value['value'], $fieldsToPurge, false);\n if ($key !== false) {\n unset($fieldsToPurge[$key]);\n }\n }\n\n // Delete the old values that are no longer used.\n $field->values()->whereIn('value', $fieldsToPurge)->delete();\n\n return $fieldValues;\n }\n\n /**\n *\n * @return FieldValue[]\n */\n private function importFieldValuesForDealInsights(Field $field): array\n {\n $fieldValues = [];\n $pipelines = $this->copperClient->pipelines()->all();\n\n foreach ($pipelines as $pipeline) {\n foreach ($pipeline['stages'] as $i => $dealStage) {\n $fieldValues[] = $field->values()->updateOrCreate(\n ['value' => $dealStage['id']],\n [\n 'label' => $dealStage['name'],\n 'sequence' => $i,\n ]\n );\n }\n }\n\n return $fieldValues;\n }\n\n /**\n * @inheritdoc\n */\n public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage\n {\n if ($types === null) {\n $types = [Stage::TYPE_LEAD, Stage::TYPE_OPPORTUNITY];\n }\n $missingStage = null;\n\n foreach ($types as $type) {\n if ($type === Stage::TYPE_LEAD) {\n $statuses = $this->requestWithExceptionHandling(\n fn () => $this->copperClient->leadStatuses()->all()\n );\n\n if (count($statuses) > 0) {\n $businessProcess = $this->config->businessProcesses()->updateOrCreate([\n 'crm_provider_id' => BusinessProcess::TYPE_LEAD,\n ], [\n 'team_id' => $this->team->id,\n 'name' => BusinessProcess::TYPE_LEAD,\n 'type' => BusinessProcess::TYPE_LEAD,\n 'is_selectable' => true,\n ]);\n\n // A record type is really a clone of the business process, used to store which record uses which pipeline.\n $this->config->recordTypes()->updateOrCreate([\n 'crm_provider_id' => BusinessProcess::TYPE_LEAD,\n ], [\n 'team_id' => $this->team->id,\n 'name' => BusinessProcess::TYPE_LEAD,\n 'is_selectable' => true,\n 'business_process_id' => $businessProcess->id ?? null,\n ]);\n\n $leadStatuses = [];\n foreach ($statuses as $status) {\n // Upsert all statuses for the team.\n $status = $this->config->stages()->updateOrCreate([\n 'crm_provider_id' => $status['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($status['name'], 0, 50),\n 'label' => mb_strimwidth($status['name'], 0, 191),\n 'type' => Stage::TYPE_LEAD,\n 'sequence' => $status['order'],\n 'is_selectable' => true,\n ]);\n\n if ($missingStageName && $missingStageName === $status['id']) {\n $missingStage = $status;\n }\n\n $leadStatuses[] = $status['id'];\n }\n\n $businessProcess->stages()->sync($leadStatuses);\n }\n } else {\n // We create a business process to contain the pipeline, and later store all stages against it.\n foreach ($this->copperClient->pipelines()->all() as $pipeline) {\n $businessProcess = $this->config->businessProcesses()->updateOrCreate([\n 'crm_provider_id' => $pipeline['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($pipeline['name'], 0, 150),\n 'type' => BusinessProcess::TYPE_OPPORTUNITY,\n 'is_selectable' => true,\n ]);\n\n // A record type is really a clone of the business process, used to store which record uses which pipeline.\n $this->config->recordTypes()->updateOrCreate([\n 'crm_provider_id' => $pipeline['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($pipeline['name'], 0, 150),\n 'is_selectable' => true,\n 'business_process_id' => $businessProcess->id ?? null,\n ]);\n\n $stages = [];\n\n foreach ($pipeline['stages'] as $i => $dealStage) {\n // Upsert all stages for the team.\n $stage = $this->config->stages()->updateOrCreate([\n 'crm_provider_id' => $dealStage['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($dealStage['name'], 0, 50),\n 'label' => mb_strimwidth($dealStage['name'], 0, 191),\n 'type' => Stage::TYPE_OPPORTUNITY,\n 'sequence' => $i,\n 'is_selectable' => true,\n 'probability' => $dealStage['win_probability'],\n ]);\n\n if ($missingStageName && $missingStageName === $dealStage['id']) {\n $missingStage = $stage;\n }\n\n $stages[] = $stage['id'];\n }\n\n $businessProcess->stages()->sync($stages);\n }\n }\n }\n\n return $missingStage;\n }\n\n /**\n * @inheritdoc\n */\n public function syncLeads(Carbon $since, ?Carbon $to = null, ?string $crmProfileId = null): int\n {\n $syncCount = 0;\n\n $cpLeads = $this->requestWithExceptionHandling(\n fn () => $this->copperClient->leads()->paginate([\n 'minimum_modified_date' => $since->timestamp,\n 'sort_by' => 'date_modified',\n ])\n );\n\n foreach ($cpLeads as $cpLead) {\n // Only sync if previously imported.\n if ($this->hasLead($cpLead['id']) === false) {\n $this->importLead($cpLead);\n $syncCount++;\n }\n }\n\n return $syncCount;\n }\n\n /**\n * @inheritdoc\n */\n public function syncLead(string $crmId): ?Lead\n {\n $cpLead = $this->requestWithExceptionHandling(\n fn () => $this->copperClient->leads()->find($crmId)\n );\n\n if (\\count($cpLead) === 0) {\n return null;\n }\n\n return $this->importLead($cpLead);\n }\n\n /**\n * @inheritdoc\n */\n private function importLead($crmData): ?Lead\n {\n $countryCode = null;\n if (! empty($crmData['address']) && isset($crmData['address']['country'])) {\n $countryCode = $this->convertCountryNameToCode($crmData['address']['country']);\n }\n\n $mobilePhone = $parsedNumber = null;\n if (! empty($crmData['phone_numbers'])) {\n foreach ($crmData['phone_numbers'] as $phoneNumber) {\n if ($phoneNumber['category'] === 'mobile') {\n $mobilePhone = $this->buildContactMobilePhone($countryCode, $phoneNumber['number']);\n } else {\n $parsedNumber = $this->buildContactPhone($countryCode, $phoneNumber['number']);\n }\n }\n }\n\n $name = $crmData['name'];\n\n if (empty($crmData['company_name'])) {\n $company = 'Unknown';\n } else {\n $company = mb_strimwidth($crmData['company_name'], 0, 191);\n }\n\n $domain = null;\n if (empty($crmData['websites']) === false) {\n $domain = mb_strimwidth($crmData['websites'][0]['url'], 0, 191);\n $domain = StringUtil::resolveDomain($domain);\n }\n\n $email = null;\n if (empty($crmData['email']) === false) {\n $email = mb_strimwidth($crmData['email']['email'], 0, 80);\n }\n\n $stage = $this->config\n ->stages()\n ->where('crm_provider_id', (string) $crmData['status_id'])\n ->where('type', Stage::TYPE_LEAD)\n ->first();\n\n if ($stage === null) {\n // Import it.\n $stage = $this->importStages([Stage::TYPE_LEAD], $crmData['status_id']);\n }\n\n if (! $stage instanceof Stage) {\n return null;\n }\n\n $profile = $this->config->profiles()->where('crm_provider_id', (string) $crmData['assignee_id'])->first();\n\n $data = [\n 'team_id' => $this->team->id,\n 'user_id' => $profile ? $profile->user_id : null,\n 'owner_id' => $crmData['assignee_id'],\n 'name' => mb_strimwidth($name, 0, 191),\n 'company' => $company,\n 'domain' => $domain,\n 'title' => $crmData['title'] ? mb_strimwidth($crmData['title'], 0, 128) : null,\n 'email' => $email,\n 'phone' => $parsedNumber['phone'] ?? null,\n 'ext' => $parsedNumber['ext'] ?? null,\n 'mobile_phone' => $mobilePhone,\n 'stage_id' => $stage->id,\n 'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(\n crmConfiguration: $this->config,\n crmProviderId: $crmData['id'],\n modelType: Lead::class,\n fileName: $crmData['id'],\n avatarText: $crmData['name']\n ),\n 'country_code' => $countryCode,\n 'remotely_created_at' => $crmData['date_created'],\n ];\n\n return $this->config->leads()->updateOrCreate(['crm_provider_id' => (string) $crmData['id']], $data);\n }\n\n /**\n * @inheritdoc\n */\n public function syncAccounts(Carbon $since, ?Carbon $to = null): int\n {\n $syncCount = 0;\n\n try {\n $cpAccounts = $this->copperClient->accounts()->paginate([\n 'minimum_modified_date' => $since->timestamp,\n 'sort_by' => 'date_modified',\n ]);\n\n foreach ($cpAccounts as $cpAccount) {\n // Only sync if previously imported.\n if ($this->hasAccount($cpAccount['id'])) {\n $this->importAccount($cpAccount);\n $syncCount++;\n }\n }\n } catch (\\Exception $exception) {\n // Do nothing for now.\n }\n\n return $syncCount;\n }\n\n /**\n * @inheritdoc\n */\n public function syncAccount(string $crmId): ?Account\n {\n $cpAccount = $this->copperClient->accounts()->find($crmId);\n\n if (empty($cpAccount)) {\n return null;\n }\n\n return $this->importAccount($cpAccount);\n }\n\n /**\n * @inheritdoc\n */\n private function importAccount($crmData): Account\n {\n $countryCode = null;\n if (empty($crmData['address']) === false && $crmData['address']['country']) {\n $countryCode = $this->convertCountryNameToCode($crmData['address']['country']);\n }\n\n $name = $crmData['name'];\n\n $parsedNumber = null;\n foreach ($crmData['phone_numbers'] as $phoneNumber) {\n $parsedNumber = parsePhoneNumber($countryCode, $phoneNumber['number']);\n }\n\n $domain = null;\n if (empty($crmData['websites']) === false) {\n $domain = mb_strimwidth($crmData['websites'][0]['url'], 0, 191);\n $domain = StringUtil::resolveDomain($domain);\n }\n\n $profile = $this->config->profiles()->where('crm_provider_id', (string) $crmData['assignee_id'])->first();\n\n $data = [\n 'team_id' => $this->team->id,\n 'user_id' => $profile ? $profile->user_id : null,\n 'owner_id' => $crmData['assignee_id'],\n 'name' => mb_strimwidth($name, 0, 191),\n 'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(\n crmConfiguration: $this->config,\n crmProviderId: $crmData['id'],\n modelType: Account::class,\n fileName: $crmData['id'],\n avatarText: $crmData['name']\n ),\n 'country_code' => $countryCode,\n 'domain' => $domain,\n 'phone' => $parsedNumber['phone'] ?? null,\n 'ext' => $parsedNumber['ext'] ?? null,\n 'remotely_created_at' => $crmData['date_created'],\n ];\n\n return $this->config->accounts()->updateOrCreate(['crm_provider_id' => (string) $crmData['id']], $data);\n }\n\n public function syncOpportunities(array $parameters, ?string $strategy = null): int\n {\n $strategyResolver = app(OpportunitySyncStrategyResolver::class, [\n 'client' => $this->copperClient,\n ]);\n $strategies = $strategyResolver->getStrategies($this->config, $strategy);\n\n $syncCount = 0;\n\n try {\n foreach ($strategies as $syncStrategy) {\n foreach ($syncStrategy->fetchOpportunities($parameters) as $opportunityData) {\n $this->importOpportunity($opportunityData);\n $syncCount++;\n }\n }\n } catch (\\Exception $exception) {\n // Do nothing for now.\n $this->logger->error(self::OPPORTUNITY_IMPORT_FAILED_ERROR_MESSAGE, [\n 'error' => $exception->getMessage(),\n ]);\n }\n\n return $syncCount;\n }\n\n public function syncOpportunity(string $crmId): ?Opportunity\n {\n $strategyResolver = app(OpportunitySyncStrategyResolver::class, [\n 'client' => $this->copperClient,\n ]);\n $strategy = $strategyResolver->resolve(\n $this->config,\n OpportunitySyncStrategyResolver::SINGLE_SYNC_OPPORTUNITY_STRATEGY\n );\n\n $parameters['crm_id'] = $crmId;\n\n $cpOpportunity = $strategy->fetchOpportunities($parameters);\n\n return $this->importOpportunity($cpOpportunity);\n }\n\n private function importOpportunity($crmData): ?Opportunity\n {\n $companyId = (string) $crmData['company_id'];\n\n if (empty($companyId)) {\n $this->logger->error(self::OPPORTUNITY_IMPORT_FAILED_ERROR_MESSAGE, [\n 'error' => 'No company id',\n ]);\n\n return null;\n }\n\n $account = $this->config->accounts()->where('crm_provider_id', $companyId)->first();\n\n if ($account === null) {\n $account = $this->syncAccount($companyId);\n }\n\n $profile = $this->config->profiles()->where('crm_provider_id', (string) $crmData['assignee_id'])->first();\n\n $businessProcess = $this->config\n ->businessProcesses()\n ->where('crm_provider_id', (string) $crmData['pipeline_id'])\n ->first();\n\n if ($businessProcess === null) {\n // It is possible for some deals to not be in a pipeline.\n $this->logger->warning(self::OPPORTUNITY_IMPORT_FAILED_ERROR_MESSAGE, [\n 'error' => 'Missing pipeline',\n ]);\n\n return null;\n }\n\n $stage = $businessProcess\n ->stages()\n ->where('crm_provider_id', (string) $crmData['pipeline_stage_id'])\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->first();\n\n if ($stage === null) {\n $this->logger->info('Stage not found, importing', [\n 'pipeline_id' => $crmData['pipeline_id'],\n 'stage_id' => $crmData['pipeline_stage_id'],\n ]);\n\n $stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $crmData['pipeline_stage_id']);\n }\n\n $recordType = $businessProcess->recordTypes()->first();\n $closeDate = null;\n $isClosed = $crmData['status'] !== self::OPPORTUNITY_STATUS_OPEN;\n\n if ($crmData['close_date']) {\n $closeDate = Carbon::parse($crmData['close_date'])->format('Y-m-d');\n }\n\n $data = [\n 'team_id' => $this->team->getId(),\n 'account_id' => $account->id,\n 'user_id' => $profile->user_id ?? null,\n 'owner_id' => $crmData['assignee_id'],\n 'name' => mb_strimwidth($crmData['name'], 0, 128),\n 'value' => $crmData['monetary_value'],\n 'currency_code' => CurrencyFormatter::formatCode($crmData['monetary_unit']),\n 'close_date' => $closeDate,\n 'is_closed' => $isClosed,\n 'is_won' => $crmData['status'] === 'Won',\n 'stage_id' => $stage->id,\n 'record_type_id' => $recordType->id ?? null,\n 'remotely_created_at' => $crmData['date_created'],\n ];\n\n /** @var Opportunity $opportunity */\n $opportunity = $this->config\n ->opportunities()\n ->updateOrCreate(['crm_provider_id' => (string) $crmData['id']], $data)\n ;\n\n // import external fields into crm_field_data if present\n $dealInsightsFields = $this->getOpportunitySyncableFields();\n $this->importOpportunityCrmFieldData($crmData, $dealInsightsFields, $opportunity->id);\n\n return $opportunity;\n }\n\n /**\n * @param array<string, mixed> $opportunityData\n * @param string[] $dealInsightsFields\n */\n public function importOpportunityCrmFieldData(\n array $opportunityData,\n array $dealInsightsFields,\n int $opportunityId\n ): void {\n foreach ($dealInsightsFields as $fieldName) {\n $field = $this->config->fields()->with('entities')\n ->where('crm_provider_id', $fieldName)\n ->where('object_type', Field::OBJECT_OPPORTUNITY)\n ->first();\n\n if (! $field instanceof Field) {\n $this->logger->warning('Field not found', [\n 'field' => $fieldName,\n 'opportunity_id' => $opportunityId,\n ]);\n\n continue;\n }\n\n $entity = $field->getEntities()->first();\n\n if (! $entity instanceof LayoutEntity) {\n continue;\n }\n\n $fieldValue = $this->extractFieldValue($opportunityData, $fieldName);\n $normalizedValue = $this->normalizeValue($field->getType(), (string) $fieldValue, true);\n\n $field->data()\n ->updateOrCreate([\n 'crm_layout_entity_id' => $entity->id,\n 'crm_field_id' => $field->id,\n 'object_type' => 'opportunity',\n 'object_id' => $opportunityId,\n ], ['value' => $normalizedValue])\n ;\n }\n }\n\n /**\n * @inheritdoc\n */\n public function syncContacts(Carbon $since, ?Carbon $to = null): int\n {\n $syncCount = 0;\n\n try {\n $cpContacts = $this->copperClient->contacts()->paginate([\n 'minimum_interaction_date' => $since->timestamp,\n 'sort_by' => 'date_modified',\n ]);\n\n foreach ($cpContacts as $cpContact) {\n // Only sync if previously imported.\n if ($this->hasContact($cpContact['id'])) {\n $this->importContact($cpContact);\n $syncCount++;\n }\n }\n } catch (\\Exception $exception) {\n // Do nothing for now.\n throw $exception;\n }\n\n return $syncCount;\n }\n\n /**\n * @inheritdoc\n */\n public function syncContact(string $crmId): ?Contact\n {\n $cpContact = $this->copperClient->contacts()->find($crmId);\n\n if (empty($cpContact)) {\n return null;\n }\n\n return $this->importContact($cpContact);\n }\n\n /**\n * @inheritdoc\n */\n private function importContact($crmData): Contact\n {\n $account = null;\n if ($crmData['company_id']) {\n $account = $this->team\n ->crm\n ->accounts()\n ->where('crm_provider_id', (string) $crmData['company_id'])\n ->first();\n\n if ($account === null) {\n $account = $this->syncAccount($crmData['company_id']);\n }\n }\n\n $countryCode = null;\n if (empty($crmData['address']) === false && $crmData['address']['country']) {\n $countryCode = $this->convertCountryNameToCode($crmData['address']['country']);\n }\n\n $mobilePhone = $parsedNumber = null;\n foreach ($crmData['phone_numbers'] as $phoneNumber) {\n if ($phoneNumber['category'] === 'mobile') {\n $mobilePhone = $this->buildContactMobilePhone($countryCode, $phoneNumber['number']);\n } else {\n $parsedNumber = $this->buildContactPhone($countryCode, $phoneNumber['number']);\n }\n }\n\n $email = null;\n if (empty($crmData['emails']) === false) {\n $email = mb_strimwidth($crmData['emails'][0]['email'], 0, 80);\n }\n\n $profile = $this->config->profiles()->where('crm_provider_id', (string) $crmData['assignee_id'])->first();\n\n $data = [\n 'team_id' => $this->team->id,\n 'account_id' => $account->id ?? null,\n 'user_id' => $profile ? $profile->user_id : null,\n 'owner_id' => $crmData['assignee_id'],\n 'name' => $crmData['name'],\n 'email' => $email,\n 'phone' => $parsedNumber['phone'] ?? null,\n 'mobile_phone' => $mobilePhone ?? null,\n 'ext' => $parsedNumber['ext'] ?? null,\n 'title' => $crmData['title'],\n 'country_code' => $countryCode,\n 'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(\n crmConfiguration: $this->config,\n crmProviderId: $crmData['id'],\n modelType: Contact::class,\n fileName: $crmData['id'],\n avatarText: $crmData['name']\n ),\n 'remotely_created_at' => $crmData['date_created'],\n ];\n\n return $this->config->contacts()->updateOrCreate(['crm_provider_id' => (string) $crmData['id']], $data);\n }\n\n private function buildContactPhone(?string $countryCode, ?string $number): ?array\n {\n if ($number) {\n // Trim to our width and attempt to parse it.\n $number = mb_strimwidth($number, 0, 25);\n $parsedNumber = parsePhoneNumber($countryCode, $number);\n } else {\n $parsedNumber = [];\n }\n\n return $parsedNumber;\n }\n\n private function buildContactMobilePhone(?string $countryCode, ?string $number): ?string\n {\n return $number ? mb_strimwidth(phone_e164($countryCode, $number), 0, 25) : null;\n }\n\n /**\n * @inheritdoc\n */\n public function syncOrganization(): void\n {\n $organization = $this->copperClient->organisation()->all();\n\n if ($this->config->crm_provider_id !== $organization['id']) {\n $this->config->update(['crm_provider_id' => $organization['id']]);\n }\n }\n\n private function syncCustomFields(): void\n {\n foreach ($this->getFieldTypes() as $fieldType) {\n $currentFields = $this->getFields($fieldType);\n\n foreach ($currentFields as $field) {\n /** @var Field $field */\n $field = $this->config->fields()->updateOrCreate([\n 'crm_provider_id' => $field['name'],\n 'object_type' => $fieldType,\n ], [\n 'label' => mb_strimwidth($field['label'], 0, Field::LABEL_MAX_LENGTH),\n 'type' => $field['type'],\n ]);\n\n if ($field->getType() === Field::TYPE_PICKLIST) {\n $this->importPicklistValues($field);\n }\n }\n }\n\n $this->config->save();\n }\n\n protected function getFieldTypes(): array\n {\n return [\n parent::OBJECT_TASK,\n parent::OBJECT_LEAD,\n parent::OBJECT_ACCOUNT,\n parent::OBJECT_CONTACT,\n parent::OBJECT_OPPORTUNITY,\n ];\n }\n\n /**\n * @inheritdoc\n */\n public function syncProfiles(?User $userToSearch = null): ?Profile\n {\n $profiles = $this->copperClient->users()->all();\n\n foreach ($profiles as $profile) {\n $user = $this->userRepository->findByTeamAndEmailAddress($this->team, $profile['email'], onlyActive: true);\n\n // Only import profiles for users we have locally.\n if ($user) {\n $profile = $user->crmProfile()->updateOrCreate([\n 'crm_configuration_id' => $this->config->id,\n 'crm_provider_id' => $profile['id'],\n ]);\n\n if ($userToSearch && $userToSearch->id === $user->id) {\n return $profile;\n }\n }\n }\n\n return null;\n }\n\n /**\n * @inheritdoc\n */\n public function syncProfileFields(): void\n {\n $fieldTypes = [\n 'task',\n 'lead',\n 'account',\n 'contact',\n 'opportunity',\n ];\n\n foreach ($fieldTypes as $fieldType) {\n try {\n $currentFields = $this->getFields(ucfirst($fieldType));\n\n $newFields = array_column($currentFields, 'name');\n $changedFields = array_merge(\n array_diff($this->profile->getFieldsAsArray($fieldType), $newFields),\n array_diff($newFields, $this->profile->getFieldsAsArray($fieldType))\n );\n\n if (\\count($changedFields) > 0) {\n $fields = implode(',', $newFields);\n $this->logger->debug(\n ucfirst($fieldType)\n . ' fields changed from '\n . $this->profile->{$fieldType . '_fields'}\n . ' to: '\n . $fields\n );\n\n $this->profile->{$fieldType . '_fields'} = $fields;\n }\n } catch (\\Exception $exception) {\n $this->logger->error('No access to ' . $fieldType . ' object, skipping...');\n\n continue;\n }\n }\n\n $this->profile->save();\n }\n\n /**\n * @inheritdoc\n */\n public function find(string $name, array $scopes): array\n {\n $count = $this->limit ?? 20;\n $offset = $this->offset ?? 0;\n\n $scopeValues = array_map(fn ($scope) => is_string($scope) ? $scope : $scope->value, $scopes);\n $cacheKey = sprintf('copper-search-%d-%s-%s-%d-%d', $this->team->id, $name, implode('-', $scopeValues), $count, $offset);\n\n return Cache::remember($cacheKey, 300, function () use ($name, $scopes, $count, $offset) {\n // Try local search first\n $localResults = $this->searchLocally($name, $scopes, $count, $offset);\n\n // If no local results, fall back to remote API search\n if (empty($localResults)) {\n return $this->searchRemotely($name, $scopes, $count, $offset);\n }\n\n return $localResults;\n });\n }\n\n /**\n * @inheritdoc\n */\n public function findOpportunities(?string $crmAccountId, ?string $crmContactId, ?int $userId = null): array\n {\n $data = [];\n $ownerData = [];\n $ownerId = null;\n $contact = null;\n $account = null;\n\n if ($crmAccountId) {\n $account = $this->config->accounts()->where('crm_provider_id', $crmAccountId)->first();\n\n if ($account === null) {\n $account = $this->syncAccount($crmAccountId);\n }\n }\n\n if ($crmContactId) {\n $contact = $this->config->contacts()->where('crm_provider_id', $crmContactId)->first();\n\n if ($contact === null) {\n $contact = $this->syncContact($crmContactId);\n }\n }\n\n if ($contact || $account) {\n if ($contact && $account === null) {\n $account = $contact->account;\n }\n\n if ($account === null) {\n return [];\n }\n\n $params = [\n 'company_ids' => [$account->crm_provider_id],\n 'status_ids' => [0], // The integer values are 0, 1, 2, 3, for \"Open\", \"Won\", \"Lost\", and \"Abandoned\".\n 'sort_by' => 'date_modified',\n 'sort_direction' => 'desc',\n ];\n\n switch ($this->config->opportunity_assignment_rule) {\n case Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_UPDATED:\n $params['sort_by'] = 'date_modified';\n $params['sort_direction'] = 'desc';\n\n break;\n\n case Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_CREATED:\n $params['sort_by'] = 'date_created';\n $params['sort_direction'] = 'desc';\n\n break;\n\n case Configuration::OPP_ASSIGNMENT_ALL_OPEN_OLDEST_CREATED:\n $params['sort_by'] = 'date_created';\n $params['sort_direction'] = 'asc';\n\n break;\n\n case Configuration::OPP_ASSIGNMENT_ALL_RECENTLY_UPDATED:\n $params['sort_by'] = 'date_modified';\n $params['sort_direction'] = 'desc';\n $params['status_ids'] = [0, 1, 2, 3];\n }\n\n $cpOpportunities = $this->copperClient->opportunities()->search($params);\n\n if ($userId) {\n $profileRepository = app(ProfileRepository::class);\n $profile = $profileRepository->findProfileByUserId($this->config, $userId);\n\n $ownerId = $profile instanceof Profile ? $profile->getCrmProviderId() : null;\n }\n\n foreach ($cpOpportunities as $cpOpportunity) {\n $businessProcess = $this->config\n ->businessProcesses()\n ->where('crm_provider_id', (string) $cpOpportunity['pipeline_id'])\n ->first();\n\n if ($businessProcess === null) {\n // Import it.\n $businessProcess = $this->importStages(\n [Stage::TYPE_OPPORTUNITY],\n $cpOpportunity['pipeline_stage_id']\n );\n }\n\n $stage = $businessProcess\n ->stages()\n ->where('crm_provider_id', (string) $cpOpportunity['pipeline_stage_id'])\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->first();\n\n if ($stage === null) {\n // Import it.\n $stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $cpOpportunity['pipeline_stage_id']);\n }\n\n $recordType = $businessProcess->recordTypes()->first();\n\n $record = [\n 'crmId' => $cpOpportunity['id'],\n 'name' => $cpOpportunity['name'],\n 'value' => currency_format($cpOpportunity['monetary_value'], $cpOpportunity['monetary_unit']),\n 'won' => $cpOpportunity['status'] === 'Won',\n 'stage' => [\n 'id' => $stage->id_string,\n 'name' => $stage->name,\n ],\n 'recordType' => [\n 'id' => $recordType->id_string,\n 'name' => $recordType->name,\n ],\n ];\n\n if ($ownerId && isset($cpOpportunity['assignee_id']) && $cpOpportunity['assignee_id'] === $ownerId) {\n $ownerData[] = $record;\n }\n\n $data[] = $record;\n }\n }\n\n if (! empty($ownerData)) {\n return $ownerData;\n }\n\n return $data;\n }\n\n /**\n * @inheritdoc\n */\n public function getTasks(string $objectType, string $objectId, ?string $opportunityId): array\n {\n $data = $cpTasks = [];\n\n switch ($objectType) {\n case 'lead':\n $cpTasks = $this->requestWithExceptionHandling(\n fn () => $this->copperClient->leads()->relatedTasks($objectId)\n );\n\n break;\n\n case 'account':\n $cpTasks = $this->copperClient->accounts()->relatedTasks($objectId);\n\n break;\n case 'contact':\n if ($opportunityId) {\n $cpTasks = $this->copperClient->opportunities()->relatedTasks($opportunityId);\n } elseif ($objectType === 'contact') {\n $cpTasks = $this->copperClient->contacts()->relatedTasks($objectId);\n }\n\n break;\n }\n\n foreach ($cpTasks as $cpTask) {\n // We only get the ID itself returned, so pull out the full object.\n try {\n $cpTask = $this->copperClient->tasks()->find($cpTask['id']);\n } catch (Exception $exception) {\n $this->logger->error('[Copper] Error fetching task', [\n 'resource_id' => $cpTask['id'],\n 'error' => $exception->getMessage(),\n ]);\n\n continue;\n }\n\n // Only allow open tasks.\n if ($cpTask['status'] === 'Open') {\n $data[] = [\n 'crmId' => $cpTask['id'],\n 'subject' => $cpTask['name'],\n 'due' => $cpTask['due_date']\n ? Carbon::createFromTimestamp($cpTask['due_date'])->format('Y-m-d\\TH:i:sO')\n : null,\n 'type' => null,\n ];\n }\n }\n\n return $data;\n }\n\n /**\n * Try to find email address in CRM service\n *\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n *}\n */\n public function matchExactlyByEmail(string $email, ?int $userId = null): ?array\n {\n $cpObjectType = 'contact';\n\n $cpObjects = $this->copperClient->contacts()->search([\n 'emails' => $email,\n 'sort_by' => 'date_modified',\n 'sort_direction' => 'desc',\n ]);\n\n if (empty($cpObjects)) {\n $cpObjectType = 'lead';\n $cpObjects = $this->requestWithExceptionHandling(\n fn () => $this->copperClient->leads()->search([\n 'emails' => $email,\n 'sort_by' => 'date_modified',\n 'sort_direction' => 'desc',\n ])\n );\n }\n\n if (empty($cpObjects)) {\n return null;\n }\n\n // Search returns an array of results, but we only want the first\n $cpObject = reset($cpObjects);\n $data = $this->convertCrmData($cpObject, $cpObjectType, $userId);\n\n return ! empty(array_filter($data)) ? $data : null;\n }\n\n /**\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n public function matchByPhone(string $phone, ?string $rawPhoneNumber = null, ?int $userId = null): ?array\n {\n // Check if the user is internal.\n $teamMember = $this->team->users()->where('phone', $phone)->exists();\n\n // Skip the attendee if internal.\n if ($teamMember === false) {\n $cpObjectType = 'contact';\n $cpObject = $this->copperClient->contacts()->search([\n 'phone_number' => $phone,\n 'sort_by' => 'date_modified',\n 'sort_direction' => 'desc',\n ]);\n\n if (\\count($cpObject) === 0) {\n $cpObjectType = 'lead';\n $cpObject = $this->requestWithExceptionHandling(\n fn () => $this->copperClient->leads()->search([\n 'phone_number' => $phone,\n 'sort_by' => 'date_modified',\n 'sort_direction' => 'desc',\n ])\n );\n }\n\n if (empty($cpObject)) {\n return null;\n }\n\n $cpObject = reset($cpObject);\n $data = $this->convertCrmData($cpObject, $cpObjectType, $userId);\n\n return ! empty(array_filter($data)) ? $data : null;\n }\n\n return null;\n }\n\n /**\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n public function matchByName(string $name, ?int $userId = null): ?array\n {\n // Don't waste time searching for single character strings.\n if (\\strlen($name) <= 1) {\n return null;\n }\n\n $result = \\Cache::remember($this->team->id . $name, 60, function () use ($name, $userId) {\n $cpObjectType = 'contact';\n $cpObject = $this->copperClient->contacts()->search([\n 'name' => $name,\n 'sort_by' => 'date_modified',\n 'sort_direction' => 'desc',\n ]);\n\n if (\\count($cpObject) === 0) {\n $cpObjectType = 'lead';\n $cpObject = $this->requestWithExceptionHandling(\n fn () => $this->copperClient->leads()->search([\n 'name' => $name,\n 'sort_by' => 'date_modified',\n 'sort_direction' => 'desc',\n ])\n );\n }\n\n if (! empty($cpObject)) {\n $cpObject = reset($cpObject);\n }\n\n $data = $this->convertCrmData($cpObject, $cpObjectType, $userId);\n\n return ! empty(array_filter($data)) ? $data : false;\n });\n\n return is_array($result) ? $result : null;\n }\n\n private function convertCrmData(array $object, string $objectType, ?int $userId = null): array\n {\n $lead = null;\n $contact = null;\n $opportunity = null;\n $account = null;\n $stage = null;\n $countryCode = null;\n\n if ($objectType === 'lead') {\n $lead = $this->importLead($object);\n\n // Lead might not be imported if the Stage is null for example.\n if ($lead) {\n $countryCode = $lead->country_code;\n $stage = $lead->stage;\n }\n } else {\n $contact = $this->importContact($object);\n $account = $contact->account;\n\n if ($contact->country_code) {\n $countryCode = $contact->country_code;\n } elseif ($account) {\n $countryCode = $account->country_code;\n }\n\n try {\n $cpOpportunities = $this->findOpportunities(\n $account?->crm_provider_id,\n $contact->crm_provider_id,\n $userId,\n );\n\n if (! empty($cpOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($cpOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n } catch (\\Exception $e) {\n // Nothing to see here.\n }\n }\n\n return [\n $lead,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n }\n\n public function saveActivity(Activity $activity): Activity\n {\n switch ($activity->type) {\n case Activity::TYPE_CONFERENCE:\n case Activity::TYPE_SOFTPHONE:\n case Activity::TYPE_SOFTPHONE_INBOUND:\n $activity = $this->buildCallPayload($activity);\n\n break;\n case Activity::TYPE_SMS_INBOUND:\n case Activity::TYPE_SMS_OUTBOUND:\n $activity = $this->buildTextMessagePayload($activity);\n\n break;\n }\n\n return $activity;\n }\n\n private function convertActivityAssociations(Activity $activity): array\n {\n if ($activity->lead) {\n $type = 'lead';\n $id = $activity->lead->crm_provider_id;\n } elseif ($activity->contact) {\n $type = 'person';\n $id = $activity->contact->crm_provider_id;\n } elseif ($activity->opportunity) {\n $type = 'opportunity';\n $id = $activity->opportunity->crm_provider_id;\n } else {\n $type = 'company';\n $id = $activity->account->crm_provider_id;\n }\n\n return [\n 'type' => $type,\n 'id' => $id,\n ];\n }\n\n private function convertActivityType(PlaybookCategory $activityType): array\n {\n return [\n 'category' => 'user',\n 'id' => $this->getCrmType($activityType)->value,\n ];\n }\n\n /**\n * @throws CrmException\n */\n private function buildCallPayload(Activity $activity): Activity\n {\n try {\n if ($activity->crm_provider_id) {\n // The activity should be logged under the existing Task (not Activity).\n $newActivityType = $this->getCrmType($activity->category);\n if ($newActivityType === null) {\n $message = sprintf('[Copper] invalid CRM Custom Activity Type: %s', $activity->category);\n\n throw new CrmException($message, 400);\n }\n $this->copperClient->tasks()->update($activity->crm_provider_id, [\n 'status' => 'Completed',\n 'custom_activity_type_id' => $newActivityType->value,\n 'details' => $this->generateActivityDescription($activity),\n ]);\n } else {\n if ($this->getCrmType($activity->category) === null) {\n $message = sprintf('[Copper] Invalid CRM Activity Type: %s', $activity->category->getName());\n\n throw new CrmException($message, 400);\n }\n\n if ($this->getCrmType($activity->category)->value === null) {\n $message = sprintf('[Copper] Invalid CRM Activity Type: %s value', $activity->category->getName());\n\n throw new CrmException($message, 400);\n }\n // Just create an activity.\n $cpActivity = $this->copperClient->activities()->create([\n 'user_id' => $this->profile->crm_provider_id,\n 'activity_date' => $activity->scheduled_start_time->timestamp,\n 'parent' => $this->convertActivityAssociations($activity),\n 'type' => $this->convertActivityType($activity->category),\n 'details' => $this->generateActivityDescription($activity),\n ]);\n\n $activity->crm_provider_id = $cpActivity['id'];\n $activity->save();\n }\n } catch (ClientException $exception) {\n //Log all errors to Sentry\n \\Sentry::captureException($exception);\n\n $response = $exception->getResponse();\n\n $responseBody = $response->getBody();\n $message = $responseBody;\n $errorCode = $response->getStatusCode();\n\n $jsonResponse = json_decode($responseBody, true);\n if (isset($jsonResponse[0]['message'])) {\n $message = $jsonResponse[0]['message'];\n }\n\n throw new CrmException($message, $errorCode);\n }\n\n return $activity;\n }\n\n private function buildTextMessagePayload(Activity $activity): Activity\n {\n if ($this->getCrmType($activity->category) === null) {\n $message = sprintf('[Copper] Invalid CRM Activity Type: %s', $activity->category->getName());\n\n throw new CrmException($message, 400);\n }\n\n if ($this->getCrmType($activity->category)->value === null) {\n $message = sprintf('[Copper] Invalid CRM Activity Type: %s value', $activity->category->getName());\n\n throw new CrmException($message, 400);\n }\n\n $cpActivity = $this->copperClient->activities()->create([\n 'user_id' => $this->profile->crm_provider_id,\n 'activity_date' => $activity->created_at->timestamp,\n 'parent' => $this->convertActivityAssociations($activity),\n 'type' => $this->convertActivityType($activity->category),\n 'details' => $this->generateActivityDescription($activity),\n ]);\n\n $activity->crm_provider_id = $cpActivity['id'];\n $activity->save();\n\n return $activity;\n }\n\n private function generateActivityDescription(Activity $activity): string\n {\n $description = '';\n\n switch ($activity->type) {\n case Activity::TYPE_SOFTPHONE:\n case Activity::TYPE_SOFTPHONE_INBOUND:\n case Activity::TYPE_CONFERENCE:\n\n $description = 'Jiminny Call Summary' . PHP_EOL . PHP_EOL;\n $description .= 'Call Duration: ' . $activity->duration_for_humans . PHP_EOL . PHP_EOL;\n\n // Tell them which number was dialed.\n if ($activity->to) {\n $description .= 'Dialed: ' . $activity->to->phone_number . PHP_EOL . PHP_EOL;\n }\n\n if ($activity->hasReasonCodeBotKicked()) {\n $description .= 'Notetaker removed from this meeting' . PHP_EOL . PHP_EOL;\n // When we fix the state to be Activity::RECORDING_RECORDED as it should be this can change.\n } elseif ($activity->hasReasonCodeNotCompliant()) {\n $description .= 'Notetaker did not join due to recording consent not being provided by attendees' . PHP_EOL . PHP_EOL;\n } elseif ($activity->canReviewActivity()) {\n $playbackUrl = $activity->user->team->partner->getPlaybackUrl($activity);\n $description .= 'Review Activity:' . PHP_EOL . $playbackUrl . PHP_EOL . PHP_EOL;\n }\n\n if ($activity->getCrmType() === Activity::TYPE_CONFERENCE) {\n $description .= 'Attendees:'\n . PHP_EOL\n . (new FilterJoinedParticipants())->toString($activity);\n }\n\n if (\\count($activity->notes) > 0) {\n $description .= PHP_EOL . PHP_EOL . 'Notes: ' . PHP_EOL;\n\n foreach ($activity->notes as $note) {\n $nTime = (int) ceil($note->time);\n $time = ($nTime > 3600) ? gmdate('H:i:s', $nTime) : gmdate('i:s', $nTime);\n $description .= $time . ' ' . $note->note . PHP_EOL;\n }\n }\n\n // Get all private messages.\n $messages = $activity->messages()\n ->where('is_private', 1)\n ->orderBy('created_at', 'asc');\n\n if ($messages->count() > 0) {\n $description .= PHP_EOL . PHP_EOL . 'Coaching Chat: ' . PHP_EOL;\n\n foreach ($messages->get() as $message) {\n $description .= $message->participant->name . ': ' . $message->message . PHP_EOL;\n }\n }\n\n // Get all public messages.\n $messages = $activity->messages()\n ->where('is_private', 0)\n ->orderBy('created_at', 'asc');\n\n if ($messages->count() > 0) {\n $description .= PHP_EOL . PHP_EOL . 'Customer Chat: ' . PHP_EOL;\n\n foreach ($messages->get() as $message) {\n $description .= $message->participant->name . ': ' . $message->message . PHP_EOL;\n }\n }\n\n if ($activity->summary) {\n $description .= PHP_EOL . PHP_EOL . 'Summary: ' . PHP_EOL . $activity->summary;\n }\n\n break;\n\n case Activity::TYPE_SMS_INBOUND:\n case Activity::TYPE_SMS_OUTBOUND:\n $description = $activity->description;\n\n break;\n }\n\n return $description;\n }\n\n public function saveFollowupActivity(Activity $activity, array $fields): ?string\n {\n // This is the user provided activity subject field.\n if (empty($fields['type'])) {\n return null;\n }\n\n $reminder = null;\n if (empty($fields['reminder_date']) === false) {\n $formatReminder = Carbon::createFromFormat('Y-m-d\\TH:i:s\\Z', $fields['reminder_date']);\n $reminder = $formatReminder->getTimestamp();\n }\n\n $due = null;\n if (empty($fields['due_date']) === false) {\n $formatDue = Carbon::createFromFormat('Y-m-d\\TH:i:s\\Z', $fields['due_date']);\n $due = $formatDue->getTimestamp();\n }\n\n $data = [\n 'custom_activity_type_id' => $fields['type'],\n 'status' => 'Open',\n 'assignee_id' => $this->profile->crm_provider_id,\n 'due_date' => $due,\n 'details' => $fields['details'] ?? '',\n 'reminder_date' => $reminder,\n 'name' => $fields['name'] ?? 'Follow-up',\n 'related_resource' => $this->convertActivityAssociations($activity),\n 'custom_fields' => $this->fetchCustomFieldData($activity, Field::OBJECT_TASK),\n ];\n\n // We don't actually create a corresponding activity object on our side yet.\n return $this->copperClient->tasks()->create($data)['id'];\n }\n\n /**\n * Store transcripts as note.\n *\n * @throws \\Exception\n */\n public function createTranscriptNotes(Activity $activity): void\n {\n // Generate activity transcription.\n $transcriptionData = $this->generateTranscription($activity);\n\n // Truncate Notes with max notes length because transcription text could be very long.\n $transcripts = mb_strimwidth($transcriptionData, 0, static::NOTE_BODY_MAX_LENGTH);\n\n $data = [\n 'user_id' => $this->profile->crm_provider_id,\n 'type' => [\n 'category' => 'user',\n 'id' => 0, // This creates a note.\n ],\n 'activity_date' => time(),\n 'details' => $transcripts,\n 'parent' => $this->convertActivityAssociations($activity),\n ];\n\n $cpActivity = $this->copperClient->activities()->create($data);\n\n // Store CRM Activity ID in transcription.\n $transcription = $activity->getTranscription();\n $transcription->crm_activity_id = $cpActivity['id'];\n $transcription->save();\n }\n\n /**\n * @inheritdoc\n */\n public function updateStage($crmObject, Stage $stage): void\n {\n if ($crmObject instanceof Lead) {\n $this->requestWithExceptionHandling(\n fn () => $this->copperClient->leads()->update($crmObject->crm_provider_id, [\n 'status_id' => $stage->crm_provider_id,\n ])\n );\n } else {\n $this->copperClient->opportunities()->update($crmObject->crm_provider_id, [\n 'pipeline_stage_id' => $stage->crm_provider_id,\n ]);\n }\n }\n\n public function prepareValueForUpdate(array $params): array\n {\n return [$params['fieldName'] => $params['fieldValue']];\n }\n\n /*\n * @inheritdoc\n */\n public function updateRecord(string $objectType, string $objectId, array $data, array $headers = []): void\n {\n $preparedData = $data;\n\n if ($objectType === Field::OBJECT_OPPORTUNITY) {\n $preparedData = $this->prepareOpportunityFieldsForUpdate($data);\n }\n\n try {\n $this->copperClient\n ->createResourceFromObjectType($objectType)\n ->update($objectId, $preparedData)\n ;\n } catch (Exception $e) {\n $this->logger->error(self::RESOURCE_UPDATE_FAILED_ERROR_MESSAGE, [\n 'resource_id' => $objectId,\n 'data' => $preparedData,\n 'error' => $e->getMessage(),\n ]);\n\n throw new CrmException(self::RESOURCE_UPDATE_FAILED_ERROR_MESSAGE, $e->getCode(), $e);\n }\n }\n\n /**\n * @param array<string,string> $fieldData\n *\n * @throws CrmException\n *\n * @return array<string,array<int,array<string,bool|float|int|string|null>>|bool|float|int|string|null>\n */\n private function prepareOpportunityFieldsForUpdate(array $fieldData): array\n {\n $preparedData = [];\n $customFields = [];\n\n foreach ($fieldData as $fieldName => $fieldValue) {\n $field = $this->config\n ->fields()\n ->where('crm_provider_id', $fieldName)\n ->first();\n\n if (! $field instanceof Field) {\n throw new CrmException('Field not found');\n }\n\n $convertedValue = $this->fieldValueConverter->convertToCrm($field->getType(), $fieldValue);\n\n if ($this->isStandardField($fieldName)) {\n $preparedData[$fieldName] = $convertedValue;\n\n continue;\n }\n\n $customFields[] = [\n 'custom_field_definition_id' => $fieldName,\n 'value' => $convertedValue,\n ];\n }\n\n if (! empty($customFields)) {\n $preparedData['custom_fields'] = $customFields;\n }\n\n return $preparedData;\n }\n\n /*\n * @inheritdoc\n */\n public function getRecord(string $objectType, string $objectId, array $fields = []): array\n {\n return $this->copperClient->createResourceFromObjectType($objectType)->find($objectId);\n }\n\n private function fetchCustomFieldData(Activity $activity, string $objectType): array\n {\n $payload = [];\n\n $fieldData = FieldData::where([\n 'object_id' => $activity->id,\n ])->whereHas('field', function ($query) use ($objectType) {\n $query->where('object_type', $objectType);\n })->get();\n\n foreach ($fieldData as $data) {\n // Add the field and value to the payload.\n $payload += [\n 'custom_field_definition_id' => $data->field->crm_provider_id,\n 'value' => $data->value,\n ];\n }\n\n return $payload;\n }\n\n public function generateProviderUrl(string $providerId, string $objectType): ?string\n {\n $baseUrl = 'https://app.prosperworks.com/companies/' . $this->config->crm_provider_id . '/app#/';\n switch ($objectType) {\n case 'lead':\n $url = $baseUrl . 'leads/' . $providerId;\n\n break;\n\n case 'account':\n $url = $baseUrl . 'organization/' . $providerId;\n\n break;\n\n case 'contact':\n $url = $baseUrl . 'contact/' . $providerId;\n\n break;\n\n case 'opportunity':\n $url = $baseUrl . 'deal/' . $providerId;\n\n break;\n\n default:\n case 'task':\n case 'activity':\n // Sadly we can't deeplink to these in Copper UI.\n $url = null;\n }\n\n return $url;\n }\n\n /**\n * Generate transcription for the activity.\n */\n private function generateTranscription(Activity $activity): string\n {\n if (! ($this->config->store_transcript)) {\n // If sending transcription to activity toggle is disabled\n return '';\n }\n\n return $this->transcriptionService\n ->findTranscriptionByActivity($activity)\n ->map(static function (array $transcriptionSegment): string {\n return $transcriptionSegment['formattedStartsAt'] . ' | ' . $transcriptionSegment['transcript'];\n })\n ->implode(PHP_EOL);\n }\n\n /**\n * @param array<string, mixed> $opportunityData\n *\n * @return int|string|bool|float|null\n */\n private function extractFieldValue(array $opportunityData, string $fieldName)\n {\n $fieldValue = $this->isStandardField($fieldName)\n ? $opportunityData[$fieldName]\n : $this->resolveCustomFieldFromOpportunityData($opportunityData, $fieldName);\n\n if (is_array($fieldValue)) {\n $fieldValue = implode(', ', $fieldValue);\n }\n\n return $fieldValue;\n }\n\n private function isStandardField(string $fieldName): bool\n {\n return array_key_exists($fieldName, self::STANDARD_OPPORTUNITY_FIELDS);\n }\n\n /**\n * @param array<string, mixed> $opportunityData\n *\n * @return string|int|float|bool|null|array<string, mixed>\n */\n private function resolveCustomFieldFromOpportunityData(array $opportunityData, string $fieldName)\n {\n $fieldData = array_filter(\n $opportunityData['custom_fields'],\n static fn ($field) => (string) $field['custom_field_definition_id'] === $fieldName\n );\n\n return array_pop($fieldData)['value'] ?? '';\n }\n\n private function isSupportedObjectType(string $objectType): bool\n {\n return $objectType !== self::UNSUPPORTED_OBJECT_TYPE;\n }\n\n private function syncStandardFields(): void\n {\n foreach (self::STANDARD_OPPORTUNITY_FIELDS as $fieldId => $fieldData) {\n $this->config->fields()->updateOrCreate([\n 'crm_provider_id' => mb_strimwidth($fieldId, 0, 128),\n 'object_type' => Field::OBJECT_OPPORTUNITY,\n ], [\n 'label' => mb_strimwidth($fieldData['label'], 0, Field::LABEL_MAX_LENGTH),\n 'type' => $fieldData['type'],\n ]);\n }\n\n $this->importStages([Stage::TYPE_OPPORTUNITY]);\n }\n\n private function requestWithExceptionHandling(\\Closure $apiRequest): iterable\n {\n try {\n return $apiRequest();\n } catch (\\Exception $exception) {\n if ($exception->getCode() === 403) {\n // Object might not be supported\n return [];\n }\n\n \\Sentry::captureException($exception);\n\n return [];\n }\n }\n\n public function verifyTaskExists(Activity $activity): bool\n {\n $crmProviderId = $activity->getCrmProviderId();\n $cacheKey = \"crm_task_exists:{$this->config->getId()}:$crmProviderId\";\n\n return Cache::remember($cacheKey, self::TASK_VERIFICATION_CACHE_TTL, function () use ($crmProviderId) {\n try {\n if ($this->copperClient === null) {\n return false;\n }\n\n $task = $this->copperClient->tasks()->find($crmProviderId);\n\n return ! empty($task);\n } catch (HttpNotFoundException) {\n // Task not found in CRM - this is expected and permanent\n $this->logger->info('[Copper] Task not found during verification', [\n 'task_id' => $crmProviderId,\n 'config_id' => $this->config->getId(),\n ]);\n\n return false;\n } catch (RequestFailedException $e) {\n // Handle 404 responses from Copper API\n if ($e->getCode() === 404) {\n $this->logger->info('[Copper] Task not found during verification (404)', [\n 'task_id' => $crmProviderId,\n 'config_id' => $this->config->getId(),\n ]);\n\n return false;\n }\n\n // Re-throw other Copper exceptions for retry\n throw $e;\n }\n // Let other exceptions (network errors, rate limits, etc.) bubble up for retry\n });\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":true,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Execute","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Explain Plan","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Browse Query History","depth":4,"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"View Parameters","depth":4,"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
2931661465023499269
|
-7959665055215974171
|
click
|
accessibility
|
NULL
|
Project: faVsco.js, menu
#12066 on JY-20725-handle Project: faVsco.js, menu
#12066 on JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
HandleHubspotRateLimitTest
Run 'HandleHubspotRateLimitTest'
Debug 'HandleHubspotRateLimitTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
Analyzing…
<?php
namespace Jiminny\Services\Crm\Copper;
use Carbon\Carbon;
use Exception;
use GuzzleHttp\Exception\ClientException;
use Illuminate\Support\Facades\Cache;
use Jiminny\Contracts\Services\Crm\LayoutManagementInterface;
use Jiminny\Contracts\Services\Crm\MatchCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\Provider\CopperInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityLookupInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityManipulationInterface;
use Jiminny\Contracts\Services\Crm\SettingsInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmMetadataInterface;
use Jiminny\Contracts\Services\Crm\VerifyTaskExistsInterface;
use Jiminny\Exceptions\CrmException;
use Jiminny\Exceptions\HttpNotFoundException;
use Jiminny\Models\Account;
use Jiminny\Models\Activity;
use Jiminny\Models\Contact;
use Jiminny\Models\Crm\BusinessProcess;
use Jiminny\Models\Crm\Configuration;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\FieldData;
use Jiminny\Models\Crm\FieldValue;
use Jiminny\Models\Crm\LayoutEntity;
use Jiminny\Models\Crm\Profile;
use Jiminny\Models\Lead;
use Jiminny\Models\Opportunity;
use Jiminny\Models\Playbook;
use Jiminny\Models\PlaybookCategory;
use Jiminny\Models\SocialAccount;
use Jiminny\Models\Stage;
use Jiminny\Models\User;
use Jiminny\Repositories\Crm\CrmEntityRepository;
use Jiminny\Repositories\Crm\FieldRepository;
use Jiminny\Repositories\Crm\ProfileRepository;
use Jiminny\Repositories\UserRepository;
use Jiminny\Services\Avatar\ProspectPhotoPathService;
use Jiminny\Services\Crm\BaseService;
use Jiminny\Services\Crm\Copper\Api\Client;
use Jiminny\Services\Crm\Copper\Api\ClientFactory;
use Jiminny\Services\Crm\Copper\Exception\RequestFailedException;
use Jiminny\Services\Crm\Helpers\FilterJoinedParticipants;
use Jiminny\Services\Crm\OpportunitySyncStrategyResolver;
use Jiminny\Services\Crm\Copper\Traits\SearchesCrmEntities;
use Jiminny\Utils\CurrencyFormatter;
use Jiminny\Utils\StringUtil;
class Service extends BaseService implements
CopperInterface,
SyncCrmEntitiesInterface,
SyncCrmMetadataInterface,
RemoteEntityManipulationInterface,
LayoutManagementInterface,
SettingsInterface,
MatchCrmEntitiesInterface,
RemoteEntityLookupInterface,
VerifyTaskExistsInterface
{
use SearchesCrmEntities;
private const int NOTE_BODY_MAX_LENGTH = 3000000;
private const int TASK_VERIFICATION_CACHE_TTL = 86400; // 1 day
private const array STANDARD_OPPORTUNITY_FIELDS = [
'monetary_value' => [
'label' => 'Value',
'available_on' => Field::OBJECT_OPPORTUNITY,
'type' => Field::TYPE_CURRENCY,
],
'pipeline_stage_id' => [
'label' => 'Stage',
'type' => Field::TYPE_PICKLIST,
'available_on' => Field::OBJECT_OPPORTUNITY,
],
'close_date' => [
'label' => 'Close date',
'type' => Field::TYPE_DATE,
'available_on' => Field::OBJECT_OPPORTUNITY,
],
'win_probability' => [
'label' => 'Win probability',
'type' => Field::TYPE_NUMBER,
'available_on' => Field::OBJECT_OPPORTUNITY,
],
];
private const string UNSUPPORTED_OBJECT_TYPE = 'unsupported';
private const string OPPORTUNITY_STATUS_OPEN = 'Open';
private const string OPPORTUNITY_IMPORT_FAILED_ERROR_MESSAGE = 'Opportunity import failed';
private const string RESOURCE_UPDATE_FAILED_ERROR_MESSAGE = 'Failed to update resource';
private FieldValueConverter $fieldValueConverter;
private ClientFactory $clientFactory;
private ?Client $copperClient = null;
public function __construct(
ClientFactory $clientFactory,
FieldValueConverter $fieldValueConverter,
private readonly UserRepository $userRepository,
private readonly ProspectPhotoPathService $prospectPhotoPathService,
private readonly CrmEntityRepository $crmEntityRepository
) {
parent::__construct();
$this->clientFactory = $clientFactory;
$this->fieldValueConverter = $fieldValueConverter;
}
public function getDisplayName(): string
{
return 'Copper';
}
/**
* Get the CRM entity repository (used by SearchesCrmEntities trait)
*/
protected function getCrmEntityRepository(): CrmEntityRepository
{
return $this->crmEntityRepository;
}
public function getMinimumApiVersion(): string
{
return Client::MIN_API_VERSION;
}
protected function getOAuthAccount(User $user): ?SocialAccount
{
// In this case, the Account Owner is always the connection for any API operations.
$owner = $user->team->owner;
return $owner->getSocialAccount(SocialAccount::PROVIDER_COPPER);
}
public function setUser(User $user): void
{
$this->configureSentryScope($user);
$this->team = $user->getTeam();
$this->teamService->setTeam($user->getTeam());
$this->setConfiguration($user->getTeam()->getCrmConfiguration());
// Reset state.
$this->profile = $user->getProfile();
if (! $this->profile instanceof Profile) {
// Try to fetch their profile, maybe it was not yet imported?
$this->profile = $this->getProfile($user);
}
}
public function setConfiguration(Configuration $configuration): void
{
$this->config = $configuration;
$this->copperClient = $this->clientFactory->create($configuration);
if ($this->team === null) {
$this->team = $configuration->getTeam();
}
}
public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string
{
return $this->fieldValueConverter->convertFromCrm($fieldType, $fieldValue);
}
private function mapObjectType(string $objectType): string
{
$objectType = strtolower($objectType);
switch ($objectType) {
case Field::OBJECT_TASK:
case Field::OBJECT_OPPORTUNITY:
case Field::OBJECT_LEAD:
$mappedType = $objectType;
break;
case Field::OBJECT_ACCOUNT:
$mappedType = 'company';
break;
case Field::OBJECT_CONTACT:
$mappedType = 'person';
break;
default:
$mappedType = self::UNSUPPORTED_OBJECT_TYPE;
break;
}
return $mappedType;
}
/**
* @param string $crmObject The name of the CRM object. i.e. Opportunity or Contact
*/
protected function getFields(string $crmObject): array
{
$mappedObjectType = $this->mapObjectType($crmObject);
if (! $this->isSupportedObjectType($mappedObjectType)) {
return [];
}
$customFields = $this->copperClient->fields()->all();
$filteredFields = array_filter(
$customFields,
static fn ($field) => in_array($mappedObjectType, $field['available_on'], true)
);
return array_map(
fn (array $property) => [
'name' => $property['id'],
'label' => $property['name'],
'type' => $this->convertFieldType($property['data_type']),
],
$filteredFields
);
}
/**
* @inheritdoc
*/
public function getDefaultFields(string $activityType): array
{
$fields = [];
if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {
$defaultFields = FieldDefinitions::defaultTaskFields();
// This lazy creates these fields if not already setup.
foreach ($defaultFields as $defaultField) {
$fields[] = $this->config->fields()->firstOrCreate($defaultField);
}
}
return $fields;
}
/**
* @inheritdoc
*/
public function getDefaultActivityField(string $activityType): Field
{
// Setup the activity field as the default Type.
$activityField = $this->config->fields()->where([
'crm_provider_id' => 'type',
'object_type' => $activityType,
])->first();
return $activityField;
}
/**
* @inheritdoc
*/
public function getSupportedPlaybookTypes(): array
{
return [Playbook::ACTIVITY_TYPE_TASK];
}
public function getDealInsightsFields(): array
{
return FieldDefinitions::dealInsightsFields();
}
protected function getDefaultFollowupLayoutFields(string $activityType): array
{
$fields = [];
$fieldRepo = app(FieldRepository::class);
$fieldFilters = FieldDefinitions::taskFollowupFieldsFilter();
foreach ($fieldFilters as $data) {
$field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $data);
// Only add the field if it is created, which it should be.
if ($field) {
$fields[] = $field;
}
}
return $fields;
}
public function syncFields(): void
{
$this->syncStandardFields();
$this->syncCustomFields();
}
public function syncField(Field $field): void
{
if ($field->getCrmProviderId() === 'type') {
/**
* This field is a special case, it is not a custom field and is not returned by the Copper API.
*
* The field is created manually during layout setup.
*
* @see getDefaultFields()
*
* There is a separate endpoint to get the activity types.
* @see importPicklistValues()
*/
return;
}
if ($field->getCrmProviderId() === 'pipeline_stage_id') {
// Not a custom type
return;
}
$crmField = $this->copperClient->fields()->find($field->getCrmProviderId());
$field->label = $crmField['name'];
$field->type = $this->convertFieldType($crmField['data_type']);
$field->save();
}
/**
* @param string $fieldType How the data should be input
*/
private function convertFieldType(string $fieldType): string
{
$fieldType = strtolower($fieldType);
switch ($fieldType) {
case 'dropdown':
$convertedType = Field::TYPE_PICKLIST;
break;
case 'string':
$convertedType = Field::TYPE_TEXT;
break;
case 'float':
case 'percentage':
$convertedType = Field::TYPE_NUMBER;
break;
case 'text':
$convertedType = Field::TYPE_TEXTAREA;
break;
case 'checkbox':
$convertedType = Field::TYPE_BOOLEAN;
break;
case Field::TYPE_DATE:
case Field::TYPE_CURRENCY:
case Field::TYPE_URL:
$convertedType = $fieldType;
break;
default:
$convertedType = Field::TYPE_UNSUPPORTED;
break;
}
return $convertedType;
}
/**
* @inheritdoc
*/
public function importPicklistValues(Field $field): array
{
$fieldValues = [];
if ($field->object_type === 'opportunity' && $field->crm_provider_id === 'pipeline_stage_id') {
return $this->importFieldValuesForDealInsights($field);
}
$values = [];
if ($field->object_type === Field::OBJECT_TASK && $field->crm_provider_id === 'type') {
$activityTypes = $this->copperClient->activityTypes()->all();
/**
* We ignore "system" activity types, disabled activity types and the "Note" activity type.
*/
$activityTypes = array_filter(
$activityTypes['user'],
fn (array $activityType) => ! $activityType['is_disabled'] && $activityType['id'] !== 0
);
// Import all activity values.
$i = 0;
foreach ($activityTypes as $activityType) {
$values[] = [
'value' => $activityType['id'],
'label' => mb_strimwidth($activityType['name'], 0, Field::LABEL_MAX_LENGTH),
'sequence' => $i++,
];
}
} else {
// We just check for the field in the custom definitions.
$crmField = $this->copperClient->fields()->find($field->crm_provider_id);
// Import all values.
foreach ($crmField['options'] as $option) {
$values[] = [
'value' => $option['id'],
'label' => mb_strimwidth($option['name'], 0, 191),
'sequence' => $option['rank'],
];
}
}
// Take the existing set of field values.
$fieldsToPurge = $field->values()->get()->pluck('value')->toArray();
foreach ($values as $value) {
// For all current values, upsert the field.
$value['value'] = substr($value['value'] ?? '', 0, 255);
$fieldValues[] = $field->values()->updateOrCreate([
'value' => $value['value'],
], $value);
// Remove this value from the ones we are going to purge.
$key = array_search($value['value'], $fieldsToPurge, false);
if ($key !== false) {
unset($fieldsToPurge[$key]);
}
}
// Delete the old values that are no longer used.
$field->values()->whereIn('value', $fieldsToPurge)->delete();
return $fieldValues;
}
/**
*
* @return FieldValue[]
*/
private function importFieldValuesForDealInsights(Field $field): array
{
$fieldValues = [];
$pipelines = $this->copperClient->pipelines()->all();
foreach ($pipelines as $pipeline) {
foreach ($pipeline['stages'] as $i => $dealStage) {
$fieldValues[] = $field->values()->updateOrCreate(
['value' => $dealStage['id']],
[
'label' => $dealStage['name'],
'sequence' => $i,
]
);
}
}
return $fieldValues;
}
/**
* @inheritdoc
*/
public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage
{
if ($types === null) {
$types = [Stage::TYPE_LEAD, Stage::TYPE_OPPORTUNITY];
}
$missingStage = null;
foreach ($types as $type) {
if ($type === Stage::TYPE_LEAD) {
$statuses = $this->requestWithExceptionHandling(
fn () => $this->copperClient->leadStatuses()->all()
);
if (count($statuses) > 0) {
$businessProcess = $this->config->businessProcesses()->updateOrCreate([
'crm_provider_id' => BusinessProcess::TYPE_LEAD,
], [
'team_id' => $this->team->id,
'name' => BusinessProcess::TYPE_LEAD,
'type' => BusinessProcess::TYPE_LEAD,
'is_selectable' => true,
]);
// A record type is really a clone of the business process, used to store which record uses which pipeline.
$this->config->recordTypes()->updateOrCreate([
'crm_provider_id' => BusinessProcess::TYPE_LEAD,
], [
'team_id' => $this->team->id,
'name' => BusinessProcess::TYPE_LEAD,
'is_selectable' => true,
'business_process_id' => $businessProcess->id ?? null,
]);
$leadStatuses = [];
foreach ($statuses as $status) {
// Upsert all statuses for the team.
$status = $this->config->stages()->updateOrCreate([
'crm_provider_id' => $status['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($status['name'], 0, 50),
'label' => mb_strimwidth($status['name'], 0, 191),
'type' => Stage::TYPE_LEAD,
'sequence' => $status['order'],
'is_selectable' => true,
]);
if ($missingStageName && $missingStageName === $status['id']) {
$missingStage = $status;
}
$leadStatuses[] = $status['id'];
}
$businessProcess->stages()->sync($leadStatuses);
}
} else {
// We create a business process to contain the pipeline, and later store all stages against it.
foreach ($this->copperClient->pipelines()->all() as $pipeline) {
$businessProcess = $this->config->businessProcesses()->updateOrCreate([
'crm_provider_id' => $pipeline['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($pipeline['name'], 0, 150),
'type' => BusinessProcess::TYPE_OPPORTUNITY,
'is_selectable' => true,
]);
// A record type is really a clone of the business process, used to store which record uses which pipeline.
$this->config->recordTypes()->updateOrCreate([
'crm_provider_id' => $pipeline['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($pipeline['name'], 0, 150),
'is_selectable' => true,
'business_process_id' => $businessProcess->id ?? null,
]);
$stages = [];
foreach ($pipeline['stages'] as $i => $dealStage) {
// Upsert all stages for the team.
$stage = $this->config->stages()->updateOrCreate([
'crm_provider_id' => $dealStage['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($dealStage['name'], 0, 50),
'label' => mb_strimwidth($dealStage['name'], 0, 191),
'type' => Stage::TYPE_OPPORTUNITY,
'sequence' => $i,
'is_selectable' => true,
'probability' => $dealStage['win_probability'],
]);
if ($missingStageName && $missingStageName === $dealStage['id']) {
$missingStage = $stage;
}
$stages[] = $stage['id'];
}
$businessProcess->stages()->sync($stages);
}
}
}
return $missingStage;
}
/**
* @inheritdoc
*/
public function syncLeads(Carbon $since, ?Carbon $to = null, ?string $crmProfileId = null): int
{
$syncCount = 0;
$cpLeads = $this->requestWithExceptionHandling(
fn () => $this->copperClient->leads()->paginate([
'minimum_modified_date' => $since->timestamp,
'sort_by' => 'date_modified',
])
);
foreach ($cpLeads as $cpLead) {
// Only sync if previously imported.
if ($this->hasLead($cpLead['id']) === false) {
$this->importLead($cpLead);
$syncCount++;
}
}
return $syncCount;
}
/**
* @inheritdoc
*/
public function syncLead(string $crmId): ?Lead
{
$cpLead = $this->requestWithExceptionHandling(
fn () => $this->copperClient->leads()->find($crmId)
);
if (\count($cpLead) === 0) {
return null;
}
return $this->importLead($cpLead);
}
/**
* @inheritdoc
*/
private function importLead($crmData): ?Lead
{
$countryCode = null;
if (! empty($crmData['address']) && isset($crmData['address']['country'])) {
$countryCode = $this->convertCountryNameToCode($crmData['address']['country']);
}
$mobilePhone = $parsedNumber = null;
if (! empty($crmData['phone_numbers'])) {
foreach ($crmData['phone_numbers'] as $phoneNumber) {
if ($phoneNumber['category'] === 'mobile') {
$mobilePhone = $this->buildContactMobilePhone($countryCode, $phoneNumber['number']);
} else {
$parsedNumber = $this->buildContactPhone($countryCode, $phoneNumber['number']);
}
}
}
$name = $crmData['name'];
if (empty($crmData['company_name'])) {
$company = 'Unknown';
} else {
$company = mb_strimwidth($crmData['company_name'], 0, 191);
}
$domain = null;
if (empty($crmData['websites']) === false) {
$domain = mb_strimwidth($crmData['websites'][0]['url'], 0, 191);
$domain = StringUtil::resolveDomain($domain);
}
$email = null;
if (empty($crmData['email']) === false) {
$email = mb_strimwidth($crmData['email']['email'], 0, 80);
}
$stage = $this->config
->stages()
->where('crm_provider_id', (string) $crmData['status_id'])
->where('type', Stage::TYPE_LEAD)
->first();
if ($stage === null) {
// Import it.
$stage = $this->importStages([Stage::TYPE_LEAD], $crmData['status_id']);
}
if (! $stage instanceof Stage) {
return null;
}
$profile = $this->config->profiles()->where('crm_provider_id', (string) $crmData['assignee_id'])->first();
$data = [
'team_id' => $this->team->id,
'user_id' => $profile ? $profile->user_id : null,
'owner_id' => $crmData['assignee_id'],
'name' => mb_strimwidth($name, 0, 191),
'company' => $company,
'domain' => $domain,
'title' => $crmData['title'] ? mb_strimwidth($crmData['title'], 0, 128) : null,
'email' => $email,
'phone' => $parsedNumber['phone'] ?? null,
'ext' => $parsedNumber['ext'] ?? null,
'mobile_phone' => $mobilePhone,
'stage_id' => $stage->id,
'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(
crmConfiguration: $this->config,
crmProviderId: $crmData['id'],
modelType: Lead::class,
fileName: $crmData['id'],
avatarText: $crmData['name']
),
'country_code' => $countryCode,
'remotely_created_at' => $crmData['date_created'],
];
return $this->config->leads()->updateOrCreate(['crm_provider_id' => (string) $crmData['id']], $data);
}
/**
* @inheritdoc
*/
public function syncAccounts(Carbon $since, ?Carbon $to = null): int
{
$syncCount = 0;
try {
$cpAccounts = $this->copperClient->accounts()->paginate([
'minimum_modified_date' => $since->timestamp,
'sort_by' => 'date_modified',
]);
foreach ($cpAccounts as $cpAccount) {
// Only sync if previously imported.
if ($this->hasAccount($cpAccount['id'])) {
$this->importAccount($cpAccount);
$syncCount++;
}
}
} catch (\Exception $exception) {
// Do nothing for now.
}
return $syncCount;
}
/**
* @inheritdoc
*/
public function syncAccount(string $crmId): ?Account
{
$cpAccount = $this->copperClient->accounts()->find($crmId);
if (empty($cpAccount)) {
return null;
}
return $this->importAccount($cpAccount);
}
/**
* @inheritdoc
*/
private function importAccount($crmData): Account
{
$countryCode = null;
if (empty($crmData['address']) === false && $crmData['address']['country']) {
$countryCode = $this->convertCountryNameToCode($crmData['address']['country']);
}
$name = $crmData['name'];
$parsedNumber = null;
foreach ($crmData['phone_numbers'] as $phoneNumber) {
$parsedNumber = parsePhoneNumber($countryCode, $phoneNumber['number']);
}
$domain = null;
if (empty($crmData['websites']) === false) {
$domain = mb_strimwidth($crmData['websites'][0]['url'], 0, 191);
$domain = StringUtil::resolveDomain($domain);
}
$profile = $this->config->profiles()->where('crm_provider_id', (string) $crmData['assignee_id'])->first();
$data = [
'team_id' => $this->team->id,
'user_id' => $profile ? $profile->user_id : null,
'owner_id' => $crmData['assignee_id'],
'name' => mb_strimwidth($name, 0, 191),
'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(
crmConfiguration: $this->config,
crmProviderId: $crmData['id'],
modelType: Account::class,
fileName: $crmData['id'],
avatarText: $crmData['name']
),
'country_code' => $countryCode,
'domain' => $domain,
'phone' => $parsedNumber['phone'] ?? null,
'ext' => $parsedNumber['ext'] ?? null,
'remotely_created_at' => $crmData['date_created'],
];
return $this->config->accounts()->updateOrCreate(['crm_provider_id' => (string) $crmData['id']], $data);
}
public function syncOpportunities(array $parameters, ?string $strategy = null): int
{
$strategyResolver = app(OpportunitySyncStrategyResolver::class, [
'client' => $this->copperClient,
]);
$strategies = $strategyResolver->getStrategies($this->config, $strategy);
$syncCount = 0;
try {
foreach ($strategies as $syncStrategy) {
foreach ($syncStrategy->fetchOpportunities($parameters) as $opportunityData) {
$this->importOpportunity($opportunityData);
$syncCount++;
}
}
} catch (\Exception $exception) {
// Do nothing for now.
$this->logger->error(self::OPPORTUNITY_IMPORT_FAILED_ERROR_MESSAGE, [
'error' => $exception->getMessage(),
]);
}
return $syncCount;
}
public function syncOpportunity(string $crmId): ?Opportunity
{
$strategyResolver = app(OpportunitySyncStrategyResolver::class, [
'client' => $this->copperClient,
]);
$strategy = $strategyResolver->resolve(
$this->config,
OpportunitySyncStrategyResolver::SINGLE_SYNC_OPPORTUNITY_STRATEGY
);
$parameters['crm_id'] = $crmId;
$cpOpportunity = $strategy->fetchOpportunities($parameters);
return $this->importOpportunity($cpOpportunity);
}
private function importOpportunity($crmData): ?Opportunity
{
$companyId = (string) $crmData['company_id'];
if (empty($companyId)) {
$this->logger->error(self::OPPORTUNITY_IMPORT_FAILED_ERROR_MESSAGE, [
'error' => 'No company id',
]);
return null;
}
$account = $this->config->accounts()->where('crm_provider_id', $companyId)->first();
if ($account === null) {
$account = $this->syncAccount($companyId);
}
$profile = $this->config->profiles()->where('crm_provider_id', (string) $crmData['assignee_id'])->first();
$businessProcess = $this->config
->businessProcesses()
->where('crm_provider_id', (string) $crmData['pipeline_id'])
->first();
if ($businessProcess === null) {
// It is possible for some deals to not be in a pipeline.
$this->logger->warning(self::OPPORTUNITY_IMPORT_FAILED_ERROR_MESSAGE, [
'error' => 'Missing pipeline',
]);
return null;
}
$stage = $businessProcess
->stages()
->where('crm_provider_id', (string) $crmData['pipeline_stage_id'])
->where('type', Stage::TYPE_OPPORTUNITY)
->first();
if ($stage === null) {
$this->logger->info('Stage not found, importing', [
'pipeline_id' => $crmData['pipeline_id'],
'stage_id' => $crmData['pipeline_stage_id'],
]);
$stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $crmData['pipeline_stage_id']);
}
$recordType = $businessProcess->recordTypes()->first();
$closeDate = null;
$isClosed = $crmData['status'] !== self::OPPORTUNITY_STATUS_OPEN;
if ($crmData['close_date']) {
$closeDate = Carbon::parse($crmData['close_date'])->format('Y-m-d');
}
$data = [
'team_id' => $this->team->getId(),
'account_id' => $account->id,
'user_id' => $profile->user_id ?? null,
'owner_id' => $crmData['assignee_id'],
'name' => mb_strimwidth($crmData['name'], 0, 128),
'value' => $crmData['monetary_value'],
'currency_code' => CurrencyFormatter::formatCode($crmData['monetary_unit']),
'close_date' => $closeDate,
'is_closed' => $isClosed,
'is_won' => $crmData['status'] === 'Won',
'stage_id' => $stage->id,
'record_type_id' => $recordType->id ?? null,
'remotely_created_at' => $crmData['date_created'],
];
/** @var Opportunity $opportunity */
$opportunity = $this->config
->opportunities()
->updateOrCreate(['crm_provider_id' => (string) $crmData['id']], $data)
;
// import external fields into crm_field_data if present
$dealInsightsFields = $this->getOpportunitySyncableFields();
$this->importOpportunityCrmFieldData($crmData, $dealInsightsFields, $opportunity->id);
return $opportunity;
}
/**
* @param array<string, mixed> $opportunityData
* @param string[] $dealInsightsFields
*/
public function importOpportunityCrmFieldData(
array $opportunityData,
array $dealInsightsFields,
int $opportunityId
): void {
foreach ($dealInsightsFields as $fieldName) {
$field = $this->config->fields()->with('entities')
->where('crm_provider_id', $fieldName)
->where('object_type', Field::OBJECT_OPPORTUNITY)
->first();
if (! $field instanceof Field) {
$this->logger->warning('Field not found', [
'field' => $fieldName,
'opportunity_id' => $opportunityId,
]);
continue;
}
$entity = $field->getEntities()->first();
if (! $entity instanceof LayoutEntity) {
continue;
}
$fieldValue = $this->extractFieldValue($opportunityData, $fieldName);
$normalizedValue = $this->normalizeValue($field->getType(), (string) $fieldValue, true);
$field->data()
->updateOrCreate([
'crm_layout_entity_id' => $entity->id,
'crm_field_id' => $field->id,
'object_type' => 'opportunity',
'object_id' => $opportunityId,
], ['value' => $normalizedValue])
;
}
}
/**
* @inheritdoc
*/
public function syncContacts(Carbon $since, ?Carbon $to = null): int
{
$syncCount = 0;
try {
$cpContacts = $this->copperClient->contacts()->paginate([
'minimum_interaction_date' => $since->timestamp,
'sort_by' => 'date_modified',
]);
foreach ($cpContacts as $cpContact) {
// Only sync if previously imported.
if ($this->hasContact($cpContact['id'])) {
$this->importContact($cpContact);
$syncCount++;
}
}
} catch (\Exception $exception) {
// Do nothing for now.
throw $exception;
}
return $syncCount;
}
/**
* @inheritdoc
*/
public function syncContact(string $crmId): ?Contact
{
$cpContact = $this->copperClient->contacts()->find($crmId);
if (empty($cpContact)) {
return null;
}
return $this->importContact($cpContact);
}
/**
* @inheritdoc
*/
private function importContact($crmData): Contact
{
$account = null;
if ($crmData['company_id']) {
$account = $this->team
->crm
->accounts()
->where('crm_provider_id', (string) $crmData['company_id'])
->first();
if ($account === null) {
$account = $this->syncAccount($crmData['company_id']);
}
}
$countryCode = null;
if (empty($crmData['address']) === false && $crmData['address']['country']) {
$countryCode = $this->convertCountryNameToCode($crmData['address']['country']);
}
$mobilePhone = $parsedNumber = null;
foreach ($crmData['phone_numbers'] as $phoneNumber) {
if ($phoneNumber['category'] === 'mobile') {
$mobilePhone = $this->buildContactMobilePhone($countryCode, $phoneNumber['number']);
} else {
$parsedNumber = $this->buildContactPhone($countryCode, $phoneNumber['number']);
}
}
$email = null;
if (empty($crmData['emails']) === false) {
$email = mb_strimwidth($crmData['emails'][0]['email'], 0, 80);
}
$profile = $this->config->profiles()->where('crm_provider_id', (string) $crmData['assignee_id'])->first();
$data = [
'team_id' => $this->team->id,
'account_id' => $account->id ?? null,
'user_id' => $profile ? $profile->user_id : null,
'owner_id' => $crmData['assignee_id'],
'name' => $crmData['name'],
'email' => $email,
'phone' => $parsedNumber['phone'] ?? null,
'mobile_phone' => $mobilePhone ?? null,
'ext' => $parsedNumber['ext'] ?? null,
'title' => $crmData['title'],
'country_code' => $countryCode,
'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(
crmConfiguration: $this->config,
crmProviderId: $crmData['id'],
modelType: Contact::class,
fileName: $crmData['id'],
avatarText: $crmData['name']
),
'remotely_created_at' => $crmData['date_created'],
];
return $this->config->contacts()->updateOrCreate(['crm_provider_id' => (string) $crmData['id']], $data);
}
private function buildContactPhone(?string $countryCode, ?string $number): ?array
{
if ($number) {
// Trim to our width and attempt to parse it.
$number = mb_strimwidth($number, 0, 25);
$parsedNumber = parsePhoneNumber($countryCode, $number);
} else {
$parsedNumber = [];
}
return $parsedNumber;
}
private function buildContactMobilePhone(?string $countryCode, ?string $number): ?string
{
return $number ? mb_strimwidth(phone_e164($countryCode, $number), 0, 25) : null;
}
/**
* @inheritdoc
*/
public function syncOrganization(): void
{
$organization = $this->copperClient->organisation()->all();
if ($this->config->crm_provider_id !== $organization['id']) {
$this->config->update(['crm_provider_id' => $organization['id']]);
}
}
private function syncCustomFields(): void
{
foreach ($this->getFieldTypes() as $fieldType) {
$currentFields = $this->getFields($fieldType);
foreach ($currentFields as $field) {
/** @var Field $field */
$field = $this->config->fields()->updateOrCreate([
'crm_provider_id' => $field['name'],
'object_type' => $fieldType,
], [
'label' => mb_strimwidth($field['label'], 0, Field::LABEL_MAX_LENGTH),
'type' => $field['type'],
]);
if ($field->getType() === Field::TYPE_PICKLIST) {
$this->importPicklistValues($field);
}
}
}
$this->config->save();
}
protected function getFieldTypes(): array
{
return [
parent::OBJECT_TASK,
parent::OBJECT_LEAD,
parent::OBJECT_ACCOUNT,
parent::OBJECT_CONTACT,
parent::OBJECT_OPPORTUNITY,
];
}
/**
* @inheritdoc
*/
public function syncProfiles(?User $userToSearch = null): ?Profile
{
$profiles = $this->copperClient->users()->all();
foreach ($profiles as $profile) {
$user = $this->userRepository->findByTeamAndEmailAddress($this->team, $profile['email'], onlyActive: true);
// Only import profiles for users we have locally.
if ($user) {
$profile = $user->crmProfile()->updateOrCreate([
'crm_configuration_id' => $this->config->id,
'crm_provider_id' => $profile['id'],
]);
if ($userToSearch && $userToSearch->id === $user->id) {
return $profile;
}
}
}
return null;
}
/**
* @inheritdoc
*/
public function syncProfileFields(): void
{
$fieldTypes = [
'task',
'lead',
'account',
'contact',
'opportunity',
];
foreach ($fieldTypes as $fieldType) {
try {
$currentFields = $this->getFields(ucfirst($fieldType));
$newFields = array_column($currentFields, 'name');
$changedFields = array_merge(
array_diff($this->profile->getFieldsAsArray($fieldType), $newFields),
array_diff($newFields, $this->profile->getFieldsAsArray($fieldType))
);
if (\count($changedFields) > 0) {
$fields = implode(',', $newFields);
$this->logger->debug(
ucfirst($fieldType)
. ' fields changed from '
. $this->profile->{$fieldType . '_fields'}
. ' to: '
. $fields
);
$this->profile->{$fieldType . '_fields'} = $fields;
}
} catch (\Exception $exception) {
$this->logger->error('No access to ' . $fieldType . ' object, skipping...');
continue;
}
}
$this->profile->save();
}
/**
* @inheritdoc
*/
public function find(string $name, array $scopes): array
{
$count = $this->limit ?? 20;
$offset = $this->offset ?? 0;
$scopeValues = array_map(fn ($scope) => is_string($scope) ? $scope : $scope->value, $scopes);
$cacheKey = sprintf('copper-search-%d-%s-%s-%d-%d', $this->team->id, $name, implode('-', $scopeValues), $count, $offset);
return Cache::remember($cacheKey, 300, function () use ($name, $scopes, $count, $offset) {
// Try local search first
$localResults = $this->searchLocally($name, $scopes, $count, $offset);
// If no local results, fall back to remote API search
if (empty($localResults)) {
return $this->searchRemotely($name, $scopes, $count, $offset);
}
return $localResults;
});
}
/**
* @inheritdoc
*/
public function findOpportunities(?string $crmAccountId, ?string $crmContactId, ?int $userId = null): array
{
$data = [];
$ownerData = [];
$ownerId = null;
$contact = null;
$account = null;
if ($crmAccountId) {
$account = $this->config->accounts()->where('crm_provider_id', $crmAccountId)->first();
if ($account === null) {
$account = $this->syncAccount($crmAccountId);
}
}
if ($crmContactId) {
$contact = $this->config->contacts()->where('crm_provider_id', $crmContactId)->first();
if ($contact === null) {
$contact = $this->syncContact($crmContactId);
}
}
if ($contact || $account) {
if ($contact && $account === null) {
$account = $contact->account;
}
if ($account === null) {
return [];
}
$params = [
'company_ids' => [$account->crm_provider_id],
'status_ids' => [0], // The integer values are 0, 1, 2, 3, for "Open", "Won", "Lost", and "Abandoned".
'sort_by' => 'date_modified',
'sort_direction' => 'desc',
];
switch ($this->config->opportunity_assignment_rule) {
case Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_UPDATED:
$params['sort_by'] = 'date_modified';
$params['sort_direction'] = 'desc';
break;
case Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_CREATED:
$params['sort_by'] = 'date_created';
$params['sort_direction'] = 'desc';
break;
case Configuration::OPP_ASSIGNMENT_ALL_OPEN_OLDEST_CREATED:
$params['sort_by'] = 'date_created';
$params['sort_direction'] = 'asc';
break;
case Configuration::OPP_ASSIGNMENT_ALL_RECENTLY_UPDATED:
$params['sort_by'] = 'date_modified';
$params['sort_direction'] = 'desc';
$params['status_ids'] = [0, 1, 2, 3];
}
$cpOpportunities = $this->copperClient->opportunities()->search($params);
if ($userId) {
$profileRepository = app(ProfileRepository::class);
$profile = $profileRepository->findProfileByUserId($this->config, $userId);
$ownerId = $profile instanceof Profile ? $profile->getCrmProviderId() : null;
}
foreach ($cpOpportunities as $cpOpportunity) {
$businessProcess = $this->config
->businessProcesses()
->where('crm_provider_id', (string) $cpOpportunity['pipeline_id'])
->first();
if ($businessProcess === null) {
// Import it.
$businessProcess = $this->importStages(
[Stage::TYPE_OPPORTUNITY],
$cpOpportunity['pipeline_stage_id']
);
}
$stage = $businessProcess
->stages()
->where('crm_provider_id', (string) $cpOpportunity['pipeline_stage_id'])
->where('type', Stage::TYPE_OPPORTUNITY)
->first();
if ($stage === null) {
// Import it.
$stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $cpOpportunity['pipeline_stage_id']);
}
$recordType = $businessProcess->recordTypes()->first();
$record = [
'crmId' => $cpOpportunity['id'],
'name' => $cpOpportunity['name'],
'value' => currency_format($cpOpportunity['monetary_value'], $cpOpportunity['monetary_unit']),
'won' => $cpOpportunity['status'] === 'Won',
'stage' => [
'id' => $stage->id_string,
'name' => $stage->name,
],
'recordType' => [
'id' => $recordType->id_string,
'name' => $recordType->name,
],
];
if ($ownerId && isset($cpOpportunity['assignee_id']) && $cpOpportunity['assignee_id'] === $ownerId) {
$ownerData[] = $record;
}
$data[] = $record;
}
}
if (! empty($ownerData)) {
return $ownerData;
}
return $data;
}
/**
* @inheritdoc
*/
public function getTasks(string $objectType, string $objectId, ?string $opportunityId): array
{
$data = $cpTasks = [];
switch ($objectType) {
case 'lead':
$cpTasks = $this->requestWithExceptionHandling(
fn () => $this->copperClient->leads()->relatedTasks($objectId)
);
break;
case 'account':
$cpTasks = $this->copperClient->accounts()->relatedTasks($objectId);
break;
case 'contact':
if ($opportunityId) {
$cpTasks = $this->copperClient->opportunities()->relatedTasks($opportunityId);
} elseif ($objectType === 'contact') {
$cpTasks = $this->copperClient->contacts()->relatedTasks($objectId);
}
break;
}
foreach ($cpTasks as $cpTask) {
// We only get the ID itself returned, so pull out the full object.
try {
$cpTask = $this->copperClient->tasks()->find($cpTask['id']);
} catch (Exception $exception) {
$this->logger->error('[Copper] Error fetching task', [
'resource_id' => $cpTask['id'],
'error' => $exception->getMessage(),
]);
continue;
}
// Only allow open tasks.
if ($cpTask['status'] === 'Open') {
$data[] = [
'crmId' => $cpTask['id'],
'subject' => $cpTask['name'],
'due' => $cpTask['due_date']
? Carbon::createFromTimestamp($cpTask['due_date'])->format('Y-m-d\TH:i:sO')
: null,
'type' => null,
];
}
}
return $data;
}
/**
* Try to find email address in CRM service
*
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
*}
*/
public function matchExactlyByEmail(string $email, ?int $userId = null): ?array
{
$cpObjectType = 'contact';
$cpObjects = $this->copperClient->contacts()->search([
'emails' => $email,
'sort_by' => 'date_modified',
'sort_direction' => 'desc',
]);
if (empty($cpObjects)) {
$cpObjectType = 'lead';
$cpObjects = $this->requestWithExceptionHandling(
fn () => $this->copperClient->leads()->search([
'emails' => $email,
'sort_by' => 'date_...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
45349
|
1625
|
10
|
2026-05-14T14:32:23.477199+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-14/1778 /Users/lukas/.screenpipe/data/data/2026-05-14/1778769143477_m1.jpg...
|
PhpStorm
|
|
1
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
Inherited members (⌘R)
Anonymous Classes (⌘I)
Lamb Inherited members (⌘R)
Anonymous Classes (⌘I)
Lambdas (⌘L)...
|
[{"role":"AXCheckBox","text [{"role":"AXCheckBox","text":"Inherited members (⌘R)","depth":1,"bounds":{"left":0.5277778,"top":0.35555556,"width":0.10972222,"height":0.031111112},"on_screen":true,"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Anonymous Classes (⌘I)","depth":1,"bounds":{"left":0.64444447,"top":0.35555556,"width":0.10972222,"height":0.031111112},"on_screen":true,"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Lambdas (⌘L)","depth":1,"bounds":{"left":0.76111114,"top":0.35555556,"width":0.10972222,"height":0.031111112},"on_screen":true,"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
3554957039528981901
|
649928483308536512
|
visual_change
|
hybrid
|
NULL
|
Inherited members (⌘R)
Anonymous Classes (⌘I)
Lamb Inherited members (⌘R)
Anonymous Classes (⌘I)
Lambdas (⌘L)
PhpStormFileEditViewNavigateCodeLaravelRefactorRunToolsGitWindowHelpFV faVsco.jsv#12066 on JY-20725-handle-HS-search-rate-limit k vabl19HandleHubspotRateLimitTestv100% C* :Projectv8• Thu 14 May 17:32:22QAutomatedReportGenerated.phpTrackAutomatedReportGeneratedEvent.phpPlaybackController.php=custom.log© FieldDefinitions.phpUserAutomatedReportsController.phpPlanhatService.phpAutomatedReportResult.phpSendReportJob.php© FieldValueConverter.php• DeleteCrmEntityTrait.phpDeleteAccountJob.php© ImportActivityTypes.phpT WriteCrmTrait.php© Service.php© DecorateActivity.php© StandardFieldMetadata.php© Salesforce/Service.php© LogActivityTrait.phpPipedrive/Service.phpClose/Service.phpCopper/Service.php Xv D Copper© PlainTextDecorateActivity.phpT ActivityPlaybookTrait.php© CrmHelperRepository.phpAccountController.php18> D Api› D ExceptionT IntegrationAppTrait.php.env.stagingE.env© DetachActivityObject.phpRematchActivityOnCrmObjectDetach.php> C OpportunitySyncStrategy> D ProspectSearchStrategy© MatchActivityCrmData.php<?phpClient.php© HubspotPaginationService.phpHandleHubspotRateLimit.php1> D TraitsService.php© FieldDefinitions.php3namespace Jiminny (Services \Crm\Copper;Inherited members (28R)Anonymous Classes (81)Lambdas (2L)© FieldValueConverter.php© Service.php> D CrmObjectsv DecorateActivity© BaseDecorateActivity.php© PlainTextDecorateActivity.pl> D Dummyv HelpersT ActivityPlaybookTrait.php© Arraylterator.php• ConnectionStateTrait.php© CrmHelperRepository.php© FilterJoinedParticipants.php* OpportunitySyncableFieldsTv D Hubspot5|51|525354555657585960616263loading….useclass Service extends BaseService implementsCopperInterface,SyncCrmEntitiesInterface,SyncCrmMetadataInterface,RemoteEntityManipulationInterface,LayoutManagementInterface,SettingsInterface,MatchCrmEntitiesInterface,RemoteEntityLookupInterface,VerifyTaskExistsInterface• AccountSyncStrategyuse SearchesCrmEntities;1 usage> @J Actions|• ContactSyncStrategy6465private const int NOTE_BODY_MAX_LENGTH = 3000000;> ODTO1 usage> D Fields> 0J Journal6667private const int TASK_VERIFICATION_CACHE_TTL = 86400; // 1 dayD Metadatav D OpportunitySyncStrategy68> D Concerns692 usagesprivate const array STANDARD_OPPORTUNITY_FIELDS = ['monetany value' => [A Lihonatl actlAndifioNDurWorkspace associated with branch 'JY-20725-handle-HS-search-rate-limit' has been restored // Rollback // Configure... (today 16:17)=laravel.logSF [jiminny@localhost]A HS_local [jiminny@localho:W4 console [QAI PROD] X4 console [PROD]A console (EU]Gojiminny v01 43 ×4 л•m_layout_id =2,1661, 66799,66id = 33;id THEN •(own230231232233 V234hubspot':-= 11512582;W Windsurf Teams59:31UTF-8Ca 4 spaces...
|
45348
|
NULL
|
NULL
|
NULL
|
|
45350
|
1625
|
11
|
2026-05-14T14:32:26.489014+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-14/1778 /Users/lukas/.screenpipe/data/data/2026-05-14/1778769146489_m1.jpg...
|
PhpStorm
|
|
1
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
sa
Inherited members (⌘R)
Anonymous Classes (⌘I)
L sa
Inherited members (⌘R)
Anonymous Classes (⌘I)
Lambdas (⌘L)
Service, class
NOTE_BODY_MAX_LENGTH: int = 3000000, private
OPPORTUNITY_IMPORT_FAILED_ERROR_MESSAGE: string = 'Opportunity import ..., private
OPPORTUNITY_STATUS_OPEN: string = 'Open', private
RESOURCE_UPDATE_FAILED_ERROR_MESSAGE: string = 'Failed to update re..., private
STANDARD_OPPORTUNITY_FIELDS: array|array[] = [...], private
TASK_VERIFICATION_CACHE_TTL: int = 86400, private
UNSUPPORTED_OBJECT_TYPE: string = 'unsupported', private
clientFactory: ClientFactory, private
copperClient: Client|null = null ↑SearchesCrmEntities, private
crmEntityRepository: CrmEntityRepository, parameter
fieldValueConverter: FieldValueConverter, private
prospectPhotoPathService: ProspectPhotoPathService, parameter
userRepository: UserRepository, parameter
__construct(clientFactory: ClientFactory, fieldValueConverter: FieldValueConverter, userRepository: UserRepository, prospectPhotoPathService: ProspectPhotoPathService, crmEntityRepository: CrmEntityRepository) ↑BaseService, method
buildCallPayload(activity: Activity): Activity, method
buildContactMobilePhone(countryCode: null|string, number: null|string): null|string, method
buildContactPhone(countryCode: null|string, number: null|string): array|null, method
buildTextMessagePayload(activity: Activity): Activity, method
convertActivityAssociations(activity: Activity): array, method
convertActivityType(activityType: PlaybookCategory): array, method
convertCrmData(object: array, objectType: string, [userId: int|null = null]): array, method
convertFieldType(fieldType: string): string, method
createTranscriptNotes(activity: Activity): void ↑RemoteEntityManipulationInterface, method
extractFieldValue(opportunityData: mixed[], fieldName: string): bool|float|int|mixed[]|null|string, method
fetchCustomFieldData(activity: Activity, objectType: string): array, method
find(name: string, scopes: array): array ↑RemoteEntityLookupInterface, method
findOpportunities(crmAccountId: null|string, crmContactId: null|string, [userId: int|null = null]): array ↑RemoteEntityLookupInterface, method
generateActivityDescription(activity: Activity): string, method
generateProviderUrl(providerId: string, objectType: string): null|string ↑SettingsInterface, method
generateTranscription(activity: Activity): string, method
getCrmEntityRepository(): CrmEntityRepository ↑SearchesCrmEntities, method
getDealInsightsFields(): array|string[][] ↑LayoutManagementInterface, method
getDefaultActivityField(activityType: string): Field ↑LayoutManagementInterface, method
getDefaultFields(activityType: string): array ↑LayoutManagementInterface, method
getDefaultFollowupLayoutFields(activityType: string): array ↑BaseService, method
getDisplayName(): string ↑SettingsInterface, method
getFields(crmObject: string): array ↑BaseService, method
getFieldTypes(): array ↑BaseService, method
getMinimumApiVersion(): string ↑ServiceInterface, method
getOAuthAccount(user: User): SocialAccount|null ↑BaseService, protected method
getRecord(objectType: string, objectId: string, [fields: array = [...]]): array ↑RemoteEntityLookupInterface, method
getSupportedPlaybookTypes(): array ↑SettingsInterface, method
getTasks(objectType: string, objectId: string, opportunityId: null|string): array ↑RemoteEntityLookupInterface, method
importAccount(crmData): Account, method
importContact(crmData): Contact, method
importFieldValuesForDealInsights(field: Field): FieldValue[], method
importLead(crmData): Lead|null, method
importOpportunity(crmData): Opportunity|null, method
importOpportunityCrmFieldData(opportunityData: array|mixed[], dealInsightsFields: string[], opportunityId: int): void ↑BaseService, method
importPicklistValues(field: Field): array|FieldValue[] ↑SyncCrmEntitiesInterface, method
importStages([types: array|null = null], [missingStageName: null|string = null]): Stage|null ↑SyncCrmEntitiesInterface, method
isStandardField(fieldName: string): bool, method
isSupportedObjectType(objectType: string): bool, method
mapObjectType(objectType: string): string, method
matchByName(name: string, [userId: int|null = null]): array|null ↑MatchCrmEntitiesInterface, method
matchByPhone(phone: string, [rawPhoneNumber: null|string = null], [userId: int|null = null]): array|null ↑MatchCrmEntitiesInterface, public method
matchExactlyByEmail(email: string, [userId: int|null = null]): array|null ↑MatchCrmEntitiesInterface, public method
normalizeValue(fieldType: string, fieldValue: string, [internal: bool = false]): string ↑SettingsInterface, public method
prepareOpportunityFieldsForUpdate(fieldData: string[]): bool[]|bool[][][]|float[]|float[][][]|int[]|int[][][]|null[]|null[][][]|string[]|string[][][], private method
prepareValueForUpdate(params: array): array ↑BaseService, public method
requestWithExceptionHandling(apiRequest: Closure): iterable, private method
resolveCustomFieldFromOpportunityData(opportunityData: mixed[], fieldName: string): bool|float|int|mixed[]|null|string, private method
saveActivity(activity: Activity): Activity ↑RemoteEntityManipulationInterface, public method
saveFollowupActivity(activity: Activity, fields: array): null|string ↑RemoteEntityManipulationInterface, public method
setConfiguration(configuration: Configuration): void ↑SettingsInterface, public method
setUser(user: User): void ↑SettingsInterface, public method
syncAccount(crmId: string): Account|null ↑SyncCrmEntitiesInterface, public method
syncAccounts(since: \Carbon\Carbon, [to: \Carbon\Carbon|null = null]): int ↑SyncCrmEntitiesInterface, public method
syncContact(crmId: string): Contact|null ↑SyncCrmEntitiesInterface, public method
syncContacts(since: \Carbon\Carbon, [to: \Carbon\Carbon|null = null]): int ↑SyncCrmEntitiesInterface, public method
syncCustomFields(): void, method
syncField(field: Field): void ↑SyncCrmEntitiesInterface, method
syncFields(): void ↑SyncCrmEntitiesInterface, method
syncLead(crmId: string): Lead|null ↑SyncCrmEntitiesInterface, method
syncLeads(since: \Carbon\Carbon, [to: \Carbon\Carbon|null = null], [crmProfileId: null|string = null]): int ↑SyncCrmEntitiesInterface, method
syncOpportunities(parameters: array, [strategy: null|string = null]): int ↑SyncCrmEntitiesInterface, method
syncOpportunity(crmId: string): Opportunity|null ↑SyncCrmEntitiesInterface, method
syncOrganization(): void ↑SyncCrmMetadataInterface, method
syncProfileFields(): void ↑SyncCrmMetadataInterface, method
syncProfiles([userToSearch: User|null = null]): Profile|null ↑SyncCrmMetadataInterface, method...
|
[{"role":"AXTextField","text [{"role":"AXTextField","text":"sa","depth":1,"bounds":{"left":0.52152777,"top":0.32,"width":0.06458333,"height":0.031111112},"on_screen":true,"value":"sa","role_description":"text field","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Inherited members (⌘R)","depth":1,"bounds":{"left":0.5277778,"top":0.35555556,"width":0.10972222,"height":0.031111112},"on_screen":true,"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Anonymous Classes (⌘I)","depth":1,"bounds":{"left":0.64444447,"top":0.35555556,"width":0.10972222,"height":0.031111112},"on_screen":true,"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Lambdas (⌘L)","depth":1,"bounds":{"left":0.76111114,"top":0.35555556,"width":0.10972222,"height":0.031111112},"on_screen":true,"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Service, class","depth":4,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"NOTE_BODY_MAX_LENGTH: int = 3000000, private","depth":5,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"OPPORTUNITY_IMPORT_FAILED_ERROR_MESSAGE: string = 'Opportunity import ..., private","depth":5,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"OPPORTUNITY_STATUS_OPEN: string = 'Open', private","depth":5,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"RESOURCE_UPDATE_FAILED_ERROR_MESSAGE: string = 'Failed to update re..., private","depth":5,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"STANDARD_OPPORTUNITY_FIELDS: array|array[] = [...], private","depth":5,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"TASK_VERIFICATION_CACHE_TTL: int = 86400, private","depth":5,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"UNSUPPORTED_OBJECT_TYPE: string = 'unsupported', private","depth":5,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"clientFactory: ClientFactory, private","depth":5,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"copperClient: Client|null = null ↑SearchesCrmEntities, private","depth":5,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"crmEntityRepository: CrmEntityRepository, parameter","depth":5,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"fieldValueConverter: FieldValueConverter, private","depth":5,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"prospectPhotoPathService: ProspectPhotoPathService, parameter","depth":5,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"userRepository: UserRepository, parameter","depth":5,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"__construct(clientFactory: ClientFactory, fieldValueConverter: FieldValueConverter, userRepository: UserRepository, prospectPhotoPathService: ProspectPhotoPathService, crmEntityRepository: CrmEntityRepository) ↑BaseService, method","depth":5,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"buildCallPayload(activity: Activity): Activity, method","depth":5,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"buildContactMobilePhone(countryCode: null|string, number: null|string): null|string, method","depth":5,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"buildContactPhone(countryCode: null|string, number: null|string): array|null, method","depth":5,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"buildTextMessagePayload(activity: Activity): Activity, method","depth":5,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"convertActivityAssociations(activity: Activity): array, method","depth":5,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"convertActivityType(activityType: PlaybookCategory): array, method","depth":5,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"convertCrmData(object: array, objectType: string, [userId: int|null = null]): array, method","depth":5,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"convertFieldType(fieldType: string): string, method","depth":5,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"createTranscriptNotes(activity: Activity): void ↑RemoteEntityManipulationInterface, method","depth":5,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"extractFieldValue(opportunityData: mixed[], fieldName: string): bool|float|int|mixed[]|null|string, method","depth":5,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"fetchCustomFieldData(activity: Activity, objectType: string): array, method","depth":5,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"find(name: string, scopes: array): array ↑RemoteEntityLookupInterface, method","depth":5,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"findOpportunities(crmAccountId: null|string, crmContactId: null|string, [userId: int|null = null]): array ↑RemoteEntityLookupInterface, method","depth":5,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"generateActivityDescription(activity: Activity): string, method","depth":5,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"generateProviderUrl(providerId: string, objectType: string): null|string ↑SettingsInterface, method","depth":5,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"generateTranscription(activity: Activity): string, method","depth":5,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"getCrmEntityRepository(): CrmEntityRepository ↑SearchesCrmEntities, method","depth":5,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"getDealInsightsFields(): array|string[][] ↑LayoutManagementInterface, method","depth":5,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"getDefaultActivityField(activityType: string): Field ↑LayoutManagementInterface, method","depth":5,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"getDefaultFields(activityType: string): array ↑LayoutManagementInterface, method","depth":5,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"getDefaultFollowupLayoutFields(activityType: string): array ↑BaseService, method","depth":5,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"getDisplayName(): string ↑SettingsInterface, method","depth":5,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"getFields(crmObject: string): array ↑BaseService, method","depth":5,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"getFieldTypes(): array ↑BaseService, method","depth":5,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"getMinimumApiVersion(): string ↑ServiceInterface, method","depth":5,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"getOAuthAccount(user: User): SocialAccount|null ↑BaseService, protected method","depth":5,"bounds":{"left":0.55277777,"top":0.48777777,"width":0.30416667,"height":0.024444444},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"getRecord(objectType: string, objectId: string, [fields: array = [...]]): array ↑RemoteEntityLookupInterface, method","depth":5,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"getSupportedPlaybookTypes(): array ↑SettingsInterface, method","depth":5,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"getTasks(objectType: string, objectId: string, opportunityId: null|string): array ↑RemoteEntityLookupInterface, method","depth":5,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"importAccount(crmData): Account, method","depth":5,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"importContact(crmData): Contact, method","depth":5,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"importFieldValuesForDealInsights(field: Field): FieldValue[], method","depth":5,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"importLead(crmData): Lead|null, method","depth":5,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"importOpportunity(crmData): Opportunity|null, method","depth":5,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"importOpportunityCrmFieldData(opportunityData: array|mixed[], dealInsightsFields: string[], opportunityId: int): void ↑BaseService, method","depth":5,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"importPicklistValues(field: Field): array|FieldValue[] ↑SyncCrmEntitiesInterface, method","depth":5,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"importStages([types: array|null = null], [missingStageName: null|string = null]): Stage|null ↑SyncCrmEntitiesInterface, method","depth":5,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"isStandardField(fieldName: string): bool, method","depth":5,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"isSupportedObjectType(objectType: string): bool, method","depth":5,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"mapObjectType(objectType: string): string, method","depth":5,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"matchByName(name: string, [userId: int|null = null]): array|null ↑MatchCrmEntitiesInterface, method","depth":5,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"matchByPhone(phone: string, [rawPhoneNumber: null|string = null], [userId: int|null = null]): array|null ↑MatchCrmEntitiesInterface, public method","depth":5,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"matchExactlyByEmail(email: string, [userId: int|null = null]): array|null ↑MatchCrmEntitiesInterface, public method","depth":5,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"normalizeValue(fieldType: string, fieldValue: string, [internal: bool = false]): string ↑SettingsInterface, public method","depth":5,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"prepareOpportunityFieldsForUpdate(fieldData: string[]): bool[]|bool[][][]|float[]|float[][][]|int[]|int[][][]|null[]|null[][][]|string[]|string[][][], private method","depth":5,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"prepareValueForUpdate(params: array): array ↑BaseService, public method","depth":5,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"requestWithExceptionHandling(apiRequest: Closure): iterable, private method","depth":5,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"resolveCustomFieldFromOpportunityData(opportunityData: mixed[], fieldName: string): bool|float|int|mixed[]|null|string, private method","depth":5,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"saveActivity(activity: Activity): Activity ↑RemoteEntityManipulationInterface, public method","depth":5,"bounds":{"left":0.55277777,"top":0.51222223,"width":0.3548611,"height":0.024444444},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"saveFollowupActivity(activity: Activity, fields: array): null|string ↑RemoteEntityManipulationInterface, public method","depth":5,"bounds":{"left":0.55277777,"top":0.5366667,"width":0.44722223,"height":0.024444444},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"setConfiguration(configuration: Configuration): void ↑SettingsInterface, public method","depth":5,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"setUser(user: User): void ↑SettingsInterface, public method","depth":5,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"syncAccount(crmId: string): Account|null ↑SyncCrmEntitiesInterface, public method","depth":5,"bounds":{"left":0.55277777,"top":0.5611111,"width":0.32291666,"height":0.024444444},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"syncAccounts(since: \\Carbon\\Carbon, [to: \\Carbon\\Carbon|null = null]): int ↑SyncCrmEntitiesInterface, public method","depth":5,"bounds":{"left":0.55277777,"top":0.58555555,"width":0.44722223,"height":0.024444444},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"syncContact(crmId: string): Contact|null ↑SyncCrmEntitiesInterface, public method","depth":5,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"syncContacts(since: \\Carbon\\Carbon, [to: \\Carbon\\Carbon|null = null]): int ↑SyncCrmEntitiesInterface, public method","depth":5,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"syncCustomFields(): void, method","depth":5,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"syncField(field: Field): void ↑SyncCrmEntitiesInterface, method","depth":5,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"syncFields(): void ↑SyncCrmEntitiesInterface, method","depth":5,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"syncLead(crmId: string): Lead|null ↑SyncCrmEntitiesInterface, method","depth":5,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"syncLeads(since: \\Carbon\\Carbon, [to: \\Carbon\\Carbon|null = null], [crmProfileId: null|string = null]): int ↑SyncCrmEntitiesInterface, method","depth":5,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"syncOpportunities(parameters: array, [strategy: null|string = null]): int ↑SyncCrmEntitiesInterface, method","depth":5,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"syncOpportunity(crmId: string): Opportunity|null ↑SyncCrmEntitiesInterface, method","depth":5,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"syncOrganization(): void ↑SyncCrmMetadataInterface, method","depth":5,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"syncProfileFields(): void ↑SyncCrmMetadataInterface, method","depth":5,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"syncProfiles([userToSearch: User|null = null]): Profile|null ↑SyncCrmMetadataInterface, method","depth":5,"on_screen":false,"role_description":"text"}]...
|
1253387167212463152
|
-1070430673578670609
|
visual_change
|
accessibility
|
NULL
|
sa
Inherited members (⌘R)
Anonymous Classes (⌘I)
L sa
Inherited members (⌘R)
Anonymous Classes (⌘I)
Lambdas (⌘L)
Service, class
NOTE_BODY_MAX_LENGTH: int = 3000000, private
OPPORTUNITY_IMPORT_FAILED_ERROR_MESSAGE: string = 'Opportunity import ..., private
OPPORTUNITY_STATUS_OPEN: string = 'Open', private
RESOURCE_UPDATE_FAILED_ERROR_MESSAGE: string = 'Failed to update re..., private
STANDARD_OPPORTUNITY_FIELDS: array|array[] = [...], private
TASK_VERIFICATION_CACHE_TTL: int = 86400, private
UNSUPPORTED_OBJECT_TYPE: string = 'unsupported', private
clientFactory: ClientFactory, private
copperClient: Client|null = null ↑SearchesCrmEntities, private
crmEntityRepository: CrmEntityRepository, parameter
fieldValueConverter: FieldValueConverter, private
prospectPhotoPathService: ProspectPhotoPathService, parameter
userRepository: UserRepository, parameter
__construct(clientFactory: ClientFactory, fieldValueConverter: FieldValueConverter, userRepository: UserRepository, prospectPhotoPathService: ProspectPhotoPathService, crmEntityRepository: CrmEntityRepository) ↑BaseService, method
buildCallPayload(activity: Activity): Activity, method
buildContactMobilePhone(countryCode: null|string, number: null|string): null|string, method
buildContactPhone(countryCode: null|string, number: null|string): array|null, method
buildTextMessagePayload(activity: Activity): Activity, method
convertActivityAssociations(activity: Activity): array, method
convertActivityType(activityType: PlaybookCategory): array, method
convertCrmData(object: array, objectType: string, [userId: int|null = null]): array, method
convertFieldType(fieldType: string): string, method
createTranscriptNotes(activity: Activity): void ↑RemoteEntityManipulationInterface, method
extractFieldValue(opportunityData: mixed[], fieldName: string): bool|float|int|mixed[]|null|string, method
fetchCustomFieldData(activity: Activity, objectType: string): array, method
find(name: string, scopes: array): array ↑RemoteEntityLookupInterface, method
findOpportunities(crmAccountId: null|string, crmContactId: null|string, [userId: int|null = null]): array ↑RemoteEntityLookupInterface, method
generateActivityDescription(activity: Activity): string, method
generateProviderUrl(providerId: string, objectType: string): null|string ↑SettingsInterface, method
generateTranscription(activity: Activity): string, method
getCrmEntityRepository(): CrmEntityRepository ↑SearchesCrmEntities, method
getDealInsightsFields(): array|string[][] ↑LayoutManagementInterface, method
getDefaultActivityField(activityType: string): Field ↑LayoutManagementInterface, method
getDefaultFields(activityType: string): array ↑LayoutManagementInterface, method
getDefaultFollowupLayoutFields(activityType: string): array ↑BaseService, method
getDisplayName(): string ↑SettingsInterface, method
getFields(crmObject: string): array ↑BaseService, method
getFieldTypes(): array ↑BaseService, method
getMinimumApiVersion(): string ↑ServiceInterface, method
getOAuthAccount(user: User): SocialAccount|null ↑BaseService, protected method
getRecord(objectType: string, objectId: string, [fields: array = [...]]): array ↑RemoteEntityLookupInterface, method
getSupportedPlaybookTypes(): array ↑SettingsInterface, method
getTasks(objectType: string, objectId: string, opportunityId: null|string): array ↑RemoteEntityLookupInterface, method
importAccount(crmData): Account, method
importContact(crmData): Contact, method
importFieldValuesForDealInsights(field: Field): FieldValue[], method
importLead(crmData): Lead|null, method
importOpportunity(crmData): Opportunity|null, method
importOpportunityCrmFieldData(opportunityData: array|mixed[], dealInsightsFields: string[], opportunityId: int): void ↑BaseService, method
importPicklistValues(field: Field): array|FieldValue[] ↑SyncCrmEntitiesInterface, method
importStages([types: array|null = null], [missingStageName: null|string = null]): Stage|null ↑SyncCrmEntitiesInterface, method
isStandardField(fieldName: string): bool, method
isSupportedObjectType(objectType: string): bool, method
mapObjectType(objectType: string): string, method
matchByName(name: string, [userId: int|null = null]): array|null ↑MatchCrmEntitiesInterface, method
matchByPhone(phone: string, [rawPhoneNumber: null|string = null], [userId: int|null = null]): array|null ↑MatchCrmEntitiesInterface, public method
matchExactlyByEmail(email: string, [userId: int|null = null]): array|null ↑MatchCrmEntitiesInterface, public method
normalizeValue(fieldType: string, fieldValue: string, [internal: bool = false]): string ↑SettingsInterface, public method
prepareOpportunityFieldsForUpdate(fieldData: string[]): bool[]|bool[][][]|float[]|float[][][]|int[]|int[][][]|null[]|null[][][]|string[]|string[][][], private method
prepareValueForUpdate(params: array): array ↑BaseService, public method
requestWithExceptionHandling(apiRequest: Closure): iterable, private method
resolveCustomFieldFromOpportunityData(opportunityData: mixed[], fieldName: string): bool|float|int|mixed[]|null|string, private method
saveActivity(activity: Activity): Activity ↑RemoteEntityManipulationInterface, public method
saveFollowupActivity(activity: Activity, fields: array): null|string ↑RemoteEntityManipulationInterface, public method
setConfiguration(configuration: Configuration): void ↑SettingsInterface, public method
setUser(user: User): void ↑SettingsInterface, public method
syncAccount(crmId: string): Account|null ↑SyncCrmEntitiesInterface, public method
syncAccounts(since: \Carbon\Carbon, [to: \Carbon\Carbon|null = null]): int ↑SyncCrmEntitiesInterface, public method
syncContact(crmId: string): Contact|null ↑SyncCrmEntitiesInterface, public method
syncContacts(since: \Carbon\Carbon, [to: \Carbon\Carbon|null = null]): int ↑SyncCrmEntitiesInterface, public method
syncCustomFields(): void, method
syncField(field: Field): void ↑SyncCrmEntitiesInterface, method
syncFields(): void ↑SyncCrmEntitiesInterface, method
syncLead(crmId: string): Lead|null ↑SyncCrmEntitiesInterface, method
syncLeads(since: \Carbon\Carbon, [to: \Carbon\Carbon|null = null], [crmProfileId: null|string = null]): int ↑SyncCrmEntitiesInterface, method
syncOpportunities(parameters: array, [strategy: null|string = null]): int ↑SyncCrmEntitiesInterface, method
syncOpportunity(crmId: string): Opportunity|null ↑SyncCrmEntitiesInterface, method
syncOrganization(): void ↑SyncCrmMetadataInterface, method
syncProfileFields(): void ↑SyncCrmMetadataInterface, method
syncProfiles([userToSearch: User|null = null]): Profile|null ↑SyncCrmMetadataInterface, method...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
45351
|
1625
|
12
|
2026-05-14T14:32:28.831004+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-14/1778 /Users/lukas/.screenpipe/data/data/2026-05-14/1778769148831_m1.jpg...
|
PhpStorm
|
faVsco.js – Copper/Service.php
|
1
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
#12066 on JY-20725-handle Project: faVsco.js, menu
#12066 on JY-20725-handle-HS-search-rate-limit, menu...
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"bounds":{"left":0.054166667,"top":0.027777778,"width":0.08055556,"height":0.035555556},"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"#12066 on JY-20725-handle-HS-search-rate-limit, menu","depth":5,"bounds":{"left":0.13472222,"top":0.027777778,"width":0.25555557,"height":0.035555556},"on_screen":true,"help_text":"Pull request #12066 exists for current branch JY-20725-handle-HS-search-rate-limit, but local branch is out of sync with remote","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
7293744273990285589
|
-6961648536715800593
|
click
|
hybrid
|
NULL
|
Project: faVsco.js, menu
#12066 on JY-20725-handle Project: faVsco.js, menu
#12066 on JY-20725-handle-HS-search-rate-limit, menu
PhpStorm•FileFV faVsco.jsvEditViewNavigateCodeLaravelRefactorRunToolsGitWindowHelpabl100% C#12066 on JY-20725-handle-HS-search-rate-limit K vHandleHubspotRateLimitTestv* :8• Thu 14 May 17:32:28QProject v:=custom.logAutomatedReportGenerated.phpTrackAutomatedReportGeneratedEvent.phpPlaybackController.php© FieldDefinitions.phpUserAutomatedReportsController.phpPlanhatService.phpAutomatedReportResult.phpSendReportJob.php© FieldValueConverter.php• DeleteCrmEntityTrait.phpDeleteAccountJob.php© ImportActivityTypes.phpT WriteCrmTrait.phpDecorateActivity.php© Service.php© StandardFieldMetadata.php© Salesforce/Service.php© LogActivityTrait.phpPipedrive/Service.phpClose/Service.phpCopper/Service.php Xv D Copper© PlainTextDecorateActivity.phpT ActivityPlaybookTrait.php© CrmHelperRepository.phpAccountController.php18> D Api> D ExceptionT IntegrationAppTrait.php.env.stagingE.env© DetachActivityObject.phpRematchActivityOnCrmObjectDetach.php> C OpportunitySyncStrategy> D ProspectSearchStrategy> CJ Traits© MatchActivityCrmData.phpClient.php© HubspotPaginationService.phpclassService extendsBaseService implementsstring/nullHandleHubspotRateLimit.php© FieldDefinitions.php© FieldValueConverter.php© Service.php> D CrmObjects521517151815191520 € ›1557* }.*/public function matchByName(string $name, ?int $userId = null): ?array{...}v DecorateActivity3 usages© BaseDecorateActivity.php1558private function convertCrmData(array $object, string $objectType, ?int $userId = null): array{.© PlainTextDecorateActivity.pl1611> D Dummy1612 Cpublic function saveActivity(Activity $activity): Activityv Helpers1613T ActivityPlaybookTrait.php1614© Arraylterator.php1615• ConnectionStateTrait.php1616© CrmHelperRepository.php1617© FilterJoinedParticipants.php1618switch (Sactivity->type) {case Activity:: TYPE_CONFERENCE:case Activity:: TYPE_SOFTPHONE:case Activity: : TYPE_SOFTPHONE_INBOUND:Sactivity = $this->buildCallPayload(Sactivity):* OpportunitySyncableFieldsT1619v C Hubspot1620• AccountSyncStrategy1621> DJ Actions |1622• ContactSyncStrategy1623break;case Activity:: TYPE_SMS_INBOUND:case Activity: : TYPE_SMS_OUTBOUND:Sactivity = $this->buildTextMessagePayload($activity);> ODTO1624› Fields1625break:> • Journal1626D Metadata1627v D OpportunitySyncStrategy1628return Sactivity:> D Concerns1629A Lihonntl actlAndifiaNDurWorkspace associated with branch 'JY-20725-handle-HS-search-rate-limit' has been restored // Rollback // Configure... (today 16:17)Analyzing…=laravel.logSF [jiminny@localhost]A HS_local [jiminny@localho:W4 console [QAI PROD] X4 console [PROD]A console (EU]DGo jiminny084143 ×4 л215|•m_layout_id =2162,1661,66799,66217218219id = 33;220221222223224id THEN '(own225226227228229230hubspot':-231+ 232233V= 11512582;234W Windsurf Teams1612:21UTF-8Ca 4 spaces...
|
45350
|
NULL
|
NULL
|
NULL
|
|
45352
|
1626
|
6
|
2026-05-14T14:32:28.831014+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-14/1778 /Users/lukas/.screenpipe/data/data/2026-05-14/1778769148831_m2.jpg...
|
PhpStorm
|
faVsco.js – Copper/Service.php
|
1
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
#12066 on JY-20725-handle Project: faVsco.js, menu
#12066 on JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
HandleHubspotRateLimitTest
Run 'HandleHubspotRateLimitTest'
Debug 'HandleHubspotRateLimitTest'
More Actions
JetBrains AI
Search Everywhere...
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"bounds":{"left":0.2962101,"top":1.0,"width":0.03856383,"height":-0.019952059},"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"#12066 on JY-20725-handle-HS-search-rate-limit, menu","depth":5,"bounds":{"left":0.33477393,"top":1.0,"width":0.122340426,"height":-0.019952059},"on_screen":true,"help_text":"Pull request #12066 exists for current branch JY-20725-handle-HS-search-rate-limit, but local branch is out of sync with remote","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"bounds":{"left":0.5731383,"top":1.0,"width":0.011303191,"height":-0.019952059},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"HandleHubspotRateLimitTest","depth":6,"bounds":{"left":0.5884308,"top":1.0,"width":0.076130316,"height":-0.019952059},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'HandleHubspotRateLimitTest'","depth":6,"bounds":{"left":0.66456115,"top":1.0,"width":0.011303191,"height":-0.019952059},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'HandleHubspotRateLimitTest'","depth":6,"bounds":{"left":0.67586434,"top":1.0,"width":0.011303191,"height":-0.019952059},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"bounds":{"left":0.6871675,"top":1.0,"width":0.011303191,"height":-0.019952059},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"bounds":{"left":0.7150931,"top":1.0,"width":0.011303191,"height":-0.019952059},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"bounds":{"left":0.72639626,"top":1.0,"width":0.011303191,"height":-0.019952059},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
1160075880258684949
|
-8349599981661484599
|
click
|
hybrid
|
NULL
|
Project: faVsco.js, menu
#12066 on JY-20725-handle Project: faVsco.js, menu
#12066 on JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
HandleHubspotRateLimitTest
Run 'HandleHubspotRateLimitTest'
Debug 'HandleHubspotRateLimitTest'
More Actions
JetBrains AI
Search Everywhere
ActivityFilesLaterJiminny... ~@ jiminny-x-integrati& platform-inner-team© Channels# ai-chapter# alertsi backend# bugscontusion-cllnia# curiosity_lab# engineering# general# jiminny-bg# platform-tickets# product_launches# random# releases# sofia-office# support# thank-yous# the people of iimi..• Direct messages8. A...• A3 62P. Galya Dimitrova Mvasil VasilevA. Stefka Stoyanova%: Todor Stamatovf. Mario GeorgievP. Nikolay Ivanov2o James Graham "2. Stoyan Tanev. Steliyan Georgiev. Petko Kashinski*. Lukas Kovali...a: Apps® ToastS lira Gloud6 Huddle with Aneliya Angelova& R. Aneliya Angelova •• Messagest Add canvasur FilesAneliya Angelova 2:30 PMЛукаш, само при Сейлсфорс и Хубспот се синкваха активити типовете, когато се направи плейбук,нали?пои останалите скМі тоябва оъчно ла се въвелат.Lukas Kovalik 2:47 PMтряова да се за всички, някьде не се ли попьлвапри зохо май беше hardcoded но май и там си връщаха две категорииAneliya Angelova @ 4:00 PMЛукаш имаш ли време да се чуем за тестването на https://jiminny.atlassian.net/browse/JY-20725X Preview insackt[HubSpot] Optimise CRM rematctC OpenReady for QA- MediumAA Aneliya AngelovaAs of today at 4:00 PM RetreshOpen in Jira+ SummariseLukas Kovalik 4:02 PMzean-iurwiniareelYou joined the huddle LIVE 4:06 PMAneliva Angelova is here toolAneliya Angelova 4:44 PM11512582Lukas Kovalik O 5:16 PMcurl --location "httns:ani.hubani.com/crm/v3/obiects/contacts/search--header 'Content-Type: application/json'\--header "Authorization: BearerC|Kvma?iMx7OINOM8kOFwrAowAcAkUAhIR24?A05D?xiVrIYMILuP6SA01Kwcmhr=n.v2 hytorDniMvDalYck-CMalovolNOM9kOFwrAUAcrkcaws-ThwRARIRAOF-ATFSAOFRAO-ROIEBAQUBEggBAQEBAYICFIґ7Au8O2Nwal9YW2CbCCxYvwngmSgNldTFSAFoAYABoAHAAeAA'\--data "{ "limit", 13'Message Aneliva Angelova+ AaIđ 0Pm~ (Q SearchDate ModifiedYesterdav at 13.36Yesterday at 13:35Yesterday at 13.34Yesterday at 13:34Yesterday at 13:33resterday at 15.5sYesterdav at 13:37Yesterday at 13:32Yesterday ar 13.31Yesterday at 13:30Yesterday at 13:30resterday at 15.4gYesterdav at 13:29Yesterday at 13:28Yesterday at 13:28Yesterday at 13:27Yesterday at 13:27resterday at 13-40Yesterdav at 13:25Yesterday at 13:25Yesterday at 13:24Yesterday at 13:24Yesterday at 13:23Yesterday at 13.21Yesterdav at 13:21Yesterday at 13:20Yesterday at 13:20Yesterday at 13:19Yesterday at 13:19Yesterday at 13.18Vecterdav at 12:17Yesterday at 13:16Yesterdav at 13:16Yesterday at 13:15Yesterday at 13:14Yecterdav at 12:1/Yesterday at 13:13Yesterday at 13.13Yecterdav at 12:12Yesterday at 13:12Yesterday at 13:11Yesterday at 13:10Yesterday at 13:10Yesterday at 13.09Yecterdav at 12:091Yesterday at 13:08Yesterdav at 13:07MPEG-4 movie13 KBMPEG-4 movie16 KB1/ Kb29 KBMPEG-4 movieMPEG-4 movie6 KB12 KBMPE0"4 movie9 K:7 KBMPEG-4 movie8 KB37 KBMP2G-4 movie10 KBMPEG-4 movie7 KBMPEG"4 movie9K:8KBMPEG-4 movie72 KB14 K8|13 KRIMPEG-4 movieMPEG-4 movie9 KB18 KBMPEG-4 movie12 KEMPEG-4 movie10 KBMPEG-4 movie16 KB6 KB1MPEG-4 movie6 KBMPEG-4 movie12 KB23 KBMPEG-4 movie8 KEMPEG-4 movie6 KBMPEG-4 movie6 KBMPEG-4 movie11 K:MPEG-4 movie11 KBMPEG-4 movie34 KBMPEG-4 movie10 KPMPEG-4 movie7 KBMPEG-4 movie5 KBMPEG-4 movie11 KBIMPEG-4 movie26 KBMPEG-4 movie111 KB102 KBRR KRMPEG-4 movieMDEG-A movie59 KB98 KBMPEG-4 movie07 K:MPEG-A movie66 KB MPEG-4 movie44 KB93 KB79 KpMPEG-4 movieMPEG-4 movie58 KBMPEG-4 movie27 KP7 KBMPEG-4 movie12 KB32 K8MPEG-4 movieAl Notes: OffLeave• 0FavouritesE jiminny© Recents* ApplicationsiCloudiCloud Drive228 Sync folderQ DXP4800PLUS-B5FA@ Network|• CRMI• Orange• Red• Yellow• Green• Purple•) All lags..lohlDownloadsNameLoom.pkgAlfred copv.alfredoreterencesB KeychronAssist-1.0.2 (1).dmgA Keychron Assist-1.0.2.dmgmazanoke-images-yWJo.zioPhotos-3-001.zipD Transcript.pdf→mage U.loge1 Orioninstaller.dmaimage.jpg- image (2).1pc• ПО-22221726037035-004-001_ORGES.pdf• %D0%9F%D0%9E-22221726037035-004-001_archive.zipПO-22221726037035-004-001_archive (1).zip• repon 4).XmAltred copy2.altredoreterences05012026_0000000026574472_ SWIFT_OB70501260015890.pdf27022026_0000000026574472_SWIFT_OB72702260049200.pdf= 03042026 [CREDIT_CARD] SWIFT [CREDIT_CARD].001B reporti) xm=pdt.odipdf-1.pdfD pdf-2.pdf-pdf-5.pd1= ndf-1 ndipdf-3.pdfKoválik Family Tree.gedbitwarden export 20251031122528.isonKoválik Family Tree.zip*macOS Storage_Cleanup.mdal favicon icofirst_aid_notes_complete.docxrenortl2).esvconfig.ymlIteration run Search HS.postman_collection.jsonm licence bettertouchtoalMariusHosting Config.json1ooks-891a6503-bbb7-4b2b-9c3.csv•Alfredmazanoke-images-YWJ6ана Ковалик.jpg•искане даниел Ковалик..pa• Фактура Март Даниел Ковалик.jрс• Фактура Април Даниел Ковалик.jpg• Dhotac 2.001Q SearchKind00,4 MDinstdlle..dckage55.9 MBAlfred...ferences10,1 MBDisk Image10,1 MBDisk ImageIL MBLiP archive6,6 MBZIP archive2,5 MB PDF Document2,5 MBJreo lmage2.2 MBDisk Image2,2 MB PDF Document2 MBJPEG image1,9 MBJPEG imaqe192 KBPDF Document140 KbZIP archive148 KВ148 KB122 KB111 KB94 KBZIP archivelZIP archiveXML documentAlfred...ferencesPDF Document92 KBPDr DocumentK:91 KB91 KB30 KB29 KBPDF DocumentXML documentPDF Document28 KBPDF Document28 KR28 KB27 KB14 KB11 KB6 KB6KBPDE DocumentDocumentJSONICSV DocumentZIP archiveMarkdo…..ument5KRWindo...n image4 KB3 KBword ..cumentCSV Document2 KBYAML document1 KBcSV Document928 byteshttlicence183 bytesZero butesJSONAlfred.. ferencesZero bytesFolde1,9 MB1,8 MB17 MB1,7 MBColdorJPEG imageJPEG ImageIPEG imadeJPEG imageColdo1 of 58 selected, 8.51 G8 availabld• Inu 14 Mаy 1/•32-20Date AddedIs Mdl ZUzo dl 19:4530 Jan 2026 at 12:3617 Mar 2026 at 20:2717 Mar 2026 at 20:26Z3 Aor 2020 al 13:0229 Jan 2026 at 15:2019 Dec 2025 at 10:1619 Dec 2025 at 12:238 Aor 2026 at 20:3519 Dec 2025 at 10:2919 Dec 2025 at 12:1819 Dec 2025 at 12:4026 Mar 2026 at 11:2410 May 2026 at 13:5326 Mar 2026 at 11:2426 Mar 2026 at 11:2426 Mar 2026 at 11:2310 May 2026 at 14:3730 Jan 2026 at 12:3713 Feb 2026 at 11:54Z3 Apr 2026 at 13.0823 Aor 2026 at 13:0810 May 2026 at 13:5410 May 2026 at 14:3710 May 2026 at 13:4910 May 2026 at 13:5010 May 2026 at 13:5110 Mav 2026 at 12:51110 May 2026 at 13:5019 Dec 2025 at 11:3431 Oct 2025 at 12:2525 Nov 2025 at 17:596 Mar 2026 at 11.2224 Anr 2026 at 16:5220 Oct 2025 at 11:0218 Mar 2026 at 15:29• Mav 2026 at 11:09129 Oct 2025 at 19:329 May 2026 at 10:0418 Mar 2026 at 11:5510 May 2026 at 13:5712 Jun 2025 at 19:0430 Jan 2026 at 12:3630 Jan 2026 at 12:3616 Oct 2025 aт 16:0123 Apr 2026 at 13:0223 Apr 2026 at 13:0223 Apr 2026 at 13:0222 Anr 2026 at 12:0223 Apr 2026 at 13:0229 Jan 2026 at 15:20...
|
45347
|
NULL
|
NULL
|
NULL
|
|
45353
|
1625
|
13
|
2026-05-14T14:32:32.534718+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-14/1778 /Users/lukas/.screenpipe/data/data/2026-05-14/1778769152534_m1.jpg...
|
iTerm2
|
NULL
|
1
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
PhpStormFileEditViewNavigateCodeLaravelRefactorRun PhpStormFileEditViewNavigateCodeLaravelRefactorRunToolsGitWindowHelp•FV faVsco.jsv* #12066 on JY-20725-handle-HS-search-rate-limit k vProject vAutomatedReportGenerated.phpTrackAutomatedReportGeneratedEvent.phpabl19HandleHubspotRateLimitTestv100% C8• Thu 14 May 17:32:32* :Q=custom.logPlaybackController.php© FieldDefinitions.php© UserAutomatedReportsController.phpPlanhatService.phpAutomatedReportResult.phpSendReportJob.php© FieldValueConverter.php• DeleteCrmEntityTrait.phpDeleteAccountJob.php© ImportActivityTypes.phpT WriteCrmTrait.php© Service.php© DecorateActivity.php© StandardFieldMetadata.php© Salesforce/Service.php© LogActivityTrait.phpPipedrive/Service.phpClose/Service.phpCopper/Service.php Xv D Copper© PlainTextDecorateActivity.phpT ActivityPlaybookTrait.php© CrmHelperRepository.phpAccountController.php18› DJ Api› D ExceptionT IntegrationAppTrait.php.env.stagingE.env© DetachActivityObject.phpRematchActivityOnCrmObjectDetach.php> C OpportunitySyncStrategy© MatchActivityCrmData.php> D ProspectSearchStrategy> CJ Traits© FieldDefinitions.php© FieldValueConverter.php© Service.php> D CrmObjectsv DecorateActivity5216531660166116621663© Client.php© HubspotPaginationService.php© HandleHubspotRateLimit.phpclass Service extends BaseService implementsprivate function convertActivityType(PlaybookCategory $activityType): array{...}A 8/***/1 usage* @throws CrmException© BaseDecorateActivity.php© PlainTextDecorateActivity.pl1664private function puildCallPayload(Activity $activity): Activity> D Dummy1665v Helpers1666try {1667T ActivityPlaybookTrait.php© Arraylterator.php16681669• ConnectionStateTrait.php© CrmHelperRepository.php16701671if (Sactivity->crm_provider_id) {// The activity should be logged under the existing Task (not Activity).SnewActivityType = $this->getCrmType(Sactivity->category);if ($newActivityType === null) {Smessage = sprintf( format: '[Copper] invalid CRM Custom Activity Type: %s', $activity->cat 227© FilterJoinedParticipants.php* OpportunitySyncableFieldsT16721673throw new CrmException($message,code: 400):v D Hubspot• AccountSyncStrategy16741675> OJ Actions |• ContactSyncStrategy16761677> ODTO1678$this->copperClient->tasks()->update($activity->crm_provider_id,'status' => 'Completed','custom_activity_type_id' => SnewActivityType->value,'details' => Sthis->generateActivityDescription(Sactivity),› Fields1679> • Journal16801):} else {D Metadata1681v D OpportunitySyncStrategy1682if(Sthis-2getCrmType(Sactivitx-2categocy2m=nullnfSmessage = sprintf( format: '[Copper) Invalid CRM Activity Type: %s', Sactivity->category->› D Concerns1683A Lihonntl actlAndifiaNDurWorkspace associated with branch 'JY-20725-handle-HS-search-rate-limit' has been restored // Rollback // Configure... (today 16:17)=laravel.logSF [jiminny@localhost]A HS_local [jiminny@localho:W4 console [QAI PROD] X4 console [PROD]A console (EU]DGo jiminny084143 ×4 л215|•m_layout_id =2162,1661,66799,66217218219-id = 33;220221222223-224id THEN '(own225226228229— 230hubspat":+231L232233 V= 11512582;-234=W Windsurf Teams1664:22UTF-8Ca 4 spaces...
|
NULL
|
-8110262541598513205
|
NULL
|
click
|
ocr
|
NULL
|
PhpStormFileEditViewNavigateCodeLaravelRefactorRun PhpStormFileEditViewNavigateCodeLaravelRefactorRunToolsGitWindowHelp•FV faVsco.jsv* #12066 on JY-20725-handle-HS-search-rate-limit k vProject vAutomatedReportGenerated.phpTrackAutomatedReportGeneratedEvent.phpabl19HandleHubspotRateLimitTestv100% C8• Thu 14 May 17:32:32* :Q=custom.logPlaybackController.php© FieldDefinitions.php© UserAutomatedReportsController.phpPlanhatService.phpAutomatedReportResult.phpSendReportJob.php© FieldValueConverter.php• DeleteCrmEntityTrait.phpDeleteAccountJob.php© ImportActivityTypes.phpT WriteCrmTrait.php© Service.php© DecorateActivity.php© StandardFieldMetadata.php© Salesforce/Service.php© LogActivityTrait.phpPipedrive/Service.phpClose/Service.phpCopper/Service.php Xv D Copper© PlainTextDecorateActivity.phpT ActivityPlaybookTrait.php© CrmHelperRepository.phpAccountController.php18› DJ Api› D ExceptionT IntegrationAppTrait.php.env.stagingE.env© DetachActivityObject.phpRematchActivityOnCrmObjectDetach.php> C OpportunitySyncStrategy© MatchActivityCrmData.php> D ProspectSearchStrategy> CJ Traits© FieldDefinitions.php© FieldValueConverter.php© Service.php> D CrmObjectsv DecorateActivity5216531660166116621663© Client.php© HubspotPaginationService.php© HandleHubspotRateLimit.phpclass Service extends BaseService implementsprivate function convertActivityType(PlaybookCategory $activityType): array{...}A 8/***/1 usage* @throws CrmException© BaseDecorateActivity.php© PlainTextDecorateActivity.pl1664private function puildCallPayload(Activity $activity): Activity> D Dummy1665v Helpers1666try {1667T ActivityPlaybookTrait.php© Arraylterator.php16681669• ConnectionStateTrait.php© CrmHelperRepository.php16701671if (Sactivity->crm_provider_id) {// The activity should be logged under the existing Task (not Activity).SnewActivityType = $this->getCrmType(Sactivity->category);if ($newActivityType === null) {Smessage = sprintf( format: '[Copper] invalid CRM Custom Activity Type: %s', $activity->cat 227© FilterJoinedParticipants.php* OpportunitySyncableFieldsT16721673throw new CrmException($message,code: 400):v D Hubspot• AccountSyncStrategy16741675> OJ Actions |• ContactSyncStrategy16761677> ODTO1678$this->copperClient->tasks()->update($activity->crm_provider_id,'status' => 'Completed','custom_activity_type_id' => SnewActivityType->value,'details' => Sthis->generateActivityDescription(Sactivity),› Fields1679> • Journal16801):} else {D Metadata1681v D OpportunitySyncStrategy1682if(Sthis-2getCrmType(Sactivitx-2categocy2m=nullnfSmessage = sprintf( format: '[Copper) Invalid CRM Activity Type: %s', Sactivity->category->› D Concerns1683A Lihonntl actlAndifiaNDurWorkspace associated with branch 'JY-20725-handle-HS-search-rate-limit' has been restored // Rollback // Configure... (today 16:17)=laravel.logSF [jiminny@localhost]A HS_local [jiminny@localho:W4 console [QAI PROD] X4 console [PROD]A console (EU]DGo jiminny084143 ×4 л215|•m_layout_id =2162,1661,66799,66217218219-id = 33;220221222223-224id THEN '(own225226228229— 230hubspat":+231L232233 V= 11512582;-234=W Windsurf Teams1664:22UTF-8Ca 4 spaces...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
45354
|
1626
|
7
|
2026-05-14T14:32:34.096934+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-14/1778 /Users/lukas/.screenpipe/data/data/2026-05-14/1778769154096_m2.jpg...
|
PhpStorm
|
faVsco.js – Copper/Service.php
|
1
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
#12066 on JY-20725-handle Project: faVsco.js, menu
#12066 on JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
HandleHubspotRateLimitTest
Run 'HandleHubspotRateLimitTest'
Debug 'HandleHubspotRateLimitTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
2
33...
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"bounds":{"left":0.2962101,"top":1.0,"width":0.03856383,"height":-0.019952059},"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"#12066 on JY-20725-handle-HS-search-rate-limit, menu","depth":5,"bounds":{"left":0.33477393,"top":1.0,"width":0.122340426,"height":-0.019952059},"on_screen":true,"help_text":"Pull request #12066 exists for current branch JY-20725-handle-HS-search-rate-limit, but local branch is out of sync with remote","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"bounds":{"left":0.5731383,"top":1.0,"width":0.011303191,"height":-0.019952059},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"HandleHubspotRateLimitTest","depth":6,"bounds":{"left":0.5884308,"top":1.0,"width":0.076130316,"height":-0.019952059},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'HandleHubspotRateLimitTest'","depth":6,"bounds":{"left":0.66456115,"top":1.0,"width":0.011303191,"height":-0.019952059},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'HandleHubspotRateLimitTest'","depth":6,"bounds":{"left":0.67586434,"top":1.0,"width":0.011303191,"height":-0.019952059},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"bounds":{"left":0.6871675,"top":1.0,"width":0.011303191,"height":-0.019952059},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"bounds":{"left":0.7150931,"top":1.0,"width":0.011303191,"height":-0.019952059},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"bounds":{"left":0.72639626,"top":1.0,"width":0.011303191,"height":-0.019952059},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"IDE and Project Settings","depth":5,"bounds":{"left":0.73769945,"top":1.0,"width":0.011303191,"height":-0.019952059},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.042220745,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"2","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"33","depth":4,"on_screen":true,"role_description":"text"}]...
|
-9194673704168071041
|
-7555842213617218742
|
click
|
hybrid
|
NULL
|
Project: faVsco.js, menu
#12066 on JY-20725-handle Project: faVsco.js, menu
#12066 on JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
HandleHubspotRateLimitTest
Run 'HandleHubspotRateLimitTest'
Debug 'HandleHubspotRateLimitTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
2
33
ActivityFilesLaterJiminny... ~@ jiminny-x-integrati& platform-inner-team© Channels# ai-chapter# alertsi backend# bugscontusion-cllnia# curiosity_lab# engineering# general# jiminny-bg# platform-tickets# product_launches# random# releases# sofia-office# support# thank-yous# the people of iimi..• Direct messages8. A...• A3 62P. Galya Dimitrova Mvasil VasilevA. Stefka Stoyanova%: Todor Stamatovf. Mario GeorgievP. Nikolay Ivanov2o James Graham "2. Stoyan Tanev. Steliyan Georgiev. Petko Kashinski*. Lukas Kovali...a: Apps® ToastS lira Gloud6 Huddle with Aneliya Angelova& R. Aneliya Angelova •• Messagest Add canvasur FilesAneliya Angelova 2:30 PMЛукаш, само при Сейлсфорс и Хубспот се синкваха активити типовете, когато се направи плейбук,нали?пои останалите скМі тоябва оъчно ла се въвелат.Lukas Kovalik 2:47 PMтряова да се за всички, някьде не се ли попьлвапри зохо май беше hardcoded но май и там си връщаха две категорииAneliya Angelova @ 4:00 PMЛукаш имаш ли време да се чуем за тестването на https://jiminny.atlassian.net/browse/JY-20725X Preview insackt[HubSpot] Optimise CRM rematctC OpenReady for QA- MediumAA Aneliya AngelovaAs of today at 4:00 PM RetreshOpen in Jira+ SummariseLukas Kovalik 4:02 PMzean-iurwiniareelYou joined the huddle LIVE 4:06 PMAneliva Angelova is here toolAneliya Angelova 4:44 PM11512582Lukas Kovalik O 5:16 PMcurl --location "httns:ani.hubani.com/crm/v3/obiects/contacts/search--header 'Content-Type: application/json'\--header "Authorization: BearerC|Kvma?iMx7OINOM8kOFwrAowAcAkUAhIR24?A05D?xiVrIYMILuP6SA01Kwcmhr=n.v2 hytorDniMvDalYck-CMalovolNOM9kOFwrAUAcrkcaws-ThwRARIRAOF-ATFSAOFRAO-ROIEBAQUBEggBAQEBAYICFIґ7Au8O2Nwal9YW2CbCCxYvwngmSgNldTFSAFoAYABoAHAAeAA'\--data "{ "limit", 13'Message Aneliva Angelova+ AaIđ 0Pm~ (Q SearchDate ModifiedYesterdav at 13.36Yesterday at 13:35Yesterday at 13.34Yesterday at 13:34Yesterday at 13:33resterday at 15.5sYesterdav at 13:37Yesterday at 13:32Yesterday ar 13.31Yesterday at 13:30Yesterday at 13:30resterday at 15.4gYesterdav at 13:29Yesterday at 13:28Yesterday at 13:28Yesterday at 13:27Yesterday at 13:27resterday at 13-40Yesterdav at 13:25Yesterday at 13:25Yesterday at 13:24Yesterday at 13:24Yesterday at 13:23Yesterday at 13.21Yesterdav at 13:21Yesterday at 13:20Yesterday at 13:20Yesterday at 13:19Yesterday at 13:19Yesterday at 13.18Vecterdav at 12:17Yesterday at 13:16Yesterdav at 13:16Yesterday at 13:15Yesterday at 13:14Yecterdav at 12:1/Yesterday at 13:13Yesterday at 13.13Yecterdav at 12:12Yesterday at 13:12Yesterday at 13:11Yesterday at 13:10Yesterday at 13:10Yesterday at 13.09Yecterdav at 12:091Yesterday at 13:08Yesterdav at 13:07MPEG-4 movie13 KBMPEG-4 movie16 KB1/ Kb29 KBMPEG-4 movieMPEG-4 movie6 KB12 KBMPE0"4 movie9 K:7 KBMPEG-4 movie8 KB37 KBMP2G-4 movie10 KBMPEG-4 movie7 KBMPEG"4 movie9K:8KBMPEG-4 movie72 KB14 K8|13 KRIMPEG-4 movieMPEG-4 movie9 KB18 KBMPEG-4 movie12 KEMPEG-4 movie10 KBMPEG-4 movie16 KB6 KB1MPEG-4 movie6 KBMPEG-4 movie12 KB23 KBMPEG-4 movie8 KEMPEG-4 movie6 KBMPEG-4 movie6 KBMPEG-4 movie11 K:MPEG-4 movie11 KBMPEG-4 movie34 KBMPEG-4 movie10 KPMPEG-4 movie7 KBMPEG-4 movie5 KBMPEG-4 movie11 KBIMPEG-4 movie26 KBMPEG-4 movie111 KB102 KBRR KRMPEG-4 movieMDEG-A movie59 KB98 KBMPEG-4 movie07 K:MPEG-A movie66 KB MPEG-4 movie44 KB93 KB79 KpMPEG-4 movieMPEG-4 movie58 KBMPEG-4 movie27 KP7 KBMPEG-4 movie12 KB32 K8MPEG-4 movieAl Notes: OffLeave• 0FavouritesE jiminny© Recents* ApplicationsiCloudiCloud Drive228 Sync folderQ DXP4800PLUS-B5FA@ Network|• CRMI• Orange• Red• Yellow• Green• Purple•) All lags..lohlDownloadsNameLoom.pkgAlfred copv.alfredoreterencesB KeychronAssist-1.0.2 (1).dmgA Keychron Assist-1.0.2.dmgmazanoke-images-yWJo.zioPhotos-3-001.zipD Transcript.pdf→mage U.loge1 Orioninstaller.dmaimage.jpg- image (2).1pc• ПО-22221726037035-004-001_ORGES.pdf• %D0%9F%D0%9E-22221726037035-004-001_archive.zipПO-22221726037035-004-001_archive (1).zip• repon 4).XmAltred copy2.altredoreterences05012026_0000000026574472_ SWIFT_OB70501260015890.pdf27022026_0000000026574472_SWIFT_OB72702260049200.pdf= 03042026 [CREDIT_CARD] SWIFT [CREDIT_CARD].001B reporti) xm=pdt.odipdf-1.pdfD pdf-2.pdf-pdf-5.pd1= ndf-1 ndipdf-3.pdfKoválik Family Tree.gedbitwarden export 20251031122528.isonKoválik Family Tree.zip*macOS Storage_Cleanup.mdal favicon icofirst_aid_notes_complete.docxrenortl2).esvconfig.ymlIteration run Search HS.postman_collection.jsonm licence bettertouchtoalMariusHosting Config.json1ooks-891a6503-bbb7-4b2b-9c3.csv•Alfredmazanoke-images-YWJ6ана Ковалик.jpg•искане даниел Ковалик..pa• Фактура Март Даниел Ковалик.jрс• Фактура Април Даниел Ковалик.jpg• Dhotac 2.001Q SearchKind00,4 MDinstdlle..dckage55.9 MBAlfred...ferences10,1 MBDisk Image10,1 MBDisk ImageIL MBLiP archive6,6 MBZIP archive2,5 MB PDF Document2,5 MBJreo lmage2.2 MBDisk Image2,2 MB PDF Document2 MBJPEG image1,9 MBJPEG imaqe192 KBPDF Document140 KbZIP archive148 KВ148 KB122 KB111 KB94 KBZIP archivelZIP archiveXML documentAlfred...ferencesPDF Document92 KBPDr DocumentK:91 KB91 KB30 KB29 KBPDF DocumentXML documentPDF Document28 KBPDF Document28 KR28 KB27 KB14 KB11 KB6 KB6KBPDE DocumentDocumentJSONICSV DocumentZIP archiveMarkdo…..ument5KRWindo...n image4 KB3 KBword ..cumentCSV Document2 KBYAML document1 KBcSV Document928 byteshttlicence183 bytesZero butesJSONAlfred.. ferencesZero bytesFolde1,9 MB1,8 MB17 MB1,7 MBColdorJPEG imageJPEG ImageIPEG imadeJPEG imageColdo1 of 58 selected, 8.51 G8 availabld• Inu 14 Mаy 1/•32.32Date AddedIs Mdl ZUzo dl 19:4530 Jan 2026 at 12:3617 Mar 2026 at 20:2717 Mar 2026 at 20:26Z3 Aor 2020 al 13:0229 Jan 2026 at 15:2019 Dec 2025 at 10:1619 Dec 2025 at 12:238 Aor 2026 at 20:3519 Dec 2025 at 10:2919 Dec 2025 at 12:1819 Dec 2025 at 12:4026 Mar 2026 at 11:2410 May 2026 at 13:5326 Mar 2026 at 11:2426 Mar 2026 at 11:2426 Mar 2026 at 11:2310 May 2026 at 14:3730 Jan 2026 at 12:3713 Feb 2026 at 11:54Z3 Apr 2026 at 13.0823 Aor 2026 at 13:0810 May 2026 at 13:5410 May 2026 at 14:3710 May 2026 at 13:4910 May 2026 at 13:5010 May 2026 at 13:5110 Mav 2026 at 12:51110 May 2026 at 13:5019 Dec 2025 at 11:3431 Oct 2025 at 12:2525 Nov 2025 at 17:596 Mar 2026 at 11.2224 Anr 2026 at 16:5220 Oct 2025 at 11:0218 Mar 2026 at 15:29• Mav 2026 at 11:09129 Oct 2025 at 19:329 May 2026 at 10:0418 Mar 2026 at 11:5510 May 2026 at 13:5712 Jun 2025 at 19:0430 Jan 2026 at 12:3630 Jan 2026 at 12:3616 Oct 2025 aт 16:0123 Apr 2026 at 13:0223 Apr 2026 at 13:0223 Apr 2026 at 13:0222 Anr 2026 at 12:0223 Apr 2026 at 13:0229 Jan 2026 at 15:20...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
45355
|
1625
|
14
|
2026-05-14T14:32:36.655804+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-14/1778 /Users/lukas/.screenpipe/data/data/2026-05-14/1778769156655_m1.jpg...
|
PhpStorm
|
faVsco.js – Copper/Service.php
|
1
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"bounds":{"left":0.054166667,"top":0.027777778,"width":0.08055556,"height":0.035555556},"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
8043719072324535154
|
-8628527368849355612
|
click
|
hybrid
|
NULL
|
Project: faVsco.js, menu
PhpStormFileEditViewNavig Project: faVsco.js, menu
PhpStormFileEditViewNavigateCodeLaravelRefactorRunToolsGitWindowHelp•FV faVsco.jsv* #12066 on JY-20725-handle-HS-search-rate-limit k vabl19HandleHubspotRateLimitTest100% C8• Thu 14 May 17:32:36* :QProject v= custom.logAutomatedReportGenerated.phpTrackAutomatedReportGeneratedEvent.phpPlaybackController.php© FieldDefinitions.phpUserAutomatedReportsController.phpPlanhatService.phpAutomatedReportResult.phpSendReportJob.php=laravel.log© FieldValueConverter.php• DeleteCrmEntityTrait.phpDeleteAccountJob.php© ImportActivityTypes.phpT WriteCrmTrait.php© Service.php© DecorateActivity.phpSF [jiminny@localhost]© StandardFieldMetadata.php© Salesforce/Service.php© LogActivityTrait.phpPipedrive/Service.phpClose/Service.phpCopper/Service.php XA HS_local [jiminny@localho:Wv D Copper© PlainTextDecorateActivity.phpT ActivityPlaybookTrait.php© CrmHelperRepository.phpAccountController.php4 console [QAI PROD] X18> D Api› D ExceptionT IntegrationAppTrait.php.env.stagingE.env© DetachActivityObject.phpRematchActivityOnCrmObjectDetach.php4 console [PROD]> C OpportunitySyncStrategy© MatchActivityCrmData.php© Client.php© HubspotPaginationService.php© HandleHubspotRateLimit.phpA console (EU]> D ProspectSearchStrategy52class Service extends BaseService implementsA5 A 36DGojiminny v> CJ Traits© FieldDefinitions.php1662* Othrows CrmException084143 ×4 л© FieldValueConverter.php1663*/215|•m_layout_id =© Service.php1 usage2162,1661,66799,66> D CrmObjects1664private function buildCallPayload(Activity $activity): Activity217v DecorateActivity1665218© BaseDecorateActivity.php1666try{219id = 33;© PlainTextDecorateActivity.pl1667if (Sactivity->crm_provider_id) {=220-221> D Dummy1668// The activity should be logged under the existing Task (not Activity).v Helpers1669$newActivityType = $this->getChmType($activity->category);=222T ActivityPlaybookTrait.php1670if ($newActivityType === null) {=223© Arraylterator.php1671Smessage = sprintf( format: '[Copper] invalid CRM Custom Activity Type: %s', $activity->cat 224id THEN '(own• ConnectionStateTrait.php1672=225© CrmHelperRepository.php1673throw new CrmException(Smessage,code: 400):226© FilterJoinedParticipants.php1674— 227* OpportunitySyncableFieldsT1675$this->copperClient->tasks()->update($activity->crm_provider_id, [228v C Hubspot1676'status'= 'Completed',229• AccountSyncStrategy1677'custom_activity_type_id' => $newActivityType->value,= 230hubspat':> @ Actions|1678'details' => $this->generateActivityDescription($activity),• ContactSyncStrategy[23.16791):1232> ODTO1680j else {233 V= 11512582;› Fields1681if (Sthis-~getCrmType(SactivitX-scategocx)minulln234> • Journal1682$message = sprintf( format:'[Copper] Invalid CRM Activity Type: %s', $activity->category->-D Metadata1683v D OpportunitySyncStrategy1684throw new CrmException($message,code: 400);› D Concerns1685=A Unhonntl actlAndifiaNDur 1686Workspace associated with branch 'JY-20725-handle-HS-search-rate-limit' has been restored // Rollback // Configure... (today 16:17)W Windsurf Teams1669:47UTF-8Ca 4 spaces...
|
45353
|
NULL
|
NULL
|
NULL
|
|
45356
|
1626
|
8
|
2026-05-14T14:32:37.929047+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-14/1778 /Users/lukas/.screenpipe/data/data/2026-05-14/1778769157929_m2.jpg...
|
PhpStorm
|
faVsco.js – Copper/Service.php
|
1
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
ActivityFilesLaterJiminny... ~@ jiminny-x-integrat ActivityFilesLaterJiminny... ~@ jiminny-x-integrati& platform-inner-team© Channels# ai-chapter# alertsi backend# bugscontusion-cllnia# curiosity_lab# engineering# general# jiminny-bg# platform-tickets# product_launches# random# releases# sofia-office# support# thank-yous# the people of iimi..• Direct messages8. A...• A3 62P. Galya Dimitrova Mvasil VasilevA. Stefka Stoyanova%: Todor Stamatovf. Mario GeorgievP. Nikolay Ivanov2o James Graham "2. Stoyan Tanev. Steliyan Georgiev. Petko Kashinski*. Lukas Kovali...a: Apps® ToastS lira Gloud6 Huddle with Aneliya Angelova& R. Aneliya Angelova •• Messagest Add canvasur FilesAneliya Angelova 2:30 PMЛукаш, само при Сейлсфорс и Хубспот се синкваха активити типовете, когато се направи плейбук,нали?пои останалите скМі тоябва оъчно ла се въвелат.Lukas Kovalik 2:47 PMтряова да се за всички, някьде не се ли попьлвапри зохо май беше hardcoded но май и там си връщаха две категорииAneliya Angelova @ 4:00 PMЛукаш имаш ли време да се чуем за тестването на https://jiminny.atlassian.net/browse/JY-20725X Preview insackt[HubSpot] Optimise CRM rematctC OpenReady for QA- MediumAA Aneliya AngelovaAs of today at 4:00 PM RetreshOpen in Jira+ SummariseLukas Kovalik 4:02 PMzean-iurwiniareelYou joined the huddle LIVE 4:06 PMAneliva Angelova is here toolAneliya Angelova 4:44 PM11512582Lukas Kovalik O 5:16 PMcurl --location "httns:ani.hubani.com/crm/v3/obiects/contacts/search--header 'Content-Type: application/json'\--header "Authorization: BearerC|Kvma?iMx7OINOM8kOFwrAowAcAkUAhIR24?A05D?xiVrIYMILuP6SA01Kwcmhr=n.v2 hytorDniMvDalYck-CMalovolNOM9kOFwrAUAcrkcaws-ThwRARIRAOF-ATFSAOFRAO-ROIEBAQUBEggBAQEBAYICFIґ7Au8O2Nwal9YW2CbCCxYvwngmSgNldTFSAFoAYABoAHAAeAA'\--data "{ "limit", 13'Message Aneliva Angelova+ AaIđ 0Pm~ (Q SearchDate ModifiedYesterdav at 13.36Yesterday at 13:35Yesterday at 13.34Yesterday at 13:34Yesterday at 13:33resterday at 15.5sYesterdav at 13:37Yesterday at 13:32Yesterday at 13.31Yesterday at 13:30Yesterday at 13:30resterday at 15.4gYesterdav at 13:29Yesterday at 13:28Yesterday at 13:28Yesterday at 13:27Yesterday at 13:27resterday at 13.-40Yesterdav at 13:25Yesterday at 13:25Yesterday at 13:24Yesterday at 13:24Yesterday at 13:23Yesterday at 13.21Yesterdav at 13:21Yesterday at 13:20Yesterday at 13:20Yesterday at 13:19Yesterday at 13:19Yesterday at 13.18Vecterdav at 12:17Yesterday at 13:16Yesterdav at 13:16Yesterday at 13:15Yesterday at 13:14Yecterdav at 12:1/Yesterday at 13:13Yesterday at 13.13Yecterdav at 12:12Yesterday at 13:12Yesterday at 13:11Yesterday at 13:10Yesterday at 13:10Yesterday at 13.09Yecterdav at 12:091Yesterday at 13:08Yesterdav at 13:07MPEG-4 movie13 KBMPEG-4 movie16 KB1/ Kb29 KBMPEG-4 movieMPEG-4 movie6 KB12 KBMPE0"4 movie9 K:7 KBMPEG-4 movie8 KB37 KBMP2G-4 movie10 KBMPEG-4 movie7 KBMPEG"4 movie9K:8KBMPEG-4 movie72 KB14 K8|13 KRIMPEG-4 movieMPEG-4 movie9 KB18 KBMPEG-4 movie12 KEMPEG-4 movie10 KBMPEG-4 movie16 KB6 KB1MPEG-4 movie6 KBMPEG-4 movie12 KB23 KBMPEG-4 movie8 KEMPEG-4 movie6 KBMPEG-4 movie6 KBMPEG-4 movie11 K:MPEG-4 movie11 KBMPEG-4 movie34 KBMPEG-4 movie10 KPMPEG-4 movie7 KBMPEG-4 movie5 KBMPEG-4 movie11 KBIMPEG-4 movie26 KBMPEG-4 movie111 KB102 KBRR KRMPEG-4 movieMDEG-A movie59 KB98 KBMPEG-4 movie07 K:MPEG-A movie66 KB MPEG-4 movie44 KB93 KB79 KpMPEG-4 movieMPEG-4 movie58 KBMPEG-4 movie27 KP7 KBMPEG-4 movie12 KB32 K8MPEG-4 movieAl Notes: OffLeave• 0FavouritesE jiminny© Recents* ApplicationsiCloudiCloud Drive228 Sync folderQ DXP4800PLUS-B5FA@ Network|• CRMI• Orange• Red• Yellow• Green• Purple•) All lags..lohlDownloadsNameLoom.pkgAlfred copv.alfredoreterencesB KeychronAssist-1.0.2 (1).dmgA Keychron Assist-1.0.2.dmgmazanoke-images-yWJo.zioPhotos-3-001.zipD Transcript.pdf→mage U.loge1 Orioninstaller.dmaimage.jpg- image (2).1pc• ПО-22221726037035-004-001_ORGES.pdf• %D0%9F%D0%9E-22221726037035-004-001_archive.zipПO-22221726037035-004-001_archive (1).zip• repon 4).XmAltred copy2.altredoreterences05012026_0000000026574472_ SWIFT_OB70501260015890.pdf27022026_0000000026574472_SWIFT_OB72702260049200.pdf= 03042026 [CREDIT_CARD] SWIFT [CREDIT_CARD].001B reporti) xm=pdt.odipdf-1.pdfD pdf-2.pdf-pdf-5.pd1= ndf-1 ndipdf-3.pdfKoválik Family Tree.gedbitwarden export 20251031122528.isonKoválik Family Tree.zip*macOS Storage_Cleanup.mdal favicon icofirst_aid_notes_complete.docxrenortl2).esvconfig.ymlIteration run Search HS.postman_collection.jsonm licence bettertouchtoalMariusHosting Config.json1ooks-891a6503-bbb7-4b2b-9c3.csv•Alfredmazanoke-images-YWJ6ана Ковалик.jpg•искане даниел Ковалик..pa• Фактура Март Даниел Ковалик.jрс• Фактура Април Даниел Ковалик.jpg• Dhotac 2.001Q SearchKind00,4 MDinstdlle..dckage55.9 MBAlfred...ferences10,1 MBDisk Image10,1 MBDisk ImageIL MBLiP archive6,6 MBZIP archive2,5 MB PDF Document2,5 MBJreo lmage2.2 MBDisk Image2,2 MB PDF Document2 MBJPEG image1,9 MBJPEG imaqe192 KBPDF Document140 KbZIP archive148 KВ148 KB122 KB111 KB94 KBZIP archivelZIP archiveXML documentAlfred...ferencesPDF Document92 KBPDr DocumentK:91 KB91 KB30 KB29 KBPDF DocumentXML documentPDF Document28 KBPDF Document28 KR28 KB27 KB14 KB11 KB6 KB6KBPDE DocumentDocumentJSONICSV DocumentZIP archiveMarkdo…..ument5KRWindo...n image4 KB3 KBword ..cumentCSV Document2 KBYAML document1 KBcSV Document928 byteshttlicence183 bytesZero butesJSONAlfred.. ferencesZero bytesFolde1,9 MB1,8 MB17 MB1,7 MBColdorJPEG imageJPEG ImageJPEG imageJPEG imageColdo1 of 58 selected, 8.5 GB availab!i• Inu 14 Mаy 1/.32.31Date AddedIs Mdl ZUzo dl 19:4530 Jan 2026 at 12:3617 Mar 2026 at 20:2717 Mar 2026 at 20:26Z3 Aor 2020 al 13:0229 Jan 2026 at 15:2019 Dec 2025 at 10:1619 Dec 2025 at 12:238 Aor 2026 at 20:3519 Dec 2025 at 10:2919 Dec 2025 at 12:1819 Dec 2025 at 12:4026 Mar 2026 at 11:2410 May 2026 at 13:5326 Mar 2026 at 11:2426 Mar 2026 at 11:2426 Mar 2026 at 11:2310 May 2026 at 14:3730 Jan 2026 at 12:3713 Feb 2026 at 11:54Z3 Apr 2026 at 13.0823 Aor 2026 at 13:0810 May 2026 at 13:5410 May 2026 at 14:3710 May 2026 at 13:4910 May 2026 at 13:5010 May 2026 at 13:5110 Mav 2026 at 12:51110 May 2026 at 13:5019 Dec 2025 at 11:3431 Oct 2025 at 12:2525 Nov 2025 at 17:596 Mar 2026 at 11.2224 Anr 2026 at 16:5220 Oct 2025 at 11:0218 Mar 2026 at 15:29• Mav 2026 at 11:09129 Oct 2025 at 19:329 May 2026 at 10:0418 Mar 2026 at 11:5510 May 2026 at 13:5712 Jun 2025 at 19:0430 Jan 2026 at 12:3630 Jan 2026 at 12:3616 Oct 2025 aт 16:0123 Apr 2026 at 13:0223 Apr 2026 at 13:0223 Apr 2026 at 13:0222 Anr 2026 at 12:0223 Apr 2026 at 13:0229 Jan 2026 at 15:20...
|
NULL
|
-4849588242053115545
|
NULL
|
click
|
ocr
|
NULL
|
ActivityFilesLaterJiminny... ~@ jiminny-x-integrat ActivityFilesLaterJiminny... ~@ jiminny-x-integrati& platform-inner-team© Channels# ai-chapter# alertsi backend# bugscontusion-cllnia# curiosity_lab# engineering# general# jiminny-bg# platform-tickets# product_launches# random# releases# sofia-office# support# thank-yous# the people of iimi..• Direct messages8. A...• A3 62P. Galya Dimitrova Mvasil VasilevA. Stefka Stoyanova%: Todor Stamatovf. Mario GeorgievP. Nikolay Ivanov2o James Graham "2. Stoyan Tanev. Steliyan Georgiev. Petko Kashinski*. Lukas Kovali...a: Apps® ToastS lira Gloud6 Huddle with Aneliya Angelova& R. Aneliya Angelova •• Messagest Add canvasur FilesAneliya Angelova 2:30 PMЛукаш, само при Сейлсфорс и Хубспот се синкваха активити типовете, когато се направи плейбук,нали?пои останалите скМі тоябва оъчно ла се въвелат.Lukas Kovalik 2:47 PMтряова да се за всички, някьде не се ли попьлвапри зохо май беше hardcoded но май и там си връщаха две категорииAneliya Angelova @ 4:00 PMЛукаш имаш ли време да се чуем за тестването на https://jiminny.atlassian.net/browse/JY-20725X Preview insackt[HubSpot] Optimise CRM rematctC OpenReady for QA- MediumAA Aneliya AngelovaAs of today at 4:00 PM RetreshOpen in Jira+ SummariseLukas Kovalik 4:02 PMzean-iurwiniareelYou joined the huddle LIVE 4:06 PMAneliva Angelova is here toolAneliya Angelova 4:44 PM11512582Lukas Kovalik O 5:16 PMcurl --location "httns:ani.hubani.com/crm/v3/obiects/contacts/search--header 'Content-Type: application/json'\--header "Authorization: BearerC|Kvma?iMx7OINOM8kOFwrAowAcAkUAhIR24?A05D?xiVrIYMILuP6SA01Kwcmhr=n.v2 hytorDniMvDalYck-CMalovolNOM9kOFwrAUAcrkcaws-ThwRARIRAOF-ATFSAOFRAO-ROIEBAQUBEggBAQEBAYICFIґ7Au8O2Nwal9YW2CbCCxYvwngmSgNldTFSAFoAYABoAHAAeAA'\--data "{ "limit", 13'Message Aneliva Angelova+ AaIđ 0Pm~ (Q SearchDate ModifiedYesterdav at 13.36Yesterday at 13:35Yesterday at 13.34Yesterday at 13:34Yesterday at 13:33resterday at 15.5sYesterdav at 13:37Yesterday at 13:32Yesterday at 13.31Yesterday at 13:30Yesterday at 13:30resterday at 15.4gYesterdav at 13:29Yesterday at 13:28Yesterday at 13:28Yesterday at 13:27Yesterday at 13:27resterday at 13.-40Yesterdav at 13:25Yesterday at 13:25Yesterday at 13:24Yesterday at 13:24Yesterday at 13:23Yesterday at 13.21Yesterdav at 13:21Yesterday at 13:20Yesterday at 13:20Yesterday at 13:19Yesterday at 13:19Yesterday at 13.18Vecterdav at 12:17Yesterday at 13:16Yesterdav at 13:16Yesterday at 13:15Yesterday at 13:14Yecterdav at 12:1/Yesterday at 13:13Yesterday at 13.13Yecterdav at 12:12Yesterday at 13:12Yesterday at 13:11Yesterday at 13:10Yesterday at 13:10Yesterday at 13.09Yecterdav at 12:091Yesterday at 13:08Yesterdav at 13:07MPEG-4 movie13 KBMPEG-4 movie16 KB1/ Kb29 KBMPEG-4 movieMPEG-4 movie6 KB12 KBMPE0"4 movie9 K:7 KBMPEG-4 movie8 KB37 KBMP2G-4 movie10 KBMPEG-4 movie7 KBMPEG"4 movie9K:8KBMPEG-4 movie72 KB14 K8|13 KRIMPEG-4 movieMPEG-4 movie9 KB18 KBMPEG-4 movie12 KEMPEG-4 movie10 KBMPEG-4 movie16 KB6 KB1MPEG-4 movie6 KBMPEG-4 movie12 KB23 KBMPEG-4 movie8 KEMPEG-4 movie6 KBMPEG-4 movie6 KBMPEG-4 movie11 K:MPEG-4 movie11 KBMPEG-4 movie34 KBMPEG-4 movie10 KPMPEG-4 movie7 KBMPEG-4 movie5 KBMPEG-4 movie11 KBIMPEG-4 movie26 KBMPEG-4 movie111 KB102 KBRR KRMPEG-4 movieMDEG-A movie59 KB98 KBMPEG-4 movie07 K:MPEG-A movie66 KB MPEG-4 movie44 KB93 KB79 KpMPEG-4 movieMPEG-4 movie58 KBMPEG-4 movie27 KP7 KBMPEG-4 movie12 KB32 K8MPEG-4 movieAl Notes: OffLeave• 0FavouritesE jiminny© Recents* ApplicationsiCloudiCloud Drive228 Sync folderQ DXP4800PLUS-B5FA@ Network|• CRMI• Orange• Red• Yellow• Green• Purple•) All lags..lohlDownloadsNameLoom.pkgAlfred copv.alfredoreterencesB KeychronAssist-1.0.2 (1).dmgA Keychron Assist-1.0.2.dmgmazanoke-images-yWJo.zioPhotos-3-001.zipD Transcript.pdf→mage U.loge1 Orioninstaller.dmaimage.jpg- image (2).1pc• ПО-22221726037035-004-001_ORGES.pdf• %D0%9F%D0%9E-22221726037035-004-001_archive.zipПO-22221726037035-004-001_archive (1).zip• repon 4).XmAltred copy2.altredoreterences05012026_0000000026574472_ SWIFT_OB70501260015890.pdf27022026_0000000026574472_SWIFT_OB72702260049200.pdf= 03042026 [CREDIT_CARD] SWIFT [CREDIT_CARD].001B reporti) xm=pdt.odipdf-1.pdfD pdf-2.pdf-pdf-5.pd1= ndf-1 ndipdf-3.pdfKoválik Family Tree.gedbitwarden export 20251031122528.isonKoválik Family Tree.zip*macOS Storage_Cleanup.mdal favicon icofirst_aid_notes_complete.docxrenortl2).esvconfig.ymlIteration run Search HS.postman_collection.jsonm licence bettertouchtoalMariusHosting Config.json1ooks-891a6503-bbb7-4b2b-9c3.csv•Alfredmazanoke-images-YWJ6ана Ковалик.jpg•искане даниел Ковалик..pa• Фактура Март Даниел Ковалик.jрс• Фактура Април Даниел Ковалик.jpg• Dhotac 2.001Q SearchKind00,4 MDinstdlle..dckage55.9 MBAlfred...ferences10,1 MBDisk Image10,1 MBDisk ImageIL MBLiP archive6,6 MBZIP archive2,5 MB PDF Document2,5 MBJreo lmage2.2 MBDisk Image2,2 MB PDF Document2 MBJPEG image1,9 MBJPEG imaqe192 KBPDF Document140 KbZIP archive148 KВ148 KB122 KB111 KB94 KBZIP archivelZIP archiveXML documentAlfred...ferencesPDF Document92 KBPDr DocumentK:91 KB91 KB30 KB29 KBPDF DocumentXML documentPDF Document28 KBPDF Document28 KR28 KB27 KB14 KB11 KB6 KB6KBPDE DocumentDocumentJSONICSV DocumentZIP archiveMarkdo…..ument5KRWindo...n image4 KB3 KBword ..cumentCSV Document2 KBYAML document1 KBcSV Document928 byteshttlicence183 bytesZero butesJSONAlfred.. ferencesZero bytesFolde1,9 MB1,8 MB17 MB1,7 MBColdorJPEG imageJPEG ImageJPEG imageJPEG imageColdo1 of 58 selected, 8.5 GB availab!i• Inu 14 Mаy 1/.32.31Date AddedIs Mdl ZUzo dl 19:4530 Jan 2026 at 12:3617 Mar 2026 at 20:2717 Mar 2026 at 20:26Z3 Aor 2020 al 13:0229 Jan 2026 at 15:2019 Dec 2025 at 10:1619 Dec 2025 at 12:238 Aor 2026 at 20:3519 Dec 2025 at 10:2919 Dec 2025 at 12:1819 Dec 2025 at 12:4026 Mar 2026 at 11:2410 May 2026 at 13:5326 Mar 2026 at 11:2426 Mar 2026 at 11:2426 Mar 2026 at 11:2310 May 2026 at 14:3730 Jan 2026 at 12:3713 Feb 2026 at 11:54Z3 Apr 2026 at 13.0823 Aor 2026 at 13:0810 May 2026 at 13:5410 May 2026 at 14:3710 May 2026 at 13:4910 May 2026 at 13:5010 May 2026 at 13:5110 Mav 2026 at 12:51110 May 2026 at 13:5019 Dec 2025 at 11:3431 Oct 2025 at 12:2525 Nov 2025 at 17:596 Mar 2026 at 11.2224 Anr 2026 at 16:5220 Oct 2025 at 11:0218 Mar 2026 at 15:29• Mav 2026 at 11:09129 Oct 2025 at 19:329 May 2026 at 10:0418 Mar 2026 at 11:5510 May 2026 at 13:5712 Jun 2025 at 19:0430 Jan 2026 at 12:3630 Jan 2026 at 12:3616 Oct 2025 aт 16:0123 Apr 2026 at 13:0223 Apr 2026 at 13:0223 Apr 2026 at 13:0222 Anr 2026 at 12:0223 Apr 2026 at 13:0229 Jan 2026 at 15:20...
|
45354
|
NULL
|
NULL
|
NULL
|
|
45357
|
1625
|
15
|
2026-05-14T14:32:40.033811+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-14/1778 /Users/lukas/.screenpipe/data/data/2026-05-14/1778769160033_m1.jpg...
|
iTerm2
|
NULL
|
1
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
PhpStormFileEditViewNavigateCodeLaravelRefactorRun PhpStormFileEditViewNavigateCodeLaravelRefactorRunToolsGitWindowHelp•FV faVsco.jsv* #12066 on JY-20725-handle-HS-search-rate-limit k vabl19HandleHubspotRateLimitTest100% C8• Thu 14 May 17:32:39* :QProjectv= custom.logAutomatedReportGenerated.phpTrackAutomatedReportGeneratedEvent.phpPlaybackController.php© FieldDefinitions.phpUserAutomatedReportsController.phpPlanhatService.phpAutomatedReportResult.phpSendReportJob.php=laravel.log© FieldValueConverter.php• DeleteCrmEntityTrait.phpDeleteAccountJob.php© ImportActivityTypes.phpT WriteCrmTrait.php© Service.php© DecorateActivity.phpSF [jiminny@localhost]© StandardFieldMetadata.php© Salesforce/Service.php© LogActivityTrait.phpPipedrive/Service.phpClose/Service.phpCopper/Service.php XA HS_local [jiminny@localho:Wv D Copper© PlainTextDecorateActivity.phpT ActivityPlaybookTrait.php© CrmHelperRepository.phpAccountController.php4 console [QAI PROD] X18> D Api› D ExceptionT IntegrationAppTrait.php.env.stagingE.env© DetachActivityObject.phpRematchActivityOnCrmObjectDetach.php4 console [PROD]> C OpportunitySyncStrategy© MatchActivityCrmData.php> D ProspectSearchStrategyClient.phpclass Service extends BaseService implements© HubspotPaginationService.php© HandleHubspotRateLimit.phpA console (EU]> CJ Traits© FieldDefinitions.php5216621663A7 A 47* Othrows CrmException© FieldValueConverter.php© Service.php*/1 usage> D CrmObjects1664private function buildCallPayload(Activity $activity): Activityv DecorateActivity1665© BaseDecorateActivity.php1666try{id = 33;© PlainTextDecorateActivity.pl1667> D Dummy1668v Helpers1669T ActivityPlaybookTrait.php1670© Arraylterator.php1671T ConnectionStateTrait.php1672© CrmHelperRepository.php1673© FilterJoinedParticipants.php1674* OpportunitySyncableFieldsT1675v D Hubspot1676• AccountSyncStrategy16771678DGojiminny v084143 ×4 л215•m_layout_id =2162,1661,66799,66217218-219if (Sactivity->crm_provider_id) {=220=// The activity should be logged under the existing Task (not Activity).221=SnewActivityType = Sthis->getCrmType(Sactivity->category);=222if ($newActivityType === null) {=223Smessage = sprintf( format: '[Copper] invalid CRM Custom Activity Type: %s', $activity->cat 224= 225|=226— 227228229=230231id THEN '(ownthrow new CrmException($message, code: 400);> OJ Actions |$this->copperClient->tasks()->update($activity->crm_provider_id, ['status'= 'Completed','custom_activity_type_id' => $newActivityType->value,'details' => $this->generateActivityDescription($activity),hubspat":• ContactSyncStrategy1679> ODTO16801):j else {› Fields1681233 V.234= 11512582;> • Journal1682if (Sthis-~getCrmType(SactivitX-scategocx)$message = sprintf( format:'[Copper] Invalid CRM Activity Type: %s', $activity->category->-D Metadata1683v D OpportunitySyncStrategy1684throw new CrmException($message,code: 400):› D Concerns1685=A Unhonntl actlAndifiaNDur 1686Workspace associated with branch 'JY-20725-handle-HS-search-rate-limit' has been restored // Rollback // Configure... (today 16:17)W Windsurf Teams1673:26UTF-8P. 4 spaces...
|
NULL
|
-3054251356718219840
|
NULL
|
click
|
ocr
|
NULL
|
PhpStormFileEditViewNavigateCodeLaravelRefactorRun PhpStormFileEditViewNavigateCodeLaravelRefactorRunToolsGitWindowHelp•FV faVsco.jsv* #12066 on JY-20725-handle-HS-search-rate-limit k vabl19HandleHubspotRateLimitTest100% C8• Thu 14 May 17:32:39* :QProjectv= custom.logAutomatedReportGenerated.phpTrackAutomatedReportGeneratedEvent.phpPlaybackController.php© FieldDefinitions.phpUserAutomatedReportsController.phpPlanhatService.phpAutomatedReportResult.phpSendReportJob.php=laravel.log© FieldValueConverter.php• DeleteCrmEntityTrait.phpDeleteAccountJob.php© ImportActivityTypes.phpT WriteCrmTrait.php© Service.php© DecorateActivity.phpSF [jiminny@localhost]© StandardFieldMetadata.php© Salesforce/Service.php© LogActivityTrait.phpPipedrive/Service.phpClose/Service.phpCopper/Service.php XA HS_local [jiminny@localho:Wv D Copper© PlainTextDecorateActivity.phpT ActivityPlaybookTrait.php© CrmHelperRepository.phpAccountController.php4 console [QAI PROD] X18> D Api› D ExceptionT IntegrationAppTrait.php.env.stagingE.env© DetachActivityObject.phpRematchActivityOnCrmObjectDetach.php4 console [PROD]> C OpportunitySyncStrategy© MatchActivityCrmData.php> D ProspectSearchStrategyClient.phpclass Service extends BaseService implements© HubspotPaginationService.php© HandleHubspotRateLimit.phpA console (EU]> CJ Traits© FieldDefinitions.php5216621663A7 A 47* Othrows CrmException© FieldValueConverter.php© Service.php*/1 usage> D CrmObjects1664private function buildCallPayload(Activity $activity): Activityv DecorateActivity1665© BaseDecorateActivity.php1666try{id = 33;© PlainTextDecorateActivity.pl1667> D Dummy1668v Helpers1669T ActivityPlaybookTrait.php1670© Arraylterator.php1671T ConnectionStateTrait.php1672© CrmHelperRepository.php1673© FilterJoinedParticipants.php1674* OpportunitySyncableFieldsT1675v D Hubspot1676• AccountSyncStrategy16771678DGojiminny v084143 ×4 л215•m_layout_id =2162,1661,66799,66217218-219if (Sactivity->crm_provider_id) {=220=// The activity should be logged under the existing Task (not Activity).221=SnewActivityType = Sthis->getCrmType(Sactivity->category);=222if ($newActivityType === null) {=223Smessage = sprintf( format: '[Copper] invalid CRM Custom Activity Type: %s', $activity->cat 224= 225|=226— 227228229=230231id THEN '(ownthrow new CrmException($message, code: 400);> OJ Actions |$this->copperClient->tasks()->update($activity->crm_provider_id, ['status'= 'Completed','custom_activity_type_id' => $newActivityType->value,'details' => $this->generateActivityDescription($activity),hubspat":• ContactSyncStrategy1679> ODTO16801):j else {› Fields1681233 V.234= 11512582;> • Journal1682if (Sthis-~getCrmType(SactivitX-scategocx)$message = sprintf( format:'[Copper] Invalid CRM Activity Type: %s', $activity->category->-D Metadata1683v D OpportunitySyncStrategy1684throw new CrmException($message,code: 400):› D Concerns1685=A Unhonntl actlAndifiaNDur 1686Workspace associated with branch 'JY-20725-handle-HS-search-rate-limit' has been restored // Rollback // Configure... (today 16:17)W Windsurf Teams1673:26UTF-8P. 4 spaces...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
45359
|
1626
|
9
|
2026-05-14T14:32:42.185935+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-14/1778 /Users/lukas/.screenpipe/data/data/2026-05-14/1778769162185_m2.jpg...
|
PhpStorm
|
faVsco.js – Copper/Service.php
|
1
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
ActivityFilesLaterJiminny... ~@ jiminny-x-integrat ActivityFilesLaterJiminny... ~@ jiminny-x-integrati& platform-inner-team© Channels# ai-chapter# alertsi backend# bugscontusion-cllnia# curiosity_lab# engineering# general# jiminny-bg# platform-tickets# product_launches# random# releases# sofia-office# support# thank-yous# the people of iimi..• Direct messages8. A...• A3 62P. Galya Dimitrova Mvasil VasilevA. Stefka Stoyanova%: Todor Stamatovf. Mario GeorgievP. Nikolay Ivanov2o James Graham "2. Stoyan Tanev. Steliyan Georgiev. Petko Kashinski*. Lukas Kovali...a: Apps® ToastS lira Gloud6 Huddle with Aneliya Angelova& R. Aneliya Angelova •• Messagest Add canvasur FilesAneliya Angelova 2:30 PMЛукаш, само при Сейлсфорс и Хубспот се синкваха активити типовете, когато се направи плейбук,нали?пои останалите скМі тоябва оъчно ла се въвелат.Lukas Kovalik 2:47 PMтряова да се за всички, някьде не се ли попьлвапри зохо май беше hardcoded но май и там си връщаха две категорииAneliya Angelova @ 4:00 PMЛукаш имаш ли време да се чуем за тестването на https://jiminny.atlassian.net/browse/JY-20725X Preview insackt[HubSpot] Optimise CRM rematctC OpenReady for QA- MediumAA Aneliya AngelovaAs of today at 4:00 PM RetreshOpen in Jira+ SummariseLukas Kovalik 4:02 PMzean-iurwiniareelYou joined the huddle LIVE 4:06 PMAneliva Angelova is here toolAneliya Angelova 4:44 PM11512582Lukas Kovalik O 5:16 PMcurl --location "httns:ani.hubani.com/crm/v3/obiects/contacts/search--header 'Content-Type: application/json'\--header "Authorization: BearerC|Kvma?iMx7OINOM8kOFwrAowAcAkUAhIR24?A05D?xiVrIYMILuP6SA01Kwcmhr=n.v2 hytorDniMvDalYck-CMalovolNOM9kOFwrAUAcrkcaws-ThwRARIRAOF-ATFSAOFRAO-ROIEBAQUBEggBAQEBAYICFIґ7Au8O2Nwal9YW2CbCCxYvwngmSgNldTFSAFoAYABoAHAAeAA'\--data "{ "limit", 13'Message Aneliva Angelova+ AaIđ 0Pm~ (Q SearchDate ModifiedYesterdav at 13.36Yesterday at 13:35Yesterday at 13.34Yesterday at 13:34Yesterday at 13:33resterday at 15.5sYesterdav at 13:37Yesterday at 13:32Yesterday ar 13.31Yesterday at 13:30Yesterday at 13:30resterday at 15.4gYesterdav at 13:29Yesterday at 13:28Yesterday at 13:28Yesterday at 13:27Yesterday at 13:27resterday at 13-40Yesterdav at 13:25Yesterday at 13:25Yesterday at 13:24Yesterday at 13:24Yesterday at 13:23Yesterday at 13.21Yesterdav at 13:21Yesterday at 13:20Yesterday at 13:20Yesterday at 13:19Yesterday at 13:19Yesterday at 13.18Vecterdav at 12:17Yesterday at 13:16Yesterdav at 13:16Yesterday at 13:15Yesterday at 13:14Yecterdav at 12:1/Yesterday at 13:13Yesterday at 13.13Yecterdav at 12:12Yesterday at 13:12Yesterday at 13:11Yesterday at 13:10Yesterday at 13:10Yesterday at 13.09Yecterdav at 12:091Yesterday at 13:08Yesterdav at 13:07MPEG-4 movie13 KBMPEG-4 movie16 KB1/ Kb29 KBMPEG-4 movieMPEG-4 movie6 KB12 KBMPE0"4 movie9 K:7 KBMPEG-4 movie8 KB37 KBMP2G-4 movie10 KBMPEG-4 movie7 KBMPEG"4 movie9K:8KBMPEG-4 movie72 KB14 K8|13 KRIMPEG-4 movieMPEG-4 movie9 KB18 KBMPEG-4 movie12 KEMPEG-4 movie10 KBMPEG-4 movie16 KB6 KB1MPEG-4 movie6 KBMPEG-4 movie12 KB23 KBMPEG-4 movie8 KEMPEG-4 movie6 KBMPEG-4 movie6 KBMPEG-4 movie11 K:MPEG-4 movie11 KBMPEG-4 movie34 KBMPEG-4 movie10 KPMPEG-4 movie7 KBMPEG-4 movie5 KBMPEG-4 movie11 KBIMPEG-4 movie26 KBMPEG-4 movie111 KB102 KBRR KRMPEG-4 movieMDEG-A movie59 KB98 KBMPEG-4 movie07 K:MPEG-A movie66 KB MPEG-4 movie44 KB93 KB79 KpMPEG-4 movieMPEG-4 movie58 KBMPEG-4 movie27 KP7 KBMPEG-4 movie12 KB32 K8MPEG-4 movieAl Notes: OffLeave• 0FavouritesE jiminny© Recents* ApplicationsiCloudiCloud Drive228 Sync folderQ DXP4800PLUS-B5FA@ Network|• CRMI• Orange• Red• Yellow• Green• Purple•) All lags..lohlDownloadsNameLoom.pkgAlfred copv.alfredoreterencesB KeychronAssist-1.0.2 (1).dmgA Keychron Assist-1.0.2.dmgmazanoke-images-yWJo.zioPhotos-3-001.zipD Transcript.pdf→mage U.loge1 Orioninstaller.dmaimage.jpg- image (2).1pc• ПО-22221726037035-004-001_ORGES.pdf• %D0%9F%D0%9E-22221726037035-004-001_archive.zipПO-22221726037035-004-001_archive (1).zip• repon 4).XmAltred copy2.altredoreterences05012026_0000000026574472_ SWIFT_OB70501260015890.pdf27022026_0000000026574472_SWIFT_OB72702260049200.pdf= 03042026 [CREDIT_CARD] SWIFT [CREDIT_CARD].001B reporti) xm=pdt.odipdf-1.pdfD pdf-2.pdf-pdf-5.pd1= ndf-1 ndipdf-3.pdfKoválik Family Tree.gedbitwarden export 20251031122528.isonKoválik Family Tree.zip*macOS Storage_Cleanup.mdal favicon icofirst_aid_notes_complete.docxrenortl2).esvconfig.ymlIteration run Search HS.postman_collection.jsonm licence bettertouchtoalMariusHosting Config.json1ooks-891a6503-bbb7-4b2b-9c3.csv•Alfredmazanoke-images-YWJ6ана Ковалик.jpg•искане даниел Ковалик..pa• Фактура Март Даниел Ковалик.jрс• Фактура Април Даниел Ковалик.jpg• Dhotac 2.001Q SearchKind00,4 MDinstdlle..dckage55.9 MBAlfred...ferences10,1 MBDisk Image10,1 MBDisk ImageIL MBLiP archive6,6 MBZIP archive2,5 MB PDF Document2,5 MBJreo lmage2.2 MBDisk Image2,2 MB PDF Document2 MBJPEG image1,9 MBJPEG imaqe192 KBPDF Document140 KbZIP archive148 KВ148 KB122 KB111 KB94 KBZIP archivelZIP archiveXML documentAlfred...ferencesPDF Document92 KBPDr DocumentK:91 KB91 KB30 KB29 KBPDF DocumentXML documentPDF Document28 KBPDF Document28 KR28 KB27 KB14 KB11 KB6 KB6KBPDE DocumentDocumentJSONICSV DocumentZIP archiveMarkdo…..ument5KRWindo...n image4 KB3 KBword ..cumentCSV Document2 KBYAML document1 KBcSV Document928 byteshttlicence183 bytesZero butesJSONAlfred.. ferencesZero bytesFolde1,9 MB1,8 MB17 MB1,7 MBColdorJPEG imageJPEG ImageJPEG imageJPEG imageColdo1 of 58 selected, 8.5 GB availab!i• Inu 14 Mау 1/•32.4]Date AddedIs Mdl ZUzo dl 19:4530 Jan 2026 at 12:3617 Mar 2026 at 20:2717 Mar 2026 at 20:26Z3 Aor 2020 al 13:0229 Jan 2026 at 15:2019 Dec 2025 at 10:1619 Dec 2025 at 12:238 Aor 2026 at 20:3519 Dec 2025 at 10:2919 Dec 2025 at 12:1819 Dec 2025 at 12:4026 Mar 2026 at 11:2410 May 2026 at 13:5326 Mar 2026 at 11:2426 Mar 2026 at 11:2426 Mar 2026 at 11:2310 May 2026 at 14:3730 Jan 2026 at 12:3713 Feb 2026 at 11:54Z3 Apr 2026 at 13.0823 Aor 2026 at 13:0810 May 2026 at 13:5410 May 2026 at 14:3710 May 2026 at 13:4910 May 2026 at 13:5010 May 2026 at 13:5110 Mav 2026 at 12:51110 May 2026 at 13:5019 Dec 2025 at 11:3431 Oct 2025 at 12:2525 Nov 2025 at 17:596 Mar 2026 at 11.2224 Anr 2026 at 16:5220 Oct 2025 at 11:0218 Mar 2026 at 15:29• Mav 2026 at 11:09129 Oct 2025 at 19:329 May 2026 at 10:0418 Mar 2026 at 11:5510 May 2026 at 13:5712 Jun 2025 at 19:0430 Jan 2026 at 12:3630 Jan 2026 at 12:3616 Oct 2025 aт 16:0123 Apr 2026 at 13:0223 Apr 2026 at 13:0223 Apr 2026 at 13:0222 Anr 2026 at 12:0223 Apr 2026 at 13:0229 Jan 2026 at 15:20...
|
NULL
|
9187213961329440757
|
NULL
|
click
|
ocr
|
NULL
|
ActivityFilesLaterJiminny... ~@ jiminny-x-integrat ActivityFilesLaterJiminny... ~@ jiminny-x-integrati& platform-inner-team© Channels# ai-chapter# alertsi backend# bugscontusion-cllnia# curiosity_lab# engineering# general# jiminny-bg# platform-tickets# product_launches# random# releases# sofia-office# support# thank-yous# the people of iimi..• Direct messages8. A...• A3 62P. Galya Dimitrova Mvasil VasilevA. Stefka Stoyanova%: Todor Stamatovf. Mario GeorgievP. Nikolay Ivanov2o James Graham "2. Stoyan Tanev. Steliyan Georgiev. Petko Kashinski*. Lukas Kovali...a: Apps® ToastS lira Gloud6 Huddle with Aneliya Angelova& R. Aneliya Angelova •• Messagest Add canvasur FilesAneliya Angelova 2:30 PMЛукаш, само при Сейлсфорс и Хубспот се синкваха активити типовете, когато се направи плейбук,нали?пои останалите скМі тоябва оъчно ла се въвелат.Lukas Kovalik 2:47 PMтряова да се за всички, някьде не се ли попьлвапри зохо май беше hardcoded но май и там си връщаха две категорииAneliya Angelova @ 4:00 PMЛукаш имаш ли време да се чуем за тестването на https://jiminny.atlassian.net/browse/JY-20725X Preview insackt[HubSpot] Optimise CRM rematctC OpenReady for QA- MediumAA Aneliya AngelovaAs of today at 4:00 PM RetreshOpen in Jira+ SummariseLukas Kovalik 4:02 PMzean-iurwiniareelYou joined the huddle LIVE 4:06 PMAneliva Angelova is here toolAneliya Angelova 4:44 PM11512582Lukas Kovalik O 5:16 PMcurl --location "httns:ani.hubani.com/crm/v3/obiects/contacts/search--header 'Content-Type: application/json'\--header "Authorization: BearerC|Kvma?iMx7OINOM8kOFwrAowAcAkUAhIR24?A05D?xiVrIYMILuP6SA01Kwcmhr=n.v2 hytorDniMvDalYck-CMalovolNOM9kOFwrAUAcrkcaws-ThwRARIRAOF-ATFSAOFRAO-ROIEBAQUBEggBAQEBAYICFIґ7Au8O2Nwal9YW2CbCCxYvwngmSgNldTFSAFoAYABoAHAAeAA'\--data "{ "limit", 13'Message Aneliva Angelova+ AaIđ 0Pm~ (Q SearchDate ModifiedYesterdav at 13.36Yesterday at 13:35Yesterday at 13.34Yesterday at 13:34Yesterday at 13:33resterday at 15.5sYesterdav at 13:37Yesterday at 13:32Yesterday ar 13.31Yesterday at 13:30Yesterday at 13:30resterday at 15.4gYesterdav at 13:29Yesterday at 13:28Yesterday at 13:28Yesterday at 13:27Yesterday at 13:27resterday at 13-40Yesterdav at 13:25Yesterday at 13:25Yesterday at 13:24Yesterday at 13:24Yesterday at 13:23Yesterday at 13.21Yesterdav at 13:21Yesterday at 13:20Yesterday at 13:20Yesterday at 13:19Yesterday at 13:19Yesterday at 13.18Vecterdav at 12:17Yesterday at 13:16Yesterdav at 13:16Yesterday at 13:15Yesterday at 13:14Yecterdav at 12:1/Yesterday at 13:13Yesterday at 13.13Yecterdav at 12:12Yesterday at 13:12Yesterday at 13:11Yesterday at 13:10Yesterday at 13:10Yesterday at 13.09Yecterdav at 12:091Yesterday at 13:08Yesterdav at 13:07MPEG-4 movie13 KBMPEG-4 movie16 KB1/ Kb29 KBMPEG-4 movieMPEG-4 movie6 KB12 KBMPE0"4 movie9 K:7 KBMPEG-4 movie8 KB37 KBMP2G-4 movie10 KBMPEG-4 movie7 KBMPEG"4 movie9K:8KBMPEG-4 movie72 KB14 K8|13 KRIMPEG-4 movieMPEG-4 movie9 KB18 KBMPEG-4 movie12 KEMPEG-4 movie10 KBMPEG-4 movie16 KB6 KB1MPEG-4 movie6 KBMPEG-4 movie12 KB23 KBMPEG-4 movie8 KEMPEG-4 movie6 KBMPEG-4 movie6 KBMPEG-4 movie11 K:MPEG-4 movie11 KBMPEG-4 movie34 KBMPEG-4 movie10 KPMPEG-4 movie7 KBMPEG-4 movie5 KBMPEG-4 movie11 KBIMPEG-4 movie26 KBMPEG-4 movie111 KB102 KBRR KRMPEG-4 movieMDEG-A movie59 KB98 KBMPEG-4 movie07 K:MPEG-A movie66 KB MPEG-4 movie44 KB93 KB79 KpMPEG-4 movieMPEG-4 movie58 KBMPEG-4 movie27 KP7 KBMPEG-4 movie12 KB32 K8MPEG-4 movieAl Notes: OffLeave• 0FavouritesE jiminny© Recents* ApplicationsiCloudiCloud Drive228 Sync folderQ DXP4800PLUS-B5FA@ Network|• CRMI• Orange• Red• Yellow• Green• Purple•) All lags..lohlDownloadsNameLoom.pkgAlfred copv.alfredoreterencesB KeychronAssist-1.0.2 (1).dmgA Keychron Assist-1.0.2.dmgmazanoke-images-yWJo.zioPhotos-3-001.zipD Transcript.pdf→mage U.loge1 Orioninstaller.dmaimage.jpg- image (2).1pc• ПО-22221726037035-004-001_ORGES.pdf• %D0%9F%D0%9E-22221726037035-004-001_archive.zipПO-22221726037035-004-001_archive (1).zip• repon 4).XmAltred copy2.altredoreterences05012026_0000000026574472_ SWIFT_OB70501260015890.pdf27022026_0000000026574472_SWIFT_OB72702260049200.pdf= 03042026 [CREDIT_CARD] SWIFT [CREDIT_CARD].001B reporti) xm=pdt.odipdf-1.pdfD pdf-2.pdf-pdf-5.pd1= ndf-1 ndipdf-3.pdfKoválik Family Tree.gedbitwarden export 20251031122528.isonKoválik Family Tree.zip*macOS Storage_Cleanup.mdal favicon icofirst_aid_notes_complete.docxrenortl2).esvconfig.ymlIteration run Search HS.postman_collection.jsonm licence bettertouchtoalMariusHosting Config.json1ooks-891a6503-bbb7-4b2b-9c3.csv•Alfredmazanoke-images-YWJ6ана Ковалик.jpg•искане даниел Ковалик..pa• Фактура Март Даниел Ковалик.jрс• Фактура Април Даниел Ковалик.jpg• Dhotac 2.001Q SearchKind00,4 MDinstdlle..dckage55.9 MBAlfred...ferences10,1 MBDisk Image10,1 MBDisk ImageIL MBLiP archive6,6 MBZIP archive2,5 MB PDF Document2,5 MBJreo lmage2.2 MBDisk Image2,2 MB PDF Document2 MBJPEG image1,9 MBJPEG imaqe192 KBPDF Document140 KbZIP archive148 KВ148 KB122 KB111 KB94 KBZIP archivelZIP archiveXML documentAlfred...ferencesPDF Document92 KBPDr DocumentK:91 KB91 KB30 KB29 KBPDF DocumentXML documentPDF Document28 KBPDF Document28 KR28 KB27 KB14 KB11 KB6 KB6KBPDE DocumentDocumentJSONICSV DocumentZIP archiveMarkdo…..ument5KRWindo...n image4 KB3 KBword ..cumentCSV Document2 KBYAML document1 KBcSV Document928 byteshttlicence183 bytesZero butesJSONAlfred.. ferencesZero bytesFolde1,9 MB1,8 MB17 MB1,7 MBColdorJPEG imageJPEG ImageJPEG imageJPEG imageColdo1 of 58 selected, 8.5 GB availab!i• Inu 14 Mау 1/•32.4]Date AddedIs Mdl ZUzo dl 19:4530 Jan 2026 at 12:3617 Mar 2026 at 20:2717 Mar 2026 at 20:26Z3 Aor 2020 al 13:0229 Jan 2026 at 15:2019 Dec 2025 at 10:1619 Dec 2025 at 12:238 Aor 2026 at 20:3519 Dec 2025 at 10:2919 Dec 2025 at 12:1819 Dec 2025 at 12:4026 Mar 2026 at 11:2410 May 2026 at 13:5326 Mar 2026 at 11:2426 Mar 2026 at 11:2426 Mar 2026 at 11:2310 May 2026 at 14:3730 Jan 2026 at 12:3713 Feb 2026 at 11:54Z3 Apr 2026 at 13.0823 Aor 2026 at 13:0810 May 2026 at 13:5410 May 2026 at 14:3710 May 2026 at 13:4910 May 2026 at 13:5010 May 2026 at 13:5110 Mav 2026 at 12:51110 May 2026 at 13:5019 Dec 2025 at 11:3431 Oct 2025 at 12:2525 Nov 2025 at 17:596 Mar 2026 at 11.2224 Anr 2026 at 16:5220 Oct 2025 at 11:0218 Mar 2026 at 15:29• Mav 2026 at 11:09129 Oct 2025 at 19:329 May 2026 at 10:0418 Mar 2026 at 11:5510 May 2026 at 13:5712 Jun 2025 at 19:0430 Jan 2026 at 12:3630 Jan 2026 at 12:3616 Oct 2025 aт 16:0123 Apr 2026 at 13:0223 Apr 2026 at 13:0223 Apr 2026 at 13:0222 Anr 2026 at 12:0223 Apr 2026 at 13:0229 Jan 2026 at 15:20...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
45358
|
1625
|
16
|
2026-05-14T14:32:43.744332+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-14/1778 /Users/lukas/.screenpipe/data/data/2026-05-14/1778769163744_m1.jpg...
|
PhpStorm
|
faVsco.js – Copper/Service.php
|
1
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
#12066 on JY-20725-handle Project: faVsco.js, menu
#12066 on JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
HandleHubspotRateLimitTest
Run 'HandleHubspotRateLimitTest'
Debug 'HandleHubspotRateLimitTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
11
72
1
4
Previous Highlighted Error
Next Highlighted Error
<?php
namespace Jiminny\Services\Crm\Copper;
use Carbon\Carbon;
use Exception;
use GuzzleHttp\Exception\ClientException;
use Illuminate\Support\Facades\Cache;
use Jiminny\Contracts\Services\Crm\LayoutManagementInterface;
use Jiminny\Contracts\Services\Crm\MatchCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\Provider\CopperInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityLookupInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityManipulationInterface;
use Jiminny\Contracts\Services\Crm\SettingsInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmMetadataInterface;
use Jiminny\Contracts\Services\Crm\VerifyTaskExistsInterface;
use Jiminny\Exceptions\CrmException;
use Jiminny\Exceptions\HttpNotFoundException;
use Jiminny\Models\Account;
use Jiminny\Models\Activity;
use Jiminny\Models\Contact;
use Jiminny\Models\Crm\BusinessProcess;
use Jiminny\Models\Crm\Configuration;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\FieldData;
use Jiminny\Models\Crm\FieldValue;
use Jiminny\Models\Crm\LayoutEntity;
use Jiminny\Models\Crm\Profile;
use Jiminny\Models\Lead;
use Jiminny\Models\Opportunity;
use Jiminny\Models\Playbook;
use Jiminny\Models\PlaybookCategory;
use Jiminny\Models\SocialAccount;
use Jiminny\Models\Stage;
use Jiminny\Models\User;
use Jiminny\Repositories\Crm\CrmEntityRepository;
use Jiminny\Repositories\Crm\FieldRepository;
use Jiminny\Repositories\Crm\ProfileRepository;
use Jiminny\Repositories\UserRepository;
use Jiminny\Services\Avatar\ProspectPhotoPathService;
use Jiminny\Services\Crm\BaseService;
use Jiminny\Services\Crm\Copper\Api\Client;
use Jiminny\Services\Crm\Copper\Api\ClientFactory;
use Jiminny\Services\Crm\Copper\Exception\RequestFailedException;
use Jiminny\Services\Crm\Helpers\FilterJoinedParticipants;
use Jiminny\Services\Crm\OpportunitySyncStrategyResolver;
use Jiminny\Services\Crm\Copper\Traits\SearchesCrmEntities;
use Jiminny\Utils\CurrencyFormatter;
use Jiminny\Utils\StringUtil;
class Service extends BaseService implements
CopperInterface,
SyncCrmEntitiesInterface,
SyncCrmMetadataInterface,
RemoteEntityManipulationInterface,
LayoutManagementInterface,
SettingsInterface,
MatchCrmEntitiesInterface,
RemoteEntityLookupInterface,
VerifyTaskExistsInterface
{
use SearchesCrmEntities;
private const int NOTE_BODY_MAX_LENGTH = 3000000;
private const int TASK_VERIFICATION_CACHE_TTL = 86400; // 1 day
private const array STANDARD_OPPORTUNITY_FIELDS = [
'monetary_value' => [
'label' => 'Value',
'available_on' => Field::OBJECT_OPPORTUNITY,
'type' => Field::TYPE_CURRENCY,
],
'pipeline_stage_id' => [
'label' => 'Stage',
'type' => Field::TYPE_PICKLIST,
'available_on' => Field::OBJECT_OPPORTUNITY,
],
'close_date' => [
'label' => 'Close date',
'type' => Field::TYPE_DATE,
'available_on' => Field::OBJECT_OPPORTUNITY,
],
'win_probability' => [
'label' => 'Win probability',
'type' => Field::TYPE_NUMBER,
'available_on' => Field::OBJECT_OPPORTUNITY,
],
];
private const string UNSUPPORTED_OBJECT_TYPE = 'unsupported';
private const string OPPORTUNITY_STATUS_OPEN = 'Open';
private const string OPPORTUNITY_IMPORT_FAILED_ERROR_MESSAGE = 'Opportunity import failed';
private const string RESOURCE_UPDATE_FAILED_ERROR_MESSAGE = 'Failed to update resource';
private FieldValueConverter $fieldValueConverter;
private ClientFactory $clientFactory;
private ?Client $copperClient = null;
public function __construct(
ClientFactory $clientFactory,
FieldValueConverter $fieldValueConverter,
private readonly UserRepository $userRepository,
private readonly ProspectPhotoPathService $prospectPhotoPathService,
private readonly CrmEntityRepository $crmEntityRepository
) {
parent::__construct();
$this->clientFactory = $clientFactory;
$this->fieldValueConverter = $fieldValueConverter;
}
public function getDisplayName(): string
{
return 'Copper';
}
/**
* Get the CRM entity repository (used by SearchesCrmEntities trait)
*/
protected function getCrmEntityRepository(): CrmEntityRepository
{
return $this->crmEntityRepository;
}
public function getMinimumApiVersion(): string
{
return Client::MIN_API_VERSION;
}
protected function getOAuthAccount(User $user): ?SocialAccount
{
// In this case, the Account Owner is always the connection for any API operations.
$owner = $user->team->owner;
return $owner->getSocialAccount(SocialAccount::PROVIDER_COPPER);
}
public function setUser(User $user): void
{
$this->configureSentryScope($user);
$this->team = $user->getTeam();
$this->teamService->setTeam($user->getTeam());
$this->setConfiguration($user->getTeam()->getCrmConfiguration());
// Reset state.
$this->profile = $user->getProfile();
if (! $this->profile instanceof Profile) {
// Try to fetch their profile, maybe it was not yet imported?
$this->profile = $this->getProfile($user);
}
}
public function setConfiguration(Configuration $configuration): void
{
$this->config = $configuration;
$this->copperClient = $this->clientFactory->create($configuration);
if ($this->team === null) {
$this->team = $configuration->getTeam();
}
}
public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string
{
return $this->fieldValueConverter->convertFromCrm($fieldType, $fieldValue);
}
private function mapObjectType(string $objectType): string
{
$objectType = strtolower($objectType);
switch ($objectType) {
case Field::OBJECT_TASK:
case Field::OBJECT_OPPORTUNITY:
case Field::OBJECT_LEAD:
$mappedType = $objectType;
break;
case Field::OBJECT_ACCOUNT:
$mappedType = 'company';
break;
case Field::OBJECT_CONTACT:
$mappedType = 'person';
break;
default:
$mappedType = self::UNSUPPORTED_OBJECT_TYPE;
break;
}
return $mappedType;
}
/**
* @param string $crmObject The name of the CRM object. i.e. Opportunity or Contact
*/
protected function getFields(string $crmObject): array
{
$mappedObjectType = $this->mapObjectType($crmObject);
if (! $this->isSupportedObjectType($mappedObjectType)) {
return [];
}
$customFields = $this->copperClient->fields()->all();
$filteredFields = array_filter(
$customFields,
static fn ($field) => in_array($mappedObjectType, $field['available_on'], true)
);
return array_map(
fn (array $property) => [
'name' => $property['id'],
'label' => $property['name'],
'type' => $this->convertFieldType($property['data_type']),
],
$filteredFields
);
}
/**
* @inheritdoc
*/
public function getDefaultFields(string $activityType): array
{
$fields = [];
if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {
$defaultFields = FieldDefinitions::defaultTaskFields();
// This lazy creates these fields if not already setup.
foreach ($defaultFields as $defaultField) {
$fields[] = $this->config->fields()->firstOrCreate($defaultField);
}
}
return $fields;
}
/**
* @inheritdoc
*/
public function getDefaultActivityField(string $activityType): Field
{
// Setup the activity field as the default Type.
$activityField = $this->config->fields()->where([
'crm_provider_id' => 'type',
'object_type' => $activityType,
])->first();
return $activityField;
}
/**
* @inheritdoc
*/
public function getSupportedPlaybookTypes(): array
{
return [Playbook::ACTIVITY_TYPE_TASK];
}
public function getDealInsightsFields(): array
{
return FieldDefinitions::dealInsightsFields();
}
protected function getDefaultFollowupLayoutFields(string $activityType): array
{
$fields = [];
$fieldRepo = app(FieldRepository::class);
$fieldFilters = FieldDefinitions::taskFollowupFieldsFilter();
foreach ($fieldFilters as $data) {
$field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $data);
// Only add the field if it is created, which it should be.
if ($field) {
$fields[] = $field;
}
}
return $fields;
}
public function syncFields(): void
{
$this->syncStandardFields();
$this->syncCustomFields();
}
public function syncField(Field $field): void
{
if ($field->getCrmProviderId() === 'type') {
/**
* This field is a special case, it is not a custom field and is not returned by the Copper API.
*
* The field is created manually during layout setup.
*
* @see getDefaultFields()
*
* There is a separate endpoint to get the activity types.
* @see importPicklistValues()
*/
return;
}
if ($field->getCrmProviderId() === 'pipeline_stage_id') {
// Not a custom type
return;
}
$crmField = $this->copperClient->fields()->find($field->getCrmProviderId());
$field->label = $crmField['name'];
$field->type = $this->convertFieldType($crmField['data_type']);
$field->save();
}
/**
* @param string $fieldType How the data should be input
*/
private function convertFieldType(string $fieldType): string
{
$fieldType = strtolower($fieldType);
switch ($fieldType) {
case 'dropdown':
$convertedType = Field::TYPE_PICKLIST;
break;
case 'string':
$convertedType = Field::TYPE_TEXT;
break;
case 'float':
case 'percentage':
$convertedType = Field::TYPE_NUMBER;
break;
case 'text':
$convertedType = Field::TYPE_TEXTAREA;
break;
case 'checkbox':
$convertedType = Field::TYPE_BOOLEAN;
break;
case Field::TYPE_DATE:
case Field::TYPE_CURRENCY:
case Field::TYPE_URL:
$convertedType = $fieldType;
break;
default:
$convertedType = Field::TYPE_UNSUPPORTED;
break;
}
return $convertedType;
}
/**
* @inheritdoc
*/
public function importPicklistValues(Field $field): array
{
$fieldValues = [];
if ($field->object_type === 'opportunity' && $field->crm_provider_id === 'pipeline_stage_id') {
return $this->importFieldValuesForDealInsights($field);
}
$values = [];
if ($field->object_type === Field::OBJECT_TASK && $field->crm_provider_id === 'type') {
$activityTypes = $this->copperClient->activityTypes()->all();
/**
* We ignore "system" activity types, disabled activity types and the "Note" activity type.
*/
$activityTypes = array_filter(
$activityTypes['user'],
fn (array $activityType) => ! $activityType['is_disabled'] && $activityType['id'] !== 0
);
// Import all activity values.
$i = 0;
foreach ($activityTypes as $activityType) {
$values[] = [
'value' => $activityType['id'],
'label' => mb_strimwidth($activityType['name'], 0, Field::LABEL_MAX_LENGTH),
'sequence' => $i++,
];
}
} else {
// We just check for the field in the custom definitions.
$crmField = $this->copperClient->fields()->find($field->crm_provider_id);
// Import all values.
foreach ($crmField['options'] as $option) {
$values[] = [
'value' => $option['id'],
'label' => mb_strimwidth($option['name'], 0, 191),
'sequence' => $option['rank'],
];
}
}
// Take the existing set of field values.
$fieldsToPurge = $field->values()->get()->pluck('value')->toArray();
foreach ($values as $value) {
// For all current values, upsert the field.
$value['value'] = substr($value['value'] ?? '', 0, 255);
$fieldValues[] = $field->values()->updateOrCreate([
'value' => $value['value'],
], $value);
// Remove this value from the ones we are going to purge.
$key = array_search($value['value'], $fieldsToPurge, false);
if ($key !== false) {
unset($fieldsToPurge[$key]);
}
}
// Delete the old values that are no longer used.
$field->values()->whereIn('value', $fieldsToPurge)->delete();
return $fieldValues;
}
/**
*
* @return FieldValue[]
*/
private function importFieldValuesForDealInsights(Field $field): array
{
$fieldValues = [];
$pipelines = $this->copperClient->pipelines()->all();
foreach ($pipelines as $pipeline) {
foreach ($pipeline['stages'] as $i => $dealStage) {
$fieldValues[] = $field->values()->updateOrCreate(
['value' => $dealStage['id']],
[
'label' => $dealStage['name'],
'sequence' => $i,
]
);
}
}
return $fieldValues;
}
/**
* @inheritdoc
*/
public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage
{
if ($types === null) {
$types = [Stage::TYPE_LEAD, Stage::TYPE_OPPORTUNITY];
}
$missingStage = null;
foreach ($types as $type) {
if ($type === Stage::TYPE_LEAD) {
$statuses = $this->requestWithExceptionHandling(
fn () => $this->copperClient->leadStatuses()->all()
);
if (count($statuses) > 0) {
$businessProcess = $this->config->businessProcesses()->updateOrCreate([
'crm_provider_id' => BusinessProcess::TYPE_LEAD,
], [
'team_id' => $this->team->id,
'name' => BusinessProcess::TYPE_LEAD,
'type' => BusinessProcess::TYPE_LEAD,
'is_selectable' => true,
]);
// A record type is really a clone of the business process, used to store which record uses which pipeline.
$this->config->recordTypes()->updateOrCreate([
'crm_provider_id' => BusinessProcess::TYPE_LEAD,
], [
'team_id' => $this->team->id,
'name' => BusinessProcess::TYPE_LEAD,
'is_selectable' => true,
'business_process_id' => $businessProcess->id ?? null,
]);
$leadStatuses = [];
foreach ($statuses as $status) {
// Upsert all statuses for the team.
$status = $this->config->stages()->updateOrCreate([
'crm_provider_id' => $status['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($status['name'], 0, 50),
'label' => mb_strimwidth($status['name'], 0, 191),
'type' => Stage::TYPE_LEAD,
'sequence' => $status['order'],
'is_selectable' => true,
]);
if ($missingStageName && $missingStageName === $status['id']) {
$missingStage = $status;
}
$leadStatuses[] = $status['id'];
}
$businessProcess->stages()->sync($leadStatuses);
}
} else {
// We create a business process to contain the pipeline, and later store all stages against it.
foreach ($this->copperClient->pipelines()->all() as $pipeline) {
$businessProcess = $this->config->businessProcesses()->updateOrCreate([
'crm_provider_id' => $pipeline['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($pipeline['name'], 0, 150),
'type' => BusinessProcess::TYPE_OPPORTUNITY,
'is_selectable' => true,
]);
// A record type is really a clone of the business process, used to store which record uses which pipeline.
$this->config->recordTypes()->updateOrCreate([
'crm_provider_id' => $pipeline['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($pipeline['name'], 0, 150),
'is_selectable' => true,
'business_process_id' => $businessProcess->id ?? null,
]);
$stages = [];
foreach ($pipeline['stages'] as $i => $dealStage) {
// Upsert all stages for the team.
$stage = $this->config->stages()->updateOrCreate([
'crm_provider_id' => $dealStage['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($dealStage['name'], 0, 50),
'label' => mb_strimwidth($dealStage['name'], 0, 191),
'type' => Stage::TYPE_OPPORTUNITY,
'sequence' => $i,
'is_selectable' => true,
'probability' => $dealStage['win_probability'],
]);
if ($missingStageName && $missingStageName === $dealStage['id']) {
$missingStage = $stage;
}
$stages[] = $stage['id'];
}
$businessProcess->stages()->sync($stages);
}
}
}
return $missingStage;
}
/**
* @inheritdoc
*/
public function syncLeads(Carbon $since, ?Carbon $to = null, ?string $crmProfileId = null): int
{
$syncCount = 0;
$cpLeads = $this->requestWithExceptionHandling(
fn () => $this->copperClient->leads()->paginate([
'minimum_modified_date' => $since->timestamp,
'sort_by' => 'date_modified',
])
);
foreach ($cpLeads as $cpLead) {
// Only sync if previously imported.
if ($this->hasLead($cpLead['id']) === false) {
$this->importLead($cpLead);
$syncCount++;
}
}
return $syncCount;
}
/**
* @inheritdoc
*/
public function syncLead(string $crmId): ?Lead
{
$cpLead = $this->requestWithExceptionHandling(
fn () => $this->copperClient->leads()->find($crmId)
);
if (\count($cpLead) === 0) {
return null;
}
return $this->importLead($cpLead);
}
/**
* @inheritdoc
*/
private function importLead($crmData): ?Lead
{
$countryCode = null;
if (! empty($crmData['address']) && isset($crmData['address']['country'])) {
$countryCode = $this->convertCountryNameToCode($crmData['address']['country']);
}
$mobilePhone = $parsedNumber = null;
if (! empty($crmData['phone_numbers'])) {
foreach ($crmData['phone_numbers'] as $phoneNumber) {
if ($phoneNumber['category'] === 'mobile') {
$mobilePhone = $this->buildContactMobilePhone($countryCode, $phoneNumber['number']);
} else {
$parsedNumber = $this->buildContactPhone($countryCode, $phoneNumber['number']);
}
}
}
$name = $crmData['name'];
if (empty($crmData['company_name'])) {
$company = 'Unknown';
} else {
$company = mb_strimwidth($crmData['company_name'], 0, 191);
}
$domain = null;
if (empty($crmData['websites']) === false) {
$domain = mb_strimwidth($crmData['websites'][0]['url'], 0, 191);
$domain = StringUtil::resolveDomain($domain);
}
$email = null;
if (empty($crmData['email']) === false) {
$email = mb_strimwidth($crmData['email']['email'], 0, 80);
}
$stage = $this->config
->stages()
->where('crm_provider_id', (string) $crmData['status_id'])
->where('type', Stage::TYPE_LEAD)
->first();
if ($stage === null) {
// Import it.
$stage = $this->importStages([Stage::TYPE_LEAD], $crmData['status_id']);
}
if (! $stage instanceof Stage) {
return null;
}
$profile = $this->config->profiles()->where('crm_provider_id', (string) $crmData['assignee_id'])->first();
$data = [
'team_id' => $this->team->id,
'user_id' => $profile ? $profile->user_id : null,
'owner_id' => $crmData['assignee_id'],
'name' => mb_strimwidth($name, 0, 191),
'company' => $company,
'domain' => $domain,
'title' => $crmData['title'] ? mb_strimwidth($crmData['title'], 0, 128) : null,
'email' => $email,
'phone' => $parsedNumber['phone'] ?? null,
'ext' => $parsedNumber['ext'] ?? null,
'mobile_phone' => $mobilePhone,
'stage_id' => $stage->id,
'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(
crmConfiguration: $this->config,
crmProviderId: $crmData['id'],
modelType: Lead::class,
fileName: $crmData['id'],
avatarText: $crmData['name']
),
'country_code' => $countryCode,
'remotely_created_at' => $crmData['date_created'],
];
return $this->config->leads()->updateOrCreate(['crm_provider_id' => (string) $crmData['id']], $data);
}
/**
* @inheritdoc
*/
public function syncAccounts(Carbon $since, ?Carbon $to = null): int
{
$syncCount = 0;
try {
$cpAccounts = $this->copperClient->accounts()->paginate([
'minimum_modified_date' => $since->timestamp,
'sort_by' => 'date_modified',
]);
foreach ($cpAccounts as $cpAccount) {
// Only sync if previously imported.
if ($this->hasAccount($cpAccount['id'])) {
$this->importAccount($cpAccount);
$syncCount++;
}
}
} catch (\Exception $exception) {
// Do nothing for now.
}
return $syncCount;
}
/**
* @inheritdoc
*/
public function syncAccount(string $crmId): ?Account
{
$cpAccount = $this->copperClient->accounts()->find($crmId);
if (empty($cpAccount)) {
return null;
}
return $this->importAccount($cpAccount);
}
/**
* @inheritdoc
*/
private function importAccount($crmData): Account
{
$countryCode = null;
if (empty($crmData['address']) === false && $crmData['address']['country']) {
$countryCode = $this->convertCountryNameToCode($crmData['address']['country']);
}
$name = $crmData['name'];
$parsedNumber = null;
foreach ($crmData['phone_numbers'] as $phoneNumber) {
$parsedNumber = parsePhoneNumber($countryCode, $phoneNumber['number']);
}
$domain = null;
if (empty($crmData['websites']) === false) {
$domain = mb_strimwidth($crmData['websites'][0]['url'], 0, 191);
$domain = StringUtil::resolveDomain($domain);
}
$profile = $this->config->profiles()->where('crm_provider_id', (string) $crmData['assignee_id'])->first();
$data = [
'team_id' => $this->team->id,
'user_id' => $profile ? $profile->user_id : null,
'owner_id' => $crmData['assignee_id'],
'name' => mb_strimwidth($name, 0, 191),
'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(
crmConfiguration: $this->config,
crmProviderId: $crmData['id'],
modelType: Account::class,
fileName: $crmData['id'],
avatarText: $crmData['name']
),
'country_code' => $countryCode,
'domain' => $domain,
'phone' => $parsedNumber['phone'] ?? null,
'ext' => $parsedNumber['ext'] ?? null,
'remotely_created_at' => $crmData['date_created'],
];
return $this->config->accounts()->updateOrCreate(['crm_provider_id' => (string) $crmData['id']], $data);
}
public function syncOpportunities(array $parameters, ?string $strategy = null): int
{
$strategyResolver = app(OpportunitySyncStrategyResolver::class, [
'client' => $this->copperClient,
]);
$strategies = $strategyResolver->getStrategies($this->config, $strategy);
$syncCount = 0;
try {
foreach ($strategies as $syncStrategy) {
foreach ($syncStrategy->fetchOpportunities($parameters) as $opportunityData) {
$this->importOpportunity($opportunityData);
$syncCount++;
}
}
} catch (\Exception $exception) {
// Do nothing for now.
$this->logger->error(self::OPPORTUNITY_IMPORT_FAILED_ERROR_MESSAGE, [
'error' => $exception->getMessage(),
]);
}
return $syncCount;
}
public function syncOpportunity(string $crmId): ?Opportunity
{
$strategyResolver = app(OpportunitySyncStrategyResolver::class, [
'client' => $this->copperClient,
]);
$strategy = $strategyResolver->resolve(
$this->config,
OpportunitySyncStrategyResolver::SINGLE_SYNC_OPPORTUNITY_STRATEGY
);
$parameters['crm_id'] = $crmId;
$cpOpportunity = $strategy->fetchOpportunities($parameters);
return $this->importOpportunity($cpOpportunity);
}
private function importOpportunity($crmData): ?Opportunity
{
$companyId = (string) $crmData['company_id'];
if (empty($companyId)) {
$this->logger->error(self::OPPORTUNITY_IMPORT_FAILED_ERROR_MESSAGE, [
'error' => 'No company id',
]);
return null;
}
$account = $this->config->accounts()->where('crm_provider_id', $companyId)->first();
if ($account === null) {
$account = $this->syncAccount($companyId);
}
$profile = $this->config->profiles()->where('crm_provider_id', (string) $crmData['assignee_id'])->first();
$businessProcess = $this->config
->businessProcesses()
->where('crm_provider_id', (string) $crmData['pipeline_id'])
->first();
if ($businessProcess === null) {
// It is possible for some deals to not be in a pipeline.
$this->logger->warning(self::OPPORTUNITY_IMPORT_FAILED_ERROR_MESSAGE, [
'error' => 'Missing pipeline',
]);
return null;
}
$stage = $businessProcess
->stages()
->where('crm_provider_id', (string) $crmData['pipeline_stage_id'])
->where('type', Stage::TYPE_OPPORTUNITY)
->first();
if ($stage === null) {
$this->logger->info('Stage not found, importing', [
'pipeline_id' => $crmData['pipeline_id'],
'stage_id' => $crmData['pipeline_stage_id'],
]);
$stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $crmData['pipeline_stage_id']);
}
$recordType = $businessProcess->recordTypes()->first();
$closeDate = null;
$isClosed = $crmData['status'] !== self::OPPORTUNITY_STATUS_OPEN;
if ($crmData['close_date']) {
$closeDate = Carbon::parse($crmData['close_date'])->format('Y-m-d');
}
$data = [
'team_id' => $this->team->getId(),
'account_id' => $account->id,
'user_id' => $profile->user_id ?? null,
'owner_id' => $crmData['assignee_id'],
'name' => mb_strimwidth($crmData['name'], 0, 128),
'value' => $crmData['monetary_value'],
'currency_code' => CurrencyFormatter::formatCode($crmData['monetary_unit']),
'close_date' => $closeDate,
'is_closed' => $isClosed,
'is_won' => $crmData['status'] === 'Won',
'stage_id' => $stage->id,
'record_type_id' => $recordType->id ?? null,
'remotely_created_at' => $crmData['date_created'],
];
/** @var Opportunity $opportunity */
$opportunity = $this->config
->opportunities()
->updateOrCreate(['crm_provider_id' => (string) $crmData['id']], $data)
;
// import external fields into crm_field_data if present
$dealInsightsFields = $this->getOpportunitySyncableFields();
$this->importOpportunityCrmFieldData($crmData, $dealInsightsFields, $opportunity->id);
return $opportunity;
}
/**
* @param array<string, mixed> $opportunityData
* @param string[] $dealInsightsFields
*/
public function importOpportunityCrmFieldData(
array $opportunityData,
array $dealInsightsFields,
int $opportunityId
): void {
foreach ($dealInsightsFields as $fieldName) {
$field = $this->config->fields()->with('entities')
->where('crm_provider_id', $fieldName)
->where('object_type', Field::OBJECT_OPPORTUNITY)
->first();
if (! $field instanceof Field) {
$this->logger->warning('Field not found', [
'field' => $fieldName,
'opportunity_id' => $opportunityId,
]);
continue;
}
$entity = $field->getEntities()->first();
if (! $entity instanceof LayoutEntity) {
continue;
}
$fieldValue = $this->extractFieldValue($opportunityData, $fieldName);
$normalizedValue = $this->normalizeValue($field->getType(), (string) $fieldValue, true);
$field->data()
->updateOrCreate([
'crm_layout_entity_id' => $entity->id,
'crm_field_id' => $field->id,
'object_type' => 'opportunity',
'object_id' => $opportunityId,
], ['value' => $normalizedValue])
;
}
}
/**
* @inheritdoc
*/
public function syncContacts(Carbon $since, ?Carbon $to = null): int
{
$syncCount = 0;
try {
$cpContacts = $this->copperClient->contacts()->paginate([
'minimum_interaction_date' => $since->timestamp,
'sort_by' => 'date_modified',
]);
foreach ($cpContacts as $cpContact) {
// Only sync if previously imported.
if ($this->hasContact($cpContact['id'])) {
$this->importContact($cpContact);
$syncCount++;
}
}
} catch (\Exception $exception) {
// Do nothing for now.
throw $exception;
}
return $syncCount;
}
/**
* @inheritdoc
*/
public function syncContact(string $crmId): ?Contact
{
$cpContact = $this->copperClient->contacts()->find($crmId);
if (empty($cpContact)) {
return null;
}
return $this->importContact($cpContact);
}
/**
* @inheritdoc
*/
private function importContact($crmData): Contact
{
$account = null;
if ($crmData['company_id']) {
$account = $this->team
->crm
->accounts()
->where('crm_provider_id', (string) $crmData['company_id'])
->first();
if ($account === null) {
$account = $this->syncAccount($crmData['company_id']);
}
}
$countryCode = null;
if (empty($crmData['address']) === false && $crmData['address']['country']) {
$countryCode = $this->convertCountryNameToCode($crmData['address']['country']);
}
$mobilePhone = $parsedNumber = null;
foreach ($crmData['phone_numbers'] as $phoneNumber) {
if ($phoneNumber['category'] === 'mobile') {
$mobilePhone = $this->buildContactMobilePhone($countryCode, $phoneNumber['number']);
} else {
$parsedNumber = $this->buildContactPhone($countryCode, $phoneNumber['number']);
}
}
$email = null;
if (empty($crmData['emails']) === false) {
$email = mb_strimwidth($crmData['emails'][0]['email'], 0, 80);
}
$profile = $this->config->profiles()->where('crm_provider_id', (string) $crmData['assignee_id'])->first();
$data = [
'team_id' => $this->team->id,
'account_id' => $account->id ?? null,
'user_id' => $profile ? $profile->user_id : null,
'owner_id' => $crmData['assignee_id'],
'name' => $crmData['name'],
'email' => $email,
'phone' => $parsedNumber['phone'] ?? null,
'mobile_phone' => $mobilePhone ?? null,
'ext' => $parsedNumber['ext'] ?? null,
'title' => $crmData['title'],
'country_code' => $countryCode,
'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(
crmConfiguration: $this->config,
crmProviderId: $crmData['id'],
modelType: Contact::class,
fileName: $crmData['id'],
avatarText: $crmData['name']
),
'remotely_created_at' => $crmData['date_created'],
];
return $this->config->contacts()->updateOrCreate(['crm_provider_id' => (string) $crmData['id']], $data);
}
private function buildContactPhone(?string $countryCode, ?string $number): ?array
{
if ($number) {
// Trim to our width and attempt to parse it.
$number = mb_strimwidth($number, 0, 25);
$parsedNumber = parsePhoneNumber($countryCode, $number);
} else {
$parsedNumber = [];
}
return $parsedNumber;
}
private function buildContactMobilePhone(?string $countryCode, ?string $number): ?string
{
return $number ? mb_strimwidth(phone_e164($countryCode, $number), 0, 25) : null;
}
/**
* @inheritdoc
*/
public function syncOrganization(): void
{
$organization = $this->copperClient->organisation()->all();
if ($this->config->crm_provider_id !== $organization['id']) {
$this->config->update(['crm_provider_id' => $organization['id']]);
}
}
private function syncCustomFields(): void
{
foreach ($this->getFieldTypes() as $fieldType) {
$currentFields = $this->getFields($fieldType);
foreach ($currentFields as $field) {
/** @var Field $field */
$field = $this->config->fields()->updateOrCreate([
'crm_provider_id' => $field['name'],
'object_type' => $fieldType,
], [
'label' => mb_strimwidth($field['label'], 0, Field::LABEL_MAX_LENGTH),
'type' => $field['type'],
]);
if ($field->getType() === Field::TYPE_PICKLIST) {
$this->importPicklistValues($field);
}
}
}
$this->config->save();
}
protected function getFieldTypes(): array
{
return [
parent::OBJECT_TASK,
parent::OBJECT_LEAD,
parent::OBJECT_ACCOUNT,
parent::OBJECT_CONTACT,
parent::OBJECT_OPPORTUNITY,
];
}
/**
* @inheritdoc
*/
public function syncProfiles(?User $userToSearch = null): ?Profile
{
$profiles = $this->copperClient->users()->all();
foreach ($profiles as $profile) {
$user = $this->userRepository->findByTeamAndEmailAddress($this->team, $profile['email'], onlyActive: true);
// Only import profiles for users we have locally.
if ($user) {
$profile = $user->crmProfile()->updateOrCreate([
'crm_configuration_id' => $this->config->id,
'crm_provider_id' => $profile['id'],
]);
if ($userToSearch && $userToSearch->id === $user->id) {
return $profile;
}
}
}
return null;
}
/**
* @inheritdoc
*/
public function syncProfileFields(): void
{
$fieldTypes = [
'task',
'lead',
'account',
'contact',
'opportunity',
];
foreach ($fieldTypes as $fieldType) {
try {
$currentFields = $this->getFields(ucfirst($fieldType));
$newFields = array_column($currentFields, 'name');
$changedFields = array_merge(
array_diff($this->profile->getFieldsAsArray($fieldType), $newFields),
array_diff($newFields, $this->profile->getFieldsAsArray($fieldType))
);
if (\count($changedFields) > 0) {
$fields = implode(',', $newFields);
$this->logger->debug(
ucfirst($fieldType)
. ' fields changed from '
. $this->profile->{$fieldType . '_fields'}
. ' to: '
. $fields
);
$this->profile->{$fieldType . '_fields'} = $fields;
}
} catch (\Exception $exception) {
$this->logger->error('No access to ' . $fieldType . ' object, skipping...');
continue;
}
}
$this->profile->save();
}
/**
* @inheritdoc
*/
public function find(string $name, array $scopes): array
{
$count = $this->limit ?? 20;
$offset = $this->offset ?? 0;
$scopeValues = array_map(fn ($scope) => is_string($scope) ? $scope : $scope->value, $scopes);
$cacheKey = sprintf('copper-search-%d-%s-%s-%d-%d', $this->team->id, $name, implode('-', $scopeValues), $count, $offset);
return Cache::remember($cacheKey, 300, function () use ($name, $scopes, $count, $offset) {
// Try local search first
$localResults = $this->searchLocally($name, $scopes, $count, $offset);
// If no local results, fall back to remote API search
if (empty($localResults)) {
return $this->searchRemotely($name, $scopes, $count, $offset);
}
return $localResults;
});
}
/**
* @inheritdoc
*/
public function findOpportunities(?string $crmAccountId, ?string $crmContactId, ?int $userId = null): array
{
$data = [];
$ownerData = [];
$ownerId = null;
$contact = null;
$account = null;
if ($crmAccountId) {
$account = $this->config->accounts()->where('crm_provider_id', $crmAccountId)->first();
if ($account === null) {
$account = $this->syncAccount($crmAccountId);
}
}
if ($crmContactId) {
$contact = $this->config->contacts()->where('crm_provider_id', $crmContactId)->first();
if ($contact === null) {
$contact = $this->syncContact($crmContactId);
}
}
if ($contact || $account) {
if ($contact && $account === null) {
$account = $contact->account;
}
if ($account === null) {
return [];
}
$params = [
'company_ids' => [$account->crm_provider_id],
'status_ids' => [0], // The integer values are 0, 1, 2, 3, for "Open", "Won", "Lost", and "Abandoned".
'sort_by' => 'date_modified',
'sort_direction' => 'desc',
];
switch ($this->config->opportunity_assignment_rule) {
case Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_UPDATED:
$params['sort_by'] = 'date_modified';
$params['sort_direction'] = 'desc';
break;
case Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_CREATED:
$params['sort_by'] = 'date_created';
$params['sort_direction'] = 'desc';
break;
case Configuration::OPP_ASSIGNMENT_ALL_OPEN_OLDEST_CREATED:
$params['sort_by'] = 'date_created';
$params['sort_direction'] = 'asc';
break;
case Configuration::OPP_ASSIGNMENT_ALL_RECENTLY_UPDATED:
$params['sort_by'] = 'date_modified';
$params['sort_direction'] = 'desc';
$params['status_ids'] = [0, 1, 2, 3];
}
$cpOpportunities = $this->copperClient->opportunities()->search($params);
if ($userId) {
$profileRepository = app(ProfileRepository::class);
$profile = $profileRepository->findProfileByUserId($this->config, $userId);
$ownerId = $profile instanceof Profile ? $profile->getCrmProviderId() : null;
}
foreach ($cpOpportunities as $cpOpportunity) {
$businessProcess = $this->config
->businessProcesses()
->where('crm_provider_id', (string) $cpOpportunity['pipeline_id'])
->first();
if ($businessProcess === null) {
// Import it.
$businessProcess = $this->importStages(
[Stage::TYPE_OPPORTUNITY],
$cpOpportunity['pipeline_stage_id']
);
}
$stage = $businessProcess
->stages()
->where('crm_provider_id', (string) $cpOpportunity['pipeline_stage_id'])
->where('type', Stage::TYPE_OPPORTUNITY)
->first();
if ($stage === null) {
// Import it.
$stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $cpOpportunity['pipeline_stage_id']);
}
$recordType = $businessProcess->recordTypes()->first();
$record = [
'crmId' => $cpOpportunity['id'],
'name' => $cpOpportunity['name'],
'value' => currency_format($cpOpportunity['monetary_value'], $cpOpportunity['monetary_unit']),
'won' => $cpOpportunity['status'] === 'Won',
'stage' => [
'id' => $stage->id_string,
'name' => $stage->name,
],
'recordType' => [
'id' => $recordType->id_string,
'name' => $recordType->name,
],
];
if ($ownerId && isset($cpOpportunity['assignee_id']) && $cpOpportunity['assignee_id'] === $ownerId) {
$ownerData[] = $record;
}
$data[] = $record;
}
}
if (! empty($ownerData)) {
return $ownerData;
}
return $data;
}
/**
* @inheritdoc
*/
public function getTasks(string $objectType, string $objectId, ?string $opportunityId): array
{
$data = $cpTasks = [];
switch ($objectType) {
case 'lead':
$cpTasks = $this->requestWithExceptionHandling(
fn () => $this->copperClient->leads()->relatedTasks($objectId)
);
break;
case 'account':
$cpTasks = $this->copperClient->accounts()->relatedTasks($objectId);
break;
case 'contact':
if ($opportunityId) {
$cpTasks = $this->copperClient->opportunities()->relatedTasks($opportunityId);
} elseif ($objectType === 'contact') {
$cpTasks = $this->copperClient->contacts()->relatedTasks($objectId);
}
break;
}
foreach ($cpTasks as $cpTask) {
// We only get the ID itself returned, so pull out the full object.
try {
$cpTask = $this->copperClient->tasks()->find($cpTask['id']);
} catch (Exception $exception) {
$this->logger->error('[Copper] Error fetching task', [
'resource_id' => $cpTask['id'],
'error' => $exception->getMessage(),
]);
continue;
}
// Only allow open tasks.
if ($cpTask['status'] === 'Open') {
$data[] = [
'crmId' => $cpTask['id'],
'subject' => $cpTask['name'],
'due' => $cpTask['due_date']
? Carbon::createFromTimestamp($cpTask['due_date'])->format('Y-m-d\TH:i:sO')
: null,
'type' => null,
];
}
}
return $data;
}
/**
* Try to find email address in CRM service
*
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
*}
*/
public function matchExactlyByEmail(string $email, ?int $userId = null): ?array
{
$cpObjectType = 'contact';
$cpObjects = $this->copperClient->contacts()->search([
'emails' => $email,
'sort_by' => 'date_modified',
'sort_direction' => 'desc',
]);
if (empty($cpObjects)) {
$cpObjectType = 'lead';
$cpObjects = $this->requestWithExceptionHandling(
fn () => $this->copperClient->leads()->search([
'emails' => ...
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"bounds":{"left":0.054166667,"top":0.027777778,"width":0.08055556,"height":0.035555556},"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"#12066 on JY-20725-handle-HS-search-rate-limit, menu","depth":5,"bounds":{"left":0.13472222,"top":0.027777778,"width":0.25555557,"height":0.035555556},"on_screen":true,"help_text":"Pull request #12066 exists for current branch JY-20725-handle-HS-search-rate-limit, but local branch is out of sync with remote","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"bounds":{"left":0.6326389,"top":0.027777778,"width":0.023611112,"height":0.035555556},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"HandleHubspotRateLimitTest","depth":6,"bounds":{"left":0.6645833,"top":0.027777778,"width":0.15902779,"height":0.035555556},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'HandleHubspotRateLimitTest'","depth":6,"bounds":{"left":0.82361114,"top":0.027777778,"width":0.023611112,"height":0.035555556},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'HandleHubspotRateLimitTest'","depth":6,"bounds":{"left":0.8472222,"top":0.027777778,"width":0.023611112,"height":0.035555556},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"bounds":{"left":0.87083334,"top":0.027777778,"width":0.023611112,"height":0.035555556},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"bounds":{"left":0.9291667,"top":0.027777778,"width":0.023611112,"height":0.035555556},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"bounds":{"left":0.9527778,"top":0.027777778,"width":0.023611112,"height":0.035555556},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"IDE and Project Settings","depth":5,"bounds":{"left":0.9763889,"top":0.027777778,"width":0.023611112,"height":0.035555556},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.088194445,"height":0.027777778},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"11","depth":4,"bounds":{"left":0.7340278,"top":0.3122222,"width":0.01875,"height":0.02111111},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"72","depth":4,"bounds":{"left":0.7569444,"top":0.3122222,"width":0.020833334,"height":0.02111111},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"1","depth":4,"bounds":{"left":0.78194445,"top":0.3122222,"width":0.015277778,"height":0.02111111},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"4","depth":4,"bounds":{"left":0.80138886,"top":0.3122222,"width":0.016666668,"height":0.02111111},"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"bounds":{"left":0.8215278,"top":0.31,"width":0.015277778,"height":0.025555555},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"bounds":{"left":0.8368056,"top":0.31,"width":0.014583333,"height":0.025555555},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\nnamespace Jiminny\\Services\\Crm\\Copper;\n\nuse Carbon\\Carbon;\nuse Exception;\nuse GuzzleHttp\\Exception\\ClientException;\nuse Illuminate\\Support\\Facades\\Cache;\nuse Jiminny\\Contracts\\Services\\Crm\\LayoutManagementInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\MatchCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\Provider\\CopperInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityLookupInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityManipulationInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SettingsInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmMetadataInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\VerifyTaskExistsInterface;\nuse Jiminny\\Exceptions\\CrmException;\nuse Jiminny\\Exceptions\\HttpNotFoundException;\nuse Jiminny\\Models\\Account;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Contact;\nuse Jiminny\\Models\\Crm\\BusinessProcess;\nuse Jiminny\\Models\\Crm\\Configuration;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Models\\Crm\\FieldData;\nuse Jiminny\\Models\\Crm\\FieldValue;\nuse Jiminny\\Models\\Crm\\LayoutEntity;\nuse Jiminny\\Models\\Crm\\Profile;\nuse Jiminny\\Models\\Lead;\nuse Jiminny\\Models\\Opportunity;\nuse Jiminny\\Models\\Playbook;\nuse Jiminny\\Models\\PlaybookCategory;\nuse Jiminny\\Models\\SocialAccount;\nuse Jiminny\\Models\\Stage;\nuse Jiminny\\Models\\User;\nuse Jiminny\\Repositories\\Crm\\CrmEntityRepository;\nuse Jiminny\\Repositories\\Crm\\FieldRepository;\nuse Jiminny\\Repositories\\Crm\\ProfileRepository;\nuse Jiminny\\Repositories\\UserRepository;\nuse Jiminny\\Services\\Avatar\\ProspectPhotoPathService;\nuse Jiminny\\Services\\Crm\\BaseService;\nuse Jiminny\\Services\\Crm\\Copper\\Api\\Client;\nuse Jiminny\\Services\\Crm\\Copper\\Api\\ClientFactory;\nuse Jiminny\\Services\\Crm\\Copper\\Exception\\RequestFailedException;\nuse Jiminny\\Services\\Crm\\Helpers\\FilterJoinedParticipants;\nuse Jiminny\\Services\\Crm\\OpportunitySyncStrategyResolver;\nuse Jiminny\\Services\\Crm\\Copper\\Traits\\SearchesCrmEntities;\nuse Jiminny\\Utils\\CurrencyFormatter;\nuse Jiminny\\Utils\\StringUtil;\n\nclass Service extends BaseService implements\n CopperInterface,\n SyncCrmEntitiesInterface,\n SyncCrmMetadataInterface,\n RemoteEntityManipulationInterface,\n LayoutManagementInterface,\n SettingsInterface,\n MatchCrmEntitiesInterface,\n RemoteEntityLookupInterface,\n VerifyTaskExistsInterface\n{\n use SearchesCrmEntities;\n private const int NOTE_BODY_MAX_LENGTH = 3000000;\n\n private const int TASK_VERIFICATION_CACHE_TTL = 86400; // 1 day\n\n private const array STANDARD_OPPORTUNITY_FIELDS = [\n 'monetary_value' => [\n 'label' => 'Value',\n 'available_on' => Field::OBJECT_OPPORTUNITY,\n 'type' => Field::TYPE_CURRENCY,\n ],\n 'pipeline_stage_id' => [\n 'label' => 'Stage',\n 'type' => Field::TYPE_PICKLIST,\n 'available_on' => Field::OBJECT_OPPORTUNITY,\n ],\n 'close_date' => [\n 'label' => 'Close date',\n 'type' => Field::TYPE_DATE,\n 'available_on' => Field::OBJECT_OPPORTUNITY,\n ],\n 'win_probability' => [\n 'label' => 'Win probability',\n 'type' => Field::TYPE_NUMBER,\n 'available_on' => Field::OBJECT_OPPORTUNITY,\n ],\n ];\n private const string UNSUPPORTED_OBJECT_TYPE = 'unsupported';\n private const string OPPORTUNITY_STATUS_OPEN = 'Open';\n private const string OPPORTUNITY_IMPORT_FAILED_ERROR_MESSAGE = 'Opportunity import failed';\n private const string RESOURCE_UPDATE_FAILED_ERROR_MESSAGE = 'Failed to update resource';\n\n private FieldValueConverter $fieldValueConverter;\n private ClientFactory $clientFactory;\n private ?Client $copperClient = null;\n\n public function __construct(\n ClientFactory $clientFactory,\n FieldValueConverter $fieldValueConverter,\n private readonly UserRepository $userRepository,\n private readonly ProspectPhotoPathService $prospectPhotoPathService,\n private readonly CrmEntityRepository $crmEntityRepository\n ) {\n parent::__construct();\n\n $this->clientFactory = $clientFactory;\n $this->fieldValueConverter = $fieldValueConverter;\n }\n\n public function getDisplayName(): string\n {\n return 'Copper';\n }\n\n /**\n * Get the CRM entity repository (used by SearchesCrmEntities trait)\n */\n protected function getCrmEntityRepository(): CrmEntityRepository\n {\n return $this->crmEntityRepository;\n }\n\n public function getMinimumApiVersion(): string\n {\n return Client::MIN_API_VERSION;\n }\n\n protected function getOAuthAccount(User $user): ?SocialAccount\n {\n // In this case, the Account Owner is always the connection for any API operations.\n $owner = $user->team->owner;\n\n return $owner->getSocialAccount(SocialAccount::PROVIDER_COPPER);\n }\n\n public function setUser(User $user): void\n {\n $this->configureSentryScope($user);\n\n $this->team = $user->getTeam();\n $this->teamService->setTeam($user->getTeam());\n $this->setConfiguration($user->getTeam()->getCrmConfiguration());\n\n // Reset state.\n $this->profile = $user->getProfile();\n\n if (! $this->profile instanceof Profile) {\n // Try to fetch their profile, maybe it was not yet imported?\n $this->profile = $this->getProfile($user);\n }\n }\n\n public function setConfiguration(Configuration $configuration): void\n {\n $this->config = $configuration;\n $this->copperClient = $this->clientFactory->create($configuration);\n\n if ($this->team === null) {\n $this->team = $configuration->getTeam();\n }\n }\n\n public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string\n {\n return $this->fieldValueConverter->convertFromCrm($fieldType, $fieldValue);\n }\n\n private function mapObjectType(string $objectType): string\n {\n $objectType = strtolower($objectType);\n\n switch ($objectType) {\n case Field::OBJECT_TASK:\n case Field::OBJECT_OPPORTUNITY:\n case Field::OBJECT_LEAD:\n $mappedType = $objectType;\n\n break;\n case Field::OBJECT_ACCOUNT:\n $mappedType = 'company';\n\n break;\n case Field::OBJECT_CONTACT:\n $mappedType = 'person';\n\n break;\n default:\n $mappedType = self::UNSUPPORTED_OBJECT_TYPE;\n\n break;\n }\n\n return $mappedType;\n }\n\n /**\n * @param string $crmObject The name of the CRM object. i.e. Opportunity or Contact\n */\n protected function getFields(string $crmObject): array\n {\n $mappedObjectType = $this->mapObjectType($crmObject);\n\n if (! $this->isSupportedObjectType($mappedObjectType)) {\n return [];\n }\n\n $customFields = $this->copperClient->fields()->all();\n\n $filteredFields = array_filter(\n $customFields,\n static fn ($field) => in_array($mappedObjectType, $field['available_on'], true)\n );\n\n return array_map(\n fn (array $property) => [\n 'name' => $property['id'],\n 'label' => $property['name'],\n 'type' => $this->convertFieldType($property['data_type']),\n ],\n $filteredFields\n );\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultFields(string $activityType): array\n {\n $fields = [];\n\n if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {\n $defaultFields = FieldDefinitions::defaultTaskFields();\n\n // This lazy creates these fields if not already setup.\n foreach ($defaultFields as $defaultField) {\n $fields[] = $this->config->fields()->firstOrCreate($defaultField);\n }\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultActivityField(string $activityType): Field\n {\n // Setup the activity field as the default Type.\n $activityField = $this->config->fields()->where([\n 'crm_provider_id' => 'type',\n 'object_type' => $activityType,\n ])->first();\n\n return $activityField;\n }\n\n /**\n * @inheritdoc\n */\n public function getSupportedPlaybookTypes(): array\n {\n return [Playbook::ACTIVITY_TYPE_TASK];\n }\n\n public function getDealInsightsFields(): array\n {\n return FieldDefinitions::dealInsightsFields();\n }\n\n protected function getDefaultFollowupLayoutFields(string $activityType): array\n {\n $fields = [];\n $fieldRepo = app(FieldRepository::class);\n $fieldFilters = FieldDefinitions::taskFollowupFieldsFilter();\n\n foreach ($fieldFilters as $data) {\n $field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $data);\n\n // Only add the field if it is created, which it should be.\n if ($field) {\n $fields[] = $field;\n }\n }\n\n return $fields;\n }\n\n public function syncFields(): void\n {\n $this->syncStandardFields();\n $this->syncCustomFields();\n }\n\n public function syncField(Field $field): void\n {\n if ($field->getCrmProviderId() === 'type') {\n /**\n * This field is a special case, it is not a custom field and is not returned by the Copper API.\n *\n * The field is created manually during layout setup.\n *\n * @see getDefaultFields()\n *\n * There is a separate endpoint to get the activity types.\n * @see importPicklistValues()\n */\n return;\n }\n\n if ($field->getCrmProviderId() === 'pipeline_stage_id') {\n // Not a custom type\n return;\n }\n\n $crmField = $this->copperClient->fields()->find($field->getCrmProviderId());\n $field->label = $crmField['name'];\n $field->type = $this->convertFieldType($crmField['data_type']);\n\n $field->save();\n }\n\n /**\n * @param string $fieldType How the data should be input\n */\n private function convertFieldType(string $fieldType): string\n {\n $fieldType = strtolower($fieldType);\n\n switch ($fieldType) {\n case 'dropdown':\n $convertedType = Field::TYPE_PICKLIST;\n\n break;\n case 'string':\n $convertedType = Field::TYPE_TEXT;\n\n break;\n case 'float':\n case 'percentage':\n $convertedType = Field::TYPE_NUMBER;\n\n break;\n case 'text':\n $convertedType = Field::TYPE_TEXTAREA;\n\n break;\n case 'checkbox':\n $convertedType = Field::TYPE_BOOLEAN;\n\n break;\n case Field::TYPE_DATE:\n case Field::TYPE_CURRENCY:\n case Field::TYPE_URL:\n $convertedType = $fieldType;\n\n break;\n default:\n $convertedType = Field::TYPE_UNSUPPORTED;\n\n break;\n }\n\n return $convertedType;\n }\n\n /**\n * @inheritdoc\n */\n public function importPicklistValues(Field $field): array\n {\n $fieldValues = [];\n\n if ($field->object_type === 'opportunity' && $field->crm_provider_id === 'pipeline_stage_id') {\n return $this->importFieldValuesForDealInsights($field);\n }\n\n $values = [];\n\n if ($field->object_type === Field::OBJECT_TASK && $field->crm_provider_id === 'type') {\n $activityTypes = $this->copperClient->activityTypes()->all();\n\n /**\n * We ignore \"system\" activity types, disabled activity types and the \"Note\" activity type.\n */\n $activityTypes = array_filter(\n $activityTypes['user'],\n fn (array $activityType) => ! $activityType['is_disabled'] && $activityType['id'] !== 0\n );\n\n // Import all activity values.\n $i = 0;\n foreach ($activityTypes as $activityType) {\n $values[] = [\n 'value' => $activityType['id'],\n 'label' => mb_strimwidth($activityType['name'], 0, Field::LABEL_MAX_LENGTH),\n 'sequence' => $i++,\n ];\n }\n } else {\n // We just check for the field in the custom definitions.\n $crmField = $this->copperClient->fields()->find($field->crm_provider_id);\n\n // Import all values.\n foreach ($crmField['options'] as $option) {\n $values[] = [\n 'value' => $option['id'],\n 'label' => mb_strimwidth($option['name'], 0, 191),\n 'sequence' => $option['rank'],\n ];\n }\n }\n\n // Take the existing set of field values.\n $fieldsToPurge = $field->values()->get()->pluck('value')->toArray();\n\n foreach ($values as $value) {\n // For all current values, upsert the field.\n $value['value'] = substr($value['value'] ?? '', 0, 255);\n $fieldValues[] = $field->values()->updateOrCreate([\n 'value' => $value['value'],\n ], $value);\n\n // Remove this value from the ones we are going to purge.\n $key = array_search($value['value'], $fieldsToPurge, false);\n if ($key !== false) {\n unset($fieldsToPurge[$key]);\n }\n }\n\n // Delete the old values that are no longer used.\n $field->values()->whereIn('value', $fieldsToPurge)->delete();\n\n return $fieldValues;\n }\n\n /**\n *\n * @return FieldValue[]\n */\n private function importFieldValuesForDealInsights(Field $field): array\n {\n $fieldValues = [];\n $pipelines = $this->copperClient->pipelines()->all();\n\n foreach ($pipelines as $pipeline) {\n foreach ($pipeline['stages'] as $i => $dealStage) {\n $fieldValues[] = $field->values()->updateOrCreate(\n ['value' => $dealStage['id']],\n [\n 'label' => $dealStage['name'],\n 'sequence' => $i,\n ]\n );\n }\n }\n\n return $fieldValues;\n }\n\n /**\n * @inheritdoc\n */\n public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage\n {\n if ($types === null) {\n $types = [Stage::TYPE_LEAD, Stage::TYPE_OPPORTUNITY];\n }\n $missingStage = null;\n\n foreach ($types as $type) {\n if ($type === Stage::TYPE_LEAD) {\n $statuses = $this->requestWithExceptionHandling(\n fn () => $this->copperClient->leadStatuses()->all()\n );\n\n if (count($statuses) > 0) {\n $businessProcess = $this->config->businessProcesses()->updateOrCreate([\n 'crm_provider_id' => BusinessProcess::TYPE_LEAD,\n ], [\n 'team_id' => $this->team->id,\n 'name' => BusinessProcess::TYPE_LEAD,\n 'type' => BusinessProcess::TYPE_LEAD,\n 'is_selectable' => true,\n ]);\n\n // A record type is really a clone of the business process, used to store which record uses which pipeline.\n $this->config->recordTypes()->updateOrCreate([\n 'crm_provider_id' => BusinessProcess::TYPE_LEAD,\n ], [\n 'team_id' => $this->team->id,\n 'name' => BusinessProcess::TYPE_LEAD,\n 'is_selectable' => true,\n 'business_process_id' => $businessProcess->id ?? null,\n ]);\n\n $leadStatuses = [];\n foreach ($statuses as $status) {\n // Upsert all statuses for the team.\n $status = $this->config->stages()->updateOrCreate([\n 'crm_provider_id' => $status['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($status['name'], 0, 50),\n 'label' => mb_strimwidth($status['name'], 0, 191),\n 'type' => Stage::TYPE_LEAD,\n 'sequence' => $status['order'],\n 'is_selectable' => true,\n ]);\n\n if ($missingStageName && $missingStageName === $status['id']) {\n $missingStage = $status;\n }\n\n $leadStatuses[] = $status['id'];\n }\n\n $businessProcess->stages()->sync($leadStatuses);\n }\n } else {\n // We create a business process to contain the pipeline, and later store all stages against it.\n foreach ($this->copperClient->pipelines()->all() as $pipeline) {\n $businessProcess = $this->config->businessProcesses()->updateOrCreate([\n 'crm_provider_id' => $pipeline['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($pipeline['name'], 0, 150),\n 'type' => BusinessProcess::TYPE_OPPORTUNITY,\n 'is_selectable' => true,\n ]);\n\n // A record type is really a clone of the business process, used to store which record uses which pipeline.\n $this->config->recordTypes()->updateOrCreate([\n 'crm_provider_id' => $pipeline['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($pipeline['name'], 0, 150),\n 'is_selectable' => true,\n 'business_process_id' => $businessProcess->id ?? null,\n ]);\n\n $stages = [];\n\n foreach ($pipeline['stages'] as $i => $dealStage) {\n // Upsert all stages for the team.\n $stage = $this->config->stages()->updateOrCreate([\n 'crm_provider_id' => $dealStage['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($dealStage['name'], 0, 50),\n 'label' => mb_strimwidth($dealStage['name'], 0, 191),\n 'type' => Stage::TYPE_OPPORTUNITY,\n 'sequence' => $i,\n 'is_selectable' => true,\n 'probability' => $dealStage['win_probability'],\n ]);\n\n if ($missingStageName && $missingStageName === $dealStage['id']) {\n $missingStage = $stage;\n }\n\n $stages[] = $stage['id'];\n }\n\n $businessProcess->stages()->sync($stages);\n }\n }\n }\n\n return $missingStage;\n }\n\n /**\n * @inheritdoc\n */\n public function syncLeads(Carbon $since, ?Carbon $to = null, ?string $crmProfileId = null): int\n {\n $syncCount = 0;\n\n $cpLeads = $this->requestWithExceptionHandling(\n fn () => $this->copperClient->leads()->paginate([\n 'minimum_modified_date' => $since->timestamp,\n 'sort_by' => 'date_modified',\n ])\n );\n\n foreach ($cpLeads as $cpLead) {\n // Only sync if previously imported.\n if ($this->hasLead($cpLead['id']) === false) {\n $this->importLead($cpLead);\n $syncCount++;\n }\n }\n\n return $syncCount;\n }\n\n /**\n * @inheritdoc\n */\n public function syncLead(string $crmId): ?Lead\n {\n $cpLead = $this->requestWithExceptionHandling(\n fn () => $this->copperClient->leads()->find($crmId)\n );\n\n if (\\count($cpLead) === 0) {\n return null;\n }\n\n return $this->importLead($cpLead);\n }\n\n /**\n * @inheritdoc\n */\n private function importLead($crmData): ?Lead\n {\n $countryCode = null;\n if (! empty($crmData['address']) && isset($crmData['address']['country'])) {\n $countryCode = $this->convertCountryNameToCode($crmData['address']['country']);\n }\n\n $mobilePhone = $parsedNumber = null;\n if (! empty($crmData['phone_numbers'])) {\n foreach ($crmData['phone_numbers'] as $phoneNumber) {\n if ($phoneNumber['category'] === 'mobile') {\n $mobilePhone = $this->buildContactMobilePhone($countryCode, $phoneNumber['number']);\n } else {\n $parsedNumber = $this->buildContactPhone($countryCode, $phoneNumber['number']);\n }\n }\n }\n\n $name = $crmData['name'];\n\n if (empty($crmData['company_name'])) {\n $company = 'Unknown';\n } else {\n $company = mb_strimwidth($crmData['company_name'], 0, 191);\n }\n\n $domain = null;\n if (empty($crmData['websites']) === false) {\n $domain = mb_strimwidth($crmData['websites'][0]['url'], 0, 191);\n $domain = StringUtil::resolveDomain($domain);\n }\n\n $email = null;\n if (empty($crmData['email']) === false) {\n $email = mb_strimwidth($crmData['email']['email'], 0, 80);\n }\n\n $stage = $this->config\n ->stages()\n ->where('crm_provider_id', (string) $crmData['status_id'])\n ->where('type', Stage::TYPE_LEAD)\n ->first();\n\n if ($stage === null) {\n // Import it.\n $stage = $this->importStages([Stage::TYPE_LEAD], $crmData['status_id']);\n }\n\n if (! $stage instanceof Stage) {\n return null;\n }\n\n $profile = $this->config->profiles()->where('crm_provider_id', (string) $crmData['assignee_id'])->first();\n\n $data = [\n 'team_id' => $this->team->id,\n 'user_id' => $profile ? $profile->user_id : null,\n 'owner_id' => $crmData['assignee_id'],\n 'name' => mb_strimwidth($name, 0, 191),\n 'company' => $company,\n 'domain' => $domain,\n 'title' => $crmData['title'] ? mb_strimwidth($crmData['title'], 0, 128) : null,\n 'email' => $email,\n 'phone' => $parsedNumber['phone'] ?? null,\n 'ext' => $parsedNumber['ext'] ?? null,\n 'mobile_phone' => $mobilePhone,\n 'stage_id' => $stage->id,\n 'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(\n crmConfiguration: $this->config,\n crmProviderId: $crmData['id'],\n modelType: Lead::class,\n fileName: $crmData['id'],\n avatarText: $crmData['name']\n ),\n 'country_code' => $countryCode,\n 'remotely_created_at' => $crmData['date_created'],\n ];\n\n return $this->config->leads()->updateOrCreate(['crm_provider_id' => (string) $crmData['id']], $data);\n }\n\n /**\n * @inheritdoc\n */\n public function syncAccounts(Carbon $since, ?Carbon $to = null): int\n {\n $syncCount = 0;\n\n try {\n $cpAccounts = $this->copperClient->accounts()->paginate([\n 'minimum_modified_date' => $since->timestamp,\n 'sort_by' => 'date_modified',\n ]);\n\n foreach ($cpAccounts as $cpAccount) {\n // Only sync if previously imported.\n if ($this->hasAccount($cpAccount['id'])) {\n $this->importAccount($cpAccount);\n $syncCount++;\n }\n }\n } catch (\\Exception $exception) {\n // Do nothing for now.\n }\n\n return $syncCount;\n }\n\n /**\n * @inheritdoc\n */\n public function syncAccount(string $crmId): ?Account\n {\n $cpAccount = $this->copperClient->accounts()->find($crmId);\n\n if (empty($cpAccount)) {\n return null;\n }\n\n return $this->importAccount($cpAccount);\n }\n\n /**\n * @inheritdoc\n */\n private function importAccount($crmData): Account\n {\n $countryCode = null;\n if (empty($crmData['address']) === false && $crmData['address']['country']) {\n $countryCode = $this->convertCountryNameToCode($crmData['address']['country']);\n }\n\n $name = $crmData['name'];\n\n $parsedNumber = null;\n foreach ($crmData['phone_numbers'] as $phoneNumber) {\n $parsedNumber = parsePhoneNumber($countryCode, $phoneNumber['number']);\n }\n\n $domain = null;\n if (empty($crmData['websites']) === false) {\n $domain = mb_strimwidth($crmData['websites'][0]['url'], 0, 191);\n $domain = StringUtil::resolveDomain($domain);\n }\n\n $profile = $this->config->profiles()->where('crm_provider_id', (string) $crmData['assignee_id'])->first();\n\n $data = [\n 'team_id' => $this->team->id,\n 'user_id' => $profile ? $profile->user_id : null,\n 'owner_id' => $crmData['assignee_id'],\n 'name' => mb_strimwidth($name, 0, 191),\n 'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(\n crmConfiguration: $this->config,\n crmProviderId: $crmData['id'],\n modelType: Account::class,\n fileName: $crmData['id'],\n avatarText: $crmData['name']\n ),\n 'country_code' => $countryCode,\n 'domain' => $domain,\n 'phone' => $parsedNumber['phone'] ?? null,\n 'ext' => $parsedNumber['ext'] ?? null,\n 'remotely_created_at' => $crmData['date_created'],\n ];\n\n return $this->config->accounts()->updateOrCreate(['crm_provider_id' => (string) $crmData['id']], $data);\n }\n\n public function syncOpportunities(array $parameters, ?string $strategy = null): int\n {\n $strategyResolver = app(OpportunitySyncStrategyResolver::class, [\n 'client' => $this->copperClient,\n ]);\n $strategies = $strategyResolver->getStrategies($this->config, $strategy);\n\n $syncCount = 0;\n\n try {\n foreach ($strategies as $syncStrategy) {\n foreach ($syncStrategy->fetchOpportunities($parameters) as $opportunityData) {\n $this->importOpportunity($opportunityData);\n $syncCount++;\n }\n }\n } catch (\\Exception $exception) {\n // Do nothing for now.\n $this->logger->error(self::OPPORTUNITY_IMPORT_FAILED_ERROR_MESSAGE, [\n 'error' => $exception->getMessage(),\n ]);\n }\n\n return $syncCount;\n }\n\n public function syncOpportunity(string $crmId): ?Opportunity\n {\n $strategyResolver = app(OpportunitySyncStrategyResolver::class, [\n 'client' => $this->copperClient,\n ]);\n $strategy = $strategyResolver->resolve(\n $this->config,\n OpportunitySyncStrategyResolver::SINGLE_SYNC_OPPORTUNITY_STRATEGY\n );\n\n $parameters['crm_id'] = $crmId;\n\n $cpOpportunity = $strategy->fetchOpportunities($parameters);\n\n return $this->importOpportunity($cpOpportunity);\n }\n\n private function importOpportunity($crmData): ?Opportunity\n {\n $companyId = (string) $crmData['company_id'];\n\n if (empty($companyId)) {\n $this->logger->error(self::OPPORTUNITY_IMPORT_FAILED_ERROR_MESSAGE, [\n 'error' => 'No company id',\n ]);\n\n return null;\n }\n\n $account = $this->config->accounts()->where('crm_provider_id', $companyId)->first();\n\n if ($account === null) {\n $account = $this->syncAccount($companyId);\n }\n\n $profile = $this->config->profiles()->where('crm_provider_id', (string) $crmData['assignee_id'])->first();\n\n $businessProcess = $this->config\n ->businessProcesses()\n ->where('crm_provider_id', (string) $crmData['pipeline_id'])\n ->first();\n\n if ($businessProcess === null) {\n // It is possible for some deals to not be in a pipeline.\n $this->logger->warning(self::OPPORTUNITY_IMPORT_FAILED_ERROR_MESSAGE, [\n 'error' => 'Missing pipeline',\n ]);\n\n return null;\n }\n\n $stage = $businessProcess\n ->stages()\n ->where('crm_provider_id', (string) $crmData['pipeline_stage_id'])\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->first();\n\n if ($stage === null) {\n $this->logger->info('Stage not found, importing', [\n 'pipeline_id' => $crmData['pipeline_id'],\n 'stage_id' => $crmData['pipeline_stage_id'],\n ]);\n\n $stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $crmData['pipeline_stage_id']);\n }\n\n $recordType = $businessProcess->recordTypes()->first();\n $closeDate = null;\n $isClosed = $crmData['status'] !== self::OPPORTUNITY_STATUS_OPEN;\n\n if ($crmData['close_date']) {\n $closeDate = Carbon::parse($crmData['close_date'])->format('Y-m-d');\n }\n\n $data = [\n 'team_id' => $this->team->getId(),\n 'account_id' => $account->id,\n 'user_id' => $profile->user_id ?? null,\n 'owner_id' => $crmData['assignee_id'],\n 'name' => mb_strimwidth($crmData['name'], 0, 128),\n 'value' => $crmData['monetary_value'],\n 'currency_code' => CurrencyFormatter::formatCode($crmData['monetary_unit']),\n 'close_date' => $closeDate,\n 'is_closed' => $isClosed,\n 'is_won' => $crmData['status'] === 'Won',\n 'stage_id' => $stage->id,\n 'record_type_id' => $recordType->id ?? null,\n 'remotely_created_at' => $crmData['date_created'],\n ];\n\n /** @var Opportunity $opportunity */\n $opportunity = $this->config\n ->opportunities()\n ->updateOrCreate(['crm_provider_id' => (string) $crmData['id']], $data)\n ;\n\n // import external fields into crm_field_data if present\n $dealInsightsFields = $this->getOpportunitySyncableFields();\n $this->importOpportunityCrmFieldData($crmData, $dealInsightsFields, $opportunity->id);\n\n return $opportunity;\n }\n\n /**\n * @param array<string, mixed> $opportunityData\n * @param string[] $dealInsightsFields\n */\n public function importOpportunityCrmFieldData(\n array $opportunityData,\n array $dealInsightsFields,\n int $opportunityId\n ): void {\n foreach ($dealInsightsFields as $fieldName) {\n $field = $this->config->fields()->with('entities')\n ->where('crm_provider_id', $fieldName)\n ->where('object_type', Field::OBJECT_OPPORTUNITY)\n ->first();\n\n if (! $field instanceof Field) {\n $this->logger->warning('Field not found', [\n 'field' => $fieldName,\n 'opportunity_id' => $opportunityId,\n ]);\n\n continue;\n }\n\n $entity = $field->getEntities()->first();\n\n if (! $entity instanceof LayoutEntity) {\n continue;\n }\n\n $fieldValue = $this->extractFieldValue($opportunityData, $fieldName);\n $normalizedValue = $this->normalizeValue($field->getType(), (string) $fieldValue, true);\n\n $field->data()\n ->updateOrCreate([\n 'crm_layout_entity_id' => $entity->id,\n 'crm_field_id' => $field->id,\n 'object_type' => 'opportunity',\n 'object_id' => $opportunityId,\n ], ['value' => $normalizedValue])\n ;\n }\n }\n\n /**\n * @inheritdoc\n */\n public function syncContacts(Carbon $since, ?Carbon $to = null): int\n {\n $syncCount = 0;\n\n try {\n $cpContacts = $this->copperClient->contacts()->paginate([\n 'minimum_interaction_date' => $since->timestamp,\n 'sort_by' => 'date_modified',\n ]);\n\n foreach ($cpContacts as $cpContact) {\n // Only sync if previously imported.\n if ($this->hasContact($cpContact['id'])) {\n $this->importContact($cpContact);\n $syncCount++;\n }\n }\n } catch (\\Exception $exception) {\n // Do nothing for now.\n throw $exception;\n }\n\n return $syncCount;\n }\n\n /**\n * @inheritdoc\n */\n public function syncContact(string $crmId): ?Contact\n {\n $cpContact = $this->copperClient->contacts()->find($crmId);\n\n if (empty($cpContact)) {\n return null;\n }\n\n return $this->importContact($cpContact);\n }\n\n /**\n * @inheritdoc\n */\n private function importContact($crmData): Contact\n {\n $account = null;\n if ($crmData['company_id']) {\n $account = $this->team\n ->crm\n ->accounts()\n ->where('crm_provider_id', (string) $crmData['company_id'])\n ->first();\n\n if ($account === null) {\n $account = $this->syncAccount($crmData['company_id']);\n }\n }\n\n $countryCode = null;\n if (empty($crmData['address']) === false && $crmData['address']['country']) {\n $countryCode = $this->convertCountryNameToCode($crmData['address']['country']);\n }\n\n $mobilePhone = $parsedNumber = null;\n foreach ($crmData['phone_numbers'] as $phoneNumber) {\n if ($phoneNumber['category'] === 'mobile') {\n $mobilePhone = $this->buildContactMobilePhone($countryCode, $phoneNumber['number']);\n } else {\n $parsedNumber = $this->buildContactPhone($countryCode, $phoneNumber['number']);\n }\n }\n\n $email = null;\n if (empty($crmData['emails']) === false) {\n $email = mb_strimwidth($crmData['emails'][0]['email'], 0, 80);\n }\n\n $profile = $this->config->profiles()->where('crm_provider_id', (string) $crmData['assignee_id'])->first();\n\n $data = [\n 'team_id' => $this->team->id,\n 'account_id' => $account->id ?? null,\n 'user_id' => $profile ? $profile->user_id : null,\n 'owner_id' => $crmData['assignee_id'],\n 'name' => $crmData['name'],\n 'email' => $email,\n 'phone' => $parsedNumber['phone'] ?? null,\n 'mobile_phone' => $mobilePhone ?? null,\n 'ext' => $parsedNumber['ext'] ?? null,\n 'title' => $crmData['title'],\n 'country_code' => $countryCode,\n 'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(\n crmConfiguration: $this->config,\n crmProviderId: $crmData['id'],\n modelType: Contact::class,\n fileName: $crmData['id'],\n avatarText: $crmData['name']\n ),\n 'remotely_created_at' => $crmData['date_created'],\n ];\n\n return $this->config->contacts()->updateOrCreate(['crm_provider_id' => (string) $crmData['id']], $data);\n }\n\n private function buildContactPhone(?string $countryCode, ?string $number): ?array\n {\n if ($number) {\n // Trim to our width and attempt to parse it.\n $number = mb_strimwidth($number, 0, 25);\n $parsedNumber = parsePhoneNumber($countryCode, $number);\n } else {\n $parsedNumber = [];\n }\n\n return $parsedNumber;\n }\n\n private function buildContactMobilePhone(?string $countryCode, ?string $number): ?string\n {\n return $number ? mb_strimwidth(phone_e164($countryCode, $number), 0, 25) : null;\n }\n\n /**\n * @inheritdoc\n */\n public function syncOrganization(): void\n {\n $organization = $this->copperClient->organisation()->all();\n\n if ($this->config->crm_provider_id !== $organization['id']) {\n $this->config->update(['crm_provider_id' => $organization['id']]);\n }\n }\n\n private function syncCustomFields(): void\n {\n foreach ($this->getFieldTypes() as $fieldType) {\n $currentFields = $this->getFields($fieldType);\n\n foreach ($currentFields as $field) {\n /** @var Field $field */\n $field = $this->config->fields()->updateOrCreate([\n 'crm_provider_id' => $field['name'],\n 'object_type' => $fieldType,\n ], [\n 'label' => mb_strimwidth($field['label'], 0, Field::LABEL_MAX_LENGTH),\n 'type' => $field['type'],\n ]);\n\n if ($field->getType() === Field::TYPE_PICKLIST) {\n $this->importPicklistValues($field);\n }\n }\n }\n\n $this->config->save();\n }\n\n protected function getFieldTypes(): array\n {\n return [\n parent::OBJECT_TASK,\n parent::OBJECT_LEAD,\n parent::OBJECT_ACCOUNT,\n parent::OBJECT_CONTACT,\n parent::OBJECT_OPPORTUNITY,\n ];\n }\n\n /**\n * @inheritdoc\n */\n public function syncProfiles(?User $userToSearch = null): ?Profile\n {\n $profiles = $this->copperClient->users()->all();\n\n foreach ($profiles as $profile) {\n $user = $this->userRepository->findByTeamAndEmailAddress($this->team, $profile['email'], onlyActive: true);\n\n // Only import profiles for users we have locally.\n if ($user) {\n $profile = $user->crmProfile()->updateOrCreate([\n 'crm_configuration_id' => $this->config->id,\n 'crm_provider_id' => $profile['id'],\n ]);\n\n if ($userToSearch && $userToSearch->id === $user->id) {\n return $profile;\n }\n }\n }\n\n return null;\n }\n\n /**\n * @inheritdoc\n */\n public function syncProfileFields(): void\n {\n $fieldTypes = [\n 'task',\n 'lead',\n 'account',\n 'contact',\n 'opportunity',\n ];\n\n foreach ($fieldTypes as $fieldType) {\n try {\n $currentFields = $this->getFields(ucfirst($fieldType));\n\n $newFields = array_column($currentFields, 'name');\n $changedFields = array_merge(\n array_diff($this->profile->getFieldsAsArray($fieldType), $newFields),\n array_diff($newFields, $this->profile->getFieldsAsArray($fieldType))\n );\n\n if (\\count($changedFields) > 0) {\n $fields = implode(',', $newFields);\n $this->logger->debug(\n ucfirst($fieldType)\n . ' fields changed from '\n . $this->profile->{$fieldType . '_fields'}\n . ' to: '\n . $fields\n );\n\n $this->profile->{$fieldType . '_fields'} = $fields;\n }\n } catch (\\Exception $exception) {\n $this->logger->error('No access to ' . $fieldType . ' object, skipping...');\n\n continue;\n }\n }\n\n $this->profile->save();\n }\n\n /**\n * @inheritdoc\n */\n public function find(string $name, array $scopes): array\n {\n $count = $this->limit ?? 20;\n $offset = $this->offset ?? 0;\n\n $scopeValues = array_map(fn ($scope) => is_string($scope) ? $scope : $scope->value, $scopes);\n $cacheKey = sprintf('copper-search-%d-%s-%s-%d-%d', $this->team->id, $name, implode('-', $scopeValues), $count, $offset);\n\n return Cache::remember($cacheKey, 300, function () use ($name, $scopes, $count, $offset) {\n // Try local search first\n $localResults = $this->searchLocally($name, $scopes, $count, $offset);\n\n // If no local results, fall back to remote API search\n if (empty($localResults)) {\n return $this->searchRemotely($name, $scopes, $count, $offset);\n }\n\n return $localResults;\n });\n }\n\n /**\n * @inheritdoc\n */\n public function findOpportunities(?string $crmAccountId, ?string $crmContactId, ?int $userId = null): array\n {\n $data = [];\n $ownerData = [];\n $ownerId = null;\n $contact = null;\n $account = null;\n\n if ($crmAccountId) {\n $account = $this->config->accounts()->where('crm_provider_id', $crmAccountId)->first();\n\n if ($account === null) {\n $account = $this->syncAccount($crmAccountId);\n }\n }\n\n if ($crmContactId) {\n $contact = $this->config->contacts()->where('crm_provider_id', $crmContactId)->first();\n\n if ($contact === null) {\n $contact = $this->syncContact($crmContactId);\n }\n }\n\n if ($contact || $account) {\n if ($contact && $account === null) {\n $account = $contact->account;\n }\n\n if ($account === null) {\n return [];\n }\n\n $params = [\n 'company_ids' => [$account->crm_provider_id],\n 'status_ids' => [0], // The integer values are 0, 1, 2, 3, for \"Open\", \"Won\", \"Lost\", and \"Abandoned\".\n 'sort_by' => 'date_modified',\n 'sort_direction' => 'desc',\n ];\n\n switch ($this->config->opportunity_assignment_rule) {\n case Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_UPDATED:\n $params['sort_by'] = 'date_modified';\n $params['sort_direction'] = 'desc';\n\n break;\n\n case Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_CREATED:\n $params['sort_by'] = 'date_created';\n $params['sort_direction'] = 'desc';\n\n break;\n\n case Configuration::OPP_ASSIGNMENT_ALL_OPEN_OLDEST_CREATED:\n $params['sort_by'] = 'date_created';\n $params['sort_direction'] = 'asc';\n\n break;\n\n case Configuration::OPP_ASSIGNMENT_ALL_RECENTLY_UPDATED:\n $params['sort_by'] = 'date_modified';\n $params['sort_direction'] = 'desc';\n $params['status_ids'] = [0, 1, 2, 3];\n }\n\n $cpOpportunities = $this->copperClient->opportunities()->search($params);\n\n if ($userId) {\n $profileRepository = app(ProfileRepository::class);\n $profile = $profileRepository->findProfileByUserId($this->config, $userId);\n\n $ownerId = $profile instanceof Profile ? $profile->getCrmProviderId() : null;\n }\n\n foreach ($cpOpportunities as $cpOpportunity) {\n $businessProcess = $this->config\n ->businessProcesses()\n ->where('crm_provider_id', (string) $cpOpportunity['pipeline_id'])\n ->first();\n\n if ($businessProcess === null) {\n // Import it.\n $businessProcess = $this->importStages(\n [Stage::TYPE_OPPORTUNITY],\n $cpOpportunity['pipeline_stage_id']\n );\n }\n\n $stage = $businessProcess\n ->stages()\n ->where('crm_provider_id', (string) $cpOpportunity['pipeline_stage_id'])\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->first();\n\n if ($stage === null) {\n // Import it.\n $stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $cpOpportunity['pipeline_stage_id']);\n }\n\n $recordType = $businessProcess->recordTypes()->first();\n\n $record = [\n 'crmId' => $cpOpportunity['id'],\n 'name' => $cpOpportunity['name'],\n 'value' => currency_format($cpOpportunity['monetary_value'], $cpOpportunity['monetary_unit']),\n 'won' => $cpOpportunity['status'] === 'Won',\n 'stage' => [\n 'id' => $stage->id_string,\n 'name' => $stage->name,\n ],\n 'recordType' => [\n 'id' => $recordType->id_string,\n 'name' => $recordType->name,\n ],\n ];\n\n if ($ownerId && isset($cpOpportunity['assignee_id']) && $cpOpportunity['assignee_id'] === $ownerId) {\n $ownerData[] = $record;\n }\n\n $data[] = $record;\n }\n }\n\n if (! empty($ownerData)) {\n return $ownerData;\n }\n\n return $data;\n }\n\n /**\n * @inheritdoc\n */\n public function getTasks(string $objectType, string $objectId, ?string $opportunityId): array\n {\n $data = $cpTasks = [];\n\n switch ($objectType) {\n case 'lead':\n $cpTasks = $this->requestWithExceptionHandling(\n fn () => $this->copperClient->leads()->relatedTasks($objectId)\n );\n\n break;\n\n case 'account':\n $cpTasks = $this->copperClient->accounts()->relatedTasks($objectId);\n\n break;\n case 'contact':\n if ($opportunityId) {\n $cpTasks = $this->copperClient->opportunities()->relatedTasks($opportunityId);\n } elseif ($objectType === 'contact') {\n $cpTasks = $this->copperClient->contacts()->relatedTasks($objectId);\n }\n\n break;\n }\n\n foreach ($cpTasks as $cpTask) {\n // We only get the ID itself returned, so pull out the full object.\n try {\n $cpTask = $this->copperClient->tasks()->find($cpTask['id']);\n } catch (Exception $exception) {\n $this->logger->error('[Copper] Error fetching task', [\n 'resource_id' => $cpTask['id'],\n 'error' => $exception->getMessage(),\n ]);\n\n continue;\n }\n\n // Only allow open tasks.\n if ($cpTask['status'] === 'Open') {\n $data[] = [\n 'crmId' => $cpTask['id'],\n 'subject' => $cpTask['name'],\n 'due' => $cpTask['due_date']\n ? Carbon::createFromTimestamp($cpTask['due_date'])->format('Y-m-d\\TH:i:sO')\n : null,\n 'type' => null,\n ];\n }\n }\n\n return $data;\n }\n\n /**\n * Try to find email address in CRM service\n *\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n *}\n */\n public function matchExactlyByEmail(string $email, ?int $userId = null): ?array\n {\n $cpObjectType = 'contact';\n\n $cpObjects = $this->copperClient->contacts()->search([\n 'emails' => $email,\n 'sort_by' => 'date_modified',\n 'sort_direction' => 'desc',\n ]);\n\n if (empty($cpObjects)) {\n $cpObjectType = 'lead';\n $cpObjects = $this->requestWithExceptionHandling(\n fn () => $this->copperClient->leads()->search([\n 'emails' => $email,\n 'sort_by' => 'date_modified',\n 'sort_direction' => 'desc',\n ])\n );\n }\n\n if (empty($cpObjects)) {\n return null;\n }\n\n // Search returns an array of results, but we only want the first\n $cpObject = reset($cpObjects);\n $data = $this->convertCrmData($cpObject, $cpObjectType, $userId);\n\n return ! empty(array_filter($data)) ? $data : null;\n }\n\n /**\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n public function matchByPhone(string $phone, ?string $rawPhoneNumber = null, ?int $userId = null): ?array\n {\n // Check if the user is internal.\n $teamMember = $this->team->users()->where('phone', $phone)->exists();\n\n // Skip the attendee if internal.\n if ($teamMember === false) {\n $cpObjectType = 'contact';\n $cpObject = $this->copperClient->contacts()->search([\n 'phone_number' => $phone,\n 'sort_by' => 'date_modified',\n 'sort_direction' => 'desc',\n ]);\n\n if (\\count($cpObject) === 0) {\n $cpObjectType = 'lead';\n $cpObject = $this->requestWithExceptionHandling(\n fn () => $this->copperClient->leads()->search([\n 'phone_number' => $phone,\n 'sort_by' => 'date_modified',\n 'sort_direction' => 'desc',\n ])\n );\n }\n\n if (empty($cpObject)) {\n return null;\n }\n\n $cpObject = reset($cpObject);\n $data = $this->convertCrmData($cpObject, $cpObjectType, $userId);\n\n return ! empty(array_filter($data)) ? $data : null;\n }\n\n return null;\n }\n\n /**\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n public function matchByName(string $name, ?int $userId = null): ?array\n {\n // Don't waste time searching for single character strings.\n if (\\strlen($name) <= 1) {\n return null;\n }\n\n $result = \\Cache::remember($this->team->id . $name, 60, function () use ($name, $userId) {\n $cpObjectType = 'contact';\n $cpObject = $this->copperClient->contacts()->search([\n 'name' => $name,\n 'sort_by' => 'date_modified',\n 'sort_direction' => 'desc',\n ]);\n\n if (\\count($cpObject) === 0) {\n $cpObjectType = 'lead';\n $cpObject = $this->requestWithExceptionHandling(\n fn () => $this->copperClient->leads()->search([\n 'name' => $name,\n 'sort_by' => 'date_modified',\n 'sort_direction' => 'desc',\n ])\n );\n }\n\n if (! empty($cpObject)) {\n $cpObject = reset($cpObject);\n }\n\n $data = $this->convertCrmData($cpObject, $cpObjectType, $userId);\n\n return ! empty(array_filter($data)) ? $data : false;\n });\n\n return is_array($result) ? $result : null;\n }\n\n private function convertCrmData(array $object, string $objectType, ?int $userId = null): array\n {\n $lead = null;\n $contact = null;\n $opportunity = null;\n $account = null;\n $stage = null;\n $countryCode = null;\n\n if ($objectType === 'lead') {\n $lead = $this->importLead($object);\n\n // Lead might not be imported if the Stage is null for example.\n if ($lead) {\n $countryCode = $lead->country_code;\n $stage = $lead->stage;\n }\n } else {\n $contact = $this->importContact($object);\n $account = $contact->account;\n\n if ($contact->country_code) {\n $countryCode = $contact->country_code;\n } elseif ($account) {\n $countryCode = $account->country_code;\n }\n\n try {\n $cpOpportunities = $this->findOpportunities(\n $account?->crm_provider_id,\n $contact->crm_provider_id,\n $userId,\n );\n\n if (! empty($cpOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($cpOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n } catch (\\Exception $e) {\n // Nothing to see here.\n }\n }\n\n return [\n $lead,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n }\n\n public function saveActivity(Activity $activity): Activity\n {\n switch ($activity->type) {\n case Activity::TYPE_CONFERENCE:\n case Activity::TYPE_SOFTPHONE:\n case Activity::TYPE_SOFTPHONE_INBOUND:\n $activity = $this->buildCallPayload($activity);\n\n break;\n case Activity::TYPE_SMS_INBOUND:\n case Activity::TYPE_SMS_OUTBOUND:\n $activity = $this->buildTextMessagePayload($activity);\n\n break;\n }\n\n return $activity;\n }\n\n private function convertActivityAssociations(Activity $activity): array\n {\n if ($activity->lead) {\n $type = 'lead';\n $id = $activity->lead->crm_provider_id;\n } elseif ($activity->contact) {\n $type = 'person';\n $id = $activity->contact->crm_provider_id;\n } elseif ($activity->opportunity) {\n $type = 'opportunity';\n $id = $activity->opportunity->crm_provider_id;\n } else {\n $type = 'company';\n $id = $activity->account->crm_provider_id;\n }\n\n return [\n 'type' => $type,\n 'id' => $id,\n ];\n }\n\n private function convertActivityType(PlaybookCategory $activityType): array\n {\n return [\n 'category' => 'user',\n 'id' => $this->getCrmType($activityType)->value,\n ];\n }\n\n /**\n * @throws CrmException\n */\n private function buildCallPayload(Activity $activity): Activity\n {\n try {\n if ($activity->crm_provider_id) {\n // The activity should be logged under the existing Task (not Activity).\n $newActivityType = $this->getCrmType($activity->category);\n if ($newActivityType === null) {\n $message = sprintf('[Copper] invalid CRM Custom Activity Type: %s', $activity->category);\n\n throw new CrmException($message, 400);\n }\n $this->copperClient->tasks()->update($activity->crm_provider_id, [\n 'status' => 'Completed',\n 'custom_activity_type_id' => $newActivityType->value,\n 'details' => $this->generateActivityDescription($activity),\n ]);\n } else {\n if ($this->getCrmType($activity->category) === null) {\n $message = sprintf('[Copper] Invalid CRM Activity Type: %s', $activity->category->getName());\n\n throw new CrmException($message, 400);\n }\n\n if ($this->getCrmType($activity->category)->value === null) {\n $message = sprintf('[Copper] Invalid CRM Activity Type: %s value', $activity->category->getName());\n\n throw new CrmException($message, 400);\n }\n // Just create an activity.\n $cpActivity = $this->copperClient->activities()->create([\n 'user_id' => $this->profile->crm_provider_id,\n 'activity_date' => $activity->scheduled_start_time->timestamp,\n 'parent' => $this->convertActivityAssociations($activity),\n 'type' => $this->convertActivityType($activity->category),\n 'details' => $this->generateActivityDescription($activity),\n ]);\n\n $activity->crm_provider_id = $cpActivity['id'];\n $activity->save();\n }\n } catch (ClientException $exception) {\n //Log all errors to Sentry\n \\Sentry::captureException($exception);\n\n $response = $exception->getResponse();\n\n $responseBody = $response->getBody();\n $message = $responseBody;\n $errorCode = $response->getStatusCode();\n\n $jsonResponse = json_decode($responseBody, true);\n if (isset($jsonResponse[0]['message'])) {\n $message = $jsonResponse[0]['message'];\n }\n\n throw new CrmException($message, $errorCode);\n }\n\n return $activity;\n }\n\n private function buildTextMessagePayload(Activity $activity): Activity\n {\n if ($this->getCrmType($activity->category) === null) {\n $message = sprintf('[Copper] Invalid CRM Activity Type: %s', $activity->category->getName());\n\n throw new CrmException($message, 400);\n }\n\n if ($this->getCrmType($activity->category)->value === null) {\n $message = sprintf('[Copper] Invalid CRM Activity Type: %s value', $activity->category->getName());\n\n throw new CrmException($message, 400);\n }\n\n $cpActivity = $this->copperClient->activities()->create([\n 'user_id' => $this->profile->crm_provider_id,\n 'activity_date' => $activity->created_at->timestamp,\n 'parent' => $this->convertActivityAssociations($activity),\n 'type' => $this->convertActivityType($activity->category),\n 'details' => $this->generateActivityDescription($activity),\n ]);\n\n $activity->crm_provider_id = $cpActivity['id'];\n $activity->save();\n\n return $activity;\n }\n\n private function generateActivityDescription(Activity $activity): string\n {\n $description = '';\n\n switch ($activity->type) {\n case Activity::TYPE_SOFTPHONE:\n case Activity::TYPE_SOFTPHONE_INBOUND:\n case Activity::TYPE_CONFERENCE:\n\n $description = 'Jiminny Call Summary' . PHP_EOL . PHP_EOL;\n $description .= 'Call Duration: ' . $activity->duration_for_humans . PHP_EOL . PHP_EOL;\n\n // Tell them which number was dialed.\n if ($activity->to) {\n $description .= 'Dialed: ' . $activity->to->phone_number . PHP_EOL . PHP_EOL;\n }\n\n if ($activity->hasReasonCodeBotKicked()) {\n $description .= 'Notetaker removed from this meeting' . PHP_EOL . PHP_EOL;\n // When we fix the state to be Activity::RECORDING_RECORDED as it should be this can change.\n } elseif ($activity->hasReasonCodeNotCompliant()) {\n $description .= 'Notetaker did not join due to recording consent not being provided by attendees' . PHP_EOL . PHP_EOL;\n } elseif ($activity->canReviewActivity()) {\n $playbackUrl = $activity->user->team->partner->getPlaybackUrl($activity);\n $description .= 'Review Activity:' . PHP_EOL . $playbackUrl . PHP_EOL . PHP_EOL;\n }\n\n if ($activity->getCrmType() === Activity::TYPE_CONFERENCE) {\n $description .= 'Attendees:'\n . PHP_EOL\n . (new FilterJoinedParticipants())->toString($activity);\n }\n\n if (\\count($activity->notes) > 0) {\n $description .= PHP_EOL . PHP_EOL . 'Notes: ' . PHP_EOL;\n\n foreach ($activity->notes as $note) {\n $nTime = (int) ceil($note->time);\n $time = ($nTime > 3600) ? gmdate('H:i:s', $nTime) : gmdate('i:s', $nTime);\n $description .= $time . ' ' . $note->note . PHP_EOL;\n }\n }\n\n // Get all private messages.\n $messages = $activity->messages()\n ->where('is_private', 1)\n ->orderBy('created_at', 'asc');\n\n if ($messages->count() > 0) {\n $description .= PHP_EOL . PHP_EOL . 'Coaching Chat: ' . PHP_EOL;\n\n foreach ($messages->get() as $message) {\n $description .= $message->participant->name . ': ' . $message->message . PHP_EOL;\n }\n }\n\n // Get all public messages.\n $messages = $activity->messages()\n ->where('is_private', 0)\n ->orderBy('created_at', 'asc');\n\n if ($messages->count() > 0) {\n $description .= PHP_EOL . PHP_EOL . 'Customer Chat: ' . PHP_EOL;\n\n foreach ($messages->get() as $message) {\n $description .= $message->participant->name . ': ' . $message->message . PHP_EOL;\n }\n }\n\n if ($activity->summary) {\n $description .= PHP_EOL . PHP_EOL . 'Summary: ' . PHP_EOL . $activity->summary;\n }\n\n break;\n\n case Activity::TYPE_SMS_INBOUND:\n case Activity::TYPE_SMS_OUTBOUND:\n $description = $activity->description;\n\n break;\n }\n\n return $description;\n }\n\n public function saveFollowupActivity(Activity $activity, array $fields): ?string\n {\n // This is the user provided activity subject field.\n if (empty($fields['type'])) {\n return null;\n }\n\n $reminder = null;\n if (empty($fields['reminder_date']) === false) {\n $formatReminder = Carbon::createFromFormat('Y-m-d\\TH:i:s\\Z', $fields['reminder_date']);\n $reminder = $formatReminder->getTimestamp();\n }\n\n $due = null;\n if (empty($fields['due_date']) === false) {\n $formatDue = Carbon::createFromFormat('Y-m-d\\TH:i:s\\Z', $fields['due_date']);\n $due = $formatDue->getTimestamp();\n }\n\n $data = [\n 'custom_activity_type_id' => $fields['type'],\n 'status' => 'Open',\n 'assignee_id' => $this->profile->crm_provider_id,\n 'due_date' => $due,\n 'details' => $fields['details'] ?? '',\n 'reminder_date' => $reminder,\n 'name' => $fields['name'] ?? 'Follow-up',\n 'related_resource' => $this->convertActivityAssociations($activity),\n 'custom_fields' => $this->fetchCustomFieldData($activity, Field::OBJECT_TASK),\n ];\n\n // We don't actually create a corresponding activity object on our side yet.\n return $this->copperClient->tasks()->create($data)['id'];\n }\n\n /**\n * Store transcripts as note.\n *\n * @throws \\Exception\n */\n public function createTranscriptNotes(Activity $activity): void\n {\n // Generate activity transcription.\n $transcriptionData = $this->generateTranscription($activity);\n\n // Truncate Notes with max notes length because transcription text could be very long.\n $transcripts = mb_strimwidth($transcriptionData, 0, static::NOTE_BODY_MAX_LENGTH);\n\n $data = [\n 'user_id' => $this->profile->crm_provider_id,\n 'type' => [\n 'category' => 'user',\n 'id' => 0, // This creates a note.\n ],\n 'activity_date' => time(),\n 'details' => $transcripts,\n 'parent' => $this->convertActivityAssociations($activity),\n ];\n\n $cpActivity = $this->copperClient->activities()->create($data);\n\n // Store CRM Activity ID in transcription.\n $transcription = $activity->getTranscription();\n $transcription->crm_activity_id = $cpActivity['id'];\n $transcription->save();\n }\n\n /**\n * @inheritdoc\n */\n public function updateStage($crmObject, Stage $stage): void\n {\n if ($crmObject instanceof Lead) {\n $this->requestWithExceptionHandling(\n fn () => $this->copperClient->leads()->update($crmObject->crm_provider_id, [\n 'status_id' => $stage->crm_provider_id,\n ])\n );\n } else {\n $this->copperClient->opportunities()->update($crmObject->crm_provider_id, [\n 'pipeline_stage_id' => $stage->crm_provider_id,\n ]);\n }\n }\n\n public function prepareValueForUpdate(array $params): array\n {\n return [$params['fieldName'] => $params['fieldValue']];\n }\n\n /*\n * @inheritdoc\n */\n public function updateRecord(string $objectType, string $objectId, array $data, array $headers = []): void\n {\n $preparedData = $data;\n\n if ($objectType === Field::OBJECT_OPPORTUNITY) {\n $preparedData = $this->prepareOpportunityFieldsForUpdate($data);\n }\n\n try {\n $this->copperClient\n ->createResourceFromObjectType($objectType)\n ->update($objectId, $preparedData)\n ;\n } catch (Exception $e) {\n $this->logger->error(self::RESOURCE_UPDATE_FAILED_ERROR_MESSAGE, [\n 'resource_id' => $objectId,\n 'data' => $preparedData,\n 'error' => $e->getMessage(),\n ]);\n\n throw new CrmException(self::RESOURCE_UPDATE_FAILED_ERROR_MESSAGE, $e->getCode(), $e);\n }\n }\n\n /**\n * @param array<string,string> $fieldData\n *\n * @throws CrmException\n *\n * @return array<string,array<int,array<string,bool|float|int|string|null>>|bool|float|int|string|null>\n */\n private function prepareOpportunityFieldsForUpdate(array $fieldData): array\n {\n $preparedData = [];\n $customFields = [];\n\n foreach ($fieldData as $fieldName => $fieldValue) {\n $field = $this->config\n ->fields()\n ->where('crm_provider_id', $fieldName)\n ->first();\n\n if (! $field instanceof Field) {\n throw new CrmException('Field not found');\n }\n\n $convertedValue = $this->fieldValueConverter->convertToCrm($field->getType(), $fieldValue);\n\n if ($this->isStandardField($fieldName)) {\n $preparedData[$fieldName] = $convertedValue;\n\n continue;\n }\n\n $customFields[] = [\n 'custom_field_definition_id' => $fieldName,\n 'value' => $convertedValue,\n ];\n }\n\n if (! empty($customFields)) {\n $preparedData['custom_fields'] = $customFields;\n }\n\n return $preparedData;\n }\n\n /*\n * @inheritdoc\n */\n public function getRecord(string $objectType, string $objectId, array $fields = []): array\n {\n return $this->copperClient->createResourceFromObjectType($objectType)->find($objectId);\n }\n\n private function fetchCustomFieldData(Activity $activity, string $objectType): array\n {\n $payload = [];\n\n $fieldData = FieldData::where([\n 'object_id' => $activity->id,\n ])->whereHas('field', function ($query) use ($objectType) {\n $query->where('object_type', $objectType);\n })->get();\n\n foreach ($fieldData as $data) {\n // Add the field and value to the payload.\n $payload += [\n 'custom_field_definition_id' => $data->field->crm_provider_id,\n 'value' => $data->value,\n ];\n }\n\n return $payload;\n }\n\n public function generateProviderUrl(string $providerId, string $objectType): ?string\n {\n $baseUrl = 'https://app.prosperworks.com/companies/' . $this->config->crm_provider_id . '/app#/';\n switch ($objectType) {\n case 'lead':\n $url = $baseUrl . 'leads/' . $providerId;\n\n break;\n\n case 'account':\n $url = $baseUrl . 'organization/' . $providerId;\n\n break;\n\n case 'contact':\n $url = $baseUrl . 'contact/' . $providerId;\n\n break;\n\n case 'opportunity':\n $url = $baseUrl . 'deal/' . $providerId;\n\n break;\n\n default:\n case 'task':\n case 'activity':\n // Sadly we can't deeplink to these in Copper UI.\n $url = null;\n }\n\n return $url;\n }\n\n /**\n * Generate transcription for the activity.\n */\n private function generateTranscription(Activity $activity): string\n {\n if (! ($this->config->store_transcript)) {\n // If sending transcription to activity toggle is disabled\n return '';\n }\n\n return $this->transcriptionService\n ->findTranscriptionByActivity($activity)\n ->map(static function (array $transcriptionSegment): string {\n return $transcriptionSegment['formattedStartsAt'] . ' | ' . $transcriptionSegment['transcript'];\n })\n ->implode(PHP_EOL);\n }\n\n /**\n * @param array<string, mixed> $opportunityData\n *\n * @return int|string|bool|float|null\n */\n private function extractFieldValue(array $opportunityData, string $fieldName)\n {\n $fieldValue = $this->isStandardField($fieldName)\n ? $opportunityData[$fieldName]\n : $this->resolveCustomFieldFromOpportunityData($opportunityData, $fieldName);\n\n if (is_array($fieldValue)) {\n $fieldValue = implode(', ', $fieldValue);\n }\n\n return $fieldValue;\n }\n\n private function isStandardField(string $fieldName): bool\n {\n return array_key_exists($fieldName, self::STANDARD_OPPORTUNITY_FIELDS);\n }\n\n /**\n * @param array<string, mixed> $opportunityData\n *\n * @return string|int|float|bool|null|array<string, mixed>\n */\n private function resolveCustomFieldFromOpportunityData(array $opportunityData, string $fieldName)\n {\n $fieldData = array_filter(\n $opportunityData['custom_fields'],\n static fn ($field) => (string) $field['custom_field_definition_id'] === $fieldName\n );\n\n return array_pop($fieldData)['value'] ?? '';\n }\n\n private function isSupportedObjectType(string $objectType): bool\n {\n return $objectType !== self::UNSUPPORTED_OBJECT_TYPE;\n }\n\n private function syncStandardFields(): void\n {\n foreach (self::STANDARD_OPPORTUNITY_FIELDS as $fieldId => $fieldData) {\n $this->config->fields()->updateOrCreate([\n 'crm_provider_id' => mb_strimwidth($fieldId, 0, 128),\n 'object_type' => Field::OBJECT_OPPORTUNITY,\n ], [\n 'label' => mb_strimwidth($fieldData['label'], 0, Field::LABEL_MAX_LENGTH),\n 'type' => $fieldData['type'],\n ]);\n }\n\n $this->importStages([Stage::TYPE_OPPORTUNITY]);\n }\n\n private function requestWithExceptionHandling(\\Closure $apiRequest): iterable\n {\n try {\n return $apiRequest();\n } catch (\\Exception $exception) {\n if ($exception->getCode() === 403) {\n // Object might not be supported\n return [];\n }\n\n \\Sentry::captureException($exception);\n\n return [];\n }\n }\n\n public function verifyTaskExists(Activity $activity): bool\n {\n $crmProviderId = $activity->getCrmProviderId();\n $cacheKey = \"crm_task_exists:{$this->config->getId()}:$crmProviderId\";\n\n return Cache::remember($cacheKey, self::TASK_VERIFICATION_CACHE_TTL, function () use ($crmProviderId) {\n try {\n if ($this->copperClient === null) {\n return false;\n }\n\n $task = $this->copperClient->tasks()->find($crmProviderId);\n\n return ! empty($task);\n } catch (HttpNotFoundException) {\n // Task not found in CRM - this is expected and permanent\n $this->logger->info('[Copper] Task not found during verification', [\n 'task_id' => $crmProviderId,\n 'config_id' => $this->config->getId(),\n ]);\n\n return false;\n } catch (RequestFailedException $e) {\n // Handle 404 responses from Copper API\n if ($e->getCode() === 404) {\n $this->logger->info('[Copper] Task not found during verification (404)', [\n 'task_id' => $crmProviderId,\n 'config_id' => $this->config->getId(),\n ]);\n\n return false;\n }\n\n // Re-throw other Copper exceptions for retry\n throw $e;\n }\n // Let other exceptions (network errors, rate limits, etc.) bubble up for retry\n });\n }\n}","depth":4,"on_screen":true,"value":"<?php\n\nnamespace Jiminny\\Services\\Crm\\Copper;\n\nuse Carbon\\Carbon;\nuse Exception;\nuse GuzzleHttp\\Exception\\ClientException;\nuse Illuminate\\Support\\Facades\\Cache;\nuse Jiminny\\Contracts\\Services\\Crm\\LayoutManagementInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\MatchCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\Provider\\CopperInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityLookupInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityManipulationInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SettingsInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmMetadataInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\VerifyTaskExistsInterface;\nuse Jiminny\\Exceptions\\CrmException;\nuse Jiminny\\Exceptions\\HttpNotFoundException;\nuse Jiminny\\Models\\Account;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Contact;\nuse Jiminny\\Models\\Crm\\BusinessProcess;\nuse Jiminny\\Models\\Crm\\Configuration;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Models\\Crm\\FieldData;\nuse Jiminny\\Models\\Crm\\FieldValue;\nuse Jiminny\\Models\\Crm\\LayoutEntity;\nuse Jiminny\\Models\\Crm\\Profile;\nuse Jiminny\\Models\\Lead;\nuse Jiminny\\Models\\Opportunity;\nuse Jiminny\\Models\\Playbook;\nuse Jiminny\\Models\\PlaybookCategory;\nuse Jiminny\\Models\\SocialAccount;\nuse Jiminny\\Models\\Stage;\nuse Jiminny\\Models\\User;\nuse Jiminny\\Repositories\\Crm\\CrmEntityRepository;\nuse Jiminny\\Repositories\\Crm\\FieldRepository;\nuse Jiminny\\Repositories\\Crm\\ProfileRepository;\nuse Jiminny\\Repositories\\UserRepository;\nuse Jiminny\\Services\\Avatar\\ProspectPhotoPathService;\nuse Jiminny\\Services\\Crm\\BaseService;\nuse Jiminny\\Services\\Crm\\Copper\\Api\\Client;\nuse Jiminny\\Services\\Crm\\Copper\\Api\\ClientFactory;\nuse Jiminny\\Services\\Crm\\Copper\\Exception\\RequestFailedException;\nuse Jiminny\\Services\\Crm\\Helpers\\FilterJoinedParticipants;\nuse Jiminny\\Services\\Crm\\OpportunitySyncStrategyResolver;\nuse Jiminny\\Services\\Crm\\Copper\\Traits\\SearchesCrmEntities;\nuse Jiminny\\Utils\\CurrencyFormatter;\nuse Jiminny\\Utils\\StringUtil;\n\nclass Service extends BaseService implements\n CopperInterface,\n SyncCrmEntitiesInterface,\n SyncCrmMetadataInterface,\n RemoteEntityManipulationInterface,\n LayoutManagementInterface,\n SettingsInterface,\n MatchCrmEntitiesInterface,\n RemoteEntityLookupInterface,\n VerifyTaskExistsInterface\n{\n use SearchesCrmEntities;\n private const int NOTE_BODY_MAX_LENGTH = 3000000;\n\n private const int TASK_VERIFICATION_CACHE_TTL = 86400; // 1 day\n\n private const array STANDARD_OPPORTUNITY_FIELDS = [\n 'monetary_value' => [\n 'label' => 'Value',\n 'available_on' => Field::OBJECT_OPPORTUNITY,\n 'type' => Field::TYPE_CURRENCY,\n ],\n 'pipeline_stage_id' => [\n 'label' => 'Stage',\n 'type' => Field::TYPE_PICKLIST,\n 'available_on' => Field::OBJECT_OPPORTUNITY,\n ],\n 'close_date' => [\n 'label' => 'Close date',\n 'type' => Field::TYPE_DATE,\n 'available_on' => Field::OBJECT_OPPORTUNITY,\n ],\n 'win_probability' => [\n 'label' => 'Win probability',\n 'type' => Field::TYPE_NUMBER,\n 'available_on' => Field::OBJECT_OPPORTUNITY,\n ],\n ];\n private const string UNSUPPORTED_OBJECT_TYPE = 'unsupported';\n private const string OPPORTUNITY_STATUS_OPEN = 'Open';\n private const string OPPORTUNITY_IMPORT_FAILED_ERROR_MESSAGE = 'Opportunity import failed';\n private const string RESOURCE_UPDATE_FAILED_ERROR_MESSAGE = 'Failed to update resource';\n\n private FieldValueConverter $fieldValueConverter;\n private ClientFactory $clientFactory;\n private ?Client $copperClient = null;\n\n public function __construct(\n ClientFactory $clientFactory,\n FieldValueConverter $fieldValueConverter,\n private readonly UserRepository $userRepository,\n private readonly ProspectPhotoPathService $prospectPhotoPathService,\n private readonly CrmEntityRepository $crmEntityRepository\n ) {\n parent::__construct();\n\n $this->clientFactory = $clientFactory;\n $this->fieldValueConverter = $fieldValueConverter;\n }\n\n public function getDisplayName(): string\n {\n return 'Copper';\n }\n\n /**\n * Get the CRM entity repository (used by SearchesCrmEntities trait)\n */\n protected function getCrmEntityRepository(): CrmEntityRepository\n {\n return $this->crmEntityRepository;\n }\n\n public function getMinimumApiVersion(): string\n {\n return Client::MIN_API_VERSION;\n }\n\n protected function getOAuthAccount(User $user): ?SocialAccount\n {\n // In this case, the Account Owner is always the connection for any API operations.\n $owner = $user->team->owner;\n\n return $owner->getSocialAccount(SocialAccount::PROVIDER_COPPER);\n }\n\n public function setUser(User $user): void\n {\n $this->configureSentryScope($user);\n\n $this->team = $user->getTeam();\n $this->teamService->setTeam($user->getTeam());\n $this->setConfiguration($user->getTeam()->getCrmConfiguration());\n\n // Reset state.\n $this->profile = $user->getProfile();\n\n if (! $this->profile instanceof Profile) {\n // Try to fetch their profile, maybe it was not yet imported?\n $this->profile = $this->getProfile($user);\n }\n }\n\n public function setConfiguration(Configuration $configuration): void\n {\n $this->config = $configuration;\n $this->copperClient = $this->clientFactory->create($configuration);\n\n if ($this->team === null) {\n $this->team = $configuration->getTeam();\n }\n }\n\n public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string\n {\n return $this->fieldValueConverter->convertFromCrm($fieldType, $fieldValue);\n }\n\n private function mapObjectType(string $objectType): string\n {\n $objectType = strtolower($objectType);\n\n switch ($objectType) {\n case Field::OBJECT_TASK:\n case Field::OBJECT_OPPORTUNITY:\n case Field::OBJECT_LEAD:\n $mappedType = $objectType;\n\n break;\n case Field::OBJECT_ACCOUNT:\n $mappedType = 'company';\n\n break;\n case Field::OBJECT_CONTACT:\n $mappedType = 'person';\n\n break;\n default:\n $mappedType = self::UNSUPPORTED_OBJECT_TYPE;\n\n break;\n }\n\n return $mappedType;\n }\n\n /**\n * @param string $crmObject The name of the CRM object. i.e. Opportunity or Contact\n */\n protected function getFields(string $crmObject): array\n {\n $mappedObjectType = $this->mapObjectType($crmObject);\n\n if (! $this->isSupportedObjectType($mappedObjectType)) {\n return [];\n }\n\n $customFields = $this->copperClient->fields()->all();\n\n $filteredFields = array_filter(\n $customFields,\n static fn ($field) => in_array($mappedObjectType, $field['available_on'], true)\n );\n\n return array_map(\n fn (array $property) => [\n 'name' => $property['id'],\n 'label' => $property['name'],\n 'type' => $this->convertFieldType($property['data_type']),\n ],\n $filteredFields\n );\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultFields(string $activityType): array\n {\n $fields = [];\n\n if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {\n $defaultFields = FieldDefinitions::defaultTaskFields();\n\n // This lazy creates these fields if not already setup.\n foreach ($defaultFields as $defaultField) {\n $fields[] = $this->config->fields()->firstOrCreate($defaultField);\n }\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultActivityField(string $activityType): Field\n {\n // Setup the activity field as the default Type.\n $activityField = $this->config->fields()->where([\n 'crm_provider_id' => 'type',\n 'object_type' => $activityType,\n ])->first();\n\n return $activityField;\n }\n\n /**\n * @inheritdoc\n */\n public function getSupportedPlaybookTypes(): array\n {\n return [Playbook::ACTIVITY_TYPE_TASK];\n }\n\n public function getDealInsightsFields(): array\n {\n return FieldDefinitions::dealInsightsFields();\n }\n\n protected function getDefaultFollowupLayoutFields(string $activityType): array\n {\n $fields = [];\n $fieldRepo = app(FieldRepository::class);\n $fieldFilters = FieldDefinitions::taskFollowupFieldsFilter();\n\n foreach ($fieldFilters as $data) {\n $field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $data);\n\n // Only add the field if it is created, which it should be.\n if ($field) {\n $fields[] = $field;\n }\n }\n\n return $fields;\n }\n\n public function syncFields(): void\n {\n $this->syncStandardFields();\n $this->syncCustomFields();\n }\n\n public function syncField(Field $field): void\n {\n if ($field->getCrmProviderId() === 'type') {\n /**\n * This field is a special case, it is not a custom field and is not returned by the Copper API.\n *\n * The field is created manually during layout setup.\n *\n * @see getDefaultFields()\n *\n * There is a separate endpoint to get the activity types.\n * @see importPicklistValues()\n */\n return;\n }\n\n if ($field->getCrmProviderId() === 'pipeline_stage_id') {\n // Not a custom type\n return;\n }\n\n $crmField = $this->copperClient->fields()->find($field->getCrmProviderId());\n $field->label = $crmField['name'];\n $field->type = $this->convertFieldType($crmField['data_type']);\n\n $field->save();\n }\n\n /**\n * @param string $fieldType How the data should be input\n */\n private function convertFieldType(string $fieldType): string\n {\n $fieldType = strtolower($fieldType);\n\n switch ($fieldType) {\n case 'dropdown':\n $convertedType = Field::TYPE_PICKLIST;\n\n break;\n case 'string':\n $convertedType = Field::TYPE_TEXT;\n\n break;\n case 'float':\n case 'percentage':\n $convertedType = Field::TYPE_NUMBER;\n\n break;\n case 'text':\n $convertedType = Field::TYPE_TEXTAREA;\n\n break;\n case 'checkbox':\n $convertedType = Field::TYPE_BOOLEAN;\n\n break;\n case Field::TYPE_DATE:\n case Field::TYPE_CURRENCY:\n case Field::TYPE_URL:\n $convertedType = $fieldType;\n\n break;\n default:\n $convertedType = Field::TYPE_UNSUPPORTED;\n\n break;\n }\n\n return $convertedType;\n }\n\n /**\n * @inheritdoc\n */\n public function importPicklistValues(Field $field): array\n {\n $fieldValues = [];\n\n if ($field->object_type === 'opportunity' && $field->crm_provider_id === 'pipeline_stage_id') {\n return $this->importFieldValuesForDealInsights($field);\n }\n\n $values = [];\n\n if ($field->object_type === Field::OBJECT_TASK && $field->crm_provider_id === 'type') {\n $activityTypes = $this->copperClient->activityTypes()->all();\n\n /**\n * We ignore \"system\" activity types, disabled activity types and the \"Note\" activity type.\n */\n $activityTypes = array_filter(\n $activityTypes['user'],\n fn (array $activityType) => ! $activityType['is_disabled'] && $activityType['id'] !== 0\n );\n\n // Import all activity values.\n $i = 0;\n foreach ($activityTypes as $activityType) {\n $values[] = [\n 'value' => $activityType['id'],\n 'label' => mb_strimwidth($activityType['name'], 0, Field::LABEL_MAX_LENGTH),\n 'sequence' => $i++,\n ];\n }\n } else {\n // We just check for the field in the custom definitions.\n $crmField = $this->copperClient->fields()->find($field->crm_provider_id);\n\n // Import all values.\n foreach ($crmField['options'] as $option) {\n $values[] = [\n 'value' => $option['id'],\n 'label' => mb_strimwidth($option['name'], 0, 191),\n 'sequence' => $option['rank'],\n ];\n }\n }\n\n // Take the existing set of field values.\n $fieldsToPurge = $field->values()->get()->pluck('value')->toArray();\n\n foreach ($values as $value) {\n // For all current values, upsert the field.\n $value['value'] = substr($value['value'] ?? '', 0, 255);\n $fieldValues[] = $field->values()->updateOrCreate([\n 'value' => $value['value'],\n ], $value);\n\n // Remove this value from the ones we are going to purge.\n $key = array_search($value['value'], $fieldsToPurge, false);\n if ($key !== false) {\n unset($fieldsToPurge[$key]);\n }\n }\n\n // Delete the old values that are no longer used.\n $field->values()->whereIn('value', $fieldsToPurge)->delete();\n\n return $fieldValues;\n }\n\n /**\n *\n * @return FieldValue[]\n */\n private function importFieldValuesForDealInsights(Field $field): array\n {\n $fieldValues = [];\n $pipelines = $this->copperClient->pipelines()->all();\n\n foreach ($pipelines as $pipeline) {\n foreach ($pipeline['stages'] as $i => $dealStage) {\n $fieldValues[] = $field->values()->updateOrCreate(\n ['value' => $dealStage['id']],\n [\n 'label' => $dealStage['name'],\n 'sequence' => $i,\n ]\n );\n }\n }\n\n return $fieldValues;\n }\n\n /**\n * @inheritdoc\n */\n public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage\n {\n if ($types === null) {\n $types = [Stage::TYPE_LEAD, Stage::TYPE_OPPORTUNITY];\n }\n $missingStage = null;\n\n foreach ($types as $type) {\n if ($type === Stage::TYPE_LEAD) {\n $statuses = $this->requestWithExceptionHandling(\n fn () => $this->copperClient->leadStatuses()->all()\n );\n\n if (count($statuses) > 0) {\n $businessProcess = $this->config->businessProcesses()->updateOrCreate([\n 'crm_provider_id' => BusinessProcess::TYPE_LEAD,\n ], [\n 'team_id' => $this->team->id,\n 'name' => BusinessProcess::TYPE_LEAD,\n 'type' => BusinessProcess::TYPE_LEAD,\n 'is_selectable' => true,\n ]);\n\n // A record type is really a clone of the business process, used to store which record uses which pipeline.\n $this->config->recordTypes()->updateOrCreate([\n 'crm_provider_id' => BusinessProcess::TYPE_LEAD,\n ], [\n 'team_id' => $this->team->id,\n 'name' => BusinessProcess::TYPE_LEAD,\n 'is_selectable' => true,\n 'business_process_id' => $businessProcess->id ?? null,\n ]);\n\n $leadStatuses = [];\n foreach ($statuses as $status) {\n // Upsert all statuses for the team.\n $status = $this->config->stages()->updateOrCreate([\n 'crm_provider_id' => $status['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($status['name'], 0, 50),\n 'label' => mb_strimwidth($status['name'], 0, 191),\n 'type' => Stage::TYPE_LEAD,\n 'sequence' => $status['order'],\n 'is_selectable' => true,\n ]);\n\n if ($missingStageName && $missingStageName === $status['id']) {\n $missingStage = $status;\n }\n\n $leadStatuses[] = $status['id'];\n }\n\n $businessProcess->stages()->sync($leadStatuses);\n }\n } else {\n // We create a business process to contain the pipeline, and later store all stages against it.\n foreach ($this->copperClient->pipelines()->all() as $pipeline) {\n $businessProcess = $this->config->businessProcesses()->updateOrCreate([\n 'crm_provider_id' => $pipeline['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($pipeline['name'], 0, 150),\n 'type' => BusinessProcess::TYPE_OPPORTUNITY,\n 'is_selectable' => true,\n ]);\n\n // A record type is really a clone of the business process, used to store which record uses which pipeline.\n $this->config->recordTypes()->updateOrCreate([\n 'crm_provider_id' => $pipeline['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($pipeline['name'], 0, 150),\n 'is_selectable' => true,\n 'business_process_id' => $businessProcess->id ?? null,\n ]);\n\n $stages = [];\n\n foreach ($pipeline['stages'] as $i => $dealStage) {\n // Upsert all stages for the team.\n $stage = $this->config->stages()->updateOrCreate([\n 'crm_provider_id' => $dealStage['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($dealStage['name'], 0, 50),\n 'label' => mb_strimwidth($dealStage['name'], 0, 191),\n 'type' => Stage::TYPE_OPPORTUNITY,\n 'sequence' => $i,\n 'is_selectable' => true,\n 'probability' => $dealStage['win_probability'],\n ]);\n\n if ($missingStageName && $missingStageName === $dealStage['id']) {\n $missingStage = $stage;\n }\n\n $stages[] = $stage['id'];\n }\n\n $businessProcess->stages()->sync($stages);\n }\n }\n }\n\n return $missingStage;\n }\n\n /**\n * @inheritdoc\n */\n public function syncLeads(Carbon $since, ?Carbon $to = null, ?string $crmProfileId = null): int\n {\n $syncCount = 0;\n\n $cpLeads = $this->requestWithExceptionHandling(\n fn () => $this->copperClient->leads()->paginate([\n 'minimum_modified_date' => $since->timestamp,\n 'sort_by' => 'date_modified',\n ])\n );\n\n foreach ($cpLeads as $cpLead) {\n // Only sync if previously imported.\n if ($this->hasLead($cpLead['id']) === false) {\n $this->importLead($cpLead);\n $syncCount++;\n }\n }\n\n return $syncCount;\n }\n\n /**\n * @inheritdoc\n */\n public function syncLead(string $crmId): ?Lead\n {\n $cpLead = $this->requestWithExceptionHandling(\n fn () => $this->copperClient->leads()->find($crmId)\n );\n\n if (\\count($cpLead) === 0) {\n return null;\n }\n\n return $this->importLead($cpLead);\n }\n\n /**\n * @inheritdoc\n */\n private function importLead($crmData): ?Lead\n {\n $countryCode = null;\n if (! empty($crmData['address']) && isset($crmData['address']['country'])) {\n $countryCode = $this->convertCountryNameToCode($crmData['address']['country']);\n }\n\n $mobilePhone = $parsedNumber = null;\n if (! empty($crmData['phone_numbers'])) {\n foreach ($crmData['phone_numbers'] as $phoneNumber) {\n if ($phoneNumber['category'] === 'mobile') {\n $mobilePhone = $this->buildContactMobilePhone($countryCode, $phoneNumber['number']);\n } else {\n $parsedNumber = $this->buildContactPhone($countryCode, $phoneNumber['number']);\n }\n }\n }\n\n $name = $crmData['name'];\n\n if (empty($crmData['company_name'])) {\n $company = 'Unknown';\n } else {\n $company = mb_strimwidth($crmData['company_name'], 0, 191);\n }\n\n $domain = null;\n if (empty($crmData['websites']) === false) {\n $domain = mb_strimwidth($crmData['websites'][0]['url'], 0, 191);\n $domain = StringUtil::resolveDomain($domain);\n }\n\n $email = null;\n if (empty($crmData['email']) === false) {\n $email = mb_strimwidth($crmData['email']['email'], 0, 80);\n }\n\n $stage = $this->config\n ->stages()\n ->where('crm_provider_id', (string) $crmData['status_id'])\n ->where('type', Stage::TYPE_LEAD)\n ->first();\n\n if ($stage === null) {\n // Import it.\n $stage = $this->importStages([Stage::TYPE_LEAD], $crmData['status_id']);\n }\n\n if (! $stage instanceof Stage) {\n return null;\n }\n\n $profile = $this->config->profiles()->where('crm_provider_id', (string) $crmData['assignee_id'])->first();\n\n $data = [\n 'team_id' => $this->team->id,\n 'user_id' => $profile ? $profile->user_id : null,\n 'owner_id' => $crmData['assignee_id'],\n 'name' => mb_strimwidth($name, 0, 191),\n 'company' => $company,\n 'domain' => $domain,\n 'title' => $crmData['title'] ? mb_strimwidth($crmData['title'], 0, 128) : null,\n 'email' => $email,\n 'phone' => $parsedNumber['phone'] ?? null,\n 'ext' => $parsedNumber['ext'] ?? null,\n 'mobile_phone' => $mobilePhone,\n 'stage_id' => $stage->id,\n 'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(\n crmConfiguration: $this->config,\n crmProviderId: $crmData['id'],\n modelType: Lead::class,\n fileName: $crmData['id'],\n avatarText: $crmData['name']\n ),\n 'country_code' => $countryCode,\n 'remotely_created_at' => $crmData['date_created'],\n ];\n\n return $this->config->leads()->updateOrCreate(['crm_provider_id' => (string) $crmData['id']], $data);\n }\n\n /**\n * @inheritdoc\n */\n public function syncAccounts(Carbon $since, ?Carbon $to = null): int\n {\n $syncCount = 0;\n\n try {\n $cpAccounts = $this->copperClient->accounts()->paginate([\n 'minimum_modified_date' => $since->timestamp,\n 'sort_by' => 'date_modified',\n ]);\n\n foreach ($cpAccounts as $cpAccount) {\n // Only sync if previously imported.\n if ($this->hasAccount($cpAccount['id'])) {\n $this->importAccount($cpAccount);\n $syncCount++;\n }\n }\n } catch (\\Exception $exception) {\n // Do nothing for now.\n }\n\n return $syncCount;\n }\n\n /**\n * @inheritdoc\n */\n public function syncAccount(string $crmId): ?Account\n {\n $cpAccount = $this->copperClient->accounts()->find($crmId);\n\n if (empty($cpAccount)) {\n return null;\n }\n\n return $this->importAccount($cpAccount);\n }\n\n /**\n * @inheritdoc\n */\n private function importAccount($crmData): Account\n {\n $countryCode = null;\n if (empty($crmData['address']) === false && $crmData['address']['country']) {\n $countryCode = $this->convertCountryNameToCode($crmData['address']['country']);\n }\n\n $name = $crmData['name'];\n\n $parsedNumber = null;\n foreach ($crmData['phone_numbers'] as $phoneNumber) {\n $parsedNumber = parsePhoneNumber($countryCode, $phoneNumber['number']);\n }\n\n $domain = null;\n if (empty($crmData['websites']) === false) {\n $domain = mb_strimwidth($crmData['websites'][0]['url'], 0, 191);\n $domain = StringUtil::resolveDomain($domain);\n }\n\n $profile = $this->config->profiles()->where('crm_provider_id', (string) $crmData['assignee_id'])->first();\n\n $data = [\n 'team_id' => $this->team->id,\n 'user_id' => $profile ? $profile->user_id : null,\n 'owner_id' => $crmData['assignee_id'],\n 'name' => mb_strimwidth($name, 0, 191),\n 'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(\n crmConfiguration: $this->config,\n crmProviderId: $crmData['id'],\n modelType: Account::class,\n fileName: $crmData['id'],\n avatarText: $crmData['name']\n ),\n 'country_code' => $countryCode,\n 'domain' => $domain,\n 'phone' => $parsedNumber['phone'] ?? null,\n 'ext' => $parsedNumber['ext'] ?? null,\n 'remotely_created_at' => $crmData['date_created'],\n ];\n\n return $this->config->accounts()->updateOrCreate(['crm_provider_id' => (string) $crmData['id']], $data);\n }\n\n public function syncOpportunities(array $parameters, ?string $strategy = null): int\n {\n $strategyResolver = app(OpportunitySyncStrategyResolver::class, [\n 'client' => $this->copperClient,\n ]);\n $strategies = $strategyResolver->getStrategies($this->config, $strategy);\n\n $syncCount = 0;\n\n try {\n foreach ($strategies as $syncStrategy) {\n foreach ($syncStrategy->fetchOpportunities($parameters) as $opportunityData) {\n $this->importOpportunity($opportunityData);\n $syncCount++;\n }\n }\n } catch (\\Exception $exception) {\n // Do nothing for now.\n $this->logger->error(self::OPPORTUNITY_IMPORT_FAILED_ERROR_MESSAGE, [\n 'error' => $exception->getMessage(),\n ]);\n }\n\n return $syncCount;\n }\n\n public function syncOpportunity(string $crmId): ?Opportunity\n {\n $strategyResolver = app(OpportunitySyncStrategyResolver::class, [\n 'client' => $this->copperClient,\n ]);\n $strategy = $strategyResolver->resolve(\n $this->config,\n OpportunitySyncStrategyResolver::SINGLE_SYNC_OPPORTUNITY_STRATEGY\n );\n\n $parameters['crm_id'] = $crmId;\n\n $cpOpportunity = $strategy->fetchOpportunities($parameters);\n\n return $this->importOpportunity($cpOpportunity);\n }\n\n private function importOpportunity($crmData): ?Opportunity\n {\n $companyId = (string) $crmData['company_id'];\n\n if (empty($companyId)) {\n $this->logger->error(self::OPPORTUNITY_IMPORT_FAILED_ERROR_MESSAGE, [\n 'error' => 'No company id',\n ]);\n\n return null;\n }\n\n $account = $this->config->accounts()->where('crm_provider_id', $companyId)->first();\n\n if ($account === null) {\n $account = $this->syncAccount($companyId);\n }\n\n $profile = $this->config->profiles()->where('crm_provider_id', (string) $crmData['assignee_id'])->first();\n\n $businessProcess = $this->config\n ->businessProcesses()\n ->where('crm_provider_id', (string) $crmData['pipeline_id'])\n ->first();\n\n if ($businessProcess === null) {\n // It is possible for some deals to not be in a pipeline.\n $this->logger->warning(self::OPPORTUNITY_IMPORT_FAILED_ERROR_MESSAGE, [\n 'error' => 'Missing pipeline',\n ]);\n\n return null;\n }\n\n $stage = $businessProcess\n ->stages()\n ->where('crm_provider_id', (string) $crmData['pipeline_stage_id'])\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->first();\n\n if ($stage === null) {\n $this->logger->info('Stage not found, importing', [\n 'pipeline_id' => $crmData['pipeline_id'],\n 'stage_id' => $crmData['pipeline_stage_id'],\n ]);\n\n $stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $crmData['pipeline_stage_id']);\n }\n\n $recordType = $businessProcess->recordTypes()->first();\n $closeDate = null;\n $isClosed = $crmData['status'] !== self::OPPORTUNITY_STATUS_OPEN;\n\n if ($crmData['close_date']) {\n $closeDate = Carbon::parse($crmData['close_date'])->format('Y-m-d');\n }\n\n $data = [\n 'team_id' => $this->team->getId(),\n 'account_id' => $account->id,\n 'user_id' => $profile->user_id ?? null,\n 'owner_id' => $crmData['assignee_id'],\n 'name' => mb_strimwidth($crmData['name'], 0, 128),\n 'value' => $crmData['monetary_value'],\n 'currency_code' => CurrencyFormatter::formatCode($crmData['monetary_unit']),\n 'close_date' => $closeDate,\n 'is_closed' => $isClosed,\n 'is_won' => $crmData['status'] === 'Won',\n 'stage_id' => $stage->id,\n 'record_type_id' => $recordType->id ?? null,\n 'remotely_created_at' => $crmData['date_created'],\n ];\n\n /** @var Opportunity $opportunity */\n $opportunity = $this->config\n ->opportunities()\n ->updateOrCreate(['crm_provider_id' => (string) $crmData['id']], $data)\n ;\n\n // import external fields into crm_field_data if present\n $dealInsightsFields = $this->getOpportunitySyncableFields();\n $this->importOpportunityCrmFieldData($crmData, $dealInsightsFields, $opportunity->id);\n\n return $opportunity;\n }\n\n /**\n * @param array<string, mixed> $opportunityData\n * @param string[] $dealInsightsFields\n */\n public function importOpportunityCrmFieldData(\n array $opportunityData,\n array $dealInsightsFields,\n int $opportunityId\n ): void {\n foreach ($dealInsightsFields as $fieldName) {\n $field = $this->config->fields()->with('entities')\n ->where('crm_provider_id', $fieldName)\n ->where('object_type', Field::OBJECT_OPPORTUNITY)\n ->first();\n\n if (! $field instanceof Field) {\n $this->logger->warning('Field not found', [\n 'field' => $fieldName,\n 'opportunity_id' => $opportunityId,\n ]);\n\n continue;\n }\n\n $entity = $field->getEntities()->first();\n\n if (! $entity instanceof LayoutEntity) {\n continue;\n }\n\n $fieldValue = $this->extractFieldValue($opportunityData, $fieldName);\n $normalizedValue = $this->normalizeValue($field->getType(), (string) $fieldValue, true);\n\n $field->data()\n ->updateOrCreate([\n 'crm_layout_entity_id' => $entity->id,\n 'crm_field_id' => $field->id,\n 'object_type' => 'opportunity',\n 'object_id' => $opportunityId,\n ], ['value' => $normalizedValue])\n ;\n }\n }\n\n /**\n * @inheritdoc\n */\n public function syncContacts(Carbon $since, ?Carbon $to = null): int\n {\n $syncCount = 0;\n\n try {\n $cpContacts = $this->copperClient->contacts()->paginate([\n 'minimum_interaction_date' => $since->timestamp,\n 'sort_by' => 'date_modified',\n ]);\n\n foreach ($cpContacts as $cpContact) {\n // Only sync if previously imported.\n if ($this->hasContact($cpContact['id'])) {\n $this->importContact($cpContact);\n $syncCount++;\n }\n }\n } catch (\\Exception $exception) {\n // Do nothing for now.\n throw $exception;\n }\n\n return $syncCount;\n }\n\n /**\n * @inheritdoc\n */\n public function syncContact(string $crmId): ?Contact\n {\n $cpContact = $this->copperClient->contacts()->find($crmId);\n\n if (empty($cpContact)) {\n return null;\n }\n\n return $this->importContact($cpContact);\n }\n\n /**\n * @inheritdoc\n */\n private function importContact($crmData): Contact\n {\n $account = null;\n if ($crmData['company_id']) {\n $account = $this->team\n ->crm\n ->accounts()\n ->where('crm_provider_id', (string) $crmData['company_id'])\n ->first();\n\n if ($account === null) {\n $account = $this->syncAccount($crmData['company_id']);\n }\n }\n\n $countryCode = null;\n if (empty($crmData['address']) === false && $crmData['address']['country']) {\n $countryCode = $this->convertCountryNameToCode($crmData['address']['country']);\n }\n\n $mobilePhone = $parsedNumber = null;\n foreach ($crmData['phone_numbers'] as $phoneNumber) {\n if ($phoneNumber['category'] === 'mobile') {\n $mobilePhone = $this->buildContactMobilePhone($countryCode, $phoneNumber['number']);\n } else {\n $parsedNumber = $this->buildContactPhone($countryCode, $phoneNumber['number']);\n }\n }\n\n $email = null;\n if (empty($crmData['emails']) === false) {\n $email = mb_strimwidth($crmData['emails'][0]['email'], 0, 80);\n }\n\n $profile = $this->config->profiles()->where('crm_provider_id', (string) $crmData['assignee_id'])->first();\n\n $data = [\n 'team_id' => $this->team->id,\n 'account_id' => $account->id ?? null,\n 'user_id' => $profile ? $profile->user_id : null,\n 'owner_id' => $crmData['assignee_id'],\n 'name' => $crmData['name'],\n 'email' => $email,\n 'phone' => $parsedNumber['phone'] ?? null,\n 'mobile_phone' => $mobilePhone ?? null,\n 'ext' => $parsedNumber['ext'] ?? null,\n 'title' => $crmData['title'],\n 'country_code' => $countryCode,\n 'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(\n crmConfiguration: $this->config,\n crmProviderId: $crmData['id'],\n modelType: Contact::class,\n fileName: $crmData['id'],\n avatarText: $crmData['name']\n ),\n 'remotely_created_at' => $crmData['date_created'],\n ];\n\n return $this->config->contacts()->updateOrCreate(['crm_provider_id' => (string) $crmData['id']], $data);\n }\n\n private function buildContactPhone(?string $countryCode, ?string $number): ?array\n {\n if ($number) {\n // Trim to our width and attempt to parse it.\n $number = mb_strimwidth($number, 0, 25);\n $parsedNumber = parsePhoneNumber($countryCode, $number);\n } else {\n $parsedNumber = [];\n }\n\n return $parsedNumber;\n }\n\n private function buildContactMobilePhone(?string $countryCode, ?string $number): ?string\n {\n return $number ? mb_strimwidth(phone_e164($countryCode, $number), 0, 25) : null;\n }\n\n /**\n * @inheritdoc\n */\n public function syncOrganization(): void\n {\n $organization = $this->copperClient->organisation()->all();\n\n if ($this->config->crm_provider_id !== $organization['id']) {\n $this->config->update(['crm_provider_id' => $organization['id']]);\n }\n }\n\n private function syncCustomFields(): void\n {\n foreach ($this->getFieldTypes() as $fieldType) {\n $currentFields = $this->getFields($fieldType);\n\n foreach ($currentFields as $field) {\n /** @var Field $field */\n $field = $this->config->fields()->updateOrCreate([\n 'crm_provider_id' => $field['name'],\n 'object_type' => $fieldType,\n ], [\n 'label' => mb_strimwidth($field['label'], 0, Field::LABEL_MAX_LENGTH),\n 'type' => $field['type'],\n ]);\n\n if ($field->getType() === Field::TYPE_PICKLIST) {\n $this->importPicklistValues($field);\n }\n }\n }\n\n $this->config->save();\n }\n\n protected function getFieldTypes(): array\n {\n return [\n parent::OBJECT_TASK,\n parent::OBJECT_LEAD,\n parent::OBJECT_ACCOUNT,\n parent::OBJECT_CONTACT,\n parent::OBJECT_OPPORTUNITY,\n ];\n }\n\n /**\n * @inheritdoc\n */\n public function syncProfiles(?User $userToSearch = null): ?Profile\n {\n $profiles = $this->copperClient->users()->all();\n\n foreach ($profiles as $profile) {\n $user = $this->userRepository->findByTeamAndEmailAddress($this->team, $profile['email'], onlyActive: true);\n\n // Only import profiles for users we have locally.\n if ($user) {\n $profile = $user->crmProfile()->updateOrCreate([\n 'crm_configuration_id' => $this->config->id,\n 'crm_provider_id' => $profile['id'],\n ]);\n\n if ($userToSearch && $userToSearch->id === $user->id) {\n return $profile;\n }\n }\n }\n\n return null;\n }\n\n /**\n * @inheritdoc\n */\n public function syncProfileFields(): void\n {\n $fieldTypes = [\n 'task',\n 'lead',\n 'account',\n 'contact',\n 'opportunity',\n ];\n\n foreach ($fieldTypes as $fieldType) {\n try {\n $currentFields = $this->getFields(ucfirst($fieldType));\n\n $newFields = array_column($currentFields, 'name');\n $changedFields = array_merge(\n array_diff($this->profile->getFieldsAsArray($fieldType), $newFields),\n array_diff($newFields, $this->profile->getFieldsAsArray($fieldType))\n );\n\n if (\\count($changedFields) > 0) {\n $fields = implode(',', $newFields);\n $this->logger->debug(\n ucfirst($fieldType)\n . ' fields changed from '\n . $this->profile->{$fieldType . '_fields'}\n . ' to: '\n . $fields\n );\n\n $this->profile->{$fieldType . '_fields'} = $fields;\n }\n } catch (\\Exception $exception) {\n $this->logger->error('No access to ' . $fieldType . ' object, skipping...');\n\n continue;\n }\n }\n\n $this->profile->save();\n }\n\n /**\n * @inheritdoc\n */\n public function find(string $name, array $scopes): array\n {\n $count = $this->limit ?? 20;\n $offset = $this->offset ?? 0;\n\n $scopeValues = array_map(fn ($scope) => is_string($scope) ? $scope : $scope->value, $scopes);\n $cacheKey = sprintf('copper-search-%d-%s-%s-%d-%d', $this->team->id, $name, implode('-', $scopeValues), $count, $offset);\n\n return Cache::remember($cacheKey, 300, function () use ($name, $scopes, $count, $offset) {\n // Try local search first\n $localResults = $this->searchLocally($name, $scopes, $count, $offset);\n\n // If no local results, fall back to remote API search\n if (empty($localResults)) {\n return $this->searchRemotely($name, $scopes, $count, $offset);\n }\n\n return $localResults;\n });\n }\n\n /**\n * @inheritdoc\n */\n public function findOpportunities(?string $crmAccountId, ?string $crmContactId, ?int $userId = null): array\n {\n $data = [];\n $ownerData = [];\n $ownerId = null;\n $contact = null;\n $account = null;\n\n if ($crmAccountId) {\n $account = $this->config->accounts()->where('crm_provider_id', $crmAccountId)->first();\n\n if ($account === null) {\n $account = $this->syncAccount($crmAccountId);\n }\n }\n\n if ($crmContactId) {\n $contact = $this->config->contacts()->where('crm_provider_id', $crmContactId)->first();\n\n if ($contact === null) {\n $contact = $this->syncContact($crmContactId);\n }\n }\n\n if ($contact || $account) {\n if ($contact && $account === null) {\n $account = $contact->account;\n }\n\n if ($account === null) {\n return [];\n }\n\n $params = [\n 'company_ids' => [$account->crm_provider_id],\n 'status_ids' => [0], // The integer values are 0, 1, 2, 3, for \"Open\", \"Won\", \"Lost\", and \"Abandoned\".\n 'sort_by' => 'date_modified',\n 'sort_direction' => 'desc',\n ];\n\n switch ($this->config->opportunity_assignment_rule) {\n case Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_UPDATED:\n $params['sort_by'] = 'date_modified';\n $params['sort_direction'] = 'desc';\n\n break;\n\n case Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_CREATED:\n $params['sort_by'] = 'date_created';\n $params['sort_direction'] = 'desc';\n\n break;\n\n case Configuration::OPP_ASSIGNMENT_ALL_OPEN_OLDEST_CREATED:\n $params['sort_by'] = 'date_created';\n $params['sort_direction'] = 'asc';\n\n break;\n\n case Configuration::OPP_ASSIGNMENT_ALL_RECENTLY_UPDATED:\n $params['sort_by'] = 'date_modified';\n $params['sort_direction'] = 'desc';\n $params['status_ids'] = [0, 1, 2, 3];\n }\n\n $cpOpportunities = $this->copperClient->opportunities()->search($params);\n\n if ($userId) {\n $profileRepository = app(ProfileRepository::class);\n $profile = $profileRepository->findProfileByUserId($this->config, $userId);\n\n $ownerId = $profile instanceof Profile ? $profile->getCrmProviderId() : null;\n }\n\n foreach ($cpOpportunities as $cpOpportunity) {\n $businessProcess = $this->config\n ->businessProcesses()\n ->where('crm_provider_id', (string) $cpOpportunity['pipeline_id'])\n ->first();\n\n if ($businessProcess === null) {\n // Import it.\n $businessProcess = $this->importStages(\n [Stage::TYPE_OPPORTUNITY],\n $cpOpportunity['pipeline_stage_id']\n );\n }\n\n $stage = $businessProcess\n ->stages()\n ->where('crm_provider_id', (string) $cpOpportunity['pipeline_stage_id'])\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->first();\n\n if ($stage === null) {\n // Import it.\n $stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $cpOpportunity['pipeline_stage_id']);\n }\n\n $recordType = $businessProcess->recordTypes()->first();\n\n $record = [\n 'crmId' => $cpOpportunity['id'],\n 'name' => $cpOpportunity['name'],\n 'value' => currency_format($cpOpportunity['monetary_value'], $cpOpportunity['monetary_unit']),\n 'won' => $cpOpportunity['status'] === 'Won',\n 'stage' => [\n 'id' => $stage->id_string,\n 'name' => $stage->name,\n ],\n 'recordType' => [\n 'id' => $recordType->id_string,\n 'name' => $recordType->name,\n ],\n ];\n\n if ($ownerId && isset($cpOpportunity['assignee_id']) && $cpOpportunity['assignee_id'] === $ownerId) {\n $ownerData[] = $record;\n }\n\n $data[] = $record;\n }\n }\n\n if (! empty($ownerData)) {\n return $ownerData;\n }\n\n return $data;\n }\n\n /**\n * @inheritdoc\n */\n public function getTasks(string $objectType, string $objectId, ?string $opportunityId): array\n {\n $data = $cpTasks = [];\n\n switch ($objectType) {\n case 'lead':\n $cpTasks = $this->requestWithExceptionHandling(\n fn () => $this->copperClient->leads()->relatedTasks($objectId)\n );\n\n break;\n\n case 'account':\n $cpTasks = $this->copperClient->accounts()->relatedTasks($objectId);\n\n break;\n case 'contact':\n if ($opportunityId) {\n $cpTasks = $this->copperClient->opportunities()->relatedTasks($opportunityId);\n } elseif ($objectType === 'contact') {\n $cpTasks = $this->copperClient->contacts()->relatedTasks($objectId);\n }\n\n break;\n }\n\n foreach ($cpTasks as $cpTask) {\n // We only get the ID itself returned, so pull out the full object.\n try {\n $cpTask = $this->copperClient->tasks()->find($cpTask['id']);\n } catch (Exception $exception) {\n $this->logger->error('[Copper] Error fetching task', [\n 'resource_id' => $cpTask['id'],\n 'error' => $exception->getMessage(),\n ]);\n\n continue;\n }\n\n // Only allow open tasks.\n if ($cpTask['status'] === 'Open') {\n $data[] = [\n 'crmId' => $cpTask['id'],\n 'subject' => $cpTask['name'],\n 'due' => $cpTask['due_date']\n ? Carbon::createFromTimestamp($cpTask['due_date'])->format('Y-m-d\\TH:i:sO')\n : null,\n 'type' => null,\n ];\n }\n }\n\n return $data;\n }\n\n /**\n * Try to find email address in CRM service\n *\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n *}\n */\n public function matchExactlyByEmail(string $email, ?int $userId = null): ?array\n {\n $cpObjectType = 'contact';\n\n $cpObjects = $this->copperClient->contacts()->search([\n 'emails' => $email,\n 'sort_by' => 'date_modified',\n 'sort_direction' => 'desc',\n ]);\n\n if (empty($cpObjects)) {\n $cpObjectType = 'lead';\n $cpObjects = $this->requestWithExceptionHandling(\n fn () => $this->copperClient->leads()->search([\n 'emails' => $email,\n 'sort_by' => 'date_modified',\n 'sort_direction' => 'desc',\n ])\n );\n }\n\n if (empty($cpObjects)) {\n return null;\n }\n\n // Search returns an array of results, but we only want the first\n $cpObject = reset($cpObjects);\n $data = $this->convertCrmData($cpObject, $cpObjectType, $userId);\n\n return ! empty(array_filter($data)) ? $data : null;\n }\n\n /**\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n public function matchByPhone(string $phone, ?string $rawPhoneNumber = null, ?int $userId = null): ?array\n {\n // Check if the user is internal.\n $teamMember = $this->team->users()->where('phone', $phone)->exists();\n\n // Skip the attendee if internal.\n if ($teamMember === false) {\n $cpObjectType = 'contact';\n $cpObject = $this->copperClient->contacts()->search([\n 'phone_number' => $phone,\n 'sort_by' => 'date_modified',\n 'sort_direction' => 'desc',\n ]);\n\n if (\\count($cpObject) === 0) {\n $cpObjectType = 'lead';\n $cpObject = $this->requestWithExceptionHandling(\n fn () => $this->copperClient->leads()->search([\n 'phone_number' => $phone,\n 'sort_by' => 'date_modified',\n 'sort_direction' => 'desc',\n ])\n );\n }\n\n if (empty($cpObject)) {\n return null;\n }\n\n $cpObject = reset($cpObject);\n $data = $this->convertCrmData($cpObject, $cpObjectType, $userId);\n\n return ! empty(array_filter($data)) ? $data : null;\n }\n\n return null;\n }\n\n /**\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n public function matchByName(string $name, ?int $userId = null): ?array\n {\n // Don't waste time searching for single character strings.\n if (\\strlen($name) <= 1) {\n return null;\n }\n\n $result = \\Cache::remember($this->team->id . $name, 60, function () use ($name, $userId) {\n $cpObjectType = 'contact';\n $cpObject = $this->copperClient->contacts()->search([\n 'name' => $name,\n 'sort_by' => 'date_modified',\n 'sort_direction' => 'desc',\n ]);\n\n if (\\count($cpObject) === 0) {\n $cpObjectType = 'lead';\n $cpObject = $this->requestWithExceptionHandling(\n fn () => $this->copperClient->leads()->search([\n 'name' => $name,\n 'sort_by' => 'date_modified',\n 'sort_direction' => 'desc',\n ])\n );\n }\n\n if (! empty($cpObject)) {\n $cpObject = reset($cpObject);\n }\n\n $data = $this->convertCrmData($cpObject, $cpObjectType, $userId);\n\n return ! empty(array_filter($data)) ? $data : false;\n });\n\n return is_array($result) ? $result : null;\n }\n\n private function convertCrmData(array $object, string $objectType, ?int $userId = null): array\n {\n $lead = null;\n $contact = null;\n $opportunity = null;\n $account = null;\n $stage = null;\n $countryCode = null;\n\n if ($objectType === 'lead') {\n $lead = $this->importLead($object);\n\n // Lead might not be imported if the Stage is null for example.\n if ($lead) {\n $countryCode = $lead->country_code;\n $stage = $lead->stage;\n }\n } else {\n $contact = $this->importContact($object);\n $account = $contact->account;\n\n if ($contact->country_code) {\n $countryCode = $contact->country_code;\n } elseif ($account) {\n $countryCode = $account->country_code;\n }\n\n try {\n $cpOpportunities = $this->findOpportunities(\n $account?->crm_provider_id,\n $contact->crm_provider_id,\n $userId,\n );\n\n if (! empty($cpOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($cpOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n } catch (\\Exception $e) {\n // Nothing to see here.\n }\n }\n\n return [\n $lead,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n }\n\n public function saveActivity(Activity $activity): Activity\n {\n switch ($activity->type) {\n case Activity::TYPE_CONFERENCE:\n case Activity::TYPE_SOFTPHONE:\n case Activity::TYPE_SOFTPHONE_INBOUND:\n $activity = $this->buildCallPayload($activity);\n\n break;\n case Activity::TYPE_SMS_INBOUND:\n case Activity::TYPE_SMS_OUTBOUND:\n $activity = $this->buildTextMessagePayload($activity);\n\n break;\n }\n\n return $activity;\n }\n\n private function convertActivityAssociations(Activity $activity): array\n {\n if ($activity->lead) {\n $type = 'lead';\n $id = $activity->lead->crm_provider_id;\n } elseif ($activity->contact) {\n $type = 'person';\n $id = $activity->contact->crm_provider_id;\n } elseif ($activity->opportunity) {\n $type = 'opportunity';\n $id = $activity->opportunity->crm_provider_id;\n } else {\n $type = 'company';\n $id = $activity->account->crm_provider_id;\n }\n\n return [\n 'type' => $type,\n 'id' => $id,\n ];\n }\n\n private function convertActivityType(PlaybookCategory $activityType): array\n {\n return [\n 'category' => 'user',\n 'id' => $this->getCrmType($activityType)->value,\n ];\n }\n\n /**\n * @throws CrmException\n */\n private function buildCallPayload(Activity $activity): Activity\n {\n try {\n if ($activity->crm_provider_id) {\n // The activity should be logged under the existing Task (not Activity).\n $newActivityType = $this->getCrmType($activity->category);\n if ($newActivityType === null) {\n $message = sprintf('[Copper] invalid CRM Custom Activity Type: %s', $activity->category);\n\n throw new CrmException($message, 400);\n }\n $this->copperClient->tasks()->update($activity->crm_provider_id, [\n 'status' => 'Completed',\n 'custom_activity_type_id' => $newActivityType->value,\n 'details' => $this->generateActivityDescription($activity),\n ]);\n } else {\n if ($this->getCrmType($activity->category) === null) {\n $message = sprintf('[Copper] Invalid CRM Activity Type: %s', $activity->category->getName());\n\n throw new CrmException($message, 400);\n }\n\n if ($this->getCrmType($activity->category)->value === null) {\n $message = sprintf('[Copper] Invalid CRM Activity Type: %s value', $activity->category->getName());\n\n throw new CrmException($message, 400);\n }\n // Just create an activity.\n $cpActivity = $this->copperClient->activities()->create([\n 'user_id' => $this->profile->crm_provider_id,\n 'activity_date' => $activity->scheduled_start_time->timestamp,\n 'parent' => $this->convertActivityAssociations($activity),\n 'type' => $this->convertActivityType($activity->category),\n 'details' => $this->generateActivityDescription($activity),\n ]);\n\n $activity->crm_provider_id = $cpActivity['id'];\n $activity->save();\n }\n } catch (ClientException $exception) {\n //Log all errors to Sentry\n \\Sentry::captureException($exception);\n\n $response = $exception->getResponse();\n\n $responseBody = $response->getBody();\n $message = $responseBody;\n $errorCode = $response->getStatusCode();\n\n $jsonResponse = json_decode($responseBody, true);\n if (isset($jsonResponse[0]['message'])) {\n $message = $jsonResponse[0]['message'];\n }\n\n throw new CrmException($message, $errorCode);\n }\n\n return $activity;\n }\n\n private function buildTextMessagePayload(Activity $activity): Activity\n {\n if ($this->getCrmType($activity->category) === null) {\n $message = sprintf('[Copper] Invalid CRM Activity Type: %s', $activity->category->getName());\n\n throw new CrmException($message, 400);\n }\n\n if ($this->getCrmType($activity->category)->value === null) {\n $message = sprintf('[Copper] Invalid CRM Activity Type: %s value', $activity->category->getName());\n\n throw new CrmException($message, 400);\n }\n\n $cpActivity = $this->copperClient->activities()->create([\n 'user_id' => $this->profile->crm_provider_id,\n 'activity_date' => $activity->created_at->timestamp,\n 'parent' => $this->convertActivityAssociations($activity),\n 'type' => $this->convertActivityType($activity->category),\n 'details' => $this->generateActivityDescription($activity),\n ]);\n\n $activity->crm_provider_id = $cpActivity['id'];\n $activity->save();\n\n return $activity;\n }\n\n private function generateActivityDescription(Activity $activity): string\n {\n $description = '';\n\n switch ($activity->type) {\n case Activity::TYPE_SOFTPHONE:\n case Activity::TYPE_SOFTPHONE_INBOUND:\n case Activity::TYPE_CONFERENCE:\n\n $description = 'Jiminny Call Summary' . PHP_EOL . PHP_EOL;\n $description .= 'Call Duration: ' . $activity->duration_for_humans . PHP_EOL . PHP_EOL;\n\n // Tell them which number was dialed.\n if ($activity->to) {\n $description .= 'Dialed: ' . $activity->to->phone_number . PHP_EOL . PHP_EOL;\n }\n\n if ($activity->hasReasonCodeBotKicked()) {\n $description .= 'Notetaker removed from this meeting' . PHP_EOL . PHP_EOL;\n // When we fix the state to be Activity::RECORDING_RECORDED as it should be this can change.\n } elseif ($activity->hasReasonCodeNotCompliant()) {\n $description .= 'Notetaker did not join due to recording consent not being provided by attendees' . PHP_EOL . PHP_EOL;\n } elseif ($activity->canReviewActivity()) {\n $playbackUrl = $activity->user->team->partner->getPlaybackUrl($activity);\n $description .= 'Review Activity:' . PHP_EOL . $playbackUrl . PHP_EOL . PHP_EOL;\n }\n\n if ($activity->getCrmType() === Activity::TYPE_CONFERENCE) {\n $description .= 'Attendees:'\n . PHP_EOL\n . (new FilterJoinedParticipants())->toString($activity);\n }\n\n if (\\count($activity->notes) > 0) {\n $description .= PHP_EOL . PHP_EOL . 'Notes: ' . PHP_EOL;\n\n foreach ($activity->notes as $note) {\n $nTime = (int) ceil($note->time);\n $time = ($nTime > 3600) ? gmdate('H:i:s', $nTime) : gmdate('i:s', $nTime);\n $description .= $time . ' ' . $note->note . PHP_EOL;\n }\n }\n\n // Get all private messages.\n $messages = $activity->messages()\n ->where('is_private', 1)\n ->orderBy('created_at', 'asc');\n\n if ($messages->count() > 0) {\n $description .= PHP_EOL . PHP_EOL . 'Coaching Chat: ' . PHP_EOL;\n\n foreach ($messages->get() as $message) {\n $description .= $message->participant->name . ': ' . $message->message . PHP_EOL;\n }\n }\n\n // Get all public messages.\n $messages = $activity->messages()\n ->where('is_private', 0)\n ->orderBy('created_at', 'asc');\n\n if ($messages->count() > 0) {\n $description .= PHP_EOL . PHP_EOL . 'Customer Chat: ' . PHP_EOL;\n\n foreach ($messages->get() as $message) {\n $description .= $message->participant->name . ': ' . $message->message . PHP_EOL;\n }\n }\n\n if ($activity->summary) {\n $description .= PHP_EOL . PHP_EOL . 'Summary: ' . PHP_EOL . $activity->summary;\n }\n\n break;\n\n case Activity::TYPE_SMS_INBOUND:\n case Activity::TYPE_SMS_OUTBOUND:\n $description = $activity->description;\n\n break;\n }\n\n return $description;\n }\n\n public function saveFollowupActivity(Activity $activity, array $fields): ?string\n {\n // This is the user provided activity subject field.\n if (empty($fields['type'])) {\n return null;\n }\n\n $reminder = null;\n if (empty($fields['reminder_date']) === false) {\n $formatReminder = Carbon::createFromFormat('Y-m-d\\TH:i:s\\Z', $fields['reminder_date']);\n $reminder = $formatReminder->getTimestamp();\n }\n\n $due = null;\n if (empty($fields['due_date']) === false) {\n $formatDue = Carbon::createFromFormat('Y-m-d\\TH:i:s\\Z', $fields['due_date']);\n $due = $formatDue->getTimestamp();\n }\n\n $data = [\n 'custom_activity_type_id' => $fields['type'],\n 'status' => 'Open',\n 'assignee_id' => $this->profile->crm_provider_id,\n 'due_date' => $due,\n 'details' => $fields['details'] ?? '',\n 'reminder_date' => $reminder,\n 'name' => $fields['name'] ?? 'Follow-up',\n 'related_resource' => $this->convertActivityAssociations($activity),\n 'custom_fields' => $this->fetchCustomFieldData($activity, Field::OBJECT_TASK),\n ];\n\n // We don't actually create a corresponding activity object on our side yet.\n return $this->copperClient->tasks()->create($data)['id'];\n }\n\n /**\n * Store transcripts as note.\n *\n * @throws \\Exception\n */\n public function createTranscriptNotes(Activity $activity): void\n {\n // Generate activity transcription.\n $transcriptionData = $this->generateTranscription($activity);\n\n // Truncate Notes with max notes length because transcription text could be very long.\n $transcripts = mb_strimwidth($transcriptionData, 0, static::NOTE_BODY_MAX_LENGTH);\n\n $data = [\n 'user_id' => $this->profile->crm_provider_id,\n 'type' => [\n 'category' => 'user',\n 'id' => 0, // This creates a note.\n ],\n 'activity_date' => time(),\n 'details' => $transcripts,\n 'parent' => $this->convertActivityAssociations($activity),\n ];\n\n $cpActivity = $this->copperClient->activities()->create($data);\n\n // Store CRM Activity ID in transcription.\n $transcription = $activity->getTranscription();\n $transcription->crm_activity_id = $cpActivity['id'];\n $transcription->save();\n }\n\n /**\n * @inheritdoc\n */\n public function updateStage($crmObject, Stage $stage): void\n {\n if ($crmObject instanceof Lead) {\n $this->requestWithExceptionHandling(\n fn () => $this->copperClient->leads()->update($crmObject->crm_provider_id, [\n 'status_id' => $stage->crm_provider_id,\n ])\n );\n } else {\n $this->copperClient->opportunities()->update($crmObject->crm_provider_id, [\n 'pipeline_stage_id' => $stage->crm_provider_id,\n ]);\n }\n }\n\n public function prepareValueForUpdate(array $params): array\n {\n return [$params['fieldName'] => $params['fieldValue']];\n }\n\n /*\n * @inheritdoc\n */\n public function updateRecord(string $objectType, string $objectId, array $data, array $headers = []): void\n {\n $preparedData = $data;\n\n if ($objectType === Field::OBJECT_OPPORTUNITY) {\n $preparedData = $this->prepareOpportunityFieldsForUpdate($data);\n }\n\n try {\n $this->copperClient\n ->createResourceFromObjectType($objectType)\n ->update($objectId, $preparedData)\n ;\n } catch (Exception $e) {\n $this->logger->error(self::RESOURCE_UPDATE_FAILED_ERROR_MESSAGE, [\n 'resource_id' => $objectId,\n 'data' => $preparedData,\n 'error' => $e->getMessage(),\n ]);\n\n throw new CrmException(self::RESOURCE_UPDATE_FAILED_ERROR_MESSAGE, $e->getCode(), $e);\n }\n }\n\n /**\n * @param array<string,string> $fieldData\n *\n * @throws CrmException\n *\n * @return array<string,array<int,array<string,bool|float|int|string|null>>|bool|float|int|string|null>\n */\n private function prepareOpportunityFieldsForUpdate(array $fieldData): array\n {\n $preparedData = [];\n $customFields = [];\n\n foreach ($fieldData as $fieldName => $fieldValue) {\n $field = $this->config\n ->fields()\n ->where('crm_provider_id', $fieldName)\n ->first();\n\n if (! $field instanceof Field) {\n throw new CrmException('Field not found');\n }\n\n $convertedValue = $this->fieldValueConverter->convertToCrm($field->getType(), $fieldValue);\n\n if ($this->isStandardField($fieldName)) {\n $preparedData[$fieldName] = $convertedValue;\n\n continue;\n }\n\n $customFields[] = [\n 'custom_field_definition_id' => $fieldName,\n 'value' => $convertedValue,\n ];\n }\n\n if (! empty($customFields)) {\n $preparedData['custom_fields'] = $customFields;\n }\n\n return $preparedData;\n }\n\n /*\n * @inheritdoc\n */\n public function getRecord(string $objectType, string $objectId, array $fields = []): array\n {\n return $this->copperClient->createResourceFromObjectType($objectType)->find($objectId);\n }\n\n private function fetchCustomFieldData(Activity $activity, string $objectType): array\n {\n $payload = [];\n\n $fieldData = FieldData::where([\n 'object_id' => $activity->id,\n ])->whereHas('field', function ($query) use ($objectType) {\n $query->where('object_type', $objectType);\n })->get();\n\n foreach ($fieldData as $data) {\n // Add the field and value to the payload.\n $payload += [\n 'custom_field_definition_id' => $data->field->crm_provider_id,\n 'value' => $data->value,\n ];\n }\n\n return $payload;\n }\n\n public function generateProviderUrl(string $providerId, string $objectType): ?string\n {\n $baseUrl = 'https://app.prosperworks.com/companies/' . $this->config->crm_provider_id . '/app#/';\n switch ($objectType) {\n case 'lead':\n $url = $baseUrl . 'leads/' . $providerId;\n\n break;\n\n case 'account':\n $url = $baseUrl . 'organization/' . $providerId;\n\n break;\n\n case 'contact':\n $url = $baseUrl . 'contact/' . $providerId;\n\n break;\n\n case 'opportunity':\n $url = $baseUrl . 'deal/' . $providerId;\n\n break;\n\n default:\n case 'task':\n case 'activity':\n // Sadly we can't deeplink to these in Copper UI.\n $url = null;\n }\n\n return $url;\n }\n\n /**\n * Generate transcription for the activity.\n */\n private function generateTranscription(Activity $activity): string\n {\n if (! ($this->config->store_transcript)) {\n // If sending transcription to activity toggle is disabled\n return '';\n }\n\n return $this->transcriptionService\n ->findTranscriptionByActivity($activity)\n ->map(static function (array $transcriptionSegment): string {\n return $transcriptionSegment['formattedStartsAt'] . ' | ' . $transcriptionSegment['transcript'];\n })\n ->implode(PHP_EOL);\n }\n\n /**\n * @param array<string, mixed> $opportunityData\n *\n * @return int|string|bool|float|null\n */\n private function extractFieldValue(array $opportunityData, string $fieldName)\n {\n $fieldValue = $this->isStandardField($fieldName)\n ? $opportunityData[$fieldName]\n : $this->resolveCustomFieldFromOpportunityData($opportunityData, $fieldName);\n\n if (is_array($fieldValue)) {\n $fieldValue = implode(', ', $fieldValue);\n }\n\n return $fieldValue;\n }\n\n private function isStandardField(string $fieldName): bool\n {\n return array_key_exists($fieldName, self::STANDARD_OPPORTUNITY_FIELDS);\n }\n\n /**\n * @param array<string, mixed> $opportunityData\n *\n * @return string|int|float|bool|null|array<string, mixed>\n */\n private function resolveCustomFieldFromOpportunityData(array $opportunityData, string $fieldName)\n {\n $fieldData = array_filter(\n $opportunityData['custom_fields'],\n static fn ($field) => (string) $field['custom_field_definition_id'] === $fieldName\n );\n\n return array_pop($fieldData)['value'] ?? '';\n }\n\n private function isSupportedObjectType(string $objectType): bool\n {\n return $objectType !== self::UNSUPPORTED_OBJECT_TYPE;\n }\n\n private function syncStandardFields(): void\n {\n foreach (self::STANDARD_OPPORTUNITY_FIELDS as $fieldId => $fieldData) {\n $this->config->fields()->updateOrCreate([\n 'crm_provider_id' => mb_strimwidth($fieldId, 0, 128),\n 'object_type' => Field::OBJECT_OPPORTUNITY,\n ], [\n 'label' => mb_strimwidth($fieldData['label'], 0, Field::LABEL_MAX_LENGTH),\n 'type' => $fieldData['type'],\n ]);\n }\n\n $this->importStages([Stage::TYPE_OPPORTUNITY]);\n }\n\n private function requestWithExceptionHandling(\\Closure $apiRequest): iterable\n {\n try {\n return $apiRequest();\n } catch (\\Exception $exception) {\n if ($exception->getCode() === 403) {\n // Object might not be supported\n return [];\n }\n\n \\Sentry::captureException($exception);\n\n return [];\n }\n }\n\n public function verifyTaskExists(Activity $activity): bool\n {\n $crmProviderId = $activity->getCrmProviderId();\n $cacheKey = \"crm_task_exists:{$this->config->getId()}:$crmProviderId\";\n\n return Cache::remember($cacheKey, self::TASK_VERIFICATION_CACHE_TTL, function () use ($crmProviderId) {\n try {\n if ($this->copperClient === null) {\n return false;\n }\n\n $task = $this->copperClient->tasks()->find($crmProviderId);\n\n return ! empty($task);\n } catch (HttpNotFoundException) {\n // Task not found in CRM - this is expected and permanent\n $this->logger->info('[Copper] Task not found during verification', [\n 'task_id' => $crmProviderId,\n 'config_id' => $this->config->getId(),\n ]);\n\n return false;\n } catch (RequestFailedException $e) {\n // Handle 404 responses from Copper API\n if ($e->getCode() === 404) {\n $this->logger->info('[Copper] Task not found during verification (404)', [\n 'task_id' => $crmProviderId,\n 'config_id' => $this->config->getId(),\n ]);\n\n return false;\n }\n\n // Re-throw other Copper exceptions for retry\n throw $e;\n }\n // Let other exceptions (network errors, rate limits, etc.) bubble up for retry\n });\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":true,"is_selected":false,"is_expanded":false}]...
|
8957564869086018857
|
-7959665055215941403
|
click
|
accessibility
|
NULL
|
Project: faVsco.js, menu
#12066 on JY-20725-handle Project: faVsco.js, menu
#12066 on JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
HandleHubspotRateLimitTest
Run 'HandleHubspotRateLimitTest'
Debug 'HandleHubspotRateLimitTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
11
72
1
4
Previous Highlighted Error
Next Highlighted Error
<?php
namespace Jiminny\Services\Crm\Copper;
use Carbon\Carbon;
use Exception;
use GuzzleHttp\Exception\ClientException;
use Illuminate\Support\Facades\Cache;
use Jiminny\Contracts\Services\Crm\LayoutManagementInterface;
use Jiminny\Contracts\Services\Crm\MatchCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\Provider\CopperInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityLookupInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityManipulationInterface;
use Jiminny\Contracts\Services\Crm\SettingsInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmMetadataInterface;
use Jiminny\Contracts\Services\Crm\VerifyTaskExistsInterface;
use Jiminny\Exceptions\CrmException;
use Jiminny\Exceptions\HttpNotFoundException;
use Jiminny\Models\Account;
use Jiminny\Models\Activity;
use Jiminny\Models\Contact;
use Jiminny\Models\Crm\BusinessProcess;
use Jiminny\Models\Crm\Configuration;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\FieldData;
use Jiminny\Models\Crm\FieldValue;
use Jiminny\Models\Crm\LayoutEntity;
use Jiminny\Models\Crm\Profile;
use Jiminny\Models\Lead;
use Jiminny\Models\Opportunity;
use Jiminny\Models\Playbook;
use Jiminny\Models\PlaybookCategory;
use Jiminny\Models\SocialAccount;
use Jiminny\Models\Stage;
use Jiminny\Models\User;
use Jiminny\Repositories\Crm\CrmEntityRepository;
use Jiminny\Repositories\Crm\FieldRepository;
use Jiminny\Repositories\Crm\ProfileRepository;
use Jiminny\Repositories\UserRepository;
use Jiminny\Services\Avatar\ProspectPhotoPathService;
use Jiminny\Services\Crm\BaseService;
use Jiminny\Services\Crm\Copper\Api\Client;
use Jiminny\Services\Crm\Copper\Api\ClientFactory;
use Jiminny\Services\Crm\Copper\Exception\RequestFailedException;
use Jiminny\Services\Crm\Helpers\FilterJoinedParticipants;
use Jiminny\Services\Crm\OpportunitySyncStrategyResolver;
use Jiminny\Services\Crm\Copper\Traits\SearchesCrmEntities;
use Jiminny\Utils\CurrencyFormatter;
use Jiminny\Utils\StringUtil;
class Service extends BaseService implements
CopperInterface,
SyncCrmEntitiesInterface,
SyncCrmMetadataInterface,
RemoteEntityManipulationInterface,
LayoutManagementInterface,
SettingsInterface,
MatchCrmEntitiesInterface,
RemoteEntityLookupInterface,
VerifyTaskExistsInterface
{
use SearchesCrmEntities;
private const int NOTE_BODY_MAX_LENGTH = 3000000;
private const int TASK_VERIFICATION_CACHE_TTL = 86400; // 1 day
private const array STANDARD_OPPORTUNITY_FIELDS = [
'monetary_value' => [
'label' => 'Value',
'available_on' => Field::OBJECT_OPPORTUNITY,
'type' => Field::TYPE_CURRENCY,
],
'pipeline_stage_id' => [
'label' => 'Stage',
'type' => Field::TYPE_PICKLIST,
'available_on' => Field::OBJECT_OPPORTUNITY,
],
'close_date' => [
'label' => 'Close date',
'type' => Field::TYPE_DATE,
'available_on' => Field::OBJECT_OPPORTUNITY,
],
'win_probability' => [
'label' => 'Win probability',
'type' => Field::TYPE_NUMBER,
'available_on' => Field::OBJECT_OPPORTUNITY,
],
];
private const string UNSUPPORTED_OBJECT_TYPE = 'unsupported';
private const string OPPORTUNITY_STATUS_OPEN = 'Open';
private const string OPPORTUNITY_IMPORT_FAILED_ERROR_MESSAGE = 'Opportunity import failed';
private const string RESOURCE_UPDATE_FAILED_ERROR_MESSAGE = 'Failed to update resource';
private FieldValueConverter $fieldValueConverter;
private ClientFactory $clientFactory;
private ?Client $copperClient = null;
public function __construct(
ClientFactory $clientFactory,
FieldValueConverter $fieldValueConverter,
private readonly UserRepository $userRepository,
private readonly ProspectPhotoPathService $prospectPhotoPathService,
private readonly CrmEntityRepository $crmEntityRepository
) {
parent::__construct();
$this->clientFactory = $clientFactory;
$this->fieldValueConverter = $fieldValueConverter;
}
public function getDisplayName(): string
{
return 'Copper';
}
/**
* Get the CRM entity repository (used by SearchesCrmEntities trait)
*/
protected function getCrmEntityRepository(): CrmEntityRepository
{
return $this->crmEntityRepository;
}
public function getMinimumApiVersion(): string
{
return Client::MIN_API_VERSION;
}
protected function getOAuthAccount(User $user): ?SocialAccount
{
// In this case, the Account Owner is always the connection for any API operations.
$owner = $user->team->owner;
return $owner->getSocialAccount(SocialAccount::PROVIDER_COPPER);
}
public function setUser(User $user): void
{
$this->configureSentryScope($user);
$this->team = $user->getTeam();
$this->teamService->setTeam($user->getTeam());
$this->setConfiguration($user->getTeam()->getCrmConfiguration());
// Reset state.
$this->profile = $user->getProfile();
if (! $this->profile instanceof Profile) {
// Try to fetch their profile, maybe it was not yet imported?
$this->profile = $this->getProfile($user);
}
}
public function setConfiguration(Configuration $configuration): void
{
$this->config = $configuration;
$this->copperClient = $this->clientFactory->create($configuration);
if ($this->team === null) {
$this->team = $configuration->getTeam();
}
}
public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string
{
return $this->fieldValueConverter->convertFromCrm($fieldType, $fieldValue);
}
private function mapObjectType(string $objectType): string
{
$objectType = strtolower($objectType);
switch ($objectType) {
case Field::OBJECT_TASK:
case Field::OBJECT_OPPORTUNITY:
case Field::OBJECT_LEAD:
$mappedType = $objectType;
break;
case Field::OBJECT_ACCOUNT:
$mappedType = 'company';
break;
case Field::OBJECT_CONTACT:
$mappedType = 'person';
break;
default:
$mappedType = self::UNSUPPORTED_OBJECT_TYPE;
break;
}
return $mappedType;
}
/**
* @param string $crmObject The name of the CRM object. i.e. Opportunity or Contact
*/
protected function getFields(string $crmObject): array
{
$mappedObjectType = $this->mapObjectType($crmObject);
if (! $this->isSupportedObjectType($mappedObjectType)) {
return [];
}
$customFields = $this->copperClient->fields()->all();
$filteredFields = array_filter(
$customFields,
static fn ($field) => in_array($mappedObjectType, $field['available_on'], true)
);
return array_map(
fn (array $property) => [
'name' => $property['id'],
'label' => $property['name'],
'type' => $this->convertFieldType($property['data_type']),
],
$filteredFields
);
}
/**
* @inheritdoc
*/
public function getDefaultFields(string $activityType): array
{
$fields = [];
if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {
$defaultFields = FieldDefinitions::defaultTaskFields();
// This lazy creates these fields if not already setup.
foreach ($defaultFields as $defaultField) {
$fields[] = $this->config->fields()->firstOrCreate($defaultField);
}
}
return $fields;
}
/**
* @inheritdoc
*/
public function getDefaultActivityField(string $activityType): Field
{
// Setup the activity field as the default Type.
$activityField = $this->config->fields()->where([
'crm_provider_id' => 'type',
'object_type' => $activityType,
])->first();
return $activityField;
}
/**
* @inheritdoc
*/
public function getSupportedPlaybookTypes(): array
{
return [Playbook::ACTIVITY_TYPE_TASK];
}
public function getDealInsightsFields(): array
{
return FieldDefinitions::dealInsightsFields();
}
protected function getDefaultFollowupLayoutFields(string $activityType): array
{
$fields = [];
$fieldRepo = app(FieldRepository::class);
$fieldFilters = FieldDefinitions::taskFollowupFieldsFilter();
foreach ($fieldFilters as $data) {
$field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $data);
// Only add the field if it is created, which it should be.
if ($field) {
$fields[] = $field;
}
}
return $fields;
}
public function syncFields(): void
{
$this->syncStandardFields();
$this->syncCustomFields();
}
public function syncField(Field $field): void
{
if ($field->getCrmProviderId() === 'type') {
/**
* This field is a special case, it is not a custom field and is not returned by the Copper API.
*
* The field is created manually during layout setup.
*
* @see getDefaultFields()
*
* There is a separate endpoint to get the activity types.
* @see importPicklistValues()
*/
return;
}
if ($field->getCrmProviderId() === 'pipeline_stage_id') {
// Not a custom type
return;
}
$crmField = $this->copperClient->fields()->find($field->getCrmProviderId());
$field->label = $crmField['name'];
$field->type = $this->convertFieldType($crmField['data_type']);
$field->save();
}
/**
* @param string $fieldType How the data should be input
*/
private function convertFieldType(string $fieldType): string
{
$fieldType = strtolower($fieldType);
switch ($fieldType) {
case 'dropdown':
$convertedType = Field::TYPE_PICKLIST;
break;
case 'string':
$convertedType = Field::TYPE_TEXT;
break;
case 'float':
case 'percentage':
$convertedType = Field::TYPE_NUMBER;
break;
case 'text':
$convertedType = Field::TYPE_TEXTAREA;
break;
case 'checkbox':
$convertedType = Field::TYPE_BOOLEAN;
break;
case Field::TYPE_DATE:
case Field::TYPE_CURRENCY:
case Field::TYPE_URL:
$convertedType = $fieldType;
break;
default:
$convertedType = Field::TYPE_UNSUPPORTED;
break;
}
return $convertedType;
}
/**
* @inheritdoc
*/
public function importPicklistValues(Field $field): array
{
$fieldValues = [];
if ($field->object_type === 'opportunity' && $field->crm_provider_id === 'pipeline_stage_id') {
return $this->importFieldValuesForDealInsights($field);
}
$values = [];
if ($field->object_type === Field::OBJECT_TASK && $field->crm_provider_id === 'type') {
$activityTypes = $this->copperClient->activityTypes()->all();
/**
* We ignore "system" activity types, disabled activity types and the "Note" activity type.
*/
$activityTypes = array_filter(
$activityTypes['user'],
fn (array $activityType) => ! $activityType['is_disabled'] && $activityType['id'] !== 0
);
// Import all activity values.
$i = 0;
foreach ($activityTypes as $activityType) {
$values[] = [
'value' => $activityType['id'],
'label' => mb_strimwidth($activityType['name'], 0, Field::LABEL_MAX_LENGTH),
'sequence' => $i++,
];
}
} else {
// We just check for the field in the custom definitions.
$crmField = $this->copperClient->fields()->find($field->crm_provider_id);
// Import all values.
foreach ($crmField['options'] as $option) {
$values[] = [
'value' => $option['id'],
'label' => mb_strimwidth($option['name'], 0, 191),
'sequence' => $option['rank'],
];
}
}
// Take the existing set of field values.
$fieldsToPurge = $field->values()->get()->pluck('value')->toArray();
foreach ($values as $value) {
// For all current values, upsert the field.
$value['value'] = substr($value['value'] ?? '', 0, 255);
$fieldValues[] = $field->values()->updateOrCreate([
'value' => $value['value'],
], $value);
// Remove this value from the ones we are going to purge.
$key = array_search($value['value'], $fieldsToPurge, false);
if ($key !== false) {
unset($fieldsToPurge[$key]);
}
}
// Delete the old values that are no longer used.
$field->values()->whereIn('value', $fieldsToPurge)->delete();
return $fieldValues;
}
/**
*
* @return FieldValue[]
*/
private function importFieldValuesForDealInsights(Field $field): array
{
$fieldValues = [];
$pipelines = $this->copperClient->pipelines()->all();
foreach ($pipelines as $pipeline) {
foreach ($pipeline['stages'] as $i => $dealStage) {
$fieldValues[] = $field->values()->updateOrCreate(
['value' => $dealStage['id']],
[
'label' => $dealStage['name'],
'sequence' => $i,
]
);
}
}
return $fieldValues;
}
/**
* @inheritdoc
*/
public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage
{
if ($types === null) {
$types = [Stage::TYPE_LEAD, Stage::TYPE_OPPORTUNITY];
}
$missingStage = null;
foreach ($types as $type) {
if ($type === Stage::TYPE_LEAD) {
$statuses = $this->requestWithExceptionHandling(
fn () => $this->copperClient->leadStatuses()->all()
);
if (count($statuses) > 0) {
$businessProcess = $this->config->businessProcesses()->updateOrCreate([
'crm_provider_id' => BusinessProcess::TYPE_LEAD,
], [
'team_id' => $this->team->id,
'name' => BusinessProcess::TYPE_LEAD,
'type' => BusinessProcess::TYPE_LEAD,
'is_selectable' => true,
]);
// A record type is really a clone of the business process, used to store which record uses which pipeline.
$this->config->recordTypes()->updateOrCreate([
'crm_provider_id' => BusinessProcess::TYPE_LEAD,
], [
'team_id' => $this->team->id,
'name' => BusinessProcess::TYPE_LEAD,
'is_selectable' => true,
'business_process_id' => $businessProcess->id ?? null,
]);
$leadStatuses = [];
foreach ($statuses as $status) {
// Upsert all statuses for the team.
$status = $this->config->stages()->updateOrCreate([
'crm_provider_id' => $status['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($status['name'], 0, 50),
'label' => mb_strimwidth($status['name'], 0, 191),
'type' => Stage::TYPE_LEAD,
'sequence' => $status['order'],
'is_selectable' => true,
]);
if ($missingStageName && $missingStageName === $status['id']) {
$missingStage = $status;
}
$leadStatuses[] = $status['id'];
}
$businessProcess->stages()->sync($leadStatuses);
}
} else {
// We create a business process to contain the pipeline, and later store all stages against it.
foreach ($this->copperClient->pipelines()->all() as $pipeline) {
$businessProcess = $this->config->businessProcesses()->updateOrCreate([
'crm_provider_id' => $pipeline['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($pipeline['name'], 0, 150),
'type' => BusinessProcess::TYPE_OPPORTUNITY,
'is_selectable' => true,
]);
// A record type is really a clone of the business process, used to store which record uses which pipeline.
$this->config->recordTypes()->updateOrCreate([
'crm_provider_id' => $pipeline['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($pipeline['name'], 0, 150),
'is_selectable' => true,
'business_process_id' => $businessProcess->id ?? null,
]);
$stages = [];
foreach ($pipeline['stages'] as $i => $dealStage) {
// Upsert all stages for the team.
$stage = $this->config->stages()->updateOrCreate([
'crm_provider_id' => $dealStage['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($dealStage['name'], 0, 50),
'label' => mb_strimwidth($dealStage['name'], 0, 191),
'type' => Stage::TYPE_OPPORTUNITY,
'sequence' => $i,
'is_selectable' => true,
'probability' => $dealStage['win_probability'],
]);
if ($missingStageName && $missingStageName === $dealStage['id']) {
$missingStage = $stage;
}
$stages[] = $stage['id'];
}
$businessProcess->stages()->sync($stages);
}
}
}
return $missingStage;
}
/**
* @inheritdoc
*/
public function syncLeads(Carbon $since, ?Carbon $to = null, ?string $crmProfileId = null): int
{
$syncCount = 0;
$cpLeads = $this->requestWithExceptionHandling(
fn () => $this->copperClient->leads()->paginate([
'minimum_modified_date' => $since->timestamp,
'sort_by' => 'date_modified',
])
);
foreach ($cpLeads as $cpLead) {
// Only sync if previously imported.
if ($this->hasLead($cpLead['id']) === false) {
$this->importLead($cpLead);
$syncCount++;
}
}
return $syncCount;
}
/**
* @inheritdoc
*/
public function syncLead(string $crmId): ?Lead
{
$cpLead = $this->requestWithExceptionHandling(
fn () => $this->copperClient->leads()->find($crmId)
);
if (\count($cpLead) === 0) {
return null;
}
return $this->importLead($cpLead);
}
/**
* @inheritdoc
*/
private function importLead($crmData): ?Lead
{
$countryCode = null;
if (! empty($crmData['address']) && isset($crmData['address']['country'])) {
$countryCode = $this->convertCountryNameToCode($crmData['address']['country']);
}
$mobilePhone = $parsedNumber = null;
if (! empty($crmData['phone_numbers'])) {
foreach ($crmData['phone_numbers'] as $phoneNumber) {
if ($phoneNumber['category'] === 'mobile') {
$mobilePhone = $this->buildContactMobilePhone($countryCode, $phoneNumber['number']);
} else {
$parsedNumber = $this->buildContactPhone($countryCode, $phoneNumber['number']);
}
}
}
$name = $crmData['name'];
if (empty($crmData['company_name'])) {
$company = 'Unknown';
} else {
$company = mb_strimwidth($crmData['company_name'], 0, 191);
}
$domain = null;
if (empty($crmData['websites']) === false) {
$domain = mb_strimwidth($crmData['websites'][0]['url'], 0, 191);
$domain = StringUtil::resolveDomain($domain);
}
$email = null;
if (empty($crmData['email']) === false) {
$email = mb_strimwidth($crmData['email']['email'], 0, 80);
}
$stage = $this->config
->stages()
->where('crm_provider_id', (string) $crmData['status_id'])
->where('type', Stage::TYPE_LEAD)
->first();
if ($stage === null) {
// Import it.
$stage = $this->importStages([Stage::TYPE_LEAD], $crmData['status_id']);
}
if (! $stage instanceof Stage) {
return null;
}
$profile = $this->config->profiles()->where('crm_provider_id', (string) $crmData['assignee_id'])->first();
$data = [
'team_id' => $this->team->id,
'user_id' => $profile ? $profile->user_id : null,
'owner_id' => $crmData['assignee_id'],
'name' => mb_strimwidth($name, 0, 191),
'company' => $company,
'domain' => $domain,
'title' => $crmData['title'] ? mb_strimwidth($crmData['title'], 0, 128) : null,
'email' => $email,
'phone' => $parsedNumber['phone'] ?? null,
'ext' => $parsedNumber['ext'] ?? null,
'mobile_phone' => $mobilePhone,
'stage_id' => $stage->id,
'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(
crmConfiguration: $this->config,
crmProviderId: $crmData['id'],
modelType: Lead::class,
fileName: $crmData['id'],
avatarText: $crmData['name']
),
'country_code' => $countryCode,
'remotely_created_at' => $crmData['date_created'],
];
return $this->config->leads()->updateOrCreate(['crm_provider_id' => (string) $crmData['id']], $data);
}
/**
* @inheritdoc
*/
public function syncAccounts(Carbon $since, ?Carbon $to = null): int
{
$syncCount = 0;
try {
$cpAccounts = $this->copperClient->accounts()->paginate([
'minimum_modified_date' => $since->timestamp,
'sort_by' => 'date_modified',
]);
foreach ($cpAccounts as $cpAccount) {
// Only sync if previously imported.
if ($this->hasAccount($cpAccount['id'])) {
$this->importAccount($cpAccount);
$syncCount++;
}
}
} catch (\Exception $exception) {
// Do nothing for now.
}
return $syncCount;
}
/**
* @inheritdoc
*/
public function syncAccount(string $crmId): ?Account
{
$cpAccount = $this->copperClient->accounts()->find($crmId);
if (empty($cpAccount)) {
return null;
}
return $this->importAccount($cpAccount);
}
/**
* @inheritdoc
*/
private function importAccount($crmData): Account
{
$countryCode = null;
if (empty($crmData['address']) === false && $crmData['address']['country']) {
$countryCode = $this->convertCountryNameToCode($crmData['address']['country']);
}
$name = $crmData['name'];
$parsedNumber = null;
foreach ($crmData['phone_numbers'] as $phoneNumber) {
$parsedNumber = parsePhoneNumber($countryCode, $phoneNumber['number']);
}
$domain = null;
if (empty($crmData['websites']) === false) {
$domain = mb_strimwidth($crmData['websites'][0]['url'], 0, 191);
$domain = StringUtil::resolveDomain($domain);
}
$profile = $this->config->profiles()->where('crm_provider_id', (string) $crmData['assignee_id'])->first();
$data = [
'team_id' => $this->team->id,
'user_id' => $profile ? $profile->user_id : null,
'owner_id' => $crmData['assignee_id'],
'name' => mb_strimwidth($name, 0, 191),
'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(
crmConfiguration: $this->config,
crmProviderId: $crmData['id'],
modelType: Account::class,
fileName: $crmData['id'],
avatarText: $crmData['name']
),
'country_code' => $countryCode,
'domain' => $domain,
'phone' => $parsedNumber['phone'] ?? null,
'ext' => $parsedNumber['ext'] ?? null,
'remotely_created_at' => $crmData['date_created'],
];
return $this->config->accounts()->updateOrCreate(['crm_provider_id' => (string) $crmData['id']], $data);
}
public function syncOpportunities(array $parameters, ?string $strategy = null): int
{
$strategyResolver = app(OpportunitySyncStrategyResolver::class, [
'client' => $this->copperClient,
]);
$strategies = $strategyResolver->getStrategies($this->config, $strategy);
$syncCount = 0;
try {
foreach ($strategies as $syncStrategy) {
foreach ($syncStrategy->fetchOpportunities($parameters) as $opportunityData) {
$this->importOpportunity($opportunityData);
$syncCount++;
}
}
} catch (\Exception $exception) {
// Do nothing for now.
$this->logger->error(self::OPPORTUNITY_IMPORT_FAILED_ERROR_MESSAGE, [
'error' => $exception->getMessage(),
]);
}
return $syncCount;
}
public function syncOpportunity(string $crmId): ?Opportunity
{
$strategyResolver = app(OpportunitySyncStrategyResolver::class, [
'client' => $this->copperClient,
]);
$strategy = $strategyResolver->resolve(
$this->config,
OpportunitySyncStrategyResolver::SINGLE_SYNC_OPPORTUNITY_STRATEGY
);
$parameters['crm_id'] = $crmId;
$cpOpportunity = $strategy->fetchOpportunities($parameters);
return $this->importOpportunity($cpOpportunity);
}
private function importOpportunity($crmData): ?Opportunity
{
$companyId = (string) $crmData['company_id'];
if (empty($companyId)) {
$this->logger->error(self::OPPORTUNITY_IMPORT_FAILED_ERROR_MESSAGE, [
'error' => 'No company id',
]);
return null;
}
$account = $this->config->accounts()->where('crm_provider_id', $companyId)->first();
if ($account === null) {
$account = $this->syncAccount($companyId);
}
$profile = $this->config->profiles()->where('crm_provider_id', (string) $crmData['assignee_id'])->first();
$businessProcess = $this->config
->businessProcesses()
->where('crm_provider_id', (string) $crmData['pipeline_id'])
->first();
if ($businessProcess === null) {
// It is possible for some deals to not be in a pipeline.
$this->logger->warning(self::OPPORTUNITY_IMPORT_FAILED_ERROR_MESSAGE, [
'error' => 'Missing pipeline',
]);
return null;
}
$stage = $businessProcess
->stages()
->where('crm_provider_id', (string) $crmData['pipeline_stage_id'])
->where('type', Stage::TYPE_OPPORTUNITY)
->first();
if ($stage === null) {
$this->logger->info('Stage not found, importing', [
'pipeline_id' => $crmData['pipeline_id'],
'stage_id' => $crmData['pipeline_stage_id'],
]);
$stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $crmData['pipeline_stage_id']);
}
$recordType = $businessProcess->recordTypes()->first();
$closeDate = null;
$isClosed = $crmData['status'] !== self::OPPORTUNITY_STATUS_OPEN;
if ($crmData['close_date']) {
$closeDate = Carbon::parse($crmData['close_date'])->format('Y-m-d');
}
$data = [
'team_id' => $this->team->getId(),
'account_id' => $account->id,
'user_id' => $profile->user_id ?? null,
'owner_id' => $crmData['assignee_id'],
'name' => mb_strimwidth($crmData['name'], 0, 128),
'value' => $crmData['monetary_value'],
'currency_code' => CurrencyFormatter::formatCode($crmData['monetary_unit']),
'close_date' => $closeDate,
'is_closed' => $isClosed,
'is_won' => $crmData['status'] === 'Won',
'stage_id' => $stage->id,
'record_type_id' => $recordType->id ?? null,
'remotely_created_at' => $crmData['date_created'],
];
/** @var Opportunity $opportunity */
$opportunity = $this->config
->opportunities()
->updateOrCreate(['crm_provider_id' => (string) $crmData['id']], $data)
;
// import external fields into crm_field_data if present
$dealInsightsFields = $this->getOpportunitySyncableFields();
$this->importOpportunityCrmFieldData($crmData, $dealInsightsFields, $opportunity->id);
return $opportunity;
}
/**
* @param array<string, mixed> $opportunityData
* @param string[] $dealInsightsFields
*/
public function importOpportunityCrmFieldData(
array $opportunityData,
array $dealInsightsFields,
int $opportunityId
): void {
foreach ($dealInsightsFields as $fieldName) {
$field = $this->config->fields()->with('entities')
->where('crm_provider_id', $fieldName)
->where('object_type', Field::OBJECT_OPPORTUNITY)
->first();
if (! $field instanceof Field) {
$this->logger->warning('Field not found', [
'field' => $fieldName,
'opportunity_id' => $opportunityId,
]);
continue;
}
$entity = $field->getEntities()->first();
if (! $entity instanceof LayoutEntity) {
continue;
}
$fieldValue = $this->extractFieldValue($opportunityData, $fieldName);
$normalizedValue = $this->normalizeValue($field->getType(), (string) $fieldValue, true);
$field->data()
->updateOrCreate([
'crm_layout_entity_id' => $entity->id,
'crm_field_id' => $field->id,
'object_type' => 'opportunity',
'object_id' => $opportunityId,
], ['value' => $normalizedValue])
;
}
}
/**
* @inheritdoc
*/
public function syncContacts(Carbon $since, ?Carbon $to = null): int
{
$syncCount = 0;
try {
$cpContacts = $this->copperClient->contacts()->paginate([
'minimum_interaction_date' => $since->timestamp,
'sort_by' => 'date_modified',
]);
foreach ($cpContacts as $cpContact) {
// Only sync if previously imported.
if ($this->hasContact($cpContact['id'])) {
$this->importContact($cpContact);
$syncCount++;
}
}
} catch (\Exception $exception) {
// Do nothing for now.
throw $exception;
}
return $syncCount;
}
/**
* @inheritdoc
*/
public function syncContact(string $crmId): ?Contact
{
$cpContact = $this->copperClient->contacts()->find($crmId);
if (empty($cpContact)) {
return null;
}
return $this->importContact($cpContact);
}
/**
* @inheritdoc
*/
private function importContact($crmData): Contact
{
$account = null;
if ($crmData['company_id']) {
$account = $this->team
->crm
->accounts()
->where('crm_provider_id', (string) $crmData['company_id'])
->first();
if ($account === null) {
$account = $this->syncAccount($crmData['company_id']);
}
}
$countryCode = null;
if (empty($crmData['address']) === false && $crmData['address']['country']) {
$countryCode = $this->convertCountryNameToCode($crmData['address']['country']);
}
$mobilePhone = $parsedNumber = null;
foreach ($crmData['phone_numbers'] as $phoneNumber) {
if ($phoneNumber['category'] === 'mobile') {
$mobilePhone = $this->buildContactMobilePhone($countryCode, $phoneNumber['number']);
} else {
$parsedNumber = $this->buildContactPhone($countryCode, $phoneNumber['number']);
}
}
$email = null;
if (empty($crmData['emails']) === false) {
$email = mb_strimwidth($crmData['emails'][0]['email'], 0, 80);
}
$profile = $this->config->profiles()->where('crm_provider_id', (string) $crmData['assignee_id'])->first();
$data = [
'team_id' => $this->team->id,
'account_id' => $account->id ?? null,
'user_id' => $profile ? $profile->user_id : null,
'owner_id' => $crmData['assignee_id'],
'name' => $crmData['name'],
'email' => $email,
'phone' => $parsedNumber['phone'] ?? null,
'mobile_phone' => $mobilePhone ?? null,
'ext' => $parsedNumber['ext'] ?? null,
'title' => $crmData['title'],
'country_code' => $countryCode,
'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(
crmConfiguration: $this->config,
crmProviderId: $crmData['id'],
modelType: Contact::class,
fileName: $crmData['id'],
avatarText: $crmData['name']
),
'remotely_created_at' => $crmData['date_created'],
];
return $this->config->contacts()->updateOrCreate(['crm_provider_id' => (string) $crmData['id']], $data);
}
private function buildContactPhone(?string $countryCode, ?string $number): ?array
{
if ($number) {
// Trim to our width and attempt to parse it.
$number = mb_strimwidth($number, 0, 25);
$parsedNumber = parsePhoneNumber($countryCode, $number);
} else {
$parsedNumber = [];
}
return $parsedNumber;
}
private function buildContactMobilePhone(?string $countryCode, ?string $number): ?string
{
return $number ? mb_strimwidth(phone_e164($countryCode, $number), 0, 25) : null;
}
/**
* @inheritdoc
*/
public function syncOrganization(): void
{
$organization = $this->copperClient->organisation()->all();
if ($this->config->crm_provider_id !== $organization['id']) {
$this->config->update(['crm_provider_id' => $organization['id']]);
}
}
private function syncCustomFields(): void
{
foreach ($this->getFieldTypes() as $fieldType) {
$currentFields = $this->getFields($fieldType);
foreach ($currentFields as $field) {
/** @var Field $field */
$field = $this->config->fields()->updateOrCreate([
'crm_provider_id' => $field['name'],
'object_type' => $fieldType,
], [
'label' => mb_strimwidth($field['label'], 0, Field::LABEL_MAX_LENGTH),
'type' => $field['type'],
]);
if ($field->getType() === Field::TYPE_PICKLIST) {
$this->importPicklistValues($field);
}
}
}
$this->config->save();
}
protected function getFieldTypes(): array
{
return [
parent::OBJECT_TASK,
parent::OBJECT_LEAD,
parent::OBJECT_ACCOUNT,
parent::OBJECT_CONTACT,
parent::OBJECT_OPPORTUNITY,
];
}
/**
* @inheritdoc
*/
public function syncProfiles(?User $userToSearch = null): ?Profile
{
$profiles = $this->copperClient->users()->all();
foreach ($profiles as $profile) {
$user = $this->userRepository->findByTeamAndEmailAddress($this->team, $profile['email'], onlyActive: true);
// Only import profiles for users we have locally.
if ($user) {
$profile = $user->crmProfile()->updateOrCreate([
'crm_configuration_id' => $this->config->id,
'crm_provider_id' => $profile['id'],
]);
if ($userToSearch && $userToSearch->id === $user->id) {
return $profile;
}
}
}
return null;
}
/**
* @inheritdoc
*/
public function syncProfileFields(): void
{
$fieldTypes = [
'task',
'lead',
'account',
'contact',
'opportunity',
];
foreach ($fieldTypes as $fieldType) {
try {
$currentFields = $this->getFields(ucfirst($fieldType));
$newFields = array_column($currentFields, 'name');
$changedFields = array_merge(
array_diff($this->profile->getFieldsAsArray($fieldType), $newFields),
array_diff($newFields, $this->profile->getFieldsAsArray($fieldType))
);
if (\count($changedFields) > 0) {
$fields = implode(',', $newFields);
$this->logger->debug(
ucfirst($fieldType)
. ' fields changed from '
. $this->profile->{$fieldType . '_fields'}
. ' to: '
. $fields
);
$this->profile->{$fieldType . '_fields'} = $fields;
}
} catch (\Exception $exception) {
$this->logger->error('No access to ' . $fieldType . ' object, skipping...');
continue;
}
}
$this->profile->save();
}
/**
* @inheritdoc
*/
public function find(string $name, array $scopes): array
{
$count = $this->limit ?? 20;
$offset = $this->offset ?? 0;
$scopeValues = array_map(fn ($scope) => is_string($scope) ? $scope : $scope->value, $scopes);
$cacheKey = sprintf('copper-search-%d-%s-%s-%d-%d', $this->team->id, $name, implode('-', $scopeValues), $count, $offset);
return Cache::remember($cacheKey, 300, function () use ($name, $scopes, $count, $offset) {
// Try local search first
$localResults = $this->searchLocally($name, $scopes, $count, $offset);
// If no local results, fall back to remote API search
if (empty($localResults)) {
return $this->searchRemotely($name, $scopes, $count, $offset);
}
return $localResults;
});
}
/**
* @inheritdoc
*/
public function findOpportunities(?string $crmAccountId, ?string $crmContactId, ?int $userId = null): array
{
$data = [];
$ownerData = [];
$ownerId = null;
$contact = null;
$account = null;
if ($crmAccountId) {
$account = $this->config->accounts()->where('crm_provider_id', $crmAccountId)->first();
if ($account === null) {
$account = $this->syncAccount($crmAccountId);
}
}
if ($crmContactId) {
$contact = $this->config->contacts()->where('crm_provider_id', $crmContactId)->first();
if ($contact === null) {
$contact = $this->syncContact($crmContactId);
}
}
if ($contact || $account) {
if ($contact && $account === null) {
$account = $contact->account;
}
if ($account === null) {
return [];
}
$params = [
'company_ids' => [$account->crm_provider_id],
'status_ids' => [0], // The integer values are 0, 1, 2, 3, for "Open", "Won", "Lost", and "Abandoned".
'sort_by' => 'date_modified',
'sort_direction' => 'desc',
];
switch ($this->config->opportunity_assignment_rule) {
case Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_UPDATED:
$params['sort_by'] = 'date_modified';
$params['sort_direction'] = 'desc';
break;
case Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_CREATED:
$params['sort_by'] = 'date_created';
$params['sort_direction'] = 'desc';
break;
case Configuration::OPP_ASSIGNMENT_ALL_OPEN_OLDEST_CREATED:
$params['sort_by'] = 'date_created';
$params['sort_direction'] = 'asc';
break;
case Configuration::OPP_ASSIGNMENT_ALL_RECENTLY_UPDATED:
$params['sort_by'] = 'date_modified';
$params['sort_direction'] = 'desc';
$params['status_ids'] = [0, 1, 2, 3];
}
$cpOpportunities = $this->copperClient->opportunities()->search($params);
if ($userId) {
$profileRepository = app(ProfileRepository::class);
$profile = $profileRepository->findProfileByUserId($this->config, $userId);
$ownerId = $profile instanceof Profile ? $profile->getCrmProviderId() : null;
}
foreach ($cpOpportunities as $cpOpportunity) {
$businessProcess = $this->config
->businessProcesses()
->where('crm_provider_id', (string) $cpOpportunity['pipeline_id'])
->first();
if ($businessProcess === null) {
// Import it.
$businessProcess = $this->importStages(
[Stage::TYPE_OPPORTUNITY],
$cpOpportunity['pipeline_stage_id']
);
}
$stage = $businessProcess
->stages()
->where('crm_provider_id', (string) $cpOpportunity['pipeline_stage_id'])
->where('type', Stage::TYPE_OPPORTUNITY)
->first();
if ($stage === null) {
// Import it.
$stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $cpOpportunity['pipeline_stage_id']);
}
$recordType = $businessProcess->recordTypes()->first();
$record = [
'crmId' => $cpOpportunity['id'],
'name' => $cpOpportunity['name'],
'value' => currency_format($cpOpportunity['monetary_value'], $cpOpportunity['monetary_unit']),
'won' => $cpOpportunity['status'] === 'Won',
'stage' => [
'id' => $stage->id_string,
'name' => $stage->name,
],
'recordType' => [
'id' => $recordType->id_string,
'name' => $recordType->name,
],
];
if ($ownerId && isset($cpOpportunity['assignee_id']) && $cpOpportunity['assignee_id'] === $ownerId) {
$ownerData[] = $record;
}
$data[] = $record;
}
}
if (! empty($ownerData)) {
return $ownerData;
}
return $data;
}
/**
* @inheritdoc
*/
public function getTasks(string $objectType, string $objectId, ?string $opportunityId): array
{
$data = $cpTasks = [];
switch ($objectType) {
case 'lead':
$cpTasks = $this->requestWithExceptionHandling(
fn () => $this->copperClient->leads()->relatedTasks($objectId)
);
break;
case 'account':
$cpTasks = $this->copperClient->accounts()->relatedTasks($objectId);
break;
case 'contact':
if ($opportunityId) {
$cpTasks = $this->copperClient->opportunities()->relatedTasks($opportunityId);
} elseif ($objectType === 'contact') {
$cpTasks = $this->copperClient->contacts()->relatedTasks($objectId);
}
break;
}
foreach ($cpTasks as $cpTask) {
// We only get the ID itself returned, so pull out the full object.
try {
$cpTask = $this->copperClient->tasks()->find($cpTask['id']);
} catch (Exception $exception) {
$this->logger->error('[Copper] Error fetching task', [
'resource_id' => $cpTask['id'],
'error' => $exception->getMessage(),
]);
continue;
}
// Only allow open tasks.
if ($cpTask['status'] === 'Open') {
$data[] = [
'crmId' => $cpTask['id'],
'subject' => $cpTask['name'],
'due' => $cpTask['due_date']
? Carbon::createFromTimestamp($cpTask['due_date'])->format('Y-m-d\TH:i:sO')
: null,
'type' => null,
];
}
}
return $data;
}
/**
* Try to find email address in CRM service
*
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
*}
*/
public function matchExactlyByEmail(string $email, ?int $userId = null): ?array
{
$cpObjectType = 'contact';
$cpObjects = $this->copperClient->contacts()->search([
'emails' => $email,
'sort_by' => 'date_modified',
'sort_direction' => 'desc',
]);
if (empty($cpObjects)) {
$cpObjectType = 'lead';
$cpObjects = $this->requestWithExceptionHandling(
fn () => $this->copperClient->leads()->search([
'emails' => ...
|
45357
|
NULL
|
NULL
|
NULL
|
|
45360
|
1625
|
17
|
2026-05-14T14:32:44.935525+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-14/1778 /Users/lukas/.screenpipe/data/data/2026-05-14/1778769164935_m1.jpg...
|
PhpStorm
|
faVsco.js – Copper/Service.php
|
1
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
PhpStormFileEditViewNavigateCodeLaravelRefactorRun PhpStormFileEditViewNavigateCodeLaravelRefactorRunToolsGitWindowHelp•FV faVsco.jsv* #12066 on JY-20725-handle-HS-search-rate-limit k vabl19HandleHubspotRateLimitTest100% C8• Thu 14 May 17:32:44* :QProjectv=custom.logAutomatedReportGenerated.phpTrackAutomatedReportGeneratedEvent.phpPlaybackController.php© FieldDefinitions.phpUserAutomatedReportsController.phpPlanhatService.phpAutomatedReportResult.phpSendReportJob.php© FieldValueConverter.php• DeleteCrmEntityTrait.phpDeleteAccountJob.php© ImportActivityTypes.phpT WriteCrmTrait.php© Service.php© DecorateActivity.php© StandardFieldMetadata.php© Salesforce/Service.php© LogActivityTrait.phpPipedrive/Service.phpClose/Service.phpCopper/Service.php Xv D Copper© PlainTextDecorateActivity.phpT ActivityPlaybookTrait.php© CrmHelperRepository.phpAccountController.php18> D Api› D ExceptionT IntegrationAppTrait.php.env.stagingE.env© DetachActivityObject.phpRematchActivityOnCrmObjectDetach.php> C OpportunitySyncStrategy© MatchActivityCrmData.php> D ProspectSearchStrategy52© Client.phpclass Service extends BaseService implements© HubspotPaginationService.php© HandleHubspotRateLimit.phpA11 A72 X1 X4 ^> CJ Traits© FieldDefinitions.php16621663* Othrows CrmException© FieldValueConverter.php© Service.php*/1 usage> D CrmObjects1664private function buildCallPayload(Activity $activity): Activityv DecorateActivity1665© BaseDecorateActivity.php1666try{© PlainTextDecorateActivity.pl1667> D Dummy1668v Helpers1669T ActivityPlaybookTrait.php1670© Arraylterator.php1671if (Sactivity->crm_provider_id) {// The activity should be logged under the existing Task (not Activity).SnewActivityType = Sthis->getCrmType(Sactivity->category);if ($newActivityType === null) {$message = sprintf( format: '[Copper] invalid CRM Custom Activity Type: %s', $activity->ca224• ConnectionStateTrait.php1672© CrmHelperRepository.php1673throw new CrmException(Smessage,code: 400);© FilterJoinedParticipants.php1674* OpportunitySyncableFieldsT1675v D Hubspot1676• AccountSyncStrategy1677> OJ Actions |1678$this->copperClient->tasks()->update($activity->crm_provider_id, ['status'= 'Completed','custom_activity_type_id' => $newActivityType->value,'details' => $this->generateActivityDescription($activity),• ContactSyncStrategy1679> ODTO16801):j else {› Fields1681> • Journal1682if (Sthis-~getCrmType(SactivitX-scategocx)minulln$message = sprintf( format:' [Copper] Invalid CRM Activity Type: %s', Sactivity->category→D Metadata1683v D OpportunitySyncStrategy1684throw new CrmException($message,code: 400);> D Concerns1685=A Unhonntl actlAndifiaNDur 1686Workspace associated with branch 'JY-20725-handle-HS-search-rate-limit' has been restored // Rollback // Configure... (today 16:17)=laravel.logSF [jiminny@localhost]A HS_local [jiminny@localho:W4 console [QAI PROD] X4 console [PROD]A console (EU]DGo jiminny084143 ×4 л215|•m_layout_id =2162,1661,66799,66217218219id = 33;=220=221=222=223id THEN •(own=225=226= 227228229=230hubspat":.231I232233V= 11512582;=234-W Windsurf Teams1664:31UTF-8P. 4 spaces...
|
NULL
|
5212527082351262392
|
NULL
|
click
|
ocr
|
NULL
|
PhpStormFileEditViewNavigateCodeLaravelRefactorRun PhpStormFileEditViewNavigateCodeLaravelRefactorRunToolsGitWindowHelp•FV faVsco.jsv* #12066 on JY-20725-handle-HS-search-rate-limit k vabl19HandleHubspotRateLimitTest100% C8• Thu 14 May 17:32:44* :QProjectv=custom.logAutomatedReportGenerated.phpTrackAutomatedReportGeneratedEvent.phpPlaybackController.php© FieldDefinitions.phpUserAutomatedReportsController.phpPlanhatService.phpAutomatedReportResult.phpSendReportJob.php© FieldValueConverter.php• DeleteCrmEntityTrait.phpDeleteAccountJob.php© ImportActivityTypes.phpT WriteCrmTrait.php© Service.php© DecorateActivity.php© StandardFieldMetadata.php© Salesforce/Service.php© LogActivityTrait.phpPipedrive/Service.phpClose/Service.phpCopper/Service.php Xv D Copper© PlainTextDecorateActivity.phpT ActivityPlaybookTrait.php© CrmHelperRepository.phpAccountController.php18> D Api› D ExceptionT IntegrationAppTrait.php.env.stagingE.env© DetachActivityObject.phpRematchActivityOnCrmObjectDetach.php> C OpportunitySyncStrategy© MatchActivityCrmData.php> D ProspectSearchStrategy52© Client.phpclass Service extends BaseService implements© HubspotPaginationService.php© HandleHubspotRateLimit.phpA11 A72 X1 X4 ^> CJ Traits© FieldDefinitions.php16621663* Othrows CrmException© FieldValueConverter.php© Service.php*/1 usage> D CrmObjects1664private function buildCallPayload(Activity $activity): Activityv DecorateActivity1665© BaseDecorateActivity.php1666try{© PlainTextDecorateActivity.pl1667> D Dummy1668v Helpers1669T ActivityPlaybookTrait.php1670© Arraylterator.php1671if (Sactivity->crm_provider_id) {// The activity should be logged under the existing Task (not Activity).SnewActivityType = Sthis->getCrmType(Sactivity->category);if ($newActivityType === null) {$message = sprintf( format: '[Copper] invalid CRM Custom Activity Type: %s', $activity->ca224• ConnectionStateTrait.php1672© CrmHelperRepository.php1673throw new CrmException(Smessage,code: 400);© FilterJoinedParticipants.php1674* OpportunitySyncableFieldsT1675v D Hubspot1676• AccountSyncStrategy1677> OJ Actions |1678$this->copperClient->tasks()->update($activity->crm_provider_id, ['status'= 'Completed','custom_activity_type_id' => $newActivityType->value,'details' => $this->generateActivityDescription($activity),• ContactSyncStrategy1679> ODTO16801):j else {› Fields1681> • Journal1682if (Sthis-~getCrmType(SactivitX-scategocx)minulln$message = sprintf( format:' [Copper] Invalid CRM Activity Type: %s', Sactivity->category→D Metadata1683v D OpportunitySyncStrategy1684throw new CrmException($message,code: 400);> D Concerns1685=A Unhonntl actlAndifiaNDur 1686Workspace associated with branch 'JY-20725-handle-HS-search-rate-limit' has been restored // Rollback // Configure... (today 16:17)=laravel.logSF [jiminny@localhost]A HS_local [jiminny@localho:W4 console [QAI PROD] X4 console [PROD]A console (EU]DGo jiminny084143 ×4 л215|•m_layout_id =2162,1661,66799,66217218219id = 33;=220=221=222=223id THEN •(own=225=226= 227228229=230hubspat":.231I232233V= 11512582;=234-W Windsurf Teams1664:31UTF-8P. 4 spaces...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
45361
|
1626
|
10
|
2026-05-14T14:32:45.610188+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-14/1778 /Users/lukas/.screenpipe/data/data/2026-05-14/1778769165610_m2.jpg...
|
PhpStorm
|
faVsco.js – Copper/Service.php
|
1
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
ActivityFilesLaterJiminny... ~@ jiminny-x-integrat ActivityFilesLaterJiminny... ~@ jiminny-x-integrati& platform-inner-team© Channels# ai-chapter# alertsi backend# bugscontusion-cllnia# curiosity_lab# engineering# general# jiminny-bg# platform-tickets# product_launches# random# releases# sofia-office# support# thank-yous# the people of iimi..• Direct messages8. A...• A3 62P. Galya Dimitrova Mvasil VasilevA. Stefka Stoyanova%: Todor Stamatovf. Mario GeorgievP. Nikolay Ivanov2o James Graham "2. Stoyan Tanev. Steliyan Georgiev. Petko Kashinski*. Lukas Kovali...a: Apps® ToastS lira Gloud6 Huddle with Aneliya Angelova& R. Aneliya Angelova •• Messagest Add canvasur FilesAneliya Angelova 2:30 PMЛукаш, само при Сейлсфорс и Хубспот се синкваха активити типовете, когато се направи плейбук,нали?пои останалите скМі тоябва оъчно ла се въвелат.Lukas Kovalik 2:47 PMтряова да се за всички, някьде не се ли попьлвапри зохо май беше hardcoded но май и там си връщаха две категорииAneliya Angelova @ 4:00 PMЛукаш имаш ли време да се чуем за тестването на https://jiminny.atlassian.net/browse/JY-20725X Preview insackt[HubSpot] Optimise CRM rematctC OpenReady for QA- MediumAA Aneliya AngelovaAs of today at 4:00 PM RetreshOpen in Jira+ SummariseLukas Kovalik 4:02 PMzean-iurwiniareelYou joined the huddle LIVE 4:06 PMAneliva Angelova is here toolAneliya Angelova 4:44 PM11512582Lukas Kovalik O 5:16 PMcurl --location "httns:ani.hubani.com/crm/v3/obiects/contacts/search--header 'Content-Type: application/json'\--header "Authorization: BearerC|Kvma?iMx7OINOM8kOFwrAowAcAkUAhIR24?A05D?xiVrIYMILuP6SA01Kwcmhr=n.v2 hytorDniMvDalYck-CMalovolNOM9kOFwrAUAcrkcaws-ThwRARIRAOF-ATFSAOFRAO-ROIEBAQUBEggBAQEBAYICFIґ7Au8O2Nwal9YW2CbCCxYvwngmSgNldTFSAFoAYABoAHAAeAA'\--data "{ "limit", 13'Message Aneliva Angelova+ AaIđ 0Pm~ (Q SearchDate ModifiedYesterdav at 13.36Yesterday at 13:35Yesterday at 13.34Yesterday at 13:34Yesterday at 13:33resterday at 15.5sYesterdav at 13:37Yesterday at 13:32Yesterday ar 13.31Yesterday at 13:30Yesterday at 13:30resterday at 15.4gYesterdav at 13:29Yesterday at 13:28Yesterday at 13:28Yesterday at 13:27Yesterday at 13:27resterday at 13-40Yesterdav at 13:25Yesterday at 13:25Yesterday at 13:24Yesterday at 13:24Yesterday at 13:23Yesterday at 13.21Yesterdav at 13:21Yesterday at 13:20Yesterday at 13:20Yesterday at 13:19Yesterday at 13:19Yesterday at 13.18Vecterdav at 12:17Yesterday at 13:16Yesterdav at 13:16Yesterday at 13:15Yesterday at 13:14Yecterdav at 12:1/Yesterday at 13:13Yesterday at 13.13Yecterdav at 12:12Yesterday at 13:12Yesterday at 13:11Yesterday at 13:10Yesterday at 13:10Yesterday at 13.09Yecterdav at 12:091Yesterday at 13:08Yesterdav at 13:07MPEG-4 movie13 KBMPEG-4 movie16 KB1/ Kb29 KBMPEG-4 movieMPEG-4 movie6 KB12 KBMPE0"4 movie9 K:7 KBMPEG-4 movie8 KB37 KBMP2G-4 movie10 KBMPEG-4 movie7 KBMPEG"4 movie9K:8KBMPEG-4 movie72 KB14 K8|13 KRIMPEG-4 movieMPEG-4 movie9 KB18 KBMPEG-4 movie12 KEMPEG-4 movie10 KBMPEG-4 movie16 KB6 KB1MPEG-4 movie6 KBMPEG-4 movie12 KB23 KBMPEG-4 movie8 KEMPEG-4 movie6 KBMPEG-4 movie6 KBMPEG-4 movie11 K:MPEG-4 movie11 KBMPEG-4 movie34 KBMPEG-4 movie10 KPMPEG-4 movie7 KBMPEG-4 movie5 KBMPEG-4 movie11 KBIMPEG-4 movie26 KBMPEG-4 movie111 KB102 KBRR KRMPEG-4 movieMDEG-A movie59 KB98 KBMPEG-4 movie07 K:MPEG-A movie66 KB MPEG-4 movie44 KB93 KB79 KpMPEG-4 movieMPEG-4 movie58 KBMPEG-4 movie27 KP7 KBMPEG-4 movie12 KB32 K8MPEG-4 movieAl Notes: OffLeave• 0FavouritesE jiminny© Recents* ApplicationsiCloudiCloud Drive228 Sync folderQ DXP4800PLUS-B5FA@ Network|• CRMI• Orange• Red• Yellow• Green• Purple•) All lags..lohlDownloadsNameLoom.pkgAlfred copv.alfredoreterencesB KeychronAssist-1.0.2 (1).dmgA Keychron Assist-1.0.2.dmgmazanoke-images-yWJo.zioPhotos-3-001.zipD Transcript.pdf→mage U.loge1 Orioninstaller.dmaimage.jpg- image (2).1pc• ПО-22221726037035-004-001_ORGES.pdf• %D0%9F%D0%9E-22221726037035-004-001_archive.zipПO-22221726037035-004-001_archive (1).zip• repon 4).XmAltred copy2.altredoreterences05012026_0000000026574472_ SWIFT_OB70501260015890.pdf27022026_0000000026574472_SWIFT_OB72702260049200.pdf= 03042026 [CREDIT_CARD] SWIFT [CREDIT_CARD].001B reporti) xm=pdt.odipdf-1.pdfD pdf-2.pdf-pdf-5.pd1= ndf-1 ndipdf-3.pdfKoválik Family Tree.gedbitwarden export 20251031122528.isonKoválik Family Tree.zip*macOS Storage_Cleanup.mdal favicon icofirst_aid_notes_complete.docxrenortl2).esvconfig.ymlIteration run Search HS.postman_collection.jsonm licence bettertouchtoalMariusHosting Config.json1ooks-891a6503-bbb7-4b2b-9c3.csv•Alfredmazanoke-images-YWJ6ана Ковалик.jpg•искане даниел Ковалик..pa• Фактура Март Даниел Ковалик.jрс• Фактура Април Даниел Ковалик.jpg• Dhotac 2.001Q SearchKind00,4 MDinstdlle..dckage55.9 MBAlfred...ferences10,1 MBDisk Image10,1 MBDisk ImageIL MBLiP archive6,6 MBZIP archive2,5 MB PDF Document2,5 MBJreo lmage2.2 MBDisk Image2,2 MB PDF Document2 MBJPEG image1,9 MBJPEG imaqe192 KBPDF Document140 KbZIP archive148 KВ148 KB122 KB111 KB94 KBZIP archivelZIP archiveXML documentAlfred...ferencesPDF Document92 KBPDr DocumentK:91 KB91 KB30 KB29 KBPDF DocumentXML documentPDF Document28 KBPDF Document28 KR28 KB27 KB14 KB11 KB6 KB6KBPDE DocumentDocumentJSONICSV DocumentZIP archiveMarkdo…..ument5KRWindo...n image4 KB3 KBword ..cumentCSV Document2 KBYAML document1 KBcSV Document928 byteshttlicence183 bytesZero butesJSONAlfred.. ferencesZero bytesFolde1,9 MB1,8 MB17 MB1,7 MBColdorJPEG imageJPEG ImageIPEG imadeJPEG imageColdo1 of 58 selected, 8.5 GB availab!i• Inu 14 Mау 1/•32.42Date AddedIs Mdl ZUzo dl 19:4530 Jan 2026 at 12:3617 Mar 2026 at 20:2717 Mar 2026 at 20:26Z3 Aor 2020 al 13:0229 Jan 2026 at 15:2019 Dec 2025 at 10:1619 Dec 2025 at 12:238 Aor 2026 at 20:3519 Dec 2025 at 10:2919 Dec 2025 at 12:1819 Dec 2025 at 12:4026 Mar 2026 at 11:2410 May 2026 at 13:5326 Mar 2026 at 11:2426 Mar 2026 at 11:2426 Mar 2026 at 11:2310 May 2026 at 14:3730 Jan 2026 at 12:3713 Feb 2026 at 11:54Z3 Apr 2026 at 13.0823 Aor 2026 at 13:0810 May 2026 at 13:5410 May 2026 at 14:3710 May 2026 at 13:4910 May 2026 at 13:5010 May 2026 at 13:5110 Mav 2026 at 12:51110 May 2026 at 13:5019 Dec 2025 at 11:3431 Oct 2025 at 12:2525 Nov 2025 at 17:596 Mar 2026 at 11.2224 Anr 2026 at 16:5220 Oct 2025 at 11:0218 Mar 2026 at 15:29• Mav 2026 at 11:09129 Oct 2025 at 19:329 May 2026 at 10:0418 Mar 2026 at 11:5510 May 2026 at 13:5712 Jun 2025 at 19:0430 Jan 2026 at 12:3630 Jan 2026 at 12:3616 Oct 2025 aт 16:0123 Apr 2026 at 13:0223 Apr 2026 at 13:0223 Apr 2026 at 13:0222 Anr 2026 at 12:0223 Apr 2026 at 13:0229 Jan 2026 at 15:20...
|
NULL
|
-4760999809465567111
|
NULL
|
click
|
ocr
|
NULL
|
ActivityFilesLaterJiminny... ~@ jiminny-x-integrat ActivityFilesLaterJiminny... ~@ jiminny-x-integrati& platform-inner-team© Channels# ai-chapter# alertsi backend# bugscontusion-cllnia# curiosity_lab# engineering# general# jiminny-bg# platform-tickets# product_launches# random# releases# sofia-office# support# thank-yous# the people of iimi..• Direct messages8. A...• A3 62P. Galya Dimitrova Mvasil VasilevA. Stefka Stoyanova%: Todor Stamatovf. Mario GeorgievP. Nikolay Ivanov2o James Graham "2. Stoyan Tanev. Steliyan Georgiev. Petko Kashinski*. Lukas Kovali...a: Apps® ToastS lira Gloud6 Huddle with Aneliya Angelova& R. Aneliya Angelova •• Messagest Add canvasur FilesAneliya Angelova 2:30 PMЛукаш, само при Сейлсфорс и Хубспот се синкваха активити типовете, когато се направи плейбук,нали?пои останалите скМі тоябва оъчно ла се въвелат.Lukas Kovalik 2:47 PMтряова да се за всички, някьде не се ли попьлвапри зохо май беше hardcoded но май и там си връщаха две категорииAneliya Angelova @ 4:00 PMЛукаш имаш ли време да се чуем за тестването на https://jiminny.atlassian.net/browse/JY-20725X Preview insackt[HubSpot] Optimise CRM rematctC OpenReady for QA- MediumAA Aneliya AngelovaAs of today at 4:00 PM RetreshOpen in Jira+ SummariseLukas Kovalik 4:02 PMzean-iurwiniareelYou joined the huddle LIVE 4:06 PMAneliva Angelova is here toolAneliya Angelova 4:44 PM11512582Lukas Kovalik O 5:16 PMcurl --location "httns:ani.hubani.com/crm/v3/obiects/contacts/search--header 'Content-Type: application/json'\--header "Authorization: BearerC|Kvma?iMx7OINOM8kOFwrAowAcAkUAhIR24?A05D?xiVrIYMILuP6SA01Kwcmhr=n.v2 hytorDniMvDalYck-CMalovolNOM9kOFwrAUAcrkcaws-ThwRARIRAOF-ATFSAOFRAO-ROIEBAQUBEggBAQEBAYICFIґ7Au8O2Nwal9YW2CbCCxYvwngmSgNldTFSAFoAYABoAHAAeAA'\--data "{ "limit", 13'Message Aneliva Angelova+ AaIđ 0Pm~ (Q SearchDate ModifiedYesterdav at 13.36Yesterday at 13:35Yesterday at 13.34Yesterday at 13:34Yesterday at 13:33resterday at 15.5sYesterdav at 13:37Yesterday at 13:32Yesterday ar 13.31Yesterday at 13:30Yesterday at 13:30resterday at 15.4gYesterdav at 13:29Yesterday at 13:28Yesterday at 13:28Yesterday at 13:27Yesterday at 13:27resterday at 13-40Yesterdav at 13:25Yesterday at 13:25Yesterday at 13:24Yesterday at 13:24Yesterday at 13:23Yesterday at 13.21Yesterdav at 13:21Yesterday at 13:20Yesterday at 13:20Yesterday at 13:19Yesterday at 13:19Yesterday at 13.18Vecterdav at 12:17Yesterday at 13:16Yesterdav at 13:16Yesterday at 13:15Yesterday at 13:14Yecterdav at 12:1/Yesterday at 13:13Yesterday at 13.13Yecterdav at 12:12Yesterday at 13:12Yesterday at 13:11Yesterday at 13:10Yesterday at 13:10Yesterday at 13.09Yecterdav at 12:091Yesterday at 13:08Yesterdav at 13:07MPEG-4 movie13 KBMPEG-4 movie16 KB1/ Kb29 KBMPEG-4 movieMPEG-4 movie6 KB12 KBMPE0"4 movie9 K:7 KBMPEG-4 movie8 KB37 KBMP2G-4 movie10 KBMPEG-4 movie7 KBMPEG"4 movie9K:8KBMPEG-4 movie72 KB14 K8|13 KRIMPEG-4 movieMPEG-4 movie9 KB18 KBMPEG-4 movie12 KEMPEG-4 movie10 KBMPEG-4 movie16 KB6 KB1MPEG-4 movie6 KBMPEG-4 movie12 KB23 KBMPEG-4 movie8 KEMPEG-4 movie6 KBMPEG-4 movie6 KBMPEG-4 movie11 K:MPEG-4 movie11 KBMPEG-4 movie34 KBMPEG-4 movie10 KPMPEG-4 movie7 KBMPEG-4 movie5 KBMPEG-4 movie11 KBIMPEG-4 movie26 KBMPEG-4 movie111 KB102 KBRR KRMPEG-4 movieMDEG-A movie59 KB98 KBMPEG-4 movie07 K:MPEG-A movie66 KB MPEG-4 movie44 KB93 KB79 KpMPEG-4 movieMPEG-4 movie58 KBMPEG-4 movie27 KP7 KBMPEG-4 movie12 KB32 K8MPEG-4 movieAl Notes: OffLeave• 0FavouritesE jiminny© Recents* ApplicationsiCloudiCloud Drive228 Sync folderQ DXP4800PLUS-B5FA@ Network|• CRMI• Orange• Red• Yellow• Green• Purple•) All lags..lohlDownloadsNameLoom.pkgAlfred copv.alfredoreterencesB KeychronAssist-1.0.2 (1).dmgA Keychron Assist-1.0.2.dmgmazanoke-images-yWJo.zioPhotos-3-001.zipD Transcript.pdf→mage U.loge1 Orioninstaller.dmaimage.jpg- image (2).1pc• ПО-22221726037035-004-001_ORGES.pdf• %D0%9F%D0%9E-22221726037035-004-001_archive.zipПO-22221726037035-004-001_archive (1).zip• repon 4).XmAltred copy2.altredoreterences05012026_0000000026574472_ SWIFT_OB70501260015890.pdf27022026_0000000026574472_SWIFT_OB72702260049200.pdf= 03042026 [CREDIT_CARD] SWIFT [CREDIT_CARD].001B reporti) xm=pdt.odipdf-1.pdfD pdf-2.pdf-pdf-5.pd1= ndf-1 ndipdf-3.pdfKoválik Family Tree.gedbitwarden export 20251031122528.isonKoválik Family Tree.zip*macOS Storage_Cleanup.mdal favicon icofirst_aid_notes_complete.docxrenortl2).esvconfig.ymlIteration run Search HS.postman_collection.jsonm licence bettertouchtoalMariusHosting Config.json1ooks-891a6503-bbb7-4b2b-9c3.csv•Alfredmazanoke-images-YWJ6ана Ковалик.jpg•искане даниел Ковалик..pa• Фактура Март Даниел Ковалик.jрс• Фактура Април Даниел Ковалик.jpg• Dhotac 2.001Q SearchKind00,4 MDinstdlle..dckage55.9 MBAlfred...ferences10,1 MBDisk Image10,1 MBDisk ImageIL MBLiP archive6,6 MBZIP archive2,5 MB PDF Document2,5 MBJreo lmage2.2 MBDisk Image2,2 MB PDF Document2 MBJPEG image1,9 MBJPEG imaqe192 KBPDF Document140 KbZIP archive148 KВ148 KB122 KB111 KB94 KBZIP archivelZIP archiveXML documentAlfred...ferencesPDF Document92 KBPDr DocumentK:91 KB91 KB30 KB29 KBPDF DocumentXML documentPDF Document28 KBPDF Document28 KR28 KB27 KB14 KB11 KB6 KB6KBPDE DocumentDocumentJSONICSV DocumentZIP archiveMarkdo…..ument5KRWindo...n image4 KB3 KBword ..cumentCSV Document2 KBYAML document1 KBcSV Document928 byteshttlicence183 bytesZero butesJSONAlfred.. ferencesZero bytesFolde1,9 MB1,8 MB17 MB1,7 MBColdorJPEG imageJPEG ImageIPEG imadeJPEG imageColdo1 of 58 selected, 8.5 GB availab!i• Inu 14 Mау 1/•32.42Date AddedIs Mdl ZUzo dl 19:4530 Jan 2026 at 12:3617 Mar 2026 at 20:2717 Mar 2026 at 20:26Z3 Aor 2020 al 13:0229 Jan 2026 at 15:2019 Dec 2025 at 10:1619 Dec 2025 at 12:238 Aor 2026 at 20:3519 Dec 2025 at 10:2919 Dec 2025 at 12:1819 Dec 2025 at 12:4026 Mar 2026 at 11:2410 May 2026 at 13:5326 Mar 2026 at 11:2426 Mar 2026 at 11:2426 Mar 2026 at 11:2310 May 2026 at 14:3730 Jan 2026 at 12:3713 Feb 2026 at 11:54Z3 Apr 2026 at 13.0823 Aor 2026 at 13:0810 May 2026 at 13:5410 May 2026 at 14:3710 May 2026 at 13:4910 May 2026 at 13:5010 May 2026 at 13:5110 Mav 2026 at 12:51110 May 2026 at 13:5019 Dec 2025 at 11:3431 Oct 2025 at 12:2525 Nov 2025 at 17:596 Mar 2026 at 11.2224 Anr 2026 at 16:5220 Oct 2025 at 11:0218 Mar 2026 at 15:29• Mav 2026 at 11:09129 Oct 2025 at 19:329 May 2026 at 10:0418 Mar 2026 at 11:5510 May 2026 at 13:5712 Jun 2025 at 19:0430 Jan 2026 at 12:3630 Jan 2026 at 12:3616 Oct 2025 aт 16:0123 Apr 2026 at 13:0223 Apr 2026 at 13:0223 Apr 2026 at 13:0222 Anr 2026 at 12:0223 Apr 2026 at 13:0229 Jan 2026 at 15:20...
|
45359
|
NULL
|
NULL
|
NULL
|
|
45362
|
1625
|
18
|
2026-05-14T14:32:47.948366+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-14/1778 /Users/lukas/.screenpipe/data/data/2026-05-14/1778769167948_m1.jpg...
|
iTerm2
|
NULL
|
1
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
PhpStormFileEditViewNavigateCodeLaravelRefactorRun PhpStormFileEditViewNavigateCodeLaravelRefactorRunToolsGitWindowHelp•FV faVsco.jsv* #12066 on JY-20725-handle-HS-search-rate-limit K vabl19HandleHubspotRateLimitTest100% C* :8• Thu 14 May 17:32:47QProjectv=custom.logAutomatedReportGenerated.phpTrackAutomatedReportGeneratedEvent.phpPlaybackController.php© FieldDefinitions.phpUserAutomatedReportsController.phpPlanhatService.phpAutomatedReportResult.phpSendReportJob.php© FieldValueConverter.php• DeleteCrmEntityTrait.phpDeleteAccountJob.php© ImportActivityTypes.phpT WriteCrmTrait.php© Service.php© DecorateActivity.php© StandardFieldMetadata.php© Salesforce/Service.php© LogActivityTrait.phpPipedrive/Service.phpClose/Service.phpCopper/Service.php Xv D Copper© PlainTextDecorateActivity.phpT ActivityPlaybookTrait.php© CrmHelperRepository.phpAccountController.php18› DJ Api› D ExceptionT IntegrationAppTrait.php.env.stagingE.env© DetachActivityObject.phpRematchActivityOnCrmObjectDetach.php> C OpportunitySyncStrategy© MatchActivityCrmData.php> D ProspectSearchStrategyClient.phpclass Service extends BaseService implements© HubspotPaginationService.php© HandleHubspotRateLimit.php> CJ Traits© FieldDefinitions.php5216621663A11 A72 X1 X4 ^* Othrows CrmException© FieldValueConverter.php© Service.php> D CrmObjects1664v DecorateActivity1665*/1 usage{private function buildCallPayload(Activity $activity): Activity© BaseDecorateActivity.php1666© PlainTextDecorateActivity.pl1667> D Dummy1668v Helpers1669T ActivityPlaybookTrait.php1670© Arraylterator.php1671try {if (Sadtivity->crm_provider_id) {// The activity should be logged under the existing Task (not Activity).SnewActivityType = Sthis->getCrmType(Sactivity->category);if ($newActivityType === null) {$message = sprintf( format: '[Copper] invalid CRM Custom Activity Type: %s', $activity->caz224• ConnectionStateTrait.php1672© CrmHelperRepository.php1673throw new CrmException(Smessage,code: 400):© FilterJoinedParticipants.php1674* OpportunitySyncableFieldsT1675v D Hubspot1676• AccountSyncStrategy1677> OJ Actions |1678$this->copperClient->tasks()->update($activity->crm_provider_id, ['status'= 'Completed','custom_activity_type_id' => $newActivityType->valve,'details' => $this->generateActivityDescription(Sactivity),• ContactSyncStrategy1679> ODTO16801):j else {› Fields1681> • Journal1682it.(Sthis-getCrmType(Sactivitx-2categ0cX2mm.nulWnf$message = sprintf( format:'[Copper] Invalid CRM Activity Type: %s',Sactivity->category→D Metadata1683v D OpportunitySyncStrategy1684throw new CrmException($message,code: 400);> D Concerns1685=A Unhonntl actlAndifiaNDur 1686Workspace associated with branch 'JY-20725-handle-HS-search-rate-limit' has been restored // Rollback // Configure... (today 16:17)=laravel.logSF [jiminny@localhost]A HS_local [jiminny@localho:W4 console [QAI PROD] X4 console [PROD]A console (EU]DGo jiminny084143 ×4 л215|•m_layout_id =2162,1661,66799,66217218219id = 33;=220=221==222=223id THEN '(own=225=226= 227228-229=230hubspat":-231: 232233 V= 11512582;234-W Windsurf Teams1667:20UTF-8Co 4 spaces...
|
NULL
|
5098640275238095072
|
NULL
|
click
|
ocr
|
NULL
|
PhpStormFileEditViewNavigateCodeLaravelRefactorRun PhpStormFileEditViewNavigateCodeLaravelRefactorRunToolsGitWindowHelp•FV faVsco.jsv* #12066 on JY-20725-handle-HS-search-rate-limit K vabl19HandleHubspotRateLimitTest100% C* :8• Thu 14 May 17:32:47QProjectv=custom.logAutomatedReportGenerated.phpTrackAutomatedReportGeneratedEvent.phpPlaybackController.php© FieldDefinitions.phpUserAutomatedReportsController.phpPlanhatService.phpAutomatedReportResult.phpSendReportJob.php© FieldValueConverter.php• DeleteCrmEntityTrait.phpDeleteAccountJob.php© ImportActivityTypes.phpT WriteCrmTrait.php© Service.php© DecorateActivity.php© StandardFieldMetadata.php© Salesforce/Service.php© LogActivityTrait.phpPipedrive/Service.phpClose/Service.phpCopper/Service.php Xv D Copper© PlainTextDecorateActivity.phpT ActivityPlaybookTrait.php© CrmHelperRepository.phpAccountController.php18› DJ Api› D ExceptionT IntegrationAppTrait.php.env.stagingE.env© DetachActivityObject.phpRematchActivityOnCrmObjectDetach.php> C OpportunitySyncStrategy© MatchActivityCrmData.php> D ProspectSearchStrategyClient.phpclass Service extends BaseService implements© HubspotPaginationService.php© HandleHubspotRateLimit.php> CJ Traits© FieldDefinitions.php5216621663A11 A72 X1 X4 ^* Othrows CrmException© FieldValueConverter.php© Service.php> D CrmObjects1664v DecorateActivity1665*/1 usage{private function buildCallPayload(Activity $activity): Activity© BaseDecorateActivity.php1666© PlainTextDecorateActivity.pl1667> D Dummy1668v Helpers1669T ActivityPlaybookTrait.php1670© Arraylterator.php1671try {if (Sadtivity->crm_provider_id) {// The activity should be logged under the existing Task (not Activity).SnewActivityType = Sthis->getCrmType(Sactivity->category);if ($newActivityType === null) {$message = sprintf( format: '[Copper] invalid CRM Custom Activity Type: %s', $activity->caz224• ConnectionStateTrait.php1672© CrmHelperRepository.php1673throw new CrmException(Smessage,code: 400):© FilterJoinedParticipants.php1674* OpportunitySyncableFieldsT1675v D Hubspot1676• AccountSyncStrategy1677> OJ Actions |1678$this->copperClient->tasks()->update($activity->crm_provider_id, ['status'= 'Completed','custom_activity_type_id' => $newActivityType->valve,'details' => $this->generateActivityDescription(Sactivity),• ContactSyncStrategy1679> ODTO16801):j else {› Fields1681> • Journal1682it.(Sthis-getCrmType(Sactivitx-2categ0cX2mm.nulWnf$message = sprintf( format:'[Copper] Invalid CRM Activity Type: %s',Sactivity->category→D Metadata1683v D OpportunitySyncStrategy1684throw new CrmException($message,code: 400);> D Concerns1685=A Unhonntl actlAndifiaNDur 1686Workspace associated with branch 'JY-20725-handle-HS-search-rate-limit' has been restored // Rollback // Configure... (today 16:17)=laravel.logSF [jiminny@localhost]A HS_local [jiminny@localho:W4 console [QAI PROD] X4 console [PROD]A console (EU]DGo jiminny084143 ×4 л215|•m_layout_id =2162,1661,66799,66217218219id = 33;=220=221==222=223id THEN '(own=225=226= 227228-229=230hubspat":-231: 232233 V= 11512582;234-W Windsurf Teams1667:20UTF-8Co 4 spaces...
|
45360
|
NULL
|
NULL
|
NULL
|
|
45363
|
1626
|
11
|
2026-05-14T14:32:51.284451+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-14/1778 /Users/lukas/.screenpipe/data/data/2026-05-14/1778769171284_m2.jpg...
|
PhpStorm
|
faVsco.js – Copper/Service.php
|
1
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
ActivityFilesLaterJiminny... ~@ jiminny-x-integrat ActivityFilesLaterJiminny... ~@ jiminny-x-integrati& platform-inner-team© Channels# ai-chapter# alertsi backend# bugscontusion-cllnia# curiosity_lab# engineering# general# jiminny-bg# platform-tickets# product_launches# random# releases# sofia-office# support# thank-yous# the people of iimi..• Direct messages8. A...• A3 62P. Galya Dimitrova Mvasil VasilevA. Stefka Stoyanova%: Todor Stamatovf. Mario GeorgievP. Nikolay Ivanov2o James Graham "2. Stoyan Tanev. Steliyan Georgiev. Petko Kashinski*. Lukas Kovali...a: Apps® ToastS lira Gloud6 Huddle with Aneliya Angelova& R. Aneliya Angelova •• Messagest Add canvasur FilesAneliya Angelova 2:30 PMЛукаш, само при Сейлсфорс и Хубспот се синкваха активити типовете, когато се направи плейбук,нали?пои останалите скМі тоябва оъчно ла се въвелат.Lukas Kovalik 2:47 PMтряова да се за всички, някьде не се ли попьлвапри зохо май беше hardcoded но май и там си връщаха две категорииAneliya Angelova @ 4:00 PMЛукаш имаш ли време да се чуем за тестването на https://jiminny.atlassian.net/browse/JY-20725X Preview insackt[HubSpot] Optimise CRM rematctC OpenReady for QA- MediumAA Aneliya AngelovaAs of today at 4:00 PM RetreshOpen in Jira+ SummariseLukas Kovalik 4:02 PMzean-iurwiniareelYou joined the huddle LIVE 4:06 PMAneliva Angelova is here toolAneliya Angelova 4:44 PM11512582Lukas Kovalik O 5:16 PMcurl --location "httns:ani.hubani.com/crm/v3/obiects/contacts/search--header 'Content-Type: application/json'\--header "Authorization: BearerC|Kvma?iMx7OINOM8kOFwrAowAcAkUAhIR24?A05D?xiVrIYMILuP6SA01Kwcmhr=n.v2 hytorDniMvDalYck-CMalovolNOM9kOFwrAUAcrkcaws-ThwRARIRAOF-ATFSAOFRAO-ROIEBAQUBEggBAQEBAYICFIґ7Au8O2Nwal9YW2CbCCxYvwngmSgNldTFSAFoAYABoAHAAeAA'\--data "{ "limit", 13'Message Aneliva Angelova+ AaIAl Notes: OffLeave~ (Q SearchDate ModifiedYesterdav at 13.36Yesterday at 13:36Yesterday at 13.30Yesterday at 13:34Yesterday at 13:34resterday at 15.5sYesterdav at 13:3kYesterday at 13:32Yesterday at 13.31Yesterday at 13:31Yesterday at 13:30resterday at 15.30Yesterdav at 13:29Yesterday at 13:29Yesterday at 13:28Yesterday at 13:28Yesterday at 13:27resterday at 13-40Yesterdav at 13:26Yesterday at 13:25Yesterday at 13:25Yesterday at 13:24Yesterday at 13:24Yesterday at 13.22Yesterdav at 13:21Yesterday at 13:20Yesterday at 13:19Yesterday at 13.18Yecterdav at 12:19Yesterday at 13-1/Yesterdav at 13:16Yesterday at 13:16Yesterday at 13:15Yecterdav at 12:1/Yesterday at 13:14Yesterday at 13.13Yecterdav at 13:10Yesterday at 13:12Yesterday at 13:11Yesterday at 13:11Yesterday at 13:10Yesterday at 13.10Yecterdav at 12:091Yesterday at 13:09Yesterdav at 13:0823K:MPEG-4 movie9KBMPEG-4 movie13 KB16 Kb17 KEMPEG-4 movieMPEG-4 movie29 KB6 KBMPE0"4 movie12 K:9 KBMPEG-4 movie7 KBMP2G-4 movie37 KBMPEG-4 movie10 KBI KDMPEG"4 movie8 KE9KBMPEG-4 movie8 KB72 KBMPEG-4 movie14 KBMPEG-4 movie13 KB9KBMPEG-4 movie18 KEMPEG-4 movie12 KBMPEG-4 movie10 KB16 K:MPEG-4 movie6 KBMPEG-4 movie12 KBMPEG-4 movie23 KBMPEG-4 movie8 KBMPEG-4 movie6 KBMPEG-4 movie16 K:11 KBMPEG-4 movie20 KBMPEG-4 movie21 KPMPEG-4 movie10 KB MPEG-4 movie7 KBMPEG-4 movie5 KB1MPEG-4 movie11 KBMPEG-4 movie26 KB111 KB102 KRMPEG-4 movieMDEG-A movie88 KB59 KBMPEG-4 movie08 KBIMPEG-A movie97 KB MPEG-4 movie66 KB44 KB02 KpMPEG-4 movieMPEG-4 movie78 KB50 KBMPEG-4 movie58 KГMPEG-A movid27 KBMPEG-4 movie7 KB12K8MPEG-4 movieFavouritesE jiminny© Recents* ApplicationsiCloudiCloud Drive228 Sync folderQ DXP4800PLUS-B5FA@ Network|• CRMI• Orange• Red• Yellow• Green• Purple•) All lags..lohlDownloadsNameLoom.pkgAlfred copv.alfredoreterencesB KeychronAssist-1.0.2 (1).dmgA Keychron Assist-1.0.2.dmgmazanoke-images-yWJo.zioPhotos-3-001.zip→mage U.loge1 Orioninstaller.dmaimage.jpg- image (2).1pc• ПО-22221726037035-004-001_ORGES.pdf• %D0%9F%D0%9E-22221726037035-004-001_archive.zipПO-22221726037035-004-001_archive (1).zipD report(2) xmlAltred copy2.altredoreterences05012026_0000000026574472_ SWIFT_OB70501260015890.pdf27022026_0000000026574472_SWIFT_OB72702260049200.pdf= 03042026 [CREDIT_CARD] SWIFT [CREDIT_CARD].001B reporti) xm=pdt.odipdf-1.pdfD pdf-2.pdf-pdf-5.pd1= ndf-1 ndipdf-3.pdfKoválik Family Tree.gedbitwarden export 20251031122528.isonKoválik Family Tree.zip*macOS Storage_Cleanup.mdal favicon icofirst_aid_notes_complete.docxrenortl2).esvconfig.ymlIteration run Search HS.postman_collection.jsonm licence hettertouchtoolMariusHosting Config.json1ooks-891a6503-bbb7-4b2b-9c3.csv•AlfredwPmazanoko.imnaoc.YWIfана Ковалик.jpg•искане даниел Ковалик..pa• Фактура Март Даниел Ковалик.jpс• Фактура Април Даниел Ковалик.jpg• Dhotac 2.001Q SearchKind00,4 MDinstdlle..dckage55.9 MBAlfred...ferences10,1 MBDisk Image10,1 MBDisk ImageIL MBLiP archive6,6 MBZIP archive2,5 MB PDF Document2,5 MBJreo lmage2.2 MBDisk Image2,2 MB PDF Document2 MBJPEG image1,9 MBJPEG imaqe192 KBPDF Document140 KbZIP archive148 KВZIP archivel148 KBZIP archive122 KBXML document111 KBAlfred...ferences94 KBPDF Document92 KBPDr DocumentPDF Document91 KB91 KB30 KB29 KBXML documentPDF Document28 KB28 KR28 KB27 KB14 KB11 KB6 KB6KB5KR4 KB3 KBPDF DocumentPDE DocumentDocumentJSONICSV DocumentZIP archiveMarkdo…..umentWindo...n imageword ..cumentCSV Document2 KBYAML document1 KBcSV Document928 byteshttlicence183 bytesZero butesJSONAlfred.. ferencesZero bytes1,9 MB1,8 MB17 MB1,7 MBFoldeColdorJPEG imageJPEG ImageJPEG imageJPEG imageColdo1 of 58 selected, 8.5 GB availab!i• Inu 14 May 1/.32:00Date AddedIs Mdl ZUzo dl 19:4530 Jan 2026 at 12:3617 Mar 2026 at 20:2717 Mar 2026 at 20:26Z3 Aor 2020 al 13:0229 Jan 2026 at 15:2019 Dec 2025 at 10:1619 Dec 2025 at 12:238 Aor 2026 at 20:3519 Dec 2025 at 10:2919 Dec 2025 at 12:1819 Dec 2025 at 12:4026 Mar 2026 at 11:2410 May 2026 at 13:5326 Mar 2026 at 11:2426 Mar 2026 at 11:2426 Mar 2026 at 11:2310 May 2026 at 14:3730 Jan 2026 at 12:3713 Feb 2026 at 11:54Z3 Apr 2026 at 13.0823 Aor 2026 at 13:0810 May 2026 at 13:5410 May 2026 at 14:3710 May 2026 at 13:4910 May 2026 at 13:5010 May 2026 at 13:5110 Mav 2026 at 12:51110 May 2026 at 13:5019 Dec 2025 at 11:3431 Oct 2025 at 12:2525 Nov 2025 at 17:596 Mar 2026 at 11.2224 Anr 2026 at 16:5220 Oct 2025 at 11:0218 Mar 2026 at 15:29• Mav 2026 at 11:09129 Oct 2025 at 19:329 May 2026 at 10:0418 Mar 2026 at 11:5510 May 2026 at 13:5712 Jun 2025 at 19:0430 Jan 2026 at 12:3630 Jan 2026 at 12:3616 Oct 2025 aт 16:0123 Apr 2026 at 13:0223 Apr 2026 at 13:0223 Apr 2026 at 13:0222 Anr 2026 at 12:0223 Apr 2026 at 13:0229 Jan 2026 at 15:20...
|
NULL
|
-4264175401579078456
|
NULL
|
click
|
ocr
|
NULL
|
ActivityFilesLaterJiminny... ~@ jiminny-x-integrat ActivityFilesLaterJiminny... ~@ jiminny-x-integrati& platform-inner-team© Channels# ai-chapter# alertsi backend# bugscontusion-cllnia# curiosity_lab# engineering# general# jiminny-bg# platform-tickets# product_launches# random# releases# sofia-office# support# thank-yous# the people of iimi..• Direct messages8. A...• A3 62P. Galya Dimitrova Mvasil VasilevA. Stefka Stoyanova%: Todor Stamatovf. Mario GeorgievP. Nikolay Ivanov2o James Graham "2. Stoyan Tanev. Steliyan Georgiev. Petko Kashinski*. Lukas Kovali...a: Apps® ToastS lira Gloud6 Huddle with Aneliya Angelova& R. Aneliya Angelova •• Messagest Add canvasur FilesAneliya Angelova 2:30 PMЛукаш, само при Сейлсфорс и Хубспот се синкваха активити типовете, когато се направи плейбук,нали?пои останалите скМі тоябва оъчно ла се въвелат.Lukas Kovalik 2:47 PMтряова да се за всички, някьде не се ли попьлвапри зохо май беше hardcoded но май и там си връщаха две категорииAneliya Angelova @ 4:00 PMЛукаш имаш ли време да се чуем за тестването на https://jiminny.atlassian.net/browse/JY-20725X Preview insackt[HubSpot] Optimise CRM rematctC OpenReady for QA- MediumAA Aneliya AngelovaAs of today at 4:00 PM RetreshOpen in Jira+ SummariseLukas Kovalik 4:02 PMzean-iurwiniareelYou joined the huddle LIVE 4:06 PMAneliva Angelova is here toolAneliya Angelova 4:44 PM11512582Lukas Kovalik O 5:16 PMcurl --location "httns:ani.hubani.com/crm/v3/obiects/contacts/search--header 'Content-Type: application/json'\--header "Authorization: BearerC|Kvma?iMx7OINOM8kOFwrAowAcAkUAhIR24?A05D?xiVrIYMILuP6SA01Kwcmhr=n.v2 hytorDniMvDalYck-CMalovolNOM9kOFwrAUAcrkcaws-ThwRARIRAOF-ATFSAOFRAO-ROIEBAQUBEggBAQEBAYICFIґ7Au8O2Nwal9YW2CbCCxYvwngmSgNldTFSAFoAYABoAHAAeAA'\--data "{ "limit", 13'Message Aneliva Angelova+ AaIAl Notes: OffLeave~ (Q SearchDate ModifiedYesterdav at 13.36Yesterday at 13:36Yesterday at 13.30Yesterday at 13:34Yesterday at 13:34resterday at 15.5sYesterdav at 13:3kYesterday at 13:32Yesterday at 13.31Yesterday at 13:31Yesterday at 13:30resterday at 15.30Yesterdav at 13:29Yesterday at 13:29Yesterday at 13:28Yesterday at 13:28Yesterday at 13:27resterday at 13-40Yesterdav at 13:26Yesterday at 13:25Yesterday at 13:25Yesterday at 13:24Yesterday at 13:24Yesterday at 13.22Yesterdav at 13:21Yesterday at 13:20Yesterday at 13:19Yesterday at 13.18Yecterdav at 12:19Yesterday at 13-1/Yesterdav at 13:16Yesterday at 13:16Yesterday at 13:15Yecterdav at 12:1/Yesterday at 13:14Yesterday at 13.13Yecterdav at 13:10Yesterday at 13:12Yesterday at 13:11Yesterday at 13:11Yesterday at 13:10Yesterday at 13.10Yecterdav at 12:091Yesterday at 13:09Yesterdav at 13:0823K:MPEG-4 movie9KBMPEG-4 movie13 KB16 Kb17 KEMPEG-4 movieMPEG-4 movie29 KB6 KBMPE0"4 movie12 K:9 KBMPEG-4 movie7 KBMP2G-4 movie37 KBMPEG-4 movie10 KBI KDMPEG"4 movie8 KE9KBMPEG-4 movie8 KB72 KBMPEG-4 movie14 KBMPEG-4 movie13 KB9KBMPEG-4 movie18 KEMPEG-4 movie12 KBMPEG-4 movie10 KB16 K:MPEG-4 movie6 KBMPEG-4 movie12 KBMPEG-4 movie23 KBMPEG-4 movie8 KBMPEG-4 movie6 KBMPEG-4 movie16 K:11 KBMPEG-4 movie20 KBMPEG-4 movie21 KPMPEG-4 movie10 KB MPEG-4 movie7 KBMPEG-4 movie5 KB1MPEG-4 movie11 KBMPEG-4 movie26 KB111 KB102 KRMPEG-4 movieMDEG-A movie88 KB59 KBMPEG-4 movie08 KBIMPEG-A movie97 KB MPEG-4 movie66 KB44 KB02 KpMPEG-4 movieMPEG-4 movie78 KB50 KBMPEG-4 movie58 KГMPEG-A movid27 KBMPEG-4 movie7 KB12K8MPEG-4 movieFavouritesE jiminny© Recents* ApplicationsiCloudiCloud Drive228 Sync folderQ DXP4800PLUS-B5FA@ Network|• CRMI• Orange• Red• Yellow• Green• Purple•) All lags..lohlDownloadsNameLoom.pkgAlfred copv.alfredoreterencesB KeychronAssist-1.0.2 (1).dmgA Keychron Assist-1.0.2.dmgmazanoke-images-yWJo.zioPhotos-3-001.zip→mage U.loge1 Orioninstaller.dmaimage.jpg- image (2).1pc• ПО-22221726037035-004-001_ORGES.pdf• %D0%9F%D0%9E-22221726037035-004-001_archive.zipПO-22221726037035-004-001_archive (1).zipD report(2) xmlAltred copy2.altredoreterences05012026_0000000026574472_ SWIFT_OB70501260015890.pdf27022026_0000000026574472_SWIFT_OB72702260049200.pdf= 03042026 [CREDIT_CARD] SWIFT [CREDIT_CARD].001B reporti) xm=pdt.odipdf-1.pdfD pdf-2.pdf-pdf-5.pd1= ndf-1 ndipdf-3.pdfKoválik Family Tree.gedbitwarden export 20251031122528.isonKoválik Family Tree.zip*macOS Storage_Cleanup.mdal favicon icofirst_aid_notes_complete.docxrenortl2).esvconfig.ymlIteration run Search HS.postman_collection.jsonm licence hettertouchtoolMariusHosting Config.json1ooks-891a6503-bbb7-4b2b-9c3.csv•AlfredwPmazanoko.imnaoc.YWIfана Ковалик.jpg•искане даниел Ковалик..pa• Фактура Март Даниел Ковалик.jpс• Фактура Април Даниел Ковалик.jpg• Dhotac 2.001Q SearchKind00,4 MDinstdlle..dckage55.9 MBAlfred...ferences10,1 MBDisk Image10,1 MBDisk ImageIL MBLiP archive6,6 MBZIP archive2,5 MB PDF Document2,5 MBJreo lmage2.2 MBDisk Image2,2 MB PDF Document2 MBJPEG image1,9 MBJPEG imaqe192 KBPDF Document140 KbZIP archive148 KВZIP archivel148 KBZIP archive122 KBXML document111 KBAlfred...ferences94 KBPDF Document92 KBPDr DocumentPDF Document91 KB91 KB30 KB29 KBXML documentPDF Document28 KB28 KR28 KB27 KB14 KB11 KB6 KB6KB5KR4 KB3 KBPDF DocumentPDE DocumentDocumentJSONICSV DocumentZIP archiveMarkdo…..umentWindo...n imageword ..cumentCSV Document2 KBYAML document1 KBcSV Document928 byteshttlicence183 bytesZero butesJSONAlfred.. ferencesZero bytes1,9 MB1,8 MB17 MB1,7 MBFoldeColdorJPEG imageJPEG ImageJPEG imageJPEG imageColdo1 of 58 selected, 8.5 GB availab!i• Inu 14 May 1/.32:00Date AddedIs Mdl ZUzo dl 19:4530 Jan 2026 at 12:3617 Mar 2026 at 20:2717 Mar 2026 at 20:26Z3 Aor 2020 al 13:0229 Jan 2026 at 15:2019 Dec 2025 at 10:1619 Dec 2025 at 12:238 Aor 2026 at 20:3519 Dec 2025 at 10:2919 Dec 2025 at 12:1819 Dec 2025 at 12:4026 Mar 2026 at 11:2410 May 2026 at 13:5326 Mar 2026 at 11:2426 Mar 2026 at 11:2426 Mar 2026 at 11:2310 May 2026 at 14:3730 Jan 2026 at 12:3713 Feb 2026 at 11:54Z3 Apr 2026 at 13.0823 Aor 2026 at 13:0810 May 2026 at 13:5410 May 2026 at 14:3710 May 2026 at 13:4910 May 2026 at 13:5010 May 2026 at 13:5110 Mav 2026 at 12:51110 May 2026 at 13:5019 Dec 2025 at 11:3431 Oct 2025 at 12:2525 Nov 2025 at 17:596 Mar 2026 at 11.2224 Anr 2026 at 16:5220 Oct 2025 at 11:0218 Mar 2026 at 15:29• Mav 2026 at 11:09129 Oct 2025 at 19:329 May 2026 at 10:0418 Mar 2026 at 11:5510 May 2026 at 13:5712 Jun 2025 at 19:0430 Jan 2026 at 12:3630 Jan 2026 at 12:3616 Oct 2025 aт 16:0123 Apr 2026 at 13:0223 Apr 2026 at 13:0223 Apr 2026 at 13:0222 Anr 2026 at 12:0223 Apr 2026 at 13:0229 Jan 2026 at 15:20...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
45364
|
1625
|
19
|
2026-05-14T14:32:53.884487+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-14/1778 /Users/lukas/.screenpipe/data/data/2026-05-14/1778769173884_m1.jpg...
|
PhpStorm
|
faVsco.js – Copper/Service.php
|
1
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
PhpStormFileEditViewNavigateCodeLaravelRefactorRun PhpStormFileEditViewNavigateCodeLaravelRefactorRunToolsGitWindowHelp•FV faVsco.jsv* #12066 on JY-20725-handle-HS-search-rate-limit K vabl19HandleHubspotRateLimitTest100% C* :8• Thu 14 May 17:32:53QProjectv=custom.logAutomatedReportGenerated.phpTrackAutomatedReportGeneratedEvent.phpPlaybackController.php© FieldDefinitions.phpUserAutomatedReportsController.phpPlanhatService.phpAutomatedReportResult.phpSendReportJob.php© FieldValueConverter.php• DeleteCrmEntityTrait.phpDeleteAccountJob.php© ImportActivityTypes.phpT WriteCrmTrait.php© Service.php© DecorateActivity.php> D CrmObjects© Salesforce/Service.php© LogActivityTrait.phpPipedrive/Service.phpClose/Service.phpCopper/Service.php X• DecorateActivity© PlainTextDecorateActivity.phpT ActivityPlaybookTrait.php© CrmHelperRepository.phpAccountController.php18© BaseDecorateActivity.php© PlainTextDecorateActivity.plT IntegrationAppTrait.php.env.stagingE.env© DetachActivityObject.phpRematchActivityOnCrmObjectDetach.php> Dummy© MatchActivityCrmData.phpv Helpers52Client.phpclass Service extends BaseService implements© HubspotPaginationService.php© HandleHubspotRateLimit.phpA11 A72 X1 X4 ^T ActivityPlaybookTrait.php© Arraylterator.phpT ConnectionStateTrait.php16621663* Othrows CrmException© CrmHelperRepository.php*/1 usage© FilterJoinedParticipants.php1664private function buildCallPayload(Activity $activity): Activity© OpportunitySyncableFieldsT 1665v D Hubspot1666• AccountSyncStrategy1667> OJ Actions |1668• ContactSyncStrategy1669> ODTO1670> D Fields1671try {if (Sadtivity->crm_provider_id) {// The activity should be logged under the existing Task (not Activity).SnewActivityType = Sthis->getCrmType(Sactivity->category);if ($newActivityType === null) {$message = sprintf( format: '[Copper] invalid CRM Custom Activity Type: %s', $activity->caz224> 0J Journal1672D Metadata1673throw new CrmException(Smessage,code: 400):v C OpportunitySyncStrategy1674› D Concerns1675© HubspotLastModifiedByF 1676© HubspotLastModifiedCre1677© HubspotLastModifiedCre1678$this->copperClient->tasks()->update($activity->crm_provider_id, ['status'= 'Completed','custom_activity_type_id' => $newActivityType->valve,'details' => $this->generateActivityDescription(Sactivity),© HubspotLastModifiedOpe 1679© HubspotLastModifiedSyn16801):j else {© HubspotSingleSyncStrate1681© HubspotSyncStrategyBa:1682it.(Sthis-getCrmType(Sactivitx-2categ0cX2mm.nulWnf$message = sprintf( format:'[Copper] Invalid CRM Activity Type: %s',Sactivity->category→© HubspotWebhookBatchS1683v [ Pagination1684throw new CrmException($message,code: 400);© HubspotPaginationServic1685=A Daninstinnfanfin nhn1686Workspace associated with branch 'JY-20725-handle-HS-search-rate-limit' has been restored // Rollback // Configure... (today 16:17)=laravel.logSF [jiminny@localhost]A HS_local [jiminny@localho:W4 console [QAI PROD] X4 console [PROD]A console (EU]DGo jiminny084143 ×4 л215|•m_layout_id =2162,1661,66799,66217218219id = 33;=220=221==222=223id THEN '(own=225=226= 227228-229=230hubspat":-231: 232233 V= 11512582;234-W Windsurf Teams1667:20UTF-8Co 4 spaces...
|
NULL
|
-7172210613695292373
|
NULL
|
click
|
ocr
|
NULL
|
PhpStormFileEditViewNavigateCodeLaravelRefactorRun PhpStormFileEditViewNavigateCodeLaravelRefactorRunToolsGitWindowHelp•FV faVsco.jsv* #12066 on JY-20725-handle-HS-search-rate-limit K vabl19HandleHubspotRateLimitTest100% C* :8• Thu 14 May 17:32:53QProjectv=custom.logAutomatedReportGenerated.phpTrackAutomatedReportGeneratedEvent.phpPlaybackController.php© FieldDefinitions.phpUserAutomatedReportsController.phpPlanhatService.phpAutomatedReportResult.phpSendReportJob.php© FieldValueConverter.php• DeleteCrmEntityTrait.phpDeleteAccountJob.php© ImportActivityTypes.phpT WriteCrmTrait.php© Service.php© DecorateActivity.php> D CrmObjects© Salesforce/Service.php© LogActivityTrait.phpPipedrive/Service.phpClose/Service.phpCopper/Service.php X• DecorateActivity© PlainTextDecorateActivity.phpT ActivityPlaybookTrait.php© CrmHelperRepository.phpAccountController.php18© BaseDecorateActivity.php© PlainTextDecorateActivity.plT IntegrationAppTrait.php.env.stagingE.env© DetachActivityObject.phpRematchActivityOnCrmObjectDetach.php> Dummy© MatchActivityCrmData.phpv Helpers52Client.phpclass Service extends BaseService implements© HubspotPaginationService.php© HandleHubspotRateLimit.phpA11 A72 X1 X4 ^T ActivityPlaybookTrait.php© Arraylterator.phpT ConnectionStateTrait.php16621663* Othrows CrmException© CrmHelperRepository.php*/1 usage© FilterJoinedParticipants.php1664private function buildCallPayload(Activity $activity): Activity© OpportunitySyncableFieldsT 1665v D Hubspot1666• AccountSyncStrategy1667> OJ Actions |1668• ContactSyncStrategy1669> ODTO1670> D Fields1671try {if (Sadtivity->crm_provider_id) {// The activity should be logged under the existing Task (not Activity).SnewActivityType = Sthis->getCrmType(Sactivity->category);if ($newActivityType === null) {$message = sprintf( format: '[Copper] invalid CRM Custom Activity Type: %s', $activity->caz224> 0J Journal1672D Metadata1673throw new CrmException(Smessage,code: 400):v C OpportunitySyncStrategy1674› D Concerns1675© HubspotLastModifiedByF 1676© HubspotLastModifiedCre1677© HubspotLastModifiedCre1678$this->copperClient->tasks()->update($activity->crm_provider_id, ['status'= 'Completed','custom_activity_type_id' => $newActivityType->valve,'details' => $this->generateActivityDescription(Sactivity),© HubspotLastModifiedOpe 1679© HubspotLastModifiedSyn16801):j else {© HubspotSingleSyncStrate1681© HubspotSyncStrategyBa:1682it.(Sthis-getCrmType(Sactivitx-2categ0cX2mm.nulWnf$message = sprintf( format:'[Copper] Invalid CRM Activity Type: %s',Sactivity->category→© HubspotWebhookBatchS1683v [ Pagination1684throw new CrmException($message,code: 400);© HubspotPaginationServic1685=A Daninstinnfanfin nhn1686Workspace associated with branch 'JY-20725-handle-HS-search-rate-limit' has been restored // Rollback // Configure... (today 16:17)=laravel.logSF [jiminny@localhost]A HS_local [jiminny@localho:W4 console [QAI PROD] X4 console [PROD]A console (EU]DGo jiminny084143 ×4 л215|•m_layout_id =2162,1661,66799,66217218219id = 33;=220=221==222=223id THEN '(own=225=226= 227228-229=230hubspat":-231: 232233 V= 11512582;234-W Windsurf Teams1667:20UTF-8Co 4 spaces...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
45365
|
1626
|
12
|
2026-05-14T14:32:55.476143+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-14/1778 /Users/lukas/.screenpipe/data/data/2026-05-14/1778769175476_m2.jpg...
|
PhpStorm
|
faVsco.js – Copper/Service.php
|
1
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
#12066 on JY-20725-handle Project: faVsco.js, menu
#12066 on JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
HandleHubspotRateLimitTest
Run 'HandleHubspotRateLimitTest'
Debug 'HandleHubspotRateLimitTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
11
72
1
4
Previous Highlighted Error
Next Highlighted Error
<?php
namespace Jiminny\Services\Crm\Copper;
use Carbon\Carbon;
use Exception;
use GuzzleHttp\Exception\ClientException;
use Illuminate\Support\Facades\Cache;
use Jiminny\Contracts\Services\Crm\LayoutManagementInterface;
use Jiminny\Contracts\Services\Crm\MatchCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\Provider\CopperInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityLookupInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityManipulationInterface;
use Jiminny\Contracts\Services\Crm\SettingsInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmMetadataInterface;
use Jiminny\Contracts\Services\Crm\VerifyTaskExistsInterface;
use Jiminny\Exceptions\CrmException;
use Jiminny\Exceptions\HttpNotFoundException;
use Jiminny\Models\Account;
use Jiminny\Models\Activity;
use Jiminny\Models\Contact;
use Jiminny\Models\Crm\BusinessProcess;
use Jiminny\Models\Crm\Configuration;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\FieldData;
use Jiminny\Models\Crm\FieldValue;
use Jiminny\Models\Crm\LayoutEntity;
use Jiminny\Models\Crm\Profile;
use Jiminny\Models\Lead;
use Jiminny\Models\Opportunity;
use Jiminny\Models\Playbook;
use Jiminny\Models\PlaybookCategory;
use Jiminny\Models\SocialAccount;
use Jiminny\Models\Stage;
use Jiminny\Models\User;
use Jiminny\Repositories\Crm\CrmEntityRepository;
use Jiminny\Repositories\Crm\FieldRepository;
use Jiminny\Repositories\Crm\ProfileRepository;
use Jiminny\Repositories\UserRepository;
use Jiminny\Services\Avatar\ProspectPhotoPathService;
use Jiminny\Services\Crm\BaseService;
use Jiminny\Services\Crm\Copper\Api\Client;
use Jiminny\Services\Crm\Copper\Api\ClientFactory;
use Jiminny\Services\Crm\Copper\Exception\RequestFailedException;
use Jiminny\Services\Crm\Helpers\FilterJoinedParticipants;
use Jiminny\Services\Crm\OpportunitySyncStrategyResolver;
use Jiminny\Services\Crm\Copper\Traits\SearchesCrmEntities;
use Jiminny\Utils\CurrencyFormatter;
use Jiminny\Utils\StringUtil;
class Service extends BaseService implements
CopperInterface,
SyncCrmEntitiesInterface,
SyncCrmMetadataInterface,
RemoteEntityManipulationInterface,
LayoutManagementInterface,
SettingsInterface,
MatchCrmEntitiesInterface,
RemoteEntityLookupInterface,
VerifyTaskExistsInterface
{
use SearchesCrmEntities;
private const int NOTE_BODY_MAX_LENGTH = 3000000;
private const int TASK_VERIFICATION_CACHE_TTL = 86400; // 1 day
private const array STANDARD_OPPORTUNITY_FIELDS = [
'monetary_value' => [
'label' => 'Value',
'available_on' => Field::OBJECT_OPPORTUNITY,
'type' => Field::TYPE_CURRENCY,
],
'pipeline_stage_id' => [
'label' => 'Stage',
'type' => Field::TYPE_PICKLIST,
'available_on' => Field::OBJECT_OPPORTUNITY,
],
'close_date' => [
'label' => 'Close date',
'type' => Field::TYPE_DATE,
'available_on' => Field::OBJECT_OPPORTUNITY,
],
'win_probability' => [
'label' => 'Win probability',
'type' => Field::TYPE_NUMBER,
'available_on' => Field::OBJECT_OPPORTUNITY,
],
];
private const string UNSUPPORTED_OBJECT_TYPE = 'unsupported';
private const string OPPORTUNITY_STATUS_OPEN = 'Open';
private const string OPPORTUNITY_IMPORT_FAILED_ERROR_MESSAGE = 'Opportunity import failed';
private const string RESOURCE_UPDATE_FAILED_ERROR_MESSAGE = 'Failed to update resource';
private FieldValueConverter $fieldValueConverter;
private ClientFactory $clientFactory;
private ?Client $copperClient = null;
public function __construct(
ClientFactory $clientFactory,
FieldValueConverter $fieldValueConverter,
private readonly UserRepository $userRepository,
private readonly ProspectPhotoPathService $prospectPhotoPathService,
private readonly CrmEntityRepository $crmEntityRepository
) {
parent::__construct();
$this->clientFactory = $clientFactory;
$this->fieldValueConverter = $fieldValueConverter;
}
public function getDisplayName(): string
{
return 'Copper';
}
/**
* Get the CRM entity repository (used by SearchesCrmEntities trait)
*/
protected function getCrmEntityRepository(): CrmEntityRepository
{
return $this->crmEntityRepository;
}
public function getMinimumApiVersion(): string
{
return Client::MIN_API_VERSION;
}
protected function getOAuthAccount(User $user): ?SocialAccount
{
// In this case, the Account Owner is always the connection for any API operations.
$owner = $user->team->owner;
return $owner->getSocialAccount(SocialAccount::PROVIDER_COPPER);
}
public function setUser(User $user): void
{
$this->configureSentryScope($user);
$this->team = $user->getTeam();
$this->teamService->setTeam($user->getTeam());
$this->setConfiguration($user->getTeam()->getCrmConfiguration());
// Reset state.
$this->profile = $user->getProfile();
if (! $this->profile instanceof Profile) {
// Try to fetch their profile, maybe it was not yet imported?
$this->profile = $this->getProfile($user);
}
}
public function setConfiguration(Configuration $configuration): void
{
$this->config = $configuration;
$this->copperClient = $this->clientFactory->create($configuration);
if ($this->team === null) {
$this->team = $configuration->getTeam();
}
}
public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string
{
return $this->fieldValueConverter->convertFromCrm($fieldType, $fieldValue);
}
private function mapObjectType(string $objectType): string
{
$objectType = strtolower($objectType);
switch ($objectType) {
case Field::OBJECT_TASK:
case Field::OBJECT_OPPORTUNITY:
case Field::OBJECT_LEAD:
$mappedType = $objectType;
break;
case Field::OBJECT_ACCOUNT:
$mappedType = 'company';
break;
case Field::OBJECT_CONTACT:
$mappedType = 'person';
break;
default:
$mappedType = self::UNSUPPORTED_OBJECT_TYPE;
break;
}
return $mappedType;
}
/**
* @param string $crmObject The name of the CRM object. i.e. Opportunity or Contact
*/
protected function getFields(string $crmObject): array
{
$mappedObjectType = $this->mapObjectType($crmObject);
if (! $this->isSupportedObjectType($mappedObjectType)) {
return [];
}
$customFields = $this->copperClient->fields()->all();
$filteredFields = array_filter(
$customFields,
static fn ($field) => in_array($mappedObjectType, $field['available_on'], true)
);
return array_map(
fn (array $property) => [
'name' => $property['id'],
'label' => $property['name'],
'type' => $this->convertFieldType($property['data_type']),
],
$filteredFields
);
}
/**
* @inheritdoc
*/
public function getDefaultFields(string $activityType): array
{
$fields = [];
if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {
$defaultFields = FieldDefinitions::defaultTaskFields();
// This lazy creates these fields if not already setup.
foreach ($defaultFields as $defaultField) {
$fields[] = $this->config->fields()->firstOrCreate($defaultField);
}
}
return $fields;
}
/**
* @inheritdoc
*/
public function getDefaultActivityField(string $activityType): Field
{
// Setup the activity field as the default Type.
$activityField = $this->config->fields()->where([
'crm_provider_id' => 'type',
'object_type' => $activityType,
])->first();
return $activityField;
}
/**
* @inheritdoc
*/
public function getSupportedPlaybookTypes(): array
{
return [Playbook::ACTIVITY_TYPE_TASK];
}
public function getDealInsightsFields(): array
{
return FieldDefinitions::dealInsightsFields();
}
protected function getDefaultFollowupLayoutFields(string $activityType): array
{
$fields = [];
$fieldRepo = app(FieldRepository::class);
$fieldFilters = FieldDefinitions::taskFollowupFieldsFilter();
foreach ($fieldFilters as $data) {
$field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $data);
// Only add the field if it is created, which it should be.
if ($field) {
$fields[] = $field;
}
}
return $fields;
}
public function syncFields(): void
{
$this->syncStandardFields();
$this->syncCustomFields();
}
public function syncField(Field $field): void
{
if ($field->getCrmProviderId() === 'type') {
/**
* This field is a special case, it is not a custom field and is not returned by the Copper API.
*
* The field is created manually during layout setup.
*
* @see getDefaultFields()
*
* There is a separate endpoint to get the activity types.
* @see importPicklistValues()
*/
return;
}
if ($field->getCrmProviderId() === 'pipeline_stage_id') {
// Not a custom type
return;
}
$crmField = $this->copperClient->fields()->find($field->getCrmProviderId());
$field->label = $crmField['name'];
$field->type = $this->convertFieldType($crmField['data_type']);
$field->save();
}
/**
* @param string $fieldType How the data should be input
*/
private function convertFieldType(string $fieldType): string
{
$fieldType = strtolower($fieldType);
switch ($fieldType) {
case 'dropdown':
$convertedType = Field::TYPE_PICKLIST;
break;
case 'string':
$convertedType = Field::TYPE_TEXT;
break;
case 'float':
case 'percentage':
$convertedType = Field::TYPE_NUMBER;
break;
case 'text':
$convertedType = Field::TYPE_TEXTAREA;
break;
case 'checkbox':
$convertedType = Field::TYPE_BOOLEAN;
break;
case Field::TYPE_DATE:
case Field::TYPE_CURRENCY:
case Field::TYPE_URL:
$convertedType = $fieldType;
break;
default:
$convertedType = Field::TYPE_UNSUPPORTED;
break;
}
return $convertedType;
}
/**
* @inheritdoc
*/
public function importPicklistValues(Field $field): array
{
$fieldValues = [];
if ($field->object_type === 'opportunity' && $field->crm_provider_id === 'pipeline_stage_id') {
return $this->importFieldValuesForDealInsights($field);
}
$values = [];
if ($field->object_type === Field::OBJECT_TASK && $field->crm_provider_id === 'type') {
$activityTypes = $this->copperClient->activityTypes()->all();
/**
* We ignore "system" activity types, disabled activity types and the "Note" activity type.
*/
$activityTypes = array_filter(
$activityTypes['user'],
fn (array $activityType) => ! $activityType['is_disabled'] && $activityType['id'] !== 0
);
// Import all activity values.
$i = 0;
foreach ($activityTypes as $activityType) {
$values[] = [
'value' => $activityType['id'],
'label' => mb_strimwidth($activityType['name'], 0, Field::LABEL_MAX_LENGTH),
'sequence' => $i++,
];
}
} else {
// We just check for the field in the custom definitions.
$crmField = $this->copperClient->fields()->find($field->crm_provider_id);
// Import all values.
foreach ($crmField['options'] as $option) {
$values[] = [
'value' => $option['id'],
'label' => mb_strimwidth($option['name'], 0, 191),
'sequence' => $option['rank'],
];
}
}
// Take the existing set of field values.
$fieldsToPurge = $field->values()->get()->pluck('value')->toArray();
foreach ($values as $value) {
// For all current values, upsert the field.
$value['value'] = substr($value['value'] ?? '', 0, 255);
$fieldValues[] = $field->values()->updateOrCreate([
'value' => $value['value'],
], $value);
// Remove this value from the ones we are going to purge.
$key = array_search($value['value'], $fieldsToPurge, false);
if ($key !== false) {
unset($fieldsToPurge[$key]);
}
}
// Delete the old values that are no longer used.
$field->values()->whereIn('value', $fieldsToPurge)->delete();
return $fieldValues;
}
/**
*
* @return FieldValue[]
*/
private function importFieldValuesForDealInsights(Field $field): array
{
$fieldValues = [];
$pipelines = $this->copperClient->pipelines()->all();
foreach ($pipelines as $pipeline) {
foreach ($pipeline['stages'] as $i => $dealStage) {
$fieldValues[] = $field->values()->updateOrCreate(
['value' => $dealStage['id']],
[
'label' => $dealStage['name'],
'sequence' => $i,
]
);
}
}
return $fieldValues;
}
/**
* @inheritdoc
*/
public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage
{
if ($types === null) {
$types = [Stage::TYPE_LEAD, Stage::TYPE_OPPORTUNITY];
}
$missingStage = null;
foreach ($types as $type) {
if ($type === Stage::TYPE_LEAD) {
$statuses = $this->requestWithExceptionHandling(
fn () => $this->copperClient->leadStatuses()->all()
);
if (count($statuses) > 0) {
$businessProcess = $this->config->businessProcesses()->updateOrCreate([
'crm_provider_id' => BusinessProcess::TYPE_LEAD,
], [
'team_id' => $this->team->id,
'name' => BusinessProcess::TYPE_LEAD,
'type' => BusinessProcess::TYPE_LEAD,
'is_selectable' => true,
]);
// A record type is really a clone of the business process, used to store which record uses which pipeline.
$this->config->recordTypes()->updateOrCreate([
'crm_provider_id' => BusinessProcess::TYPE_LEAD,
], [
'team_id' => $this->team->id,
'name' => BusinessProcess::TYPE_LEAD,
'is_selectable' => true,
'business_process_id' => $businessProcess->id ?? null,
]);
$leadStatuses = [];
foreach ($statuses as $status) {
// Upsert all statuses for the team.
$status = $this->config->stages()->updateOrCreate([
'crm_provider_id' => $status['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($status['name'], 0, 50),
'label' => mb_strimwidth($status['name'], 0, 191),
'type' => Stage::TYPE_LEAD,
'sequence' => $status['order'],
'is_selectable' => true,
]);
if ($missingStageName && $missingStageName === $status['id']) {
$missingStage = $status;
}
$leadStatuses[] = $status['id'];
}
$businessProcess->stages()->sync($leadStatuses);
}
} else {
// We create a business process to contain the pipeline, and later store all stages against it.
foreach ($this->copperClient->pipelines()->all() as $pipeline) {
$businessProcess = $this->config->businessProcesses()->updateOrCreate([
'crm_provider_id' => $pipeline['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($pipeline['name'], 0, 150),
'type' => BusinessProcess::TYPE_OPPORTUNITY,
'is_selectable' => true,
]);
// A record type is really a clone of the business process, used to store which record uses which pipeline.
$this->config->recordTypes()->updateOrCreate([
'crm_provider_id' => $pipeline['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($pipeline['name'], 0, 150),
'is_selectable' => true,
'business_process_id' => $businessProcess->id ?? null,
]);
$stages = [];
foreach ($pipeline['stages'] as $i => $dealStage) {
// Upsert all stages for the team.
$stage = $this->config->stages()->updateOrCreate([
'crm_provider_id' => $dealStage['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($dealStage['name'], 0, 50),
'label' => mb_strimwidth($dealStage['name'], 0, 191),
'type' => Stage::TYPE_OPPORTUNITY,
'sequence' => $i,
'is_selectable' => true,
'probability' => $dealStage['win_probability'],
]);
if ($missingStageName && $missingStageName === $dealStage['id']) {
$missingStage = $stage;
}
$stages[] = $stage['id'];
}
$businessProcess->stages()->sync($stages);
}
}
}
return $missingStage;
}
/**
* @inheritdoc
*/
public function syncLeads(Carbon $since, ?Carbon $to = null, ?string $crmProfileId = null): int
{
$syncCount = 0;
$cpLeads = $this->requestWithExceptionHandling(
fn () => $this->copperClient->leads()->paginate([
'minimum_modified_date' => $since->timestamp,
'sort_by' => 'date_modified',
])
);
foreach ($cpLeads as $cpLead) {
// Only sync if previously imported.
if ($this->hasLead($cpLead['id']) === false) {
$this->importLead($cpLead);
$syncCount++;
}
}
return $syncCount;
}
/**
* @inheritdoc
*/
public function syncLead(string $crmId): ?Lead
{
$cpLead = $this->requestWithExceptionHandling(
fn () => $this->copperClient->leads()->find($crmId)
);
if (\count($cpLead) === 0) {
return null;
}
return $this->importLead($cpLead);
}
/**
* @inheritdoc
*/
private function importLead($crmData): ?Lead
{
$countryCode = null;
if (! empty($crmData['address']) && isset($crmData['address']['country'])) {
$countryCode = $this->convertCountryNameToCode($crmData['address']['country']);
}
$mobilePhone = $parsedNumber = null;
if (! empty($crmData['phone_numbers'])) {
foreach ($crmData['phone_numbers'] as $phoneNumber) {
if ($phoneNumber['category'] === 'mobile') {
$mobilePhone = $this->buildContactMobilePhone($countryCode, $phoneNumber['number']);
} else {
$parsedNumber = $this->buildContactPhone($countryCode, $phoneNumber['number']);
}
}
}
$name = $crmData['name'];
if (empty($crmData['company_name'])) {
$company = 'Unknown';
} else {
$company = mb_strimwidth($crmData['company_name'], 0, 191);
}
$domain = null;
if (empty($crmData['websites']) === false) {
$domain = mb_strimwidth($crmData['websites'][0]['url'], 0, 191);
$domain = StringUtil::resolveDomain($domain);
}
$email = null;
if (empty($crmData['email']) === false) {
$email = mb_strimwidth($crmData['email']['email'], 0, 80);
}
$stage = $this->config
->stages()
->where('crm_provider_id', (string) $crmData['status_id'])
->where('type', Stage::TYPE_LEAD)
->first();
if ($stage === null) {
// Import it.
$stage = $this->importStages([Stage::TYPE_LEAD], $crmData['status_id']);
}
if (! $stage instanceof Stage) {
return null;
}
$profile = $this->config->profiles()->where('crm_provider_id', (string) $crmData['assignee_id'])->first();
$data = [
'team_id' => $this->team->id,
'user_id' => $profile ? $profile->user_id : null,
'owner_id' => $crmData['assignee_id'],
'name' => mb_strimwidth($name, 0, 191),
'company' => $company,
'domain' => $domain,
'title' => $crmData['title'] ? mb_strimwidth($crmData['title'], 0, 128) : null,
'email' => $email,
'phone' => $parsedNumber['phone'] ?? null,
'ext' => $parsedNumber['ext'] ?? null,
'mobile_phone' => $mobilePhone,
'stage_id' => $stage->id,
'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(
crmConfiguration: $this->config,
crmProviderId: $crmData['id'],
modelType: Lead::class,
fileName: $crmData['id'],
avatarText: $crmData['name']
),
'country_code' => $countryCode,
'remotely_created_at' => $crmData['date_created'],
];
return $this->config->leads()->updateOrCreate(['crm_provider_id' => (string) $crmData['id']], $data);
}
/**
* @inheritdoc
*/
public function syncAccounts(Carbon $since, ?Carbon $to = null): int
{
$syncCount = 0;
try {
$cpAccounts = $this->copperClient->accounts()->paginate([
'minimum_modified_date' => $since->timestamp,
'sort_by' => 'date_modified',
]);
foreach ($cpAccounts as $cpAccount) {
// Only sync if previously imported.
if ($this->hasAccount($cpAccount['id'])) {
$this->importAccount($cpAccount);
$syncCount++;
}
}
} catch (\Exception $exception) {
// Do nothing for now.
}
return $syncCount;
}
/**
* @inheritdoc
*/
public function syncAccount(string $crmId): ?Account
{
$cpAccount = $this->copperClient->accounts()->find($crmId);
if (empty($cpAccount)) {
return null;
}
return $this->importAccount($cpAccount);
}
/**
* @inheritdoc
*/
private function importAccount($crmData): Account
{
$countryCode = null;
if (empty($crmData['address']) === false && $crmData['address']['country']) {
$countryCode = $this->convertCountryNameToCode($crmData['address']['country']);
}
$name = $crmData['name'];
$parsedNumber = null;
foreach ($crmData['phone_numbers'] as $phoneNumber) {
$parsedNumber = parsePhoneNumber($countryCode, $phoneNumber['number']);
}
$domain = null;
if (empty($crmData['websites']) === false) {
$domain = mb_strimwidth($crmData['websites'][0]['url'], 0, 191);
$domain = StringUtil::resolveDomain($domain);
}
$profile = $this->config->profiles()->where('crm_provider_id', (string) $crmData['assignee_id'])->first();
$data = [
'team_id' => $this->team->id,
'user_id' => $profile ? $profile->user_id : null,
'owner_id' => $crmData['assignee_id'],
'name' => mb_strimwidth($name, 0, 191),
'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(
crmConfiguration: $this->config,
crmProviderId: $crmData['id'],
modelType: Account::class,
fileName: $crmData['id'],
avatarText: $crmData['name']
),
'country_code' => $countryCode,
'domain' => $domain,
'phone' => $parsedNumber['phone'] ?? null,
'ext' => $parsedNumber['ext'] ?? null,
'remotely_created_at' => $crmData['date_created'],
];
return $this->config->accounts()->updateOrCreate(['crm_provider_id' => (string) $crmData['id']], $data);
}
public function syncOpportunities(array $parameters, ?string $strategy = null): int
{
$strategyResolver = app(OpportunitySyncStrategyResolver::class, [
'client' => $this->copperClient,
]);
$strategies = $strategyResolver->getStrategies($this->config, $strategy);
$syncCount = 0;
try {
foreach ($strategies as $syncStrategy) {
foreach ($syncStrategy->fetchOpportunities($parameters) as $opportunityData) {
$this->importOpportunity($opportunityData);
$syncCount++;
}
}
} catch (\Exception $exception) {
// Do nothing for now.
$this->logger->error(self::OPPORTUNITY_IMPORT_FAILED_ERROR_MESSAGE, [
'error' => $exception->getMessage(),
]);
}
return $syncCount;
}
public function syncOpportunity(string $crmId): ?Opportunity
{
$strategyResolver = app(OpportunitySyncStrategyResolver::class, [
'client' => $this->copperClient,
]);
$strategy = $strategyResolver->resolve(
$this->config,
OpportunitySyncStrategyResolver::SINGLE_SYNC_OPPORTUNITY_STRATEGY
);
$parameters['crm_id'] = $crmId;
$cpOpportunity = $strategy->fetchOpportunities($parameters);
return $this->importOpportunity($cpOpportunity);
}
private function importOpportunity($crmData): ?Opportunity
{
$companyId = (string) $crmData['company_id'];
if (empty($companyId)) {
$this->logger->error(self::OPPORTUNITY_IMPORT_FAILED_ERROR_MESSAGE, [
'error' => 'No company id',
]);
return null;
}
$account = $this->config->accounts()->where('crm_provider_id', $companyId)->first();
if ($account === null) {
$account = $this->syncAccount($companyId);
}
$profile = $this->config->profiles()->where('crm_provider_id', (string) $crmData['assignee_id'])->first();
$businessProcess = $this->config
->businessProcesses()
->where('crm_provider_id', (string) $crmData['pipeline_id'])
->first();
if ($businessProcess === null) {
// It is possible for some deals to not be in a pipeline.
$this->logger->warning(self::OPPORTUNITY_IMPORT_FAILED_ERROR_MESSAGE, [
'error' => 'Missing pipeline',
]);
return null;
}
$stage = $businessProcess
->stages()
->where('crm_provider_id', (string) $crmData['pipeline_stage_id'])
->where('type', Stage::TYPE_OPPORTUNITY)
->first();
if ($stage === null) {
$this->logger->info('Stage not found, importing', [
'pipeline_id' => $crmData['pipeline_id'],
'stage_id' => $crmData['pipeline_stage_id'],
]);
$stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $crmData['pipeline_stage_id']);
}
$recordType = $businessProcess->recordTypes()->first();
$closeDate = null;
$isClosed = $crmData['status'] !== self::OPPORTUNITY_STATUS_OPEN;
if ($crmData['close_date']) {
$closeDate = Carbon::parse($crmData['close_date'])->format('Y-m-d');
}
$data = [
'team_id' => $this->team->getId(),
'account_id' => $account->id,
'user_id' => $profile->user_id ?? null,
'owner_id' => $crmData['assignee_id'],
'name' => mb_strimwidth($crmData['name'], 0, 128),
'value' => $crmData['monetary_value'],
'currency_code' => CurrencyFormatter::formatCode($crmData['monetary_unit']),
'close_date' => $closeDate,
'is_closed' => $isClosed,
'is_won' => $crmData['status'] === 'Won',
'stage_id' => $stage->id,
'record_type_id' => $recordType->id ?? null,
'remotely_created_at' => $crmData['date_created'],
];
/** @var Opportunity $opportunity */
$opportunity = $this->config
->opportunities()
->updateOrCreate(['crm_provider_id' => (string) $crmData['id']], $data)
;
// import external fields into crm_field_data if present
$dealInsightsFields = $this->getOpportunitySyncableFields();
$this->importOpportunityCrmFieldData($crmData, $dealInsightsFields, $opportunity->id);
return $opportunity;
}
/**
* @param array<string, mixed> $opportunityData
* @param string[] $dealInsightsFields
*/
public function importOpportunityCrmFieldData(
array $opportunityData,
array $dealInsightsFields,
int $opportunityId
): void {
foreach ($dealInsightsFields as $fieldName) {
$field = $this->config->fields()->with('entities')
->where('crm_provider_id', $fieldName)
->where('object_type', Field::OBJECT_OPPORTUNITY)
->first();
if (! $field instanceof Field) {
$this->logger->warning('Field not found', [
'field' => $fieldName,
'opportunity_id' => $opportunityId,
]);
continue;
}
$entity = $field->getEntities()->first();
if (! $entity instanceof LayoutEntity) {
continue;
}
$fieldValue = $this->extractFieldValue($opportunityData, $fieldName);
$normalizedValue = $this->normalizeValue($field->getType(), (string) $fieldValue, true);
$field->data()
->updateOrCreate([
'crm_layout_entity_id' => $entity->id,
'crm_field_id' => $field->id,
'object_type' => 'opportunity',
'object_id' => $opportunityId,
], ['value' => $normalizedValue])
;
}
}
/**
* @inheritdoc
*/
public function syncContacts(Carbon $since, ?Carbon $to = null): int
{
$syncCount = 0;
try {
$cpContacts = $this->copperClient->contacts()->paginate([
'minimum_interaction_date' => $since->timestamp,
'sort_by' => 'date_modified',
]);
foreach ($cpContacts as $cpContact) {
// Only sync if previously imported.
if ($this->hasContact($cpContact['id'])) {
$this->importContact($cpContact);
$syncCount++;
}
}
} catch (\Exception $exception) {
// Do nothing for now.
throw $exception;
}
return $syncCount;
}
/**
* @inheritdoc
*/
public function syncContact(string $crmId): ?Contact
{
$cpContact = $this->copperClient->contacts()->find($crmId);
if (empty($cpContact)) {
return null;
}
return $this->importContact($cpContact);
}
/**
* @inheritdoc
*/
private function importContact($crmData): Contact
{
$account = null;
if ($crmData['company_id']) {
$account = $this->team
->crm
->accounts()
->where('crm_provider_id', (string) $crmData['company_id'])
->first();
if ($account === null) {
$account = $this->syncAccount($crmData['company_id']);
}
}
$countryCode = null;
if (empty($crmData['address']) === false && $crmData['address']['country']) {
$countryCode = $this->convertCountryNameToCode($crmData['address']['country']);
}
$mobilePhone = $parsedNumber = null;
foreach ($crmData['phone_numbers'] as $phoneNumber) {
if ($phoneNumber['category'] === 'mobile') {
$mobilePhone = $this->buildContactMobilePhone($countryCode, $phoneNumber['number']);
} else {
$parsedNumber = $this->buildContactPhone($countryCode, $phoneNumber['number']);
}
}
$email = null;
if (empty($crmData['emails']) === false) {
$email = mb_strimwidth($crmData['emails'][0]['email'], 0, 80);
}
$profile = $this->config->profiles()->where('crm_provider_id', (string) $crmData['assignee_id'])->first();
$data = [
'team_id' => $this->team->id,
'account_id' => $account->id ?? null,
'user_id' => $profile ? $profile->user_id : null,
'owner_id' => $crmData['assignee_id'],
'name' => $crmData['name'],
'email' => $email,
'phone' => $parsedNumber['phone'] ?? null,
'mobile_phone' => $mobilePhone ?? null,
'ext' => $parsedNumber['ext'] ?? null,
'title' => $crmData['title'],
'country_code' => $countryCode,
'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(
crmConfiguration: $this->config,
crmProviderId: $crmData['id'],
modelType: Contact::class,
fileName: $crmData['id'],
avatarText: $crmData['name']
),
'remotely_created_at' => $crmData['date_created'],
];
return $this->config->contacts()->updateOrCreate(['crm_provider_id' => (string) $crmData['id']], $data);
}
private function buildContactPhone(?string $countryCode, ?string $number): ?array
{
if ($number) {
// Trim to our width and attempt to parse it.
$number = mb_strimwidth($number, 0, 25);
$parsedNumber = parsePhoneNumber($countryCode, $number);
} else {
$parsedNumber = [];
}
return $parsedNumber;
}
private function buildContactMobilePhone(?string $countryCode, ?string $number): ?string
{
return $number ? mb_strimwidth(phone_e164($countryCode, $number), 0, 25) : null;
}
/**
* @inheritdoc
*/
public function syncOrganization(): void
{
$organization = $this->copperClient->organisation()->all();
if ($this->config->crm_provider_id !== $organization['id']) {
$this->config->update(['crm_provider_id' => $organization['id']]);
}
}
private function syncCustomFields(): void
{
foreach ($this->getFieldTypes() as $fieldType) {
$currentFields = $this->getFields($fieldType);
foreach ($currentFields as $field) {
/** @var Field $field */
$field = $this->config->fields()->updateOrCreate([
'crm_provider_id' => $field['name'],
'object_type' => $fieldType,
], [
'label' => mb_strimwidth($field['label'], 0, Field::LABEL_MAX_LENGTH),
'type' => $field['type'],
]);
if ($field->getType() === Field::TYPE_PICKLIST) {
$this->importPicklistValues($field);
}
}
}
$this->config->save();
}
protected function getFieldTypes(): array
{
return [
parent::OBJECT_TASK,
parent::OBJECT_LEAD,
parent::OBJECT_ACCOUNT,
parent::OBJECT_CONTACT,
parent::OBJECT_OPPORTUNITY,
];
}
/**
* @inheritdoc
*/
public function syncProfiles(?User $userToSearch = null): ?Profile
{
$profiles = $this->copperClient->users()->all();
foreach ($profiles as $profile) {
$user = $this->userRepository->findByTeamAndEmailAddress($this->team, $profile['email'], onlyActive: true);
// Only import profiles for users we have locally.
if ($user) {
$profile = $user->crmProfile()->updateOrCreate([
'crm_configuration_id' => $this->config->id,
'crm_provider_id' => $profile['id'],
]);
if ($userToSearch && $userToSearch->id === $user->id) {
return $profile;
}
}
}
return null;
}
/**
* @inheritdoc
*/
public function syncProfileFields(): void
{
$fieldTypes = [
'task',
'lead',
'account',
'contact',
'opportunity',
];
foreach ($fieldTypes as $fieldType) {
try {
$currentFields = $this->getFields(ucfirst($fieldType));
$newFields = array_column($currentFields, 'name');
$changedFields = array_merge(
array_diff($this->profile->getFieldsAsArray($fieldType), $newFields),
array_diff($newFields, $this->profile->getFieldsAsArray($fieldType))
);
if (\count($changedFields) > 0) {
$fields = implode(',', $newFields);
$this->logger->debug(
ucfirst($fieldType)
. ' fields changed from '
. $this->profile->{$fieldType . '_fields'}
. ' to: '
. $fields
);
$this->profile->{$fieldType . '_fields'} = $fields;
}
} catch (\Exception $exception) {
$this->logger->error('No access to ' . $fieldType . ' object, skipping...');
continue;
}
}
$this->profile->save();
}
/**
* @inheritdoc
*/
public function find(string $name, array $scopes): array
{
$count = $this->limit ?? 20;
$offset = $this->offset ?? 0;
$scopeValues = array_map(fn ($scope) => is_string($scope) ? $scope : $scope->value, $scopes);
$cacheKey = sprintf('copper-search-%d-%s-%s-%d-%d', $this->team->id, $name, implode('-', $scopeValues), $count, $offset);
return Cache::remember($cacheKey, 300, function () use ($name, $scopes, $count, $offset) {
// Try local search first
$localResults = $this->searchLocally($name, $scopes, $count, $offset);
// If no local results, fall back to remote API search
if (empty($localResults)) {
return $this->searchRemotely($name, $scopes, $count, $offset);
}
return $localResults;
});
}
/**
* @inheritdoc
*/
public function findOpportunities(?string $crmAccountId, ?string $crmContactId, ?int $userId = null): array
{
$data = [];
$ownerData = [];
$ownerId = null;
$contact = null;
$account = null;
if ($crmAccountId) {
$account = $this->config->accounts()->where('crm_provider_id', $crmAccountId)->first();
if ($account === null) {
$account = $this->syncAccount($crmAccountId);
}
}
if ($crmContactId) {
$contact = $this->config->contacts()->where('crm_provider_id', $crmContactId)->first();
if ($contact === null) {
$contact = $this->syncContact($crmContactId);
}
}
if ($contact || $account) {
if ($contact && $account === null) {
$account = $contact->account;
}
if ($account === null) {
return [];
}
$params = [
'company_ids' => [$account->crm_provider_id],
'status_ids' => [0], // The integer values are 0, 1, 2, 3, for "Open", "Won", "Lost", and "Abandoned".
'sort_by' => 'date_modified',
'sort_direction' => 'desc',
];
switch ($this->config->opportunity_assignment_rule) {
case Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_UPDATED:
$params['sort_by'] = 'date_modified';
$params['sort_direction'] = 'desc';
break;
case Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_CREATED:
$params['sort_by'] = 'date_created';
$params['sort_direction'] = 'desc';
break;
case Configuration::OPP_ASSIGNMENT_ALL_OPEN_OLDEST_CREATED:
$params['sort_by'] = 'date_created';
$params['sort_direction'] = 'asc';
break;
case Configuration::OPP_ASSIGNMENT_ALL_RECENTLY_UPDATED:
$params['sort_by'] = 'date_modified';
$params['sort_direction'] = 'desc';
$params['status_ids'] = [0, 1, 2, 3];
}
$cpOpportunities = $this->copperClient->opportunities()->search($params);
if ($userId) {
$profileRepository = app(ProfileRepository::class);
$profile = $profileRepository->findProfileByUserId($this->config, $userId);
$ownerId = $profile instanceof Profile ? $profile->getCrmProviderId() : null;
}
foreach ($cpOpportunities as $cpOpportunity) {
$businessProcess = $this->config
->businessProcesses()
->where('crm_provider_id', (string) $cpOpportunity['pipeline_id'])
->first();
if ($businessProcess === null) {
// Import it.
$businessProcess = $this->importStages(
[Stage::TYPE_OPPORTUNITY],
$cpOpportunity['pipeline_stage_id']
);
}
$stage = $businessProcess
->stages()
->where('crm_provider_id', (string) $cpOpportunity['pipeline_stage_id'])
->where('type', Stage::TYPE_OPPORTUNITY)
->first();
if ($stage === null) {
// Import it.
$stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $cpOpportunity['pipeline_stage_id']);
}
$recordType = $businessProcess->recordTypes()->first();
$record = [
'crmId' => $cpOpportunity['id'],
'name' => $cpOpportunity['name'],
'value' => currency_format($cpOpportunity['monetary_value'], $cpOpportunity['monetary_unit']),
'won' => $cpOpportunity['status'] === 'Won',
'stage' => [
'id' => $stage->id_string,
'name' => $stage->name,
],
'recordType' => [
'id' => $recordType->id_string,
'name' => $recordType->name,
],
];
if ($ownerId && isset($cpOpportunity['assignee_id']) && $cpOpportunity['assignee_id'] === $ownerId) {
$ownerData[] = $record;
}
$data[] = $record;
}
}
if (! empty($ownerData)) {
return $ownerData;
}
return $data;
}
/**
* @inheritdoc
*/
public function getTasks(string $objectType, string $objectId, ?string $opportunityId): array
{
$data = $cpTasks = [];
switch ($objectType) {
case 'lead':
$cpTasks = $this->requestWithExceptionHandling(
fn () => $this->copperClient->leads()->relatedTasks($objectId)
);
break;
case 'account':
$cpTasks = $this->copperClient->accounts()->relatedTasks($objectId);
break;
case 'contact':
if ($opportunityId) {
$cpTasks = $this->copperClient->opportunities()->relatedTasks($opportunityId);
} elseif ($objectType === 'contact') {
$cpTasks = $this->copperClient->contacts()->relatedTasks($objectId);
}
break;
}
foreach ($cpTasks as $cpTask) {
// We only get the ID itself returned, so pull out the full object.
try {
$cpTask = $this->copperClient->tasks()->find($cpTask['id']);
} catch (Exception $exception) {
$this->logger->error('[Copper] Error fetching task', [
'resource_id' => $cpTask['id'],
'error' => $exception->getMessage(),
]);
continue;
}
// Only allow open tasks.
if ($cpTask['status'] === 'Open') {
$data[] = [
'crmId' => $cpTask['id'],
'subject' => $cpTask['name'],
'due' => $cpTask['due_date']
? Carbon::createFromTimestamp($cpTask['due_date'])->format('Y-m-d\TH:i:sO')
: null,
'type' => null,
];
}
}
return $data;
}
/**
* Try to find email address in CRM service
*
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
*}
*/
public function matchExactlyByEmail(string $email, ?int $userId = null): ?array
{
$cpObjectType = 'contact';
$cpObjects = $this->copperClient->contacts()->search([
'emails' => $email,
'sort_by' => 'date_modified',
'sort_direction' => 'desc',
]);
if (empty($cpObjects)) {
$cpObjectType = 'lead';
$cpObjects = $this->requestWithExceptionHandling(
fn () => $this->copperClient->leads()->search([
'emails' => ...
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"bounds":{"left":0.2962101,"top":1.0,"width":0.03856383,"height":-0.019952059},"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"#12066 on JY-20725-handle-HS-search-rate-limit, menu","depth":5,"bounds":{"left":0.33477393,"top":1.0,"width":0.122340426,"height":-0.019952059},"on_screen":true,"help_text":"Pull request #12066 exists for current branch JY-20725-handle-HS-search-rate-limit, but local branch is out of sync with remote","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"bounds":{"left":0.5731383,"top":1.0,"width":0.011303191,"height":-0.019952059},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"HandleHubspotRateLimitTest","depth":6,"bounds":{"left":0.5884308,"top":1.0,"width":0.076130316,"height":-0.019952059},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'HandleHubspotRateLimitTest'","depth":6,"bounds":{"left":0.66456115,"top":1.0,"width":0.011303191,"height":-0.019952059},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'HandleHubspotRateLimitTest'","depth":6,"bounds":{"left":0.67586434,"top":1.0,"width":0.011303191,"height":-0.019952059},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"bounds":{"left":0.6871675,"top":1.0,"width":0.011303191,"height":-0.019952059},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"bounds":{"left":0.7150931,"top":1.0,"width":0.011303191,"height":-0.019952059},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"bounds":{"left":0.72639626,"top":1.0,"width":0.011303191,"height":-0.019952059},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"IDE and Project Settings","depth":5,"bounds":{"left":0.73769945,"top":1.0,"width":0.011303191,"height":-0.019952059},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.042220745,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"11","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"72","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"1","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"4","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\nnamespace Jiminny\\Services\\Crm\\Copper;\n\nuse Carbon\\Carbon;\nuse Exception;\nuse GuzzleHttp\\Exception\\ClientException;\nuse Illuminate\\Support\\Facades\\Cache;\nuse Jiminny\\Contracts\\Services\\Crm\\LayoutManagementInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\MatchCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\Provider\\CopperInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityLookupInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityManipulationInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SettingsInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmMetadataInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\VerifyTaskExistsInterface;\nuse Jiminny\\Exceptions\\CrmException;\nuse Jiminny\\Exceptions\\HttpNotFoundException;\nuse Jiminny\\Models\\Account;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Contact;\nuse Jiminny\\Models\\Crm\\BusinessProcess;\nuse Jiminny\\Models\\Crm\\Configuration;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Models\\Crm\\FieldData;\nuse Jiminny\\Models\\Crm\\FieldValue;\nuse Jiminny\\Models\\Crm\\LayoutEntity;\nuse Jiminny\\Models\\Crm\\Profile;\nuse Jiminny\\Models\\Lead;\nuse Jiminny\\Models\\Opportunity;\nuse Jiminny\\Models\\Playbook;\nuse Jiminny\\Models\\PlaybookCategory;\nuse Jiminny\\Models\\SocialAccount;\nuse Jiminny\\Models\\Stage;\nuse Jiminny\\Models\\User;\nuse Jiminny\\Repositories\\Crm\\CrmEntityRepository;\nuse Jiminny\\Repositories\\Crm\\FieldRepository;\nuse Jiminny\\Repositories\\Crm\\ProfileRepository;\nuse Jiminny\\Repositories\\UserRepository;\nuse Jiminny\\Services\\Avatar\\ProspectPhotoPathService;\nuse Jiminny\\Services\\Crm\\BaseService;\nuse Jiminny\\Services\\Crm\\Copper\\Api\\Client;\nuse Jiminny\\Services\\Crm\\Copper\\Api\\ClientFactory;\nuse Jiminny\\Services\\Crm\\Copper\\Exception\\RequestFailedException;\nuse Jiminny\\Services\\Crm\\Helpers\\FilterJoinedParticipants;\nuse Jiminny\\Services\\Crm\\OpportunitySyncStrategyResolver;\nuse Jiminny\\Services\\Crm\\Copper\\Traits\\SearchesCrmEntities;\nuse Jiminny\\Utils\\CurrencyFormatter;\nuse Jiminny\\Utils\\StringUtil;\n\nclass Service extends BaseService implements\n CopperInterface,\n SyncCrmEntitiesInterface,\n SyncCrmMetadataInterface,\n RemoteEntityManipulationInterface,\n LayoutManagementInterface,\n SettingsInterface,\n MatchCrmEntitiesInterface,\n RemoteEntityLookupInterface,\n VerifyTaskExistsInterface\n{\n use SearchesCrmEntities;\n private const int NOTE_BODY_MAX_LENGTH = 3000000;\n\n private const int TASK_VERIFICATION_CACHE_TTL = 86400; // 1 day\n\n private const array STANDARD_OPPORTUNITY_FIELDS = [\n 'monetary_value' => [\n 'label' => 'Value',\n 'available_on' => Field::OBJECT_OPPORTUNITY,\n 'type' => Field::TYPE_CURRENCY,\n ],\n 'pipeline_stage_id' => [\n 'label' => 'Stage',\n 'type' => Field::TYPE_PICKLIST,\n 'available_on' => Field::OBJECT_OPPORTUNITY,\n ],\n 'close_date' => [\n 'label' => 'Close date',\n 'type' => Field::TYPE_DATE,\n 'available_on' => Field::OBJECT_OPPORTUNITY,\n ],\n 'win_probability' => [\n 'label' => 'Win probability',\n 'type' => Field::TYPE_NUMBER,\n 'available_on' => Field::OBJECT_OPPORTUNITY,\n ],\n ];\n private const string UNSUPPORTED_OBJECT_TYPE = 'unsupported';\n private const string OPPORTUNITY_STATUS_OPEN = 'Open';\n private const string OPPORTUNITY_IMPORT_FAILED_ERROR_MESSAGE = 'Opportunity import failed';\n private const string RESOURCE_UPDATE_FAILED_ERROR_MESSAGE = 'Failed to update resource';\n\n private FieldValueConverter $fieldValueConverter;\n private ClientFactory $clientFactory;\n private ?Client $copperClient = null;\n\n public function __construct(\n ClientFactory $clientFactory,\n FieldValueConverter $fieldValueConverter,\n private readonly UserRepository $userRepository,\n private readonly ProspectPhotoPathService $prospectPhotoPathService,\n private readonly CrmEntityRepository $crmEntityRepository\n ) {\n parent::__construct();\n\n $this->clientFactory = $clientFactory;\n $this->fieldValueConverter = $fieldValueConverter;\n }\n\n public function getDisplayName(): string\n {\n return 'Copper';\n }\n\n /**\n * Get the CRM entity repository (used by SearchesCrmEntities trait)\n */\n protected function getCrmEntityRepository(): CrmEntityRepository\n {\n return $this->crmEntityRepository;\n }\n\n public function getMinimumApiVersion(): string\n {\n return Client::MIN_API_VERSION;\n }\n\n protected function getOAuthAccount(User $user): ?SocialAccount\n {\n // In this case, the Account Owner is always the connection for any API operations.\n $owner = $user->team->owner;\n\n return $owner->getSocialAccount(SocialAccount::PROVIDER_COPPER);\n }\n\n public function setUser(User $user): void\n {\n $this->configureSentryScope($user);\n\n $this->team = $user->getTeam();\n $this->teamService->setTeam($user->getTeam());\n $this->setConfiguration($user->getTeam()->getCrmConfiguration());\n\n // Reset state.\n $this->profile = $user->getProfile();\n\n if (! $this->profile instanceof Profile) {\n // Try to fetch their profile, maybe it was not yet imported?\n $this->profile = $this->getProfile($user);\n }\n }\n\n public function setConfiguration(Configuration $configuration): void\n {\n $this->config = $configuration;\n $this->copperClient = $this->clientFactory->create($configuration);\n\n if ($this->team === null) {\n $this->team = $configuration->getTeam();\n }\n }\n\n public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string\n {\n return $this->fieldValueConverter->convertFromCrm($fieldType, $fieldValue);\n }\n\n private function mapObjectType(string $objectType): string\n {\n $objectType = strtolower($objectType);\n\n switch ($objectType) {\n case Field::OBJECT_TASK:\n case Field::OBJECT_OPPORTUNITY:\n case Field::OBJECT_LEAD:\n $mappedType = $objectType;\n\n break;\n case Field::OBJECT_ACCOUNT:\n $mappedType = 'company';\n\n break;\n case Field::OBJECT_CONTACT:\n $mappedType = 'person';\n\n break;\n default:\n $mappedType = self::UNSUPPORTED_OBJECT_TYPE;\n\n break;\n }\n\n return $mappedType;\n }\n\n /**\n * @param string $crmObject The name of the CRM object. i.e. Opportunity or Contact\n */\n protected function getFields(string $crmObject): array\n {\n $mappedObjectType = $this->mapObjectType($crmObject);\n\n if (! $this->isSupportedObjectType($mappedObjectType)) {\n return [];\n }\n\n $customFields = $this->copperClient->fields()->all();\n\n $filteredFields = array_filter(\n $customFields,\n static fn ($field) => in_array($mappedObjectType, $field['available_on'], true)\n );\n\n return array_map(\n fn (array $property) => [\n 'name' => $property['id'],\n 'label' => $property['name'],\n 'type' => $this->convertFieldType($property['data_type']),\n ],\n $filteredFields\n );\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultFields(string $activityType): array\n {\n $fields = [];\n\n if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {\n $defaultFields = FieldDefinitions::defaultTaskFields();\n\n // This lazy creates these fields if not already setup.\n foreach ($defaultFields as $defaultField) {\n $fields[] = $this->config->fields()->firstOrCreate($defaultField);\n }\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultActivityField(string $activityType): Field\n {\n // Setup the activity field as the default Type.\n $activityField = $this->config->fields()->where([\n 'crm_provider_id' => 'type',\n 'object_type' => $activityType,\n ])->first();\n\n return $activityField;\n }\n\n /**\n * @inheritdoc\n */\n public function getSupportedPlaybookTypes(): array\n {\n return [Playbook::ACTIVITY_TYPE_TASK];\n }\n\n public function getDealInsightsFields(): array\n {\n return FieldDefinitions::dealInsightsFields();\n }\n\n protected function getDefaultFollowupLayoutFields(string $activityType): array\n {\n $fields = [];\n $fieldRepo = app(FieldRepository::class);\n $fieldFilters = FieldDefinitions::taskFollowupFieldsFilter();\n\n foreach ($fieldFilters as $data) {\n $field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $data);\n\n // Only add the field if it is created, which it should be.\n if ($field) {\n $fields[] = $field;\n }\n }\n\n return $fields;\n }\n\n public function syncFields(): void\n {\n $this->syncStandardFields();\n $this->syncCustomFields();\n }\n\n public function syncField(Field $field): void\n {\n if ($field->getCrmProviderId() === 'type') {\n /**\n * This field is a special case, it is not a custom field and is not returned by the Copper API.\n *\n * The field is created manually during layout setup.\n *\n * @see getDefaultFields()\n *\n * There is a separate endpoint to get the activity types.\n * @see importPicklistValues()\n */\n return;\n }\n\n if ($field->getCrmProviderId() === 'pipeline_stage_id') {\n // Not a custom type\n return;\n }\n\n $crmField = $this->copperClient->fields()->find($field->getCrmProviderId());\n $field->label = $crmField['name'];\n $field->type = $this->convertFieldType($crmField['data_type']);\n\n $field->save();\n }\n\n /**\n * @param string $fieldType How the data should be input\n */\n private function convertFieldType(string $fieldType): string\n {\n $fieldType = strtolower($fieldType);\n\n switch ($fieldType) {\n case 'dropdown':\n $convertedType = Field::TYPE_PICKLIST;\n\n break;\n case 'string':\n $convertedType = Field::TYPE_TEXT;\n\n break;\n case 'float':\n case 'percentage':\n $convertedType = Field::TYPE_NUMBER;\n\n break;\n case 'text':\n $convertedType = Field::TYPE_TEXTAREA;\n\n break;\n case 'checkbox':\n $convertedType = Field::TYPE_BOOLEAN;\n\n break;\n case Field::TYPE_DATE:\n case Field::TYPE_CURRENCY:\n case Field::TYPE_URL:\n $convertedType = $fieldType;\n\n break;\n default:\n $convertedType = Field::TYPE_UNSUPPORTED;\n\n break;\n }\n\n return $convertedType;\n }\n\n /**\n * @inheritdoc\n */\n public function importPicklistValues(Field $field): array\n {\n $fieldValues = [];\n\n if ($field->object_type === 'opportunity' && $field->crm_provider_id === 'pipeline_stage_id') {\n return $this->importFieldValuesForDealInsights($field);\n }\n\n $values = [];\n\n if ($field->object_type === Field::OBJECT_TASK && $field->crm_provider_id === 'type') {\n $activityTypes = $this->copperClient->activityTypes()->all();\n\n /**\n * We ignore \"system\" activity types, disabled activity types and the \"Note\" activity type.\n */\n $activityTypes = array_filter(\n $activityTypes['user'],\n fn (array $activityType) => ! $activityType['is_disabled'] && $activityType['id'] !== 0\n );\n\n // Import all activity values.\n $i = 0;\n foreach ($activityTypes as $activityType) {\n $values[] = [\n 'value' => $activityType['id'],\n 'label' => mb_strimwidth($activityType['name'], 0, Field::LABEL_MAX_LENGTH),\n 'sequence' => $i++,\n ];\n }\n } else {\n // We just check for the field in the custom definitions.\n $crmField = $this->copperClient->fields()->find($field->crm_provider_id);\n\n // Import all values.\n foreach ($crmField['options'] as $option) {\n $values[] = [\n 'value' => $option['id'],\n 'label' => mb_strimwidth($option['name'], 0, 191),\n 'sequence' => $option['rank'],\n ];\n }\n }\n\n // Take the existing set of field values.\n $fieldsToPurge = $field->values()->get()->pluck('value')->toArray();\n\n foreach ($values as $value) {\n // For all current values, upsert the field.\n $value['value'] = substr($value['value'] ?? '', 0, 255);\n $fieldValues[] = $field->values()->updateOrCreate([\n 'value' => $value['value'],\n ], $value);\n\n // Remove this value from the ones we are going to purge.\n $key = array_search($value['value'], $fieldsToPurge, false);\n if ($key !== false) {\n unset($fieldsToPurge[$key]);\n }\n }\n\n // Delete the old values that are no longer used.\n $field->values()->whereIn('value', $fieldsToPurge)->delete();\n\n return $fieldValues;\n }\n\n /**\n *\n * @return FieldValue[]\n */\n private function importFieldValuesForDealInsights(Field $field): array\n {\n $fieldValues = [];\n $pipelines = $this->copperClient->pipelines()->all();\n\n foreach ($pipelines as $pipeline) {\n foreach ($pipeline['stages'] as $i => $dealStage) {\n $fieldValues[] = $field->values()->updateOrCreate(\n ['value' => $dealStage['id']],\n [\n 'label' => $dealStage['name'],\n 'sequence' => $i,\n ]\n );\n }\n }\n\n return $fieldValues;\n }\n\n /**\n * @inheritdoc\n */\n public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage\n {\n if ($types === null) {\n $types = [Stage::TYPE_LEAD, Stage::TYPE_OPPORTUNITY];\n }\n $missingStage = null;\n\n foreach ($types as $type) {\n if ($type === Stage::TYPE_LEAD) {\n $statuses = $this->requestWithExceptionHandling(\n fn () => $this->copperClient->leadStatuses()->all()\n );\n\n if (count($statuses) > 0) {\n $businessProcess = $this->config->businessProcesses()->updateOrCreate([\n 'crm_provider_id' => BusinessProcess::TYPE_LEAD,\n ], [\n 'team_id' => $this->team->id,\n 'name' => BusinessProcess::TYPE_LEAD,\n 'type' => BusinessProcess::TYPE_LEAD,\n 'is_selectable' => true,\n ]);\n\n // A record type is really a clone of the business process, used to store which record uses which pipeline.\n $this->config->recordTypes()->updateOrCreate([\n 'crm_provider_id' => BusinessProcess::TYPE_LEAD,\n ], [\n 'team_id' => $this->team->id,\n 'name' => BusinessProcess::TYPE_LEAD,\n 'is_selectable' => true,\n 'business_process_id' => $businessProcess->id ?? null,\n ]);\n\n $leadStatuses = [];\n foreach ($statuses as $status) {\n // Upsert all statuses for the team.\n $status = $this->config->stages()->updateOrCreate([\n 'crm_provider_id' => $status['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($status['name'], 0, 50),\n 'label' => mb_strimwidth($status['name'], 0, 191),\n 'type' => Stage::TYPE_LEAD,\n 'sequence' => $status['order'],\n 'is_selectable' => true,\n ]);\n\n if ($missingStageName && $missingStageName === $status['id']) {\n $missingStage = $status;\n }\n\n $leadStatuses[] = $status['id'];\n }\n\n $businessProcess->stages()->sync($leadStatuses);\n }\n } else {\n // We create a business process to contain the pipeline, and later store all stages against it.\n foreach ($this->copperClient->pipelines()->all() as $pipeline) {\n $businessProcess = $this->config->businessProcesses()->updateOrCreate([\n 'crm_provider_id' => $pipeline['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($pipeline['name'], 0, 150),\n 'type' => BusinessProcess::TYPE_OPPORTUNITY,\n 'is_selectable' => true,\n ]);\n\n // A record type is really a clone of the business process, used to store which record uses which pipeline.\n $this->config->recordTypes()->updateOrCreate([\n 'crm_provider_id' => $pipeline['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($pipeline['name'], 0, 150),\n 'is_selectable' => true,\n 'business_process_id' => $businessProcess->id ?? null,\n ]);\n\n $stages = [];\n\n foreach ($pipeline['stages'] as $i => $dealStage) {\n // Upsert all stages for the team.\n $stage = $this->config->stages()->updateOrCreate([\n 'crm_provider_id' => $dealStage['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($dealStage['name'], 0, 50),\n 'label' => mb_strimwidth($dealStage['name'], 0, 191),\n 'type' => Stage::TYPE_OPPORTUNITY,\n 'sequence' => $i,\n 'is_selectable' => true,\n 'probability' => $dealStage['win_probability'],\n ]);\n\n if ($missingStageName && $missingStageName === $dealStage['id']) {\n $missingStage = $stage;\n }\n\n $stages[] = $stage['id'];\n }\n\n $businessProcess->stages()->sync($stages);\n }\n }\n }\n\n return $missingStage;\n }\n\n /**\n * @inheritdoc\n */\n public function syncLeads(Carbon $since, ?Carbon $to = null, ?string $crmProfileId = null): int\n {\n $syncCount = 0;\n\n $cpLeads = $this->requestWithExceptionHandling(\n fn () => $this->copperClient->leads()->paginate([\n 'minimum_modified_date' => $since->timestamp,\n 'sort_by' => 'date_modified',\n ])\n );\n\n foreach ($cpLeads as $cpLead) {\n // Only sync if previously imported.\n if ($this->hasLead($cpLead['id']) === false) {\n $this->importLead($cpLead);\n $syncCount++;\n }\n }\n\n return $syncCount;\n }\n\n /**\n * @inheritdoc\n */\n public function syncLead(string $crmId): ?Lead\n {\n $cpLead = $this->requestWithExceptionHandling(\n fn () => $this->copperClient->leads()->find($crmId)\n );\n\n if (\\count($cpLead) === 0) {\n return null;\n }\n\n return $this->importLead($cpLead);\n }\n\n /**\n * @inheritdoc\n */\n private function importLead($crmData): ?Lead\n {\n $countryCode = null;\n if (! empty($crmData['address']) && isset($crmData['address']['country'])) {\n $countryCode = $this->convertCountryNameToCode($crmData['address']['country']);\n }\n\n $mobilePhone = $parsedNumber = null;\n if (! empty($crmData['phone_numbers'])) {\n foreach ($crmData['phone_numbers'] as $phoneNumber) {\n if ($phoneNumber['category'] === 'mobile') {\n $mobilePhone = $this->buildContactMobilePhone($countryCode, $phoneNumber['number']);\n } else {\n $parsedNumber = $this->buildContactPhone($countryCode, $phoneNumber['number']);\n }\n }\n }\n\n $name = $crmData['name'];\n\n if (empty($crmData['company_name'])) {\n $company = 'Unknown';\n } else {\n $company = mb_strimwidth($crmData['company_name'], 0, 191);\n }\n\n $domain = null;\n if (empty($crmData['websites']) === false) {\n $domain = mb_strimwidth($crmData['websites'][0]['url'], 0, 191);\n $domain = StringUtil::resolveDomain($domain);\n }\n\n $email = null;\n if (empty($crmData['email']) === false) {\n $email = mb_strimwidth($crmData['email']['email'], 0, 80);\n }\n\n $stage = $this->config\n ->stages()\n ->where('crm_provider_id', (string) $crmData['status_id'])\n ->where('type', Stage::TYPE_LEAD)\n ->first();\n\n if ($stage === null) {\n // Import it.\n $stage = $this->importStages([Stage::TYPE_LEAD], $crmData['status_id']);\n }\n\n if (! $stage instanceof Stage) {\n return null;\n }\n\n $profile = $this->config->profiles()->where('crm_provider_id', (string) $crmData['assignee_id'])->first();\n\n $data = [\n 'team_id' => $this->team->id,\n 'user_id' => $profile ? $profile->user_id : null,\n 'owner_id' => $crmData['assignee_id'],\n 'name' => mb_strimwidth($name, 0, 191),\n 'company' => $company,\n 'domain' => $domain,\n 'title' => $crmData['title'] ? mb_strimwidth($crmData['title'], 0, 128) : null,\n 'email' => $email,\n 'phone' => $parsedNumber['phone'] ?? null,\n 'ext' => $parsedNumber['ext'] ?? null,\n 'mobile_phone' => $mobilePhone,\n 'stage_id' => $stage->id,\n 'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(\n crmConfiguration: $this->config,\n crmProviderId: $crmData['id'],\n modelType: Lead::class,\n fileName: $crmData['id'],\n avatarText: $crmData['name']\n ),\n 'country_code' => $countryCode,\n 'remotely_created_at' => $crmData['date_created'],\n ];\n\n return $this->config->leads()->updateOrCreate(['crm_provider_id' => (string) $crmData['id']], $data);\n }\n\n /**\n * @inheritdoc\n */\n public function syncAccounts(Carbon $since, ?Carbon $to = null): int\n {\n $syncCount = 0;\n\n try {\n $cpAccounts = $this->copperClient->accounts()->paginate([\n 'minimum_modified_date' => $since->timestamp,\n 'sort_by' => 'date_modified',\n ]);\n\n foreach ($cpAccounts as $cpAccount) {\n // Only sync if previously imported.\n if ($this->hasAccount($cpAccount['id'])) {\n $this->importAccount($cpAccount);\n $syncCount++;\n }\n }\n } catch (\\Exception $exception) {\n // Do nothing for now.\n }\n\n return $syncCount;\n }\n\n /**\n * @inheritdoc\n */\n public function syncAccount(string $crmId): ?Account\n {\n $cpAccount = $this->copperClient->accounts()->find($crmId);\n\n if (empty($cpAccount)) {\n return null;\n }\n\n return $this->importAccount($cpAccount);\n }\n\n /**\n * @inheritdoc\n */\n private function importAccount($crmData): Account\n {\n $countryCode = null;\n if (empty($crmData['address']) === false && $crmData['address']['country']) {\n $countryCode = $this->convertCountryNameToCode($crmData['address']['country']);\n }\n\n $name = $crmData['name'];\n\n $parsedNumber = null;\n foreach ($crmData['phone_numbers'] as $phoneNumber) {\n $parsedNumber = parsePhoneNumber($countryCode, $phoneNumber['number']);\n }\n\n $domain = null;\n if (empty($crmData['websites']) === false) {\n $domain = mb_strimwidth($crmData['websites'][0]['url'], 0, 191);\n $domain = StringUtil::resolveDomain($domain);\n }\n\n $profile = $this->config->profiles()->where('crm_provider_id', (string) $crmData['assignee_id'])->first();\n\n $data = [\n 'team_id' => $this->team->id,\n 'user_id' => $profile ? $profile->user_id : null,\n 'owner_id' => $crmData['assignee_id'],\n 'name' => mb_strimwidth($name, 0, 191),\n 'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(\n crmConfiguration: $this->config,\n crmProviderId: $crmData['id'],\n modelType: Account::class,\n fileName: $crmData['id'],\n avatarText: $crmData['name']\n ),\n 'country_code' => $countryCode,\n 'domain' => $domain,\n 'phone' => $parsedNumber['phone'] ?? null,\n 'ext' => $parsedNumber['ext'] ?? null,\n 'remotely_created_at' => $crmData['date_created'],\n ];\n\n return $this->config->accounts()->updateOrCreate(['crm_provider_id' => (string) $crmData['id']], $data);\n }\n\n public function syncOpportunities(array $parameters, ?string $strategy = null): int\n {\n $strategyResolver = app(OpportunitySyncStrategyResolver::class, [\n 'client' => $this->copperClient,\n ]);\n $strategies = $strategyResolver->getStrategies($this->config, $strategy);\n\n $syncCount = 0;\n\n try {\n foreach ($strategies as $syncStrategy) {\n foreach ($syncStrategy->fetchOpportunities($parameters) as $opportunityData) {\n $this->importOpportunity($opportunityData);\n $syncCount++;\n }\n }\n } catch (\\Exception $exception) {\n // Do nothing for now.\n $this->logger->error(self::OPPORTUNITY_IMPORT_FAILED_ERROR_MESSAGE, [\n 'error' => $exception->getMessage(),\n ]);\n }\n\n return $syncCount;\n }\n\n public function syncOpportunity(string $crmId): ?Opportunity\n {\n $strategyResolver = app(OpportunitySyncStrategyResolver::class, [\n 'client' => $this->copperClient,\n ]);\n $strategy = $strategyResolver->resolve(\n $this->config,\n OpportunitySyncStrategyResolver::SINGLE_SYNC_OPPORTUNITY_STRATEGY\n );\n\n $parameters['crm_id'] = $crmId;\n\n $cpOpportunity = $strategy->fetchOpportunities($parameters);\n\n return $this->importOpportunity($cpOpportunity);\n }\n\n private function importOpportunity($crmData): ?Opportunity\n {\n $companyId = (string) $crmData['company_id'];\n\n if (empty($companyId)) {\n $this->logger->error(self::OPPORTUNITY_IMPORT_FAILED_ERROR_MESSAGE, [\n 'error' => 'No company id',\n ]);\n\n return null;\n }\n\n $account = $this->config->accounts()->where('crm_provider_id', $companyId)->first();\n\n if ($account === null) {\n $account = $this->syncAccount($companyId);\n }\n\n $profile = $this->config->profiles()->where('crm_provider_id', (string) $crmData['assignee_id'])->first();\n\n $businessProcess = $this->config\n ->businessProcesses()\n ->where('crm_provider_id', (string) $crmData['pipeline_id'])\n ->first();\n\n if ($businessProcess === null) {\n // It is possible for some deals to not be in a pipeline.\n $this->logger->warning(self::OPPORTUNITY_IMPORT_FAILED_ERROR_MESSAGE, [\n 'error' => 'Missing pipeline',\n ]);\n\n return null;\n }\n\n $stage = $businessProcess\n ->stages()\n ->where('crm_provider_id', (string) $crmData['pipeline_stage_id'])\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->first();\n\n if ($stage === null) {\n $this->logger->info('Stage not found, importing', [\n 'pipeline_id' => $crmData['pipeline_id'],\n 'stage_id' => $crmData['pipeline_stage_id'],\n ]);\n\n $stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $crmData['pipeline_stage_id']);\n }\n\n $recordType = $businessProcess->recordTypes()->first();\n $closeDate = null;\n $isClosed = $crmData['status'] !== self::OPPORTUNITY_STATUS_OPEN;\n\n if ($crmData['close_date']) {\n $closeDate = Carbon::parse($crmData['close_date'])->format('Y-m-d');\n }\n\n $data = [\n 'team_id' => $this->team->getId(),\n 'account_id' => $account->id,\n 'user_id' => $profile->user_id ?? null,\n 'owner_id' => $crmData['assignee_id'],\n 'name' => mb_strimwidth($crmData['name'], 0, 128),\n 'value' => $crmData['monetary_value'],\n 'currency_code' => CurrencyFormatter::formatCode($crmData['monetary_unit']),\n 'close_date' => $closeDate,\n 'is_closed' => $isClosed,\n 'is_won' => $crmData['status'] === 'Won',\n 'stage_id' => $stage->id,\n 'record_type_id' => $recordType->id ?? null,\n 'remotely_created_at' => $crmData['date_created'],\n ];\n\n /** @var Opportunity $opportunity */\n $opportunity = $this->config\n ->opportunities()\n ->updateOrCreate(['crm_provider_id' => (string) $crmData['id']], $data)\n ;\n\n // import external fields into crm_field_data if present\n $dealInsightsFields = $this->getOpportunitySyncableFields();\n $this->importOpportunityCrmFieldData($crmData, $dealInsightsFields, $opportunity->id);\n\n return $opportunity;\n }\n\n /**\n * @param array<string, mixed> $opportunityData\n * @param string[] $dealInsightsFields\n */\n public function importOpportunityCrmFieldData(\n array $opportunityData,\n array $dealInsightsFields,\n int $opportunityId\n ): void {\n foreach ($dealInsightsFields as $fieldName) {\n $field = $this->config->fields()->with('entities')\n ->where('crm_provider_id', $fieldName)\n ->where('object_type', Field::OBJECT_OPPORTUNITY)\n ->first();\n\n if (! $field instanceof Field) {\n $this->logger->warning('Field not found', [\n 'field' => $fieldName,\n 'opportunity_id' => $opportunityId,\n ]);\n\n continue;\n }\n\n $entity = $field->getEntities()->first();\n\n if (! $entity instanceof LayoutEntity) {\n continue;\n }\n\n $fieldValue = $this->extractFieldValue($opportunityData, $fieldName);\n $normalizedValue = $this->normalizeValue($field->getType(), (string) $fieldValue, true);\n\n $field->data()\n ->updateOrCreate([\n 'crm_layout_entity_id' => $entity->id,\n 'crm_field_id' => $field->id,\n 'object_type' => 'opportunity',\n 'object_id' => $opportunityId,\n ], ['value' => $normalizedValue])\n ;\n }\n }\n\n /**\n * @inheritdoc\n */\n public function syncContacts(Carbon $since, ?Carbon $to = null): int\n {\n $syncCount = 0;\n\n try {\n $cpContacts = $this->copperClient->contacts()->paginate([\n 'minimum_interaction_date' => $since->timestamp,\n 'sort_by' => 'date_modified',\n ]);\n\n foreach ($cpContacts as $cpContact) {\n // Only sync if previously imported.\n if ($this->hasContact($cpContact['id'])) {\n $this->importContact($cpContact);\n $syncCount++;\n }\n }\n } catch (\\Exception $exception) {\n // Do nothing for now.\n throw $exception;\n }\n\n return $syncCount;\n }\n\n /**\n * @inheritdoc\n */\n public function syncContact(string $crmId): ?Contact\n {\n $cpContact = $this->copperClient->contacts()->find($crmId);\n\n if (empty($cpContact)) {\n return null;\n }\n\n return $this->importContact($cpContact);\n }\n\n /**\n * @inheritdoc\n */\n private function importContact($crmData): Contact\n {\n $account = null;\n if ($crmData['company_id']) {\n $account = $this->team\n ->crm\n ->accounts()\n ->where('crm_provider_id', (string) $crmData['company_id'])\n ->first();\n\n if ($account === null) {\n $account = $this->syncAccount($crmData['company_id']);\n }\n }\n\n $countryCode = null;\n if (empty($crmData['address']) === false && $crmData['address']['country']) {\n $countryCode = $this->convertCountryNameToCode($crmData['address']['country']);\n }\n\n $mobilePhone = $parsedNumber = null;\n foreach ($crmData['phone_numbers'] as $phoneNumber) {\n if ($phoneNumber['category'] === 'mobile') {\n $mobilePhone = $this->buildContactMobilePhone($countryCode, $phoneNumber['number']);\n } else {\n $parsedNumber = $this->buildContactPhone($countryCode, $phoneNumber['number']);\n }\n }\n\n $email = null;\n if (empty($crmData['emails']) === false) {\n $email = mb_strimwidth($crmData['emails'][0]['email'], 0, 80);\n }\n\n $profile = $this->config->profiles()->where('crm_provider_id', (string) $crmData['assignee_id'])->first();\n\n $data = [\n 'team_id' => $this->team->id,\n 'account_id' => $account->id ?? null,\n 'user_id' => $profile ? $profile->user_id : null,\n 'owner_id' => $crmData['assignee_id'],\n 'name' => $crmData['name'],\n 'email' => $email,\n 'phone' => $parsedNumber['phone'] ?? null,\n 'mobile_phone' => $mobilePhone ?? null,\n 'ext' => $parsedNumber['ext'] ?? null,\n 'title' => $crmData['title'],\n 'country_code' => $countryCode,\n 'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(\n crmConfiguration: $this->config,\n crmProviderId: $crmData['id'],\n modelType: Contact::class,\n fileName: $crmData['id'],\n avatarText: $crmData['name']\n ),\n 'remotely_created_at' => $crmData['date_created'],\n ];\n\n return $this->config->contacts()->updateOrCreate(['crm_provider_id' => (string) $crmData['id']], $data);\n }\n\n private function buildContactPhone(?string $countryCode, ?string $number): ?array\n {\n if ($number) {\n // Trim to our width and attempt to parse it.\n $number = mb_strimwidth($number, 0, 25);\n $parsedNumber = parsePhoneNumber($countryCode, $number);\n } else {\n $parsedNumber = [];\n }\n\n return $parsedNumber;\n }\n\n private function buildContactMobilePhone(?string $countryCode, ?string $number): ?string\n {\n return $number ? mb_strimwidth(phone_e164($countryCode, $number), 0, 25) : null;\n }\n\n /**\n * @inheritdoc\n */\n public function syncOrganization(): void\n {\n $organization = $this->copperClient->organisation()->all();\n\n if ($this->config->crm_provider_id !== $organization['id']) {\n $this->config->update(['crm_provider_id' => $organization['id']]);\n }\n }\n\n private function syncCustomFields(): void\n {\n foreach ($this->getFieldTypes() as $fieldType) {\n $currentFields = $this->getFields($fieldType);\n\n foreach ($currentFields as $field) {\n /** @var Field $field */\n $field = $this->config->fields()->updateOrCreate([\n 'crm_provider_id' => $field['name'],\n 'object_type' => $fieldType,\n ], [\n 'label' => mb_strimwidth($field['label'], 0, Field::LABEL_MAX_LENGTH),\n 'type' => $field['type'],\n ]);\n\n if ($field->getType() === Field::TYPE_PICKLIST) {\n $this->importPicklistValues($field);\n }\n }\n }\n\n $this->config->save();\n }\n\n protected function getFieldTypes(): array\n {\n return [\n parent::OBJECT_TASK,\n parent::OBJECT_LEAD,\n parent::OBJECT_ACCOUNT,\n parent::OBJECT_CONTACT,\n parent::OBJECT_OPPORTUNITY,\n ];\n }\n\n /**\n * @inheritdoc\n */\n public function syncProfiles(?User $userToSearch = null): ?Profile\n {\n $profiles = $this->copperClient->users()->all();\n\n foreach ($profiles as $profile) {\n $user = $this->userRepository->findByTeamAndEmailAddress($this->team, $profile['email'], onlyActive: true);\n\n // Only import profiles for users we have locally.\n if ($user) {\n $profile = $user->crmProfile()->updateOrCreate([\n 'crm_configuration_id' => $this->config->id,\n 'crm_provider_id' => $profile['id'],\n ]);\n\n if ($userToSearch && $userToSearch->id === $user->id) {\n return $profile;\n }\n }\n }\n\n return null;\n }\n\n /**\n * @inheritdoc\n */\n public function syncProfileFields(): void\n {\n $fieldTypes = [\n 'task',\n 'lead',\n 'account',\n 'contact',\n 'opportunity',\n ];\n\n foreach ($fieldTypes as $fieldType) {\n try {\n $currentFields = $this->getFields(ucfirst($fieldType));\n\n $newFields = array_column($currentFields, 'name');\n $changedFields = array_merge(\n array_diff($this->profile->getFieldsAsArray($fieldType), $newFields),\n array_diff($newFields, $this->profile->getFieldsAsArray($fieldType))\n );\n\n if (\\count($changedFields) > 0) {\n $fields = implode(',', $newFields);\n $this->logger->debug(\n ucfirst($fieldType)\n . ' fields changed from '\n . $this->profile->{$fieldType . '_fields'}\n . ' to: '\n . $fields\n );\n\n $this->profile->{$fieldType . '_fields'} = $fields;\n }\n } catch (\\Exception $exception) {\n $this->logger->error('No access to ' . $fieldType . ' object, skipping...');\n\n continue;\n }\n }\n\n $this->profile->save();\n }\n\n /**\n * @inheritdoc\n */\n public function find(string $name, array $scopes): array\n {\n $count = $this->limit ?? 20;\n $offset = $this->offset ?? 0;\n\n $scopeValues = array_map(fn ($scope) => is_string($scope) ? $scope : $scope->value, $scopes);\n $cacheKey = sprintf('copper-search-%d-%s-%s-%d-%d', $this->team->id, $name, implode('-', $scopeValues), $count, $offset);\n\n return Cache::remember($cacheKey, 300, function () use ($name, $scopes, $count, $offset) {\n // Try local search first\n $localResults = $this->searchLocally($name, $scopes, $count, $offset);\n\n // If no local results, fall back to remote API search\n if (empty($localResults)) {\n return $this->searchRemotely($name, $scopes, $count, $offset);\n }\n\n return $localResults;\n });\n }\n\n /**\n * @inheritdoc\n */\n public function findOpportunities(?string $crmAccountId, ?string $crmContactId, ?int $userId = null): array\n {\n $data = [];\n $ownerData = [];\n $ownerId = null;\n $contact = null;\n $account = null;\n\n if ($crmAccountId) {\n $account = $this->config->accounts()->where('crm_provider_id', $crmAccountId)->first();\n\n if ($account === null) {\n $account = $this->syncAccount($crmAccountId);\n }\n }\n\n if ($crmContactId) {\n $contact = $this->config->contacts()->where('crm_provider_id', $crmContactId)->first();\n\n if ($contact === null) {\n $contact = $this->syncContact($crmContactId);\n }\n }\n\n if ($contact || $account) {\n if ($contact && $account === null) {\n $account = $contact->account;\n }\n\n if ($account === null) {\n return [];\n }\n\n $params = [\n 'company_ids' => [$account->crm_provider_id],\n 'status_ids' => [0], // The integer values are 0, 1, 2, 3, for \"Open\", \"Won\", \"Lost\", and \"Abandoned\".\n 'sort_by' => 'date_modified',\n 'sort_direction' => 'desc',\n ];\n\n switch ($this->config->opportunity_assignment_rule) {\n case Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_UPDATED:\n $params['sort_by'] = 'date_modified';\n $params['sort_direction'] = 'desc';\n\n break;\n\n case Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_CREATED:\n $params['sort_by'] = 'date_created';\n $params['sort_direction'] = 'desc';\n\n break;\n\n case Configuration::OPP_ASSIGNMENT_ALL_OPEN_OLDEST_CREATED:\n $params['sort_by'] = 'date_created';\n $params['sort_direction'] = 'asc';\n\n break;\n\n case Configuration::OPP_ASSIGNMENT_ALL_RECENTLY_UPDATED:\n $params['sort_by'] = 'date_modified';\n $params['sort_direction'] = 'desc';\n $params['status_ids'] = [0, 1, 2, 3];\n }\n\n $cpOpportunities = $this->copperClient->opportunities()->search($params);\n\n if ($userId) {\n $profileRepository = app(ProfileRepository::class);\n $profile = $profileRepository->findProfileByUserId($this->config, $userId);\n\n $ownerId = $profile instanceof Profile ? $profile->getCrmProviderId() : null;\n }\n\n foreach ($cpOpportunities as $cpOpportunity) {\n $businessProcess = $this->config\n ->businessProcesses()\n ->where('crm_provider_id', (string) $cpOpportunity['pipeline_id'])\n ->first();\n\n if ($businessProcess === null) {\n // Import it.\n $businessProcess = $this->importStages(\n [Stage::TYPE_OPPORTUNITY],\n $cpOpportunity['pipeline_stage_id']\n );\n }\n\n $stage = $businessProcess\n ->stages()\n ->where('crm_provider_id', (string) $cpOpportunity['pipeline_stage_id'])\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->first();\n\n if ($stage === null) {\n // Import it.\n $stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $cpOpportunity['pipeline_stage_id']);\n }\n\n $recordType = $businessProcess->recordTypes()->first();\n\n $record = [\n 'crmId' => $cpOpportunity['id'],\n 'name' => $cpOpportunity['name'],\n 'value' => currency_format($cpOpportunity['monetary_value'], $cpOpportunity['monetary_unit']),\n 'won' => $cpOpportunity['status'] === 'Won',\n 'stage' => [\n 'id' => $stage->id_string,\n 'name' => $stage->name,\n ],\n 'recordType' => [\n 'id' => $recordType->id_string,\n 'name' => $recordType->name,\n ],\n ];\n\n if ($ownerId && isset($cpOpportunity['assignee_id']) && $cpOpportunity['assignee_id'] === $ownerId) {\n $ownerData[] = $record;\n }\n\n $data[] = $record;\n }\n }\n\n if (! empty($ownerData)) {\n return $ownerData;\n }\n\n return $data;\n }\n\n /**\n * @inheritdoc\n */\n public function getTasks(string $objectType, string $objectId, ?string $opportunityId): array\n {\n $data = $cpTasks = [];\n\n switch ($objectType) {\n case 'lead':\n $cpTasks = $this->requestWithExceptionHandling(\n fn () => $this->copperClient->leads()->relatedTasks($objectId)\n );\n\n break;\n\n case 'account':\n $cpTasks = $this->copperClient->accounts()->relatedTasks($objectId);\n\n break;\n case 'contact':\n if ($opportunityId) {\n $cpTasks = $this->copperClient->opportunities()->relatedTasks($opportunityId);\n } elseif ($objectType === 'contact') {\n $cpTasks = $this->copperClient->contacts()->relatedTasks($objectId);\n }\n\n break;\n }\n\n foreach ($cpTasks as $cpTask) {\n // We only get the ID itself returned, so pull out the full object.\n try {\n $cpTask = $this->copperClient->tasks()->find($cpTask['id']);\n } catch (Exception $exception) {\n $this->logger->error('[Copper] Error fetching task', [\n 'resource_id' => $cpTask['id'],\n 'error' => $exception->getMessage(),\n ]);\n\n continue;\n }\n\n // Only allow open tasks.\n if ($cpTask['status'] === 'Open') {\n $data[] = [\n 'crmId' => $cpTask['id'],\n 'subject' => $cpTask['name'],\n 'due' => $cpTask['due_date']\n ? Carbon::createFromTimestamp($cpTask['due_date'])->format('Y-m-d\\TH:i:sO')\n : null,\n 'type' => null,\n ];\n }\n }\n\n return $data;\n }\n\n /**\n * Try to find email address in CRM service\n *\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n *}\n */\n public function matchExactlyByEmail(string $email, ?int $userId = null): ?array\n {\n $cpObjectType = 'contact';\n\n $cpObjects = $this->copperClient->contacts()->search([\n 'emails' => $email,\n 'sort_by' => 'date_modified',\n 'sort_direction' => 'desc',\n ]);\n\n if (empty($cpObjects)) {\n $cpObjectType = 'lead';\n $cpObjects = $this->requestWithExceptionHandling(\n fn () => $this->copperClient->leads()->search([\n 'emails' => $email,\n 'sort_by' => 'date_modified',\n 'sort_direction' => 'desc',\n ])\n );\n }\n\n if (empty($cpObjects)) {\n return null;\n }\n\n // Search returns an array of results, but we only want the first\n $cpObject = reset($cpObjects);\n $data = $this->convertCrmData($cpObject, $cpObjectType, $userId);\n\n return ! empty(array_filter($data)) ? $data : null;\n }\n\n /**\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n public function matchByPhone(string $phone, ?string $rawPhoneNumber = null, ?int $userId = null): ?array\n {\n // Check if the user is internal.\n $teamMember = $this->team->users()->where('phone', $phone)->exists();\n\n // Skip the attendee if internal.\n if ($teamMember === false) {\n $cpObjectType = 'contact';\n $cpObject = $this->copperClient->contacts()->search([\n 'phone_number' => $phone,\n 'sort_by' => 'date_modified',\n 'sort_direction' => 'desc',\n ]);\n\n if (\\count($cpObject) === 0) {\n $cpObjectType = 'lead';\n $cpObject = $this->requestWithExceptionHandling(\n fn () => $this->copperClient->leads()->search([\n 'phone_number' => $phone,\n 'sort_by' => 'date_modified',\n 'sort_direction' => 'desc',\n ])\n );\n }\n\n if (empty($cpObject)) {\n return null;\n }\n\n $cpObject = reset($cpObject);\n $data = $this->convertCrmData($cpObject, $cpObjectType, $userId);\n\n return ! empty(array_filter($data)) ? $data : null;\n }\n\n return null;\n }\n\n /**\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n public function matchByName(string $name, ?int $userId = null): ?array\n {\n // Don't waste time searching for single character strings.\n if (\\strlen($name) <= 1) {\n return null;\n }\n\n $result = \\Cache::remember($this->team->id . $name, 60, function () use ($name, $userId) {\n $cpObjectType = 'contact';\n $cpObject = $this->copperClient->contacts()->search([\n 'name' => $name,\n 'sort_by' => 'date_modified',\n 'sort_direction' => 'desc',\n ]);\n\n if (\\count($cpObject) === 0) {\n $cpObjectType = 'lead';\n $cpObject = $this->requestWithExceptionHandling(\n fn () => $this->copperClient->leads()->search([\n 'name' => $name,\n 'sort_by' => 'date_modified',\n 'sort_direction' => 'desc',\n ])\n );\n }\n\n if (! empty($cpObject)) {\n $cpObject = reset($cpObject);\n }\n\n $data = $this->convertCrmData($cpObject, $cpObjectType, $userId);\n\n return ! empty(array_filter($data)) ? $data : false;\n });\n\n return is_array($result) ? $result : null;\n }\n\n private function convertCrmData(array $object, string $objectType, ?int $userId = null): array\n {\n $lead = null;\n $contact = null;\n $opportunity = null;\n $account = null;\n $stage = null;\n $countryCode = null;\n\n if ($objectType === 'lead') {\n $lead = $this->importLead($object);\n\n // Lead might not be imported if the Stage is null for example.\n if ($lead) {\n $countryCode = $lead->country_code;\n $stage = $lead->stage;\n }\n } else {\n $contact = $this->importContact($object);\n $account = $contact->account;\n\n if ($contact->country_code) {\n $countryCode = $contact->country_code;\n } elseif ($account) {\n $countryCode = $account->country_code;\n }\n\n try {\n $cpOpportunities = $this->findOpportunities(\n $account?->crm_provider_id,\n $contact->crm_provider_id,\n $userId,\n );\n\n if (! empty($cpOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($cpOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n } catch (\\Exception $e) {\n // Nothing to see here.\n }\n }\n\n return [\n $lead,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n }\n\n public function saveActivity(Activity $activity): Activity\n {\n switch ($activity->type) {\n case Activity::TYPE_CONFERENCE:\n case Activity::TYPE_SOFTPHONE:\n case Activity::TYPE_SOFTPHONE_INBOUND:\n $activity = $this->buildCallPayload($activity);\n\n break;\n case Activity::TYPE_SMS_INBOUND:\n case Activity::TYPE_SMS_OUTBOUND:\n $activity = $this->buildTextMessagePayload($activity);\n\n break;\n }\n\n return $activity;\n }\n\n private function convertActivityAssociations(Activity $activity): array\n {\n if ($activity->lead) {\n $type = 'lead';\n $id = $activity->lead->crm_provider_id;\n } elseif ($activity->contact) {\n $type = 'person';\n $id = $activity->contact->crm_provider_id;\n } elseif ($activity->opportunity) {\n $type = 'opportunity';\n $id = $activity->opportunity->crm_provider_id;\n } else {\n $type = 'company';\n $id = $activity->account->crm_provider_id;\n }\n\n return [\n 'type' => $type,\n 'id' => $id,\n ];\n }\n\n private function convertActivityType(PlaybookCategory $activityType): array\n {\n return [\n 'category' => 'user',\n 'id' => $this->getCrmType($activityType)->value,\n ];\n }\n\n /**\n * @throws CrmException\n */\n private function buildCallPayload(Activity $activity): Activity\n {\n try {\n if ($activity->crm_provider_id) {\n // The activity should be logged under the existing Task (not Activity).\n $newActivityType = $this->getCrmType($activity->category);\n if ($newActivityType === null) {\n $message = sprintf('[Copper] invalid CRM Custom Activity Type: %s', $activity->category);\n\n throw new CrmException($message, 400);\n }\n $this->copperClient->tasks()->update($activity->crm_provider_id, [\n 'status' => 'Completed',\n 'custom_activity_type_id' => $newActivityType->value,\n 'details' => $this->generateActivityDescription($activity),\n ]);\n } else {\n if ($this->getCrmType($activity->category) === null) {\n $message = sprintf('[Copper] Invalid CRM Activity Type: %s', $activity->category->getName());\n\n throw new CrmException($message, 400);\n }\n\n if ($this->getCrmType($activity->category)->value === null) {\n $message = sprintf('[Copper] Invalid CRM Activity Type: %s value', $activity->category->getName());\n\n throw new CrmException($message, 400);\n }\n // Just create an activity.\n $cpActivity = $this->copperClient->activities()->create([\n 'user_id' => $this->profile->crm_provider_id,\n 'activity_date' => $activity->scheduled_start_time->timestamp,\n 'parent' => $this->convertActivityAssociations($activity),\n 'type' => $this->convertActivityType($activity->category),\n 'details' => $this->generateActivityDescription($activity),\n ]);\n\n $activity->crm_provider_id = $cpActivity['id'];\n $activity->save();\n }\n } catch (ClientException $exception) {\n //Log all errors to Sentry\n \\Sentry::captureException($exception);\n\n $response = $exception->getResponse();\n\n $responseBody = $response->getBody();\n $message = $responseBody;\n $errorCode = $response->getStatusCode();\n\n $jsonResponse = json_decode($responseBody, true);\n if (isset($jsonResponse[0]['message'])) {\n $message = $jsonResponse[0]['message'];\n }\n\n throw new CrmException($message, $errorCode);\n }\n\n return $activity;\n }\n\n private function buildTextMessagePayload(Activity $activity): Activity\n {\n if ($this->getCrmType($activity->category) === null) {\n $message = sprintf('[Copper] Invalid CRM Activity Type: %s', $activity->category->getName());\n\n throw new CrmException($message, 400);\n }\n\n if ($this->getCrmType($activity->category)->value === null) {\n $message = sprintf('[Copper] Invalid CRM Activity Type: %s value', $activity->category->getName());\n\n throw new CrmException($message, 400);\n }\n\n $cpActivity = $this->copperClient->activities()->create([\n 'user_id' => $this->profile->crm_provider_id,\n 'activity_date' => $activity->created_at->timestamp,\n 'parent' => $this->convertActivityAssociations($activity),\n 'type' => $this->convertActivityType($activity->category),\n 'details' => $this->generateActivityDescription($activity),\n ]);\n\n $activity->crm_provider_id = $cpActivity['id'];\n $activity->save();\n\n return $activity;\n }\n\n private function generateActivityDescription(Activity $activity): string\n {\n $description = '';\n\n switch ($activity->type) {\n case Activity::TYPE_SOFTPHONE:\n case Activity::TYPE_SOFTPHONE_INBOUND:\n case Activity::TYPE_CONFERENCE:\n\n $description = 'Jiminny Call Summary' . PHP_EOL . PHP_EOL;\n $description .= 'Call Duration: ' . $activity->duration_for_humans . PHP_EOL . PHP_EOL;\n\n // Tell them which number was dialed.\n if ($activity->to) {\n $description .= 'Dialed: ' . $activity->to->phone_number . PHP_EOL . PHP_EOL;\n }\n\n if ($activity->hasReasonCodeBotKicked()) {\n $description .= 'Notetaker removed from this meeting' . PHP_EOL . PHP_EOL;\n // When we fix the state to be Activity::RECORDING_RECORDED as it should be this can change.\n } elseif ($activity->hasReasonCodeNotCompliant()) {\n $description .= 'Notetaker did not join due to recording consent not being provided by attendees' . PHP_EOL . PHP_EOL;\n } elseif ($activity->canReviewActivity()) {\n $playbackUrl = $activity->user->team->partner->getPlaybackUrl($activity);\n $description .= 'Review Activity:' . PHP_EOL . $playbackUrl . PHP_EOL . PHP_EOL;\n }\n\n if ($activity->getCrmType() === Activity::TYPE_CONFERENCE) {\n $description .= 'Attendees:'\n . PHP_EOL\n . (new FilterJoinedParticipants())->toString($activity);\n }\n\n if (\\count($activity->notes) > 0) {\n $description .= PHP_EOL . PHP_EOL . 'Notes: ' . PHP_EOL;\n\n foreach ($activity->notes as $note) {\n $nTime = (int) ceil($note->time);\n $time = ($nTime > 3600) ? gmdate('H:i:s', $nTime) : gmdate('i:s', $nTime);\n $description .= $time . ' ' . $note->note . PHP_EOL;\n }\n }\n\n // Get all private messages.\n $messages = $activity->messages()\n ->where('is_private', 1)\n ->orderBy('created_at', 'asc');\n\n if ($messages->count() > 0) {\n $description .= PHP_EOL . PHP_EOL . 'Coaching Chat: ' . PHP_EOL;\n\n foreach ($messages->get() as $message) {\n $description .= $message->participant->name . ': ' . $message->message . PHP_EOL;\n }\n }\n\n // Get all public messages.\n $messages = $activity->messages()\n ->where('is_private', 0)\n ->orderBy('created_at', 'asc');\n\n if ($messages->count() > 0) {\n $description .= PHP_EOL . PHP_EOL . 'Customer Chat: ' . PHP_EOL;\n\n foreach ($messages->get() as $message) {\n $description .= $message->participant->name . ': ' . $message->message . PHP_EOL;\n }\n }\n\n if ($activity->summary) {\n $description .= PHP_EOL . PHP_EOL . 'Summary: ' . PHP_EOL . $activity->summary;\n }\n\n break;\n\n case Activity::TYPE_SMS_INBOUND:\n case Activity::TYPE_SMS_OUTBOUND:\n $description = $activity->description;\n\n break;\n }\n\n return $description;\n }\n\n public function saveFollowupActivity(Activity $activity, array $fields): ?string\n {\n // This is the user provided activity subject field.\n if (empty($fields['type'])) {\n return null;\n }\n\n $reminder = null;\n if (empty($fields['reminder_date']) === false) {\n $formatReminder = Carbon::createFromFormat('Y-m-d\\TH:i:s\\Z', $fields['reminder_date']);\n $reminder = $formatReminder->getTimestamp();\n }\n\n $due = null;\n if (empty($fields['due_date']) === false) {\n $formatDue = Carbon::createFromFormat('Y-m-d\\TH:i:s\\Z', $fields['due_date']);\n $due = $formatDue->getTimestamp();\n }\n\n $data = [\n 'custom_activity_type_id' => $fields['type'],\n 'status' => 'Open',\n 'assignee_id' => $this->profile->crm_provider_id,\n 'due_date' => $due,\n 'details' => $fields['details'] ?? '',\n 'reminder_date' => $reminder,\n 'name' => $fields['name'] ?? 'Follow-up',\n 'related_resource' => $this->convertActivityAssociations($activity),\n 'custom_fields' => $this->fetchCustomFieldData($activity, Field::OBJECT_TASK),\n ];\n\n // We don't actually create a corresponding activity object on our side yet.\n return $this->copperClient->tasks()->create($data)['id'];\n }\n\n /**\n * Store transcripts as note.\n *\n * @throws \\Exception\n */\n public function createTranscriptNotes(Activity $activity): void\n {\n // Generate activity transcription.\n $transcriptionData = $this->generateTranscription($activity);\n\n // Truncate Notes with max notes length because transcription text could be very long.\n $transcripts = mb_strimwidth($transcriptionData, 0, static::NOTE_BODY_MAX_LENGTH);\n\n $data = [\n 'user_id' => $this->profile->crm_provider_id,\n 'type' => [\n 'category' => 'user',\n 'id' => 0, // This creates a note.\n ],\n 'activity_date' => time(),\n 'details' => $transcripts,\n 'parent' => $this->convertActivityAssociations($activity),\n ];\n\n $cpActivity = $this->copperClient->activities()->create($data);\n\n // Store CRM Activity ID in transcription.\n $transcription = $activity->getTranscription();\n $transcription->crm_activity_id = $cpActivity['id'];\n $transcription->save();\n }\n\n /**\n * @inheritdoc\n */\n public function updateStage($crmObject, Stage $stage): void\n {\n if ($crmObject instanceof Lead) {\n $this->requestWithExceptionHandling(\n fn () => $this->copperClient->leads()->update($crmObject->crm_provider_id, [\n 'status_id' => $stage->crm_provider_id,\n ])\n );\n } else {\n $this->copperClient->opportunities()->update($crmObject->crm_provider_id, [\n 'pipeline_stage_id' => $stage->crm_provider_id,\n ]);\n }\n }\n\n public function prepareValueForUpdate(array $params): array\n {\n return [$params['fieldName'] => $params['fieldValue']];\n }\n\n /*\n * @inheritdoc\n */\n public function updateRecord(string $objectType, string $objectId, array $data, array $headers = []): void\n {\n $preparedData = $data;\n\n if ($objectType === Field::OBJECT_OPPORTUNITY) {\n $preparedData = $this->prepareOpportunityFieldsForUpdate($data);\n }\n\n try {\n $this->copperClient\n ->createResourceFromObjectType($objectType)\n ->update($objectId, $preparedData)\n ;\n } catch (Exception $e) {\n $this->logger->error(self::RESOURCE_UPDATE_FAILED_ERROR_MESSAGE, [\n 'resource_id' => $objectId,\n 'data' => $preparedData,\n 'error' => $e->getMessage(),\n ]);\n\n throw new CrmException(self::RESOURCE_UPDATE_FAILED_ERROR_MESSAGE, $e->getCode(), $e);\n }\n }\n\n /**\n * @param array<string,string> $fieldData\n *\n * @throws CrmException\n *\n * @return array<string,array<int,array<string,bool|float|int|string|null>>|bool|float|int|string|null>\n */\n private function prepareOpportunityFieldsForUpdate(array $fieldData): array\n {\n $preparedData = [];\n $customFields = [];\n\n foreach ($fieldData as $fieldName => $fieldValue) {\n $field = $this->config\n ->fields()\n ->where('crm_provider_id', $fieldName)\n ->first();\n\n if (! $field instanceof Field) {\n throw new CrmException('Field not found');\n }\n\n $convertedValue = $this->fieldValueConverter->convertToCrm($field->getType(), $fieldValue);\n\n if ($this->isStandardField($fieldName)) {\n $preparedData[$fieldName] = $convertedValue;\n\n continue;\n }\n\n $customFields[] = [\n 'custom_field_definition_id' => $fieldName,\n 'value' => $convertedValue,\n ];\n }\n\n if (! empty($customFields)) {\n $preparedData['custom_fields'] = $customFields;\n }\n\n return $preparedData;\n }\n\n /*\n * @inheritdoc\n */\n public function getRecord(string $objectType, string $objectId, array $fields = []): array\n {\n return $this->copperClient->createResourceFromObjectType($objectType)->find($objectId);\n }\n\n private function fetchCustomFieldData(Activity $activity, string $objectType): array\n {\n $payload = [];\n\n $fieldData = FieldData::where([\n 'object_id' => $activity->id,\n ])->whereHas('field', function ($query) use ($objectType) {\n $query->where('object_type', $objectType);\n })->get();\n\n foreach ($fieldData as $data) {\n // Add the field and value to the payload.\n $payload += [\n 'custom_field_definition_id' => $data->field->crm_provider_id,\n 'value' => $data->value,\n ];\n }\n\n return $payload;\n }\n\n public function generateProviderUrl(string $providerId, string $objectType): ?string\n {\n $baseUrl = 'https://app.prosperworks.com/companies/' . $this->config->crm_provider_id . '/app#/';\n switch ($objectType) {\n case 'lead':\n $url = $baseUrl . 'leads/' . $providerId;\n\n break;\n\n case 'account':\n $url = $baseUrl . 'organization/' . $providerId;\n\n break;\n\n case 'contact':\n $url = $baseUrl . 'contact/' . $providerId;\n\n break;\n\n case 'opportunity':\n $url = $baseUrl . 'deal/' . $providerId;\n\n break;\n\n default:\n case 'task':\n case 'activity':\n // Sadly we can't deeplink to these in Copper UI.\n $url = null;\n }\n\n return $url;\n }\n\n /**\n * Generate transcription for the activity.\n */\n private function generateTranscription(Activity $activity): string\n {\n if (! ($this->config->store_transcript)) {\n // If sending transcription to activity toggle is disabled\n return '';\n }\n\n return $this->transcriptionService\n ->findTranscriptionByActivity($activity)\n ->map(static function (array $transcriptionSegment): string {\n return $transcriptionSegment['formattedStartsAt'] . ' | ' . $transcriptionSegment['transcript'];\n })\n ->implode(PHP_EOL);\n }\n\n /**\n * @param array<string, mixed> $opportunityData\n *\n * @return int|string|bool|float|null\n */\n private function extractFieldValue(array $opportunityData, string $fieldName)\n {\n $fieldValue = $this->isStandardField($fieldName)\n ? $opportunityData[$fieldName]\n : $this->resolveCustomFieldFromOpportunityData($opportunityData, $fieldName);\n\n if (is_array($fieldValue)) {\n $fieldValue = implode(', ', $fieldValue);\n }\n\n return $fieldValue;\n }\n\n private function isStandardField(string $fieldName): bool\n {\n return array_key_exists($fieldName, self::STANDARD_OPPORTUNITY_FIELDS);\n }\n\n /**\n * @param array<string, mixed> $opportunityData\n *\n * @return string|int|float|bool|null|array<string, mixed>\n */\n private function resolveCustomFieldFromOpportunityData(array $opportunityData, string $fieldName)\n {\n $fieldData = array_filter(\n $opportunityData['custom_fields'],\n static fn ($field) => (string) $field['custom_field_definition_id'] === $fieldName\n );\n\n return array_pop($fieldData)['value'] ?? '';\n }\n\n private function isSupportedObjectType(string $objectType): bool\n {\n return $objectType !== self::UNSUPPORTED_OBJECT_TYPE;\n }\n\n private function syncStandardFields(): void\n {\n foreach (self::STANDARD_OPPORTUNITY_FIELDS as $fieldId => $fieldData) {\n $this->config->fields()->updateOrCreate([\n 'crm_provider_id' => mb_strimwidth($fieldId, 0, 128),\n 'object_type' => Field::OBJECT_OPPORTUNITY,\n ], [\n 'label' => mb_strimwidth($fieldData['label'], 0, Field::LABEL_MAX_LENGTH),\n 'type' => $fieldData['type'],\n ]);\n }\n\n $this->importStages([Stage::TYPE_OPPORTUNITY]);\n }\n\n private function requestWithExceptionHandling(\\Closure $apiRequest): iterable\n {\n try {\n return $apiRequest();\n } catch (\\Exception $exception) {\n if ($exception->getCode() === 403) {\n // Object might not be supported\n return [];\n }\n\n \\Sentry::captureException($exception);\n\n return [];\n }\n }\n\n public function verifyTaskExists(Activity $activity): bool\n {\n $crmProviderId = $activity->getCrmProviderId();\n $cacheKey = \"crm_task_exists:{$this->config->getId()}:$crmProviderId\";\n\n return Cache::remember($cacheKey, self::TASK_VERIFICATION_CACHE_TTL, function () use ($crmProviderId) {\n try {\n if ($this->copperClient === null) {\n return false;\n }\n\n $task = $this->copperClient->tasks()->find($crmProviderId);\n\n return ! empty($task);\n } catch (HttpNotFoundException) {\n // Task not found in CRM - this is expected and permanent\n $this->logger->info('[Copper] Task not found during verification', [\n 'task_id' => $crmProviderId,\n 'config_id' => $this->config->getId(),\n ]);\n\n return false;\n } catch (RequestFailedException $e) {\n // Handle 404 responses from Copper API\n if ($e->getCode() === 404) {\n $this->logger->info('[Copper] Task not found during verification (404)', [\n 'task_id' => $crmProviderId,\n 'config_id' => $this->config->getId(),\n ]);\n\n return false;\n }\n\n // Re-throw other Copper exceptions for retry\n throw $e;\n }\n // Let other exceptions (network errors, rate limits, etc.) bubble up for retry\n });\n }\n}","depth":4,"on_screen":true,"value":"<?php\n\nnamespace Jiminny\\Services\\Crm\\Copper;\n\nuse Carbon\\Carbon;\nuse Exception;\nuse GuzzleHttp\\Exception\\ClientException;\nuse Illuminate\\Support\\Facades\\Cache;\nuse Jiminny\\Contracts\\Services\\Crm\\LayoutManagementInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\MatchCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\Provider\\CopperInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityLookupInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityManipulationInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SettingsInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmMetadataInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\VerifyTaskExistsInterface;\nuse Jiminny\\Exceptions\\CrmException;\nuse Jiminny\\Exceptions\\HttpNotFoundException;\nuse Jiminny\\Models\\Account;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Contact;\nuse Jiminny\\Models\\Crm\\BusinessProcess;\nuse Jiminny\\Models\\Crm\\Configuration;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Models\\Crm\\FieldData;\nuse Jiminny\\Models\\Crm\\FieldValue;\nuse Jiminny\\Models\\Crm\\LayoutEntity;\nuse Jiminny\\Models\\Crm\\Profile;\nuse Jiminny\\Models\\Lead;\nuse Jiminny\\Models\\Opportunity;\nuse Jiminny\\Models\\Playbook;\nuse Jiminny\\Models\\PlaybookCategory;\nuse Jiminny\\Models\\SocialAccount;\nuse Jiminny\\Models\\Stage;\nuse Jiminny\\Models\\User;\nuse Jiminny\\Repositories\\Crm\\CrmEntityRepository;\nuse Jiminny\\Repositories\\Crm\\FieldRepository;\nuse Jiminny\\Repositories\\Crm\\ProfileRepository;\nuse Jiminny\\Repositories\\UserRepository;\nuse Jiminny\\Services\\Avatar\\ProspectPhotoPathService;\nuse Jiminny\\Services\\Crm\\BaseService;\nuse Jiminny\\Services\\Crm\\Copper\\Api\\Client;\nuse Jiminny\\Services\\Crm\\Copper\\Api\\ClientFactory;\nuse Jiminny\\Services\\Crm\\Copper\\Exception\\RequestFailedException;\nuse Jiminny\\Services\\Crm\\Helpers\\FilterJoinedParticipants;\nuse Jiminny\\Services\\Crm\\OpportunitySyncStrategyResolver;\nuse Jiminny\\Services\\Crm\\Copper\\Traits\\SearchesCrmEntities;\nuse Jiminny\\Utils\\CurrencyFormatter;\nuse Jiminny\\Utils\\StringUtil;\n\nclass Service extends BaseService implements\n CopperInterface,\n SyncCrmEntitiesInterface,\n SyncCrmMetadataInterface,\n RemoteEntityManipulationInterface,\n LayoutManagementInterface,\n SettingsInterface,\n MatchCrmEntitiesInterface,\n RemoteEntityLookupInterface,\n VerifyTaskExistsInterface\n{\n use SearchesCrmEntities;\n private const int NOTE_BODY_MAX_LENGTH = 3000000;\n\n private const int TASK_VERIFICATION_CACHE_TTL = 86400; // 1 day\n\n private const array STANDARD_OPPORTUNITY_FIELDS = [\n 'monetary_value' => [\n 'label' => 'Value',\n 'available_on' => Field::OBJECT_OPPORTUNITY,\n 'type' => Field::TYPE_CURRENCY,\n ],\n 'pipeline_stage_id' => [\n 'label' => 'Stage',\n 'type' => Field::TYPE_PICKLIST,\n 'available_on' => Field::OBJECT_OPPORTUNITY,\n ],\n 'close_date' => [\n 'label' => 'Close date',\n 'type' => Field::TYPE_DATE,\n 'available_on' => Field::OBJECT_OPPORTUNITY,\n ],\n 'win_probability' => [\n 'label' => 'Win probability',\n 'type' => Field::TYPE_NUMBER,\n 'available_on' => Field::OBJECT_OPPORTUNITY,\n ],\n ];\n private const string UNSUPPORTED_OBJECT_TYPE = 'unsupported';\n private const string OPPORTUNITY_STATUS_OPEN = 'Open';\n private const string OPPORTUNITY_IMPORT_FAILED_ERROR_MESSAGE = 'Opportunity import failed';\n private const string RESOURCE_UPDATE_FAILED_ERROR_MESSAGE = 'Failed to update resource';\n\n private FieldValueConverter $fieldValueConverter;\n private ClientFactory $clientFactory;\n private ?Client $copperClient = null;\n\n public function __construct(\n ClientFactory $clientFactory,\n FieldValueConverter $fieldValueConverter,\n private readonly UserRepository $userRepository,\n private readonly ProspectPhotoPathService $prospectPhotoPathService,\n private readonly CrmEntityRepository $crmEntityRepository\n ) {\n parent::__construct();\n\n $this->clientFactory = $clientFactory;\n $this->fieldValueConverter = $fieldValueConverter;\n }\n\n public function getDisplayName(): string\n {\n return 'Copper';\n }\n\n /**\n * Get the CRM entity repository (used by SearchesCrmEntities trait)\n */\n protected function getCrmEntityRepository(): CrmEntityRepository\n {\n return $this->crmEntityRepository;\n }\n\n public function getMinimumApiVersion(): string\n {\n return Client::MIN_API_VERSION;\n }\n\n protected function getOAuthAccount(User $user): ?SocialAccount\n {\n // In this case, the Account Owner is always the connection for any API operations.\n $owner = $user->team->owner;\n\n return $owner->getSocialAccount(SocialAccount::PROVIDER_COPPER);\n }\n\n public function setUser(User $user): void\n {\n $this->configureSentryScope($user);\n\n $this->team = $user->getTeam();\n $this->teamService->setTeam($user->getTeam());\n $this->setConfiguration($user->getTeam()->getCrmConfiguration());\n\n // Reset state.\n $this->profile = $user->getProfile();\n\n if (! $this->profile instanceof Profile) {\n // Try to fetch their profile, maybe it was not yet imported?\n $this->profile = $this->getProfile($user);\n }\n }\n\n public function setConfiguration(Configuration $configuration): void\n {\n $this->config = $configuration;\n $this->copperClient = $this->clientFactory->create($configuration);\n\n if ($this->team === null) {\n $this->team = $configuration->getTeam();\n }\n }\n\n public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string\n {\n return $this->fieldValueConverter->convertFromCrm($fieldType, $fieldValue);\n }\n\n private function mapObjectType(string $objectType): string\n {\n $objectType = strtolower($objectType);\n\n switch ($objectType) {\n case Field::OBJECT_TASK:\n case Field::OBJECT_OPPORTUNITY:\n case Field::OBJECT_LEAD:\n $mappedType = $objectType;\n\n break;\n case Field::OBJECT_ACCOUNT:\n $mappedType = 'company';\n\n break;\n case Field::OBJECT_CONTACT:\n $mappedType = 'person';\n\n break;\n default:\n $mappedType = self::UNSUPPORTED_OBJECT_TYPE;\n\n break;\n }\n\n return $mappedType;\n }\n\n /**\n * @param string $crmObject The name of the CRM object. i.e. Opportunity or Contact\n */\n protected function getFields(string $crmObject): array\n {\n $mappedObjectType = $this->mapObjectType($crmObject);\n\n if (! $this->isSupportedObjectType($mappedObjectType)) {\n return [];\n }\n\n $customFields = $this->copperClient->fields()->all();\n\n $filteredFields = array_filter(\n $customFields,\n static fn ($field) => in_array($mappedObjectType, $field['available_on'], true)\n );\n\n return array_map(\n fn (array $property) => [\n 'name' => $property['id'],\n 'label' => $property['name'],\n 'type' => $this->convertFieldType($property['data_type']),\n ],\n $filteredFields\n );\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultFields(string $activityType): array\n {\n $fields = [];\n\n if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {\n $defaultFields = FieldDefinitions::defaultTaskFields();\n\n // This lazy creates these fields if not already setup.\n foreach ($defaultFields as $defaultField) {\n $fields[] = $this->config->fields()->firstOrCreate($defaultField);\n }\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultActivityField(string $activityType): Field\n {\n // Setup the activity field as the default Type.\n $activityField = $this->config->fields()->where([\n 'crm_provider_id' => 'type',\n 'object_type' => $activityType,\n ])->first();\n\n return $activityField;\n }\n\n /**\n * @inheritdoc\n */\n public function getSupportedPlaybookTypes(): array\n {\n return [Playbook::ACTIVITY_TYPE_TASK];\n }\n\n public function getDealInsightsFields(): array\n {\n return FieldDefinitions::dealInsightsFields();\n }\n\n protected function getDefaultFollowupLayoutFields(string $activityType): array\n {\n $fields = [];\n $fieldRepo = app(FieldRepository::class);\n $fieldFilters = FieldDefinitions::taskFollowupFieldsFilter();\n\n foreach ($fieldFilters as $data) {\n $field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $data);\n\n // Only add the field if it is created, which it should be.\n if ($field) {\n $fields[] = $field;\n }\n }\n\n return $fields;\n }\n\n public function syncFields(): void\n {\n $this->syncStandardFields();\n $this->syncCustomFields();\n }\n\n public function syncField(Field $field): void\n {\n if ($field->getCrmProviderId() === 'type') {\n /**\n * This field is a special case, it is not a custom field and is not returned by the Copper API.\n *\n * The field is created manually during layout setup.\n *\n * @see getDefaultFields()\n *\n * There is a separate endpoint to get the activity types.\n * @see importPicklistValues()\n */\n return;\n }\n\n if ($field->getCrmProviderId() === 'pipeline_stage_id') {\n // Not a custom type\n return;\n }\n\n $crmField = $this->copperClient->fields()->find($field->getCrmProviderId());\n $field->label = $crmField['name'];\n $field->type = $this->convertFieldType($crmField['data_type']);\n\n $field->save();\n }\n\n /**\n * @param string $fieldType How the data should be input\n */\n private function convertFieldType(string $fieldType): string\n {\n $fieldType = strtolower($fieldType);\n\n switch ($fieldType) {\n case 'dropdown':\n $convertedType = Field::TYPE_PICKLIST;\n\n break;\n case 'string':\n $convertedType = Field::TYPE_TEXT;\n\n break;\n case 'float':\n case 'percentage':\n $convertedType = Field::TYPE_NUMBER;\n\n break;\n case 'text':\n $convertedType = Field::TYPE_TEXTAREA;\n\n break;\n case 'checkbox':\n $convertedType = Field::TYPE_BOOLEAN;\n\n break;\n case Field::TYPE_DATE:\n case Field::TYPE_CURRENCY:\n case Field::TYPE_URL:\n $convertedType = $fieldType;\n\n break;\n default:\n $convertedType = Field::TYPE_UNSUPPORTED;\n\n break;\n }\n\n return $convertedType;\n }\n\n /**\n * @inheritdoc\n */\n public function importPicklistValues(Field $field): array\n {\n $fieldValues = [];\n\n if ($field->object_type === 'opportunity' && $field->crm_provider_id === 'pipeline_stage_id') {\n return $this->importFieldValuesForDealInsights($field);\n }\n\n $values = [];\n\n if ($field->object_type === Field::OBJECT_TASK && $field->crm_provider_id === 'type') {\n $activityTypes = $this->copperClient->activityTypes()->all();\n\n /**\n * We ignore \"system\" activity types, disabled activity types and the \"Note\" activity type.\n */\n $activityTypes = array_filter(\n $activityTypes['user'],\n fn (array $activityType) => ! $activityType['is_disabled'] && $activityType['id'] !== 0\n );\n\n // Import all activity values.\n $i = 0;\n foreach ($activityTypes as $activityType) {\n $values[] = [\n 'value' => $activityType['id'],\n 'label' => mb_strimwidth($activityType['name'], 0, Field::LABEL_MAX_LENGTH),\n 'sequence' => $i++,\n ];\n }\n } else {\n // We just check for the field in the custom definitions.\n $crmField = $this->copperClient->fields()->find($field->crm_provider_id);\n\n // Import all values.\n foreach ($crmField['options'] as $option) {\n $values[] = [\n 'value' => $option['id'],\n 'label' => mb_strimwidth($option['name'], 0, 191),\n 'sequence' => $option['rank'],\n ];\n }\n }\n\n // Take the existing set of field values.\n $fieldsToPurge = $field->values()->get()->pluck('value')->toArray();\n\n foreach ($values as $value) {\n // For all current values, upsert the field.\n $value['value'] = substr($value['value'] ?? '', 0, 255);\n $fieldValues[] = $field->values()->updateOrCreate([\n 'value' => $value['value'],\n ], $value);\n\n // Remove this value from the ones we are going to purge.\n $key = array_search($value['value'], $fieldsToPurge, false);\n if ($key !== false) {\n unset($fieldsToPurge[$key]);\n }\n }\n\n // Delete the old values that are no longer used.\n $field->values()->whereIn('value', $fieldsToPurge)->delete();\n\n return $fieldValues;\n }\n\n /**\n *\n * @return FieldValue[]\n */\n private function importFieldValuesForDealInsights(Field $field): array\n {\n $fieldValues = [];\n $pipelines = $this->copperClient->pipelines()->all();\n\n foreach ($pipelines as $pipeline) {\n foreach ($pipeline['stages'] as $i => $dealStage) {\n $fieldValues[] = $field->values()->updateOrCreate(\n ['value' => $dealStage['id']],\n [\n 'label' => $dealStage['name'],\n 'sequence' => $i,\n ]\n );\n }\n }\n\n return $fieldValues;\n }\n\n /**\n * @inheritdoc\n */\n public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage\n {\n if ($types === null) {\n $types = [Stage::TYPE_LEAD, Stage::TYPE_OPPORTUNITY];\n }\n $missingStage = null;\n\n foreach ($types as $type) {\n if ($type === Stage::TYPE_LEAD) {\n $statuses = $this->requestWithExceptionHandling(\n fn () => $this->copperClient->leadStatuses()->all()\n );\n\n if (count($statuses) > 0) {\n $businessProcess = $this->config->businessProcesses()->updateOrCreate([\n 'crm_provider_id' => BusinessProcess::TYPE_LEAD,\n ], [\n 'team_id' => $this->team->id,\n 'name' => BusinessProcess::TYPE_LEAD,\n 'type' => BusinessProcess::TYPE_LEAD,\n 'is_selectable' => true,\n ]);\n\n // A record type is really a clone of the business process, used to store which record uses which pipeline.\n $this->config->recordTypes()->updateOrCreate([\n 'crm_provider_id' => BusinessProcess::TYPE_LEAD,\n ], [\n 'team_id' => $this->team->id,\n 'name' => BusinessProcess::TYPE_LEAD,\n 'is_selectable' => true,\n 'business_process_id' => $businessProcess->id ?? null,\n ]);\n\n $leadStatuses = [];\n foreach ($statuses as $status) {\n // Upsert all statuses for the team.\n $status = $this->config->stages()->updateOrCreate([\n 'crm_provider_id' => $status['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($status['name'], 0, 50),\n 'label' => mb_strimwidth($status['name'], 0, 191),\n 'type' => Stage::TYPE_LEAD,\n 'sequence' => $status['order'],\n 'is_selectable' => true,\n ]);\n\n if ($missingStageName && $missingStageName === $status['id']) {\n $missingStage = $status;\n }\n\n $leadStatuses[] = $status['id'];\n }\n\n $businessProcess->stages()->sync($leadStatuses);\n }\n } else {\n // We create a business process to contain the pipeline, and later store all stages against it.\n foreach ($this->copperClient->pipelines()->all() as $pipeline) {\n $businessProcess = $this->config->businessProcesses()->updateOrCreate([\n 'crm_provider_id' => $pipeline['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($pipeline['name'], 0, 150),\n 'type' => BusinessProcess::TYPE_OPPORTUNITY,\n 'is_selectable' => true,\n ]);\n\n // A record type is really a clone of the business process, used to store which record uses which pipeline.\n $this->config->recordTypes()->updateOrCreate([\n 'crm_provider_id' => $pipeline['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($pipeline['name'], 0, 150),\n 'is_selectable' => true,\n 'business_process_id' => $businessProcess->id ?? null,\n ]);\n\n $stages = [];\n\n foreach ($pipeline['stages'] as $i => $dealStage) {\n // Upsert all stages for the team.\n $stage = $this->config->stages()->updateOrCreate([\n 'crm_provider_id' => $dealStage['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($dealStage['name'], 0, 50),\n 'label' => mb_strimwidth($dealStage['name'], 0, 191),\n 'type' => Stage::TYPE_OPPORTUNITY,\n 'sequence' => $i,\n 'is_selectable' => true,\n 'probability' => $dealStage['win_probability'],\n ]);\n\n if ($missingStageName && $missingStageName === $dealStage['id']) {\n $missingStage = $stage;\n }\n\n $stages[] = $stage['id'];\n }\n\n $businessProcess->stages()->sync($stages);\n }\n }\n }\n\n return $missingStage;\n }\n\n /**\n * @inheritdoc\n */\n public function syncLeads(Carbon $since, ?Carbon $to = null, ?string $crmProfileId = null): int\n {\n $syncCount = 0;\n\n $cpLeads = $this->requestWithExceptionHandling(\n fn () => $this->copperClient->leads()->paginate([\n 'minimum_modified_date' => $since->timestamp,\n 'sort_by' => 'date_modified',\n ])\n );\n\n foreach ($cpLeads as $cpLead) {\n // Only sync if previously imported.\n if ($this->hasLead($cpLead['id']) === false) {\n $this->importLead($cpLead);\n $syncCount++;\n }\n }\n\n return $syncCount;\n }\n\n /**\n * @inheritdoc\n */\n public function syncLead(string $crmId): ?Lead\n {\n $cpLead = $this->requestWithExceptionHandling(\n fn () => $this->copperClient->leads()->find($crmId)\n );\n\n if (\\count($cpLead) === 0) {\n return null;\n }\n\n return $this->importLead($cpLead);\n }\n\n /**\n * @inheritdoc\n */\n private function importLead($crmData): ?Lead\n {\n $countryCode = null;\n if (! empty($crmData['address']) && isset($crmData['address']['country'])) {\n $countryCode = $this->convertCountryNameToCode($crmData['address']['country']);\n }\n\n $mobilePhone = $parsedNumber = null;\n if (! empty($crmData['phone_numbers'])) {\n foreach ($crmData['phone_numbers'] as $phoneNumber) {\n if ($phoneNumber['category'] === 'mobile') {\n $mobilePhone = $this->buildContactMobilePhone($countryCode, $phoneNumber['number']);\n } else {\n $parsedNumber = $this->buildContactPhone($countryCode, $phoneNumber['number']);\n }\n }\n }\n\n $name = $crmData['name'];\n\n if (empty($crmData['company_name'])) {\n $company = 'Unknown';\n } else {\n $company = mb_strimwidth($crmData['company_name'], 0, 191);\n }\n\n $domain = null;\n if (empty($crmData['websites']) === false) {\n $domain = mb_strimwidth($crmData['websites'][0]['url'], 0, 191);\n $domain = StringUtil::resolveDomain($domain);\n }\n\n $email = null;\n if (empty($crmData['email']) === false) {\n $email = mb_strimwidth($crmData['email']['email'], 0, 80);\n }\n\n $stage = $this->config\n ->stages()\n ->where('crm_provider_id', (string) $crmData['status_id'])\n ->where('type', Stage::TYPE_LEAD)\n ->first();\n\n if ($stage === null) {\n // Import it.\n $stage = $this->importStages([Stage::TYPE_LEAD], $crmData['status_id']);\n }\n\n if (! $stage instanceof Stage) {\n return null;\n }\n\n $profile = $this->config->profiles()->where('crm_provider_id', (string) $crmData['assignee_id'])->first();\n\n $data = [\n 'team_id' => $this->team->id,\n 'user_id' => $profile ? $profile->user_id : null,\n 'owner_id' => $crmData['assignee_id'],\n 'name' => mb_strimwidth($name, 0, 191),\n 'company' => $company,\n 'domain' => $domain,\n 'title' => $crmData['title'] ? mb_strimwidth($crmData['title'], 0, 128) : null,\n 'email' => $email,\n 'phone' => $parsedNumber['phone'] ?? null,\n 'ext' => $parsedNumber['ext'] ?? null,\n 'mobile_phone' => $mobilePhone,\n 'stage_id' => $stage->id,\n 'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(\n crmConfiguration: $this->config,\n crmProviderId: $crmData['id'],\n modelType: Lead::class,\n fileName: $crmData['id'],\n avatarText: $crmData['name']\n ),\n 'country_code' => $countryCode,\n 'remotely_created_at' => $crmData['date_created'],\n ];\n\n return $this->config->leads()->updateOrCreate(['crm_provider_id' => (string) $crmData['id']], $data);\n }\n\n /**\n * @inheritdoc\n */\n public function syncAccounts(Carbon $since, ?Carbon $to = null): int\n {\n $syncCount = 0;\n\n try {\n $cpAccounts = $this->copperClient->accounts()->paginate([\n 'minimum_modified_date' => $since->timestamp,\n 'sort_by' => 'date_modified',\n ]);\n\n foreach ($cpAccounts as $cpAccount) {\n // Only sync if previously imported.\n if ($this->hasAccount($cpAccount['id'])) {\n $this->importAccount($cpAccount);\n $syncCount++;\n }\n }\n } catch (\\Exception $exception) {\n // Do nothing for now.\n }\n\n return $syncCount;\n }\n\n /**\n * @inheritdoc\n */\n public function syncAccount(string $crmId): ?Account\n {\n $cpAccount = $this->copperClient->accounts()->find($crmId);\n\n if (empty($cpAccount)) {\n return null;\n }\n\n return $this->importAccount($cpAccount);\n }\n\n /**\n * @inheritdoc\n */\n private function importAccount($crmData): Account\n {\n $countryCode = null;\n if (empty($crmData['address']) === false && $crmData['address']['country']) {\n $countryCode = $this->convertCountryNameToCode($crmData['address']['country']);\n }\n\n $name = $crmData['name'];\n\n $parsedNumber = null;\n foreach ($crmData['phone_numbers'] as $phoneNumber) {\n $parsedNumber = parsePhoneNumber($countryCode, $phoneNumber['number']);\n }\n\n $domain = null;\n if (empty($crmData['websites']) === false) {\n $domain = mb_strimwidth($crmData['websites'][0]['url'], 0, 191);\n $domain = StringUtil::resolveDomain($domain);\n }\n\n $profile = $this->config->profiles()->where('crm_provider_id', (string) $crmData['assignee_id'])->first();\n\n $data = [\n 'team_id' => $this->team->id,\n 'user_id' => $profile ? $profile->user_id : null,\n 'owner_id' => $crmData['assignee_id'],\n 'name' => mb_strimwidth($name, 0, 191),\n 'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(\n crmConfiguration: $this->config,\n crmProviderId: $crmData['id'],\n modelType: Account::class,\n fileName: $crmData['id'],\n avatarText: $crmData['name']\n ),\n 'country_code' => $countryCode,\n 'domain' => $domain,\n 'phone' => $parsedNumber['phone'] ?? null,\n 'ext' => $parsedNumber['ext'] ?? null,\n 'remotely_created_at' => $crmData['date_created'],\n ];\n\n return $this->config->accounts()->updateOrCreate(['crm_provider_id' => (string) $crmData['id']], $data);\n }\n\n public function syncOpportunities(array $parameters, ?string $strategy = null): int\n {\n $strategyResolver = app(OpportunitySyncStrategyResolver::class, [\n 'client' => $this->copperClient,\n ]);\n $strategies = $strategyResolver->getStrategies($this->config, $strategy);\n\n $syncCount = 0;\n\n try {\n foreach ($strategies as $syncStrategy) {\n foreach ($syncStrategy->fetchOpportunities($parameters) as $opportunityData) {\n $this->importOpportunity($opportunityData);\n $syncCount++;\n }\n }\n } catch (\\Exception $exception) {\n // Do nothing for now.\n $this->logger->error(self::OPPORTUNITY_IMPORT_FAILED_ERROR_MESSAGE, [\n 'error' => $exception->getMessage(),\n ]);\n }\n\n return $syncCount;\n }\n\n public function syncOpportunity(string $crmId): ?Opportunity\n {\n $strategyResolver = app(OpportunitySyncStrategyResolver::class, [\n 'client' => $this->copperClient,\n ]);\n $strategy = $strategyResolver->resolve(\n $this->config,\n OpportunitySyncStrategyResolver::SINGLE_SYNC_OPPORTUNITY_STRATEGY\n );\n\n $parameters['crm_id'] = $crmId;\n\n $cpOpportunity = $strategy->fetchOpportunities($parameters);\n\n return $this->importOpportunity($cpOpportunity);\n }\n\n private function importOpportunity($crmData): ?Opportunity\n {\n $companyId = (string) $crmData['company_id'];\n\n if (empty($companyId)) {\n $this->logger->error(self::OPPORTUNITY_IMPORT_FAILED_ERROR_MESSAGE, [\n 'error' => 'No company id',\n ]);\n\n return null;\n }\n\n $account = $this->config->accounts()->where('crm_provider_id', $companyId)->first();\n\n if ($account === null) {\n $account = $this->syncAccount($companyId);\n }\n\n $profile = $this->config->profiles()->where('crm_provider_id', (string) $crmData['assignee_id'])->first();\n\n $businessProcess = $this->config\n ->businessProcesses()\n ->where('crm_provider_id', (string) $crmData['pipeline_id'])\n ->first();\n\n if ($businessProcess === null) {\n // It is possible for some deals to not be in a pipeline.\n $this->logger->warning(self::OPPORTUNITY_IMPORT_FAILED_ERROR_MESSAGE, [\n 'error' => 'Missing pipeline',\n ]);\n\n return null;\n }\n\n $stage = $businessProcess\n ->stages()\n ->where('crm_provider_id', (string) $crmData['pipeline_stage_id'])\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->first();\n\n if ($stage === null) {\n $this->logger->info('Stage not found, importing', [\n 'pipeline_id' => $crmData['pipeline_id'],\n 'stage_id' => $crmData['pipeline_stage_id'],\n ]);\n\n $stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $crmData['pipeline_stage_id']);\n }\n\n $recordType = $businessProcess->recordTypes()->first();\n $closeDate = null;\n $isClosed = $crmData['status'] !== self::OPPORTUNITY_STATUS_OPEN;\n\n if ($crmData['close_date']) {\n $closeDate = Carbon::parse($crmData['close_date'])->format('Y-m-d');\n }\n\n $data = [\n 'team_id' => $this->team->getId(),\n 'account_id' => $account->id,\n 'user_id' => $profile->user_id ?? null,\n 'owner_id' => $crmData['assignee_id'],\n 'name' => mb_strimwidth($crmData['name'], 0, 128),\n 'value' => $crmData['monetary_value'],\n 'currency_code' => CurrencyFormatter::formatCode($crmData['monetary_unit']),\n 'close_date' => $closeDate,\n 'is_closed' => $isClosed,\n 'is_won' => $crmData['status'] === 'Won',\n 'stage_id' => $stage->id,\n 'record_type_id' => $recordType->id ?? null,\n 'remotely_created_at' => $crmData['date_created'],\n ];\n\n /** @var Opportunity $opportunity */\n $opportunity = $this->config\n ->opportunities()\n ->updateOrCreate(['crm_provider_id' => (string) $crmData['id']], $data)\n ;\n\n // import external fields into crm_field_data if present\n $dealInsightsFields = $this->getOpportunitySyncableFields();\n $this->importOpportunityCrmFieldData($crmData, $dealInsightsFields, $opportunity->id);\n\n return $opportunity;\n }\n\n /**\n * @param array<string, mixed> $opportunityData\n * @param string[] $dealInsightsFields\n */\n public function importOpportunityCrmFieldData(\n array $opportunityData,\n array $dealInsightsFields,\n int $opportunityId\n ): void {\n foreach ($dealInsightsFields as $fieldName) {\n $field = $this->config->fields()->with('entities')\n ->where('crm_provider_id', $fieldName)\n ->where('object_type', Field::OBJECT_OPPORTUNITY)\n ->first();\n\n if (! $field instanceof Field) {\n $this->logger->warning('Field not found', [\n 'field' => $fieldName,\n 'opportunity_id' => $opportunityId,\n ]);\n\n continue;\n }\n\n $entity = $field->getEntities()->first();\n\n if (! $entity instanceof LayoutEntity) {\n continue;\n }\n\n $fieldValue = $this->extractFieldValue($opportunityData, $fieldName);\n $normalizedValue = $this->normalizeValue($field->getType(), (string) $fieldValue, true);\n\n $field->data()\n ->updateOrCreate([\n 'crm_layout_entity_id' => $entity->id,\n 'crm_field_id' => $field->id,\n 'object_type' => 'opportunity',\n 'object_id' => $opportunityId,\n ], ['value' => $normalizedValue])\n ;\n }\n }\n\n /**\n * @inheritdoc\n */\n public function syncContacts(Carbon $since, ?Carbon $to = null): int\n {\n $syncCount = 0;\n\n try {\n $cpContacts = $this->copperClient->contacts()->paginate([\n 'minimum_interaction_date' => $since->timestamp,\n 'sort_by' => 'date_modified',\n ]);\n\n foreach ($cpContacts as $cpContact) {\n // Only sync if previously imported.\n if ($this->hasContact($cpContact['id'])) {\n $this->importContact($cpContact);\n $syncCount++;\n }\n }\n } catch (\\Exception $exception) {\n // Do nothing for now.\n throw $exception;\n }\n\n return $syncCount;\n }\n\n /**\n * @inheritdoc\n */\n public function syncContact(string $crmId): ?Contact\n {\n $cpContact = $this->copperClient->contacts()->find($crmId);\n\n if (empty($cpContact)) {\n return null;\n }\n\n return $this->importContact($cpContact);\n }\n\n /**\n * @inheritdoc\n */\n private function importContact($crmData): Contact\n {\n $account = null;\n if ($crmData['company_id']) {\n $account = $this->team\n ->crm\n ->accounts()\n ->where('crm_provider_id', (string) $crmData['company_id'])\n ->first();\n\n if ($account === null) {\n $account = $this->syncAccount($crmData['company_id']);\n }\n }\n\n $countryCode = null;\n if (empty($crmData['address']) === false && $crmData['address']['country']) {\n $countryCode = $this->convertCountryNameToCode($crmData['address']['country']);\n }\n\n $mobilePhone = $parsedNumber = null;\n foreach ($crmData['phone_numbers'] as $phoneNumber) {\n if ($phoneNumber['category'] === 'mobile') {\n $mobilePhone = $this->buildContactMobilePhone($countryCode, $phoneNumber['number']);\n } else {\n $parsedNumber = $this->buildContactPhone($countryCode, $phoneNumber['number']);\n }\n }\n\n $email = null;\n if (empty($crmData['emails']) === false) {\n $email = mb_strimwidth($crmData['emails'][0]['email'], 0, 80);\n }\n\n $profile = $this->config->profiles()->where('crm_provider_id', (string) $crmData['assignee_id'])->first();\n\n $data = [\n 'team_id' => $this->team->id,\n 'account_id' => $account->id ?? null,\n 'user_id' => $profile ? $profile->user_id : null,\n 'owner_id' => $crmData['assignee_id'],\n 'name' => $crmData['name'],\n 'email' => $email,\n 'phone' => $parsedNumber['phone'] ?? null,\n 'mobile_phone' => $mobilePhone ?? null,\n 'ext' => $parsedNumber['ext'] ?? null,\n 'title' => $crmData['title'],\n 'country_code' => $countryCode,\n 'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(\n crmConfiguration: $this->config,\n crmProviderId: $crmData['id'],\n modelType: Contact::class,\n fileName: $crmData['id'],\n avatarText: $crmData['name']\n ),\n 'remotely_created_at' => $crmData['date_created'],\n ];\n\n return $this->config->contacts()->updateOrCreate(['crm_provider_id' => (string) $crmData['id']], $data);\n }\n\n private function buildContactPhone(?string $countryCode, ?string $number): ?array\n {\n if ($number) {\n // Trim to our width and attempt to parse it.\n $number = mb_strimwidth($number, 0, 25);\n $parsedNumber = parsePhoneNumber($countryCode, $number);\n } else {\n $parsedNumber = [];\n }\n\n return $parsedNumber;\n }\n\n private function buildContactMobilePhone(?string $countryCode, ?string $number): ?string\n {\n return $number ? mb_strimwidth(phone_e164($countryCode, $number), 0, 25) : null;\n }\n\n /**\n * @inheritdoc\n */\n public function syncOrganization(): void\n {\n $organization = $this->copperClient->organisation()->all();\n\n if ($this->config->crm_provider_id !== $organization['id']) {\n $this->config->update(['crm_provider_id' => $organization['id']]);\n }\n }\n\n private function syncCustomFields(): void\n {\n foreach ($this->getFieldTypes() as $fieldType) {\n $currentFields = $this->getFields($fieldType);\n\n foreach ($currentFields as $field) {\n /** @var Field $field */\n $field = $this->config->fields()->updateOrCreate([\n 'crm_provider_id' => $field['name'],\n 'object_type' => $fieldType,\n ], [\n 'label' => mb_strimwidth($field['label'], 0, Field::LABEL_MAX_LENGTH),\n 'type' => $field['type'],\n ]);\n\n if ($field->getType() === Field::TYPE_PICKLIST) {\n $this->importPicklistValues($field);\n }\n }\n }\n\n $this->config->save();\n }\n\n protected function getFieldTypes(): array\n {\n return [\n parent::OBJECT_TASK,\n parent::OBJECT_LEAD,\n parent::OBJECT_ACCOUNT,\n parent::OBJECT_CONTACT,\n parent::OBJECT_OPPORTUNITY,\n ];\n }\n\n /**\n * @inheritdoc\n */\n public function syncProfiles(?User $userToSearch = null): ?Profile\n {\n $profiles = $this->copperClient->users()->all();\n\n foreach ($profiles as $profile) {\n $user = $this->userRepository->findByTeamAndEmailAddress($this->team, $profile['email'], onlyActive: true);\n\n // Only import profiles for users we have locally.\n if ($user) {\n $profile = $user->crmProfile()->updateOrCreate([\n 'crm_configuration_id' => $this->config->id,\n 'crm_provider_id' => $profile['id'],\n ]);\n\n if ($userToSearch && $userToSearch->id === $user->id) {\n return $profile;\n }\n }\n }\n\n return null;\n }\n\n /**\n * @inheritdoc\n */\n public function syncProfileFields(): void\n {\n $fieldTypes = [\n 'task',\n 'lead',\n 'account',\n 'contact',\n 'opportunity',\n ];\n\n foreach ($fieldTypes as $fieldType) {\n try {\n $currentFields = $this->getFields(ucfirst($fieldType));\n\n $newFields = array_column($currentFields, 'name');\n $changedFields = array_merge(\n array_diff($this->profile->getFieldsAsArray($fieldType), $newFields),\n array_diff($newFields, $this->profile->getFieldsAsArray($fieldType))\n );\n\n if (\\count($changedFields) > 0) {\n $fields = implode(',', $newFields);\n $this->logger->debug(\n ucfirst($fieldType)\n . ' fields changed from '\n . $this->profile->{$fieldType . '_fields'}\n . ' to: '\n . $fields\n );\n\n $this->profile->{$fieldType . '_fields'} = $fields;\n }\n } catch (\\Exception $exception) {\n $this->logger->error('No access to ' . $fieldType . ' object, skipping...');\n\n continue;\n }\n }\n\n $this->profile->save();\n }\n\n /**\n * @inheritdoc\n */\n public function find(string $name, array $scopes): array\n {\n $count = $this->limit ?? 20;\n $offset = $this->offset ?? 0;\n\n $scopeValues = array_map(fn ($scope) => is_string($scope) ? $scope : $scope->value, $scopes);\n $cacheKey = sprintf('copper-search-%d-%s-%s-%d-%d', $this->team->id, $name, implode('-', $scopeValues), $count, $offset);\n\n return Cache::remember($cacheKey, 300, function () use ($name, $scopes, $count, $offset) {\n // Try local search first\n $localResults = $this->searchLocally($name, $scopes, $count, $offset);\n\n // If no local results, fall back to remote API search\n if (empty($localResults)) {\n return $this->searchRemotely($name, $scopes, $count, $offset);\n }\n\n return $localResults;\n });\n }\n\n /**\n * @inheritdoc\n */\n public function findOpportunities(?string $crmAccountId, ?string $crmContactId, ?int $userId = null): array\n {\n $data = [];\n $ownerData = [];\n $ownerId = null;\n $contact = null;\n $account = null;\n\n if ($crmAccountId) {\n $account = $this->config->accounts()->where('crm_provider_id', $crmAccountId)->first();\n\n if ($account === null) {\n $account = $this->syncAccount($crmAccountId);\n }\n }\n\n if ($crmContactId) {\n $contact = $this->config->contacts()->where('crm_provider_id', $crmContactId)->first();\n\n if ($contact === null) {\n $contact = $this->syncContact($crmContactId);\n }\n }\n\n if ($contact || $account) {\n if ($contact && $account === null) {\n $account = $contact->account;\n }\n\n if ($account === null) {\n return [];\n }\n\n $params = [\n 'company_ids' => [$account->crm_provider_id],\n 'status_ids' => [0], // The integer values are 0, 1, 2, 3, for \"Open\", \"Won\", \"Lost\", and \"Abandoned\".\n 'sort_by' => 'date_modified',\n 'sort_direction' => 'desc',\n ];\n\n switch ($this->config->opportunity_assignment_rule) {\n case Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_UPDATED:\n $params['sort_by'] = 'date_modified';\n $params['sort_direction'] = 'desc';\n\n break;\n\n case Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_CREATED:\n $params['sort_by'] = 'date_created';\n $params['sort_direction'] = 'desc';\n\n break;\n\n case Configuration::OPP_ASSIGNMENT_ALL_OPEN_OLDEST_CREATED:\n $params['sort_by'] = 'date_created';\n $params['sort_direction'] = 'asc';\n\n break;\n\n case Configuration::OPP_ASSIGNMENT_ALL_RECENTLY_UPDATED:\n $params['sort_by'] = 'date_modified';\n $params['sort_direction'] = 'desc';\n $params['status_ids'] = [0, 1, 2, 3];\n }\n\n $cpOpportunities = $this->copperClient->opportunities()->search($params);\n\n if ($userId) {\n $profileRepository = app(ProfileRepository::class);\n $profile = $profileRepository->findProfileByUserId($this->config, $userId);\n\n $ownerId = $profile instanceof Profile ? $profile->getCrmProviderId() : null;\n }\n\n foreach ($cpOpportunities as $cpOpportunity) {\n $businessProcess = $this->config\n ->businessProcesses()\n ->where('crm_provider_id', (string) $cpOpportunity['pipeline_id'])\n ->first();\n\n if ($businessProcess === null) {\n // Import it.\n $businessProcess = $this->importStages(\n [Stage::TYPE_OPPORTUNITY],\n $cpOpportunity['pipeline_stage_id']\n );\n }\n\n $stage = $businessProcess\n ->stages()\n ->where('crm_provider_id', (string) $cpOpportunity['pipeline_stage_id'])\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->first();\n\n if ($stage === null) {\n // Import it.\n $stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $cpOpportunity['pipeline_stage_id']);\n }\n\n $recordType = $businessProcess->recordTypes()->first();\n\n $record = [\n 'crmId' => $cpOpportunity['id'],\n 'name' => $cpOpportunity['name'],\n 'value' => currency_format($cpOpportunity['monetary_value'], $cpOpportunity['monetary_unit']),\n 'won' => $cpOpportunity['status'] === 'Won',\n 'stage' => [\n 'id' => $stage->id_string,\n 'name' => $stage->name,\n ],\n 'recordType' => [\n 'id' => $recordType->id_string,\n 'name' => $recordType->name,\n ],\n ];\n\n if ($ownerId && isset($cpOpportunity['assignee_id']) && $cpOpportunity['assignee_id'] === $ownerId) {\n $ownerData[] = $record;\n }\n\n $data[] = $record;\n }\n }\n\n if (! empty($ownerData)) {\n return $ownerData;\n }\n\n return $data;\n }\n\n /**\n * @inheritdoc\n */\n public function getTasks(string $objectType, string $objectId, ?string $opportunityId): array\n {\n $data = $cpTasks = [];\n\n switch ($objectType) {\n case 'lead':\n $cpTasks = $this->requestWithExceptionHandling(\n fn () => $this->copperClient->leads()->relatedTasks($objectId)\n );\n\n break;\n\n case 'account':\n $cpTasks = $this->copperClient->accounts()->relatedTasks($objectId);\n\n break;\n case 'contact':\n if ($opportunityId) {\n $cpTasks = $this->copperClient->opportunities()->relatedTasks($opportunityId);\n } elseif ($objectType === 'contact') {\n $cpTasks = $this->copperClient->contacts()->relatedTasks($objectId);\n }\n\n break;\n }\n\n foreach ($cpTasks as $cpTask) {\n // We only get the ID itself returned, so pull out the full object.\n try {\n $cpTask = $this->copperClient->tasks()->find($cpTask['id']);\n } catch (Exception $exception) {\n $this->logger->error('[Copper] Error fetching task', [\n 'resource_id' => $cpTask['id'],\n 'error' => $exception->getMessage(),\n ]);\n\n continue;\n }\n\n // Only allow open tasks.\n if ($cpTask['status'] === 'Open') {\n $data[] = [\n 'crmId' => $cpTask['id'],\n 'subject' => $cpTask['name'],\n 'due' => $cpTask['due_date']\n ? Carbon::createFromTimestamp($cpTask['due_date'])->format('Y-m-d\\TH:i:sO')\n : null,\n 'type' => null,\n ];\n }\n }\n\n return $data;\n }\n\n /**\n * Try to find email address in CRM service\n *\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n *}\n */\n public function matchExactlyByEmail(string $email, ?int $userId = null): ?array\n {\n $cpObjectType = 'contact';\n\n $cpObjects = $this->copperClient->contacts()->search([\n 'emails' => $email,\n 'sort_by' => 'date_modified',\n 'sort_direction' => 'desc',\n ]);\n\n if (empty($cpObjects)) {\n $cpObjectType = 'lead';\n $cpObjects = $this->requestWithExceptionHandling(\n fn () => $this->copperClient->leads()->search([\n 'emails' => $email,\n 'sort_by' => 'date_modified',\n 'sort_direction' => 'desc',\n ])\n );\n }\n\n if (empty($cpObjects)) {\n return null;\n }\n\n // Search returns an array of results, but we only want the first\n $cpObject = reset($cpObjects);\n $data = $this->convertCrmData($cpObject, $cpObjectType, $userId);\n\n return ! empty(array_filter($data)) ? $data : null;\n }\n\n /**\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n public function matchByPhone(string $phone, ?string $rawPhoneNumber = null, ?int $userId = null): ?array\n {\n // Check if the user is internal.\n $teamMember = $this->team->users()->where('phone', $phone)->exists();\n\n // Skip the attendee if internal.\n if ($teamMember === false) {\n $cpObjectType = 'contact';\n $cpObject = $this->copperClient->contacts()->search([\n 'phone_number' => $phone,\n 'sort_by' => 'date_modified',\n 'sort_direction' => 'desc',\n ]);\n\n if (\\count($cpObject) === 0) {\n $cpObjectType = 'lead';\n $cpObject = $this->requestWithExceptionHandling(\n fn () => $this->copperClient->leads()->search([\n 'phone_number' => $phone,\n 'sort_by' => 'date_modified',\n 'sort_direction' => 'desc',\n ])\n );\n }\n\n if (empty($cpObject)) {\n return null;\n }\n\n $cpObject = reset($cpObject);\n $data = $this->convertCrmData($cpObject, $cpObjectType, $userId);\n\n return ! empty(array_filter($data)) ? $data : null;\n }\n\n return null;\n }\n\n /**\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n public function matchByName(string $name, ?int $userId = null): ?array\n {\n // Don't waste time searching for single character strings.\n if (\\strlen($name) <= 1) {\n return null;\n }\n\n $result = \\Cache::remember($this->team->id . $name, 60, function () use ($name, $userId) {\n $cpObjectType = 'contact';\n $cpObject = $this->copperClient->contacts()->search([\n 'name' => $name,\n 'sort_by' => 'date_modified',\n 'sort_direction' => 'desc',\n ]);\n\n if (\\count($cpObject) === 0) {\n $cpObjectType = 'lead';\n $cpObject = $this->requestWithExceptionHandling(\n fn () => $this->copperClient->leads()->search([\n 'name' => $name,\n 'sort_by' => 'date_modified',\n 'sort_direction' => 'desc',\n ])\n );\n }\n\n if (! empty($cpObject)) {\n $cpObject = reset($cpObject);\n }\n\n $data = $this->convertCrmData($cpObject, $cpObjectType, $userId);\n\n return ! empty(array_filter($data)) ? $data : false;\n });\n\n return is_array($result) ? $result : null;\n }\n\n private function convertCrmData(array $object, string $objectType, ?int $userId = null): array\n {\n $lead = null;\n $contact = null;\n $opportunity = null;\n $account = null;\n $stage = null;\n $countryCode = null;\n\n if ($objectType === 'lead') {\n $lead = $this->importLead($object);\n\n // Lead might not be imported if the Stage is null for example.\n if ($lead) {\n $countryCode = $lead->country_code;\n $stage = $lead->stage;\n }\n } else {\n $contact = $this->importContact($object);\n $account = $contact->account;\n\n if ($contact->country_code) {\n $countryCode = $contact->country_code;\n } elseif ($account) {\n $countryCode = $account->country_code;\n }\n\n try {\n $cpOpportunities = $this->findOpportunities(\n $account?->crm_provider_id,\n $contact->crm_provider_id,\n $userId,\n );\n\n if (! empty($cpOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($cpOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n } catch (\\Exception $e) {\n // Nothing to see here.\n }\n }\n\n return [\n $lead,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n }\n\n public function saveActivity(Activity $activity): Activity\n {\n switch ($activity->type) {\n case Activity::TYPE_CONFERENCE:\n case Activity::TYPE_SOFTPHONE:\n case Activity::TYPE_SOFTPHONE_INBOUND:\n $activity = $this->buildCallPayload($activity);\n\n break;\n case Activity::TYPE_SMS_INBOUND:\n case Activity::TYPE_SMS_OUTBOUND:\n $activity = $this->buildTextMessagePayload($activity);\n\n break;\n }\n\n return $activity;\n }\n\n private function convertActivityAssociations(Activity $activity): array\n {\n if ($activity->lead) {\n $type = 'lead';\n $id = $activity->lead->crm_provider_id;\n } elseif ($activity->contact) {\n $type = 'person';\n $id = $activity->contact->crm_provider_id;\n } elseif ($activity->opportunity) {\n $type = 'opportunity';\n $id = $activity->opportunity->crm_provider_id;\n } else {\n $type = 'company';\n $id = $activity->account->crm_provider_id;\n }\n\n return [\n 'type' => $type,\n 'id' => $id,\n ];\n }\n\n private function convertActivityType(PlaybookCategory $activityType): array\n {\n return [\n 'category' => 'user',\n 'id' => $this->getCrmType($activityType)->value,\n ];\n }\n\n /**\n * @throws CrmException\n */\n private function buildCallPayload(Activity $activity): Activity\n {\n try {\n if ($activity->crm_provider_id) {\n // The activity should be logged under the existing Task (not Activity).\n $newActivityType = $this->getCrmType($activity->category);\n if ($newActivityType === null) {\n $message = sprintf('[Copper] invalid CRM Custom Activity Type: %s', $activity->category);\n\n throw new CrmException($message, 400);\n }\n $this->copperClient->tasks()->update($activity->crm_provider_id, [\n 'status' => 'Completed',\n 'custom_activity_type_id' => $newActivityType->value,\n 'details' => $this->generateActivityDescription($activity),\n ]);\n } else {\n if ($this->getCrmType($activity->category) === null) {\n $message = sprintf('[Copper] Invalid CRM Activity Type: %s', $activity->category->getName());\n\n throw new CrmException($message, 400);\n }\n\n if ($this->getCrmType($activity->category)->value === null) {\n $message = sprintf('[Copper] Invalid CRM Activity Type: %s value', $activity->category->getName());\n\n throw new CrmException($message, 400);\n }\n // Just create an activity.\n $cpActivity = $this->copperClient->activities()->create([\n 'user_id' => $this->profile->crm_provider_id,\n 'activity_date' => $activity->scheduled_start_time->timestamp,\n 'parent' => $this->convertActivityAssociations($activity),\n 'type' => $this->convertActivityType($activity->category),\n 'details' => $this->generateActivityDescription($activity),\n ]);\n\n $activity->crm_provider_id = $cpActivity['id'];\n $activity->save();\n }\n } catch (ClientException $exception) {\n //Log all errors to Sentry\n \\Sentry::captureException($exception);\n\n $response = $exception->getResponse();\n\n $responseBody = $response->getBody();\n $message = $responseBody;\n $errorCode = $response->getStatusCode();\n\n $jsonResponse = json_decode($responseBody, true);\n if (isset($jsonResponse[0]['message'])) {\n $message = $jsonResponse[0]['message'];\n }\n\n throw new CrmException($message, $errorCode);\n }\n\n return $activity;\n }\n\n private function buildTextMessagePayload(Activity $activity): Activity\n {\n if ($this->getCrmType($activity->category) === null) {\n $message = sprintf('[Copper] Invalid CRM Activity Type: %s', $activity->category->getName());\n\n throw new CrmException($message, 400);\n }\n\n if ($this->getCrmType($activity->category)->value === null) {\n $message = sprintf('[Copper] Invalid CRM Activity Type: %s value', $activity->category->getName());\n\n throw new CrmException($message, 400);\n }\n\n $cpActivity = $this->copperClient->activities()->create([\n 'user_id' => $this->profile->crm_provider_id,\n 'activity_date' => $activity->created_at->timestamp,\n 'parent' => $this->convertActivityAssociations($activity),\n 'type' => $this->convertActivityType($activity->category),\n 'details' => $this->generateActivityDescription($activity),\n ]);\n\n $activity->crm_provider_id = $cpActivity['id'];\n $activity->save();\n\n return $activity;\n }\n\n private function generateActivityDescription(Activity $activity): string\n {\n $description = '';\n\n switch ($activity->type) {\n case Activity::TYPE_SOFTPHONE:\n case Activity::TYPE_SOFTPHONE_INBOUND:\n case Activity::TYPE_CONFERENCE:\n\n $description = 'Jiminny Call Summary' . PHP_EOL . PHP_EOL;\n $description .= 'Call Duration: ' . $activity->duration_for_humans . PHP_EOL . PHP_EOL;\n\n // Tell them which number was dialed.\n if ($activity->to) {\n $description .= 'Dialed: ' . $activity->to->phone_number . PHP_EOL . PHP_EOL;\n }\n\n if ($activity->hasReasonCodeBotKicked()) {\n $description .= 'Notetaker removed from this meeting' . PHP_EOL . PHP_EOL;\n // When we fix the state to be Activity::RECORDING_RECORDED as it should be this can change.\n } elseif ($activity->hasReasonCodeNotCompliant()) {\n $description .= 'Notetaker did not join due to recording consent not being provided by attendees' . PHP_EOL . PHP_EOL;\n } elseif ($activity->canReviewActivity()) {\n $playbackUrl = $activity->user->team->partner->getPlaybackUrl($activity);\n $description .= 'Review Activity:' . PHP_EOL . $playbackUrl . PHP_EOL . PHP_EOL;\n }\n\n if ($activity->getCrmType() === Activity::TYPE_CONFERENCE) {\n $description .= 'Attendees:'\n . PHP_EOL\n . (new FilterJoinedParticipants())->toString($activity);\n }\n\n if (\\count($activity->notes) > 0) {\n $description .= PHP_EOL . PHP_EOL . 'Notes: ' . PHP_EOL;\n\n foreach ($activity->notes as $note) {\n $nTime = (int) ceil($note->time);\n $time = ($nTime > 3600) ? gmdate('H:i:s', $nTime) : gmdate('i:s', $nTime);\n $description .= $time . ' ' . $note->note . PHP_EOL;\n }\n }\n\n // Get all private messages.\n $messages = $activity->messages()\n ->where('is_private', 1)\n ->orderBy('created_at', 'asc');\n\n if ($messages->count() > 0) {\n $description .= PHP_EOL . PHP_EOL . 'Coaching Chat: ' . PHP_EOL;\n\n foreach ($messages->get() as $message) {\n $description .= $message->participant->name . ': ' . $message->message . PHP_EOL;\n }\n }\n\n // Get all public messages.\n $messages = $activity->messages()\n ->where('is_private', 0)\n ->orderBy('created_at', 'asc');\n\n if ($messages->count() > 0) {\n $description .= PHP_EOL . PHP_EOL . 'Customer Chat: ' . PHP_EOL;\n\n foreach ($messages->get() as $message) {\n $description .= $message->participant->name . ': ' . $message->message . PHP_EOL;\n }\n }\n\n if ($activity->summary) {\n $description .= PHP_EOL . PHP_EOL . 'Summary: ' . PHP_EOL . $activity->summary;\n }\n\n break;\n\n case Activity::TYPE_SMS_INBOUND:\n case Activity::TYPE_SMS_OUTBOUND:\n $description = $activity->description;\n\n break;\n }\n\n return $description;\n }\n\n public function saveFollowupActivity(Activity $activity, array $fields): ?string\n {\n // This is the user provided activity subject field.\n if (empty($fields['type'])) {\n return null;\n }\n\n $reminder = null;\n if (empty($fields['reminder_date']) === false) {\n $formatReminder = Carbon::createFromFormat('Y-m-d\\TH:i:s\\Z', $fields['reminder_date']);\n $reminder = $formatReminder->getTimestamp();\n }\n\n $due = null;\n if (empty($fields['due_date']) === false) {\n $formatDue = Carbon::createFromFormat('Y-m-d\\TH:i:s\\Z', $fields['due_date']);\n $due = $formatDue->getTimestamp();\n }\n\n $data = [\n 'custom_activity_type_id' => $fields['type'],\n 'status' => 'Open',\n 'assignee_id' => $this->profile->crm_provider_id,\n 'due_date' => $due,\n 'details' => $fields['details'] ?? '',\n 'reminder_date' => $reminder,\n 'name' => $fields['name'] ?? 'Follow-up',\n 'related_resource' => $this->convertActivityAssociations($activity),\n 'custom_fields' => $this->fetchCustomFieldData($activity, Field::OBJECT_TASK),\n ];\n\n // We don't actually create a corresponding activity object on our side yet.\n return $this->copperClient->tasks()->create($data)['id'];\n }\n\n /**\n * Store transcripts as note.\n *\n * @throws \\Exception\n */\n public function createTranscriptNotes(Activity $activity): void\n {\n // Generate activity transcription.\n $transcriptionData = $this->generateTranscription($activity);\n\n // Truncate Notes with max notes length because transcription text could be very long.\n $transcripts = mb_strimwidth($transcriptionData, 0, static::NOTE_BODY_MAX_LENGTH);\n\n $data = [\n 'user_id' => $this->profile->crm_provider_id,\n 'type' => [\n 'category' => 'user',\n 'id' => 0, // This creates a note.\n ],\n 'activity_date' => time(),\n 'details' => $transcripts,\n 'parent' => $this->convertActivityAssociations($activity),\n ];\n\n $cpActivity = $this->copperClient->activities()->create($data);\n\n // Store CRM Activity ID in transcription.\n $transcription = $activity->getTranscription();\n $transcription->crm_activity_id = $cpActivity['id'];\n $transcription->save();\n }\n\n /**\n * @inheritdoc\n */\n public function updateStage($crmObject, Stage $stage): void\n {\n if ($crmObject instanceof Lead) {\n $this->requestWithExceptionHandling(\n fn () => $this->copperClient->leads()->update($crmObject->crm_provider_id, [\n 'status_id' => $stage->crm_provider_id,\n ])\n );\n } else {\n $this->copperClient->opportunities()->update($crmObject->crm_provider_id, [\n 'pipeline_stage_id' => $stage->crm_provider_id,\n ]);\n }\n }\n\n public function prepareValueForUpdate(array $params): array\n {\n return [$params['fieldName'] => $params['fieldValue']];\n }\n\n /*\n * @inheritdoc\n */\n public function updateRecord(string $objectType, string $objectId, array $data, array $headers = []): void\n {\n $preparedData = $data;\n\n if ($objectType === Field::OBJECT_OPPORTUNITY) {\n $preparedData = $this->prepareOpportunityFieldsForUpdate($data);\n }\n\n try {\n $this->copperClient\n ->createResourceFromObjectType($objectType)\n ->update($objectId, $preparedData)\n ;\n } catch (Exception $e) {\n $this->logger->error(self::RESOURCE_UPDATE_FAILED_ERROR_MESSAGE, [\n 'resource_id' => $objectId,\n 'data' => $preparedData,\n 'error' => $e->getMessage(),\n ]);\n\n throw new CrmException(self::RESOURCE_UPDATE_FAILED_ERROR_MESSAGE, $e->getCode(), $e);\n }\n }\n\n /**\n * @param array<string,string> $fieldData\n *\n * @throws CrmException\n *\n * @return array<string,array<int,array<string,bool|float|int|string|null>>|bool|float|int|string|null>\n */\n private function prepareOpportunityFieldsForUpdate(array $fieldData): array\n {\n $preparedData = [];\n $customFields = [];\n\n foreach ($fieldData as $fieldName => $fieldValue) {\n $field = $this->config\n ->fields()\n ->where('crm_provider_id', $fieldName)\n ->first();\n\n if (! $field instanceof Field) {\n throw new CrmException('Field not found');\n }\n\n $convertedValue = $this->fieldValueConverter->convertToCrm($field->getType(), $fieldValue);\n\n if ($this->isStandardField($fieldName)) {\n $preparedData[$fieldName] = $convertedValue;\n\n continue;\n }\n\n $customFields[] = [\n 'custom_field_definition_id' => $fieldName,\n 'value' => $convertedValue,\n ];\n }\n\n if (! empty($customFields)) {\n $preparedData['custom_fields'] = $customFields;\n }\n\n return $preparedData;\n }\n\n /*\n * @inheritdoc\n */\n public function getRecord(string $objectType, string $objectId, array $fields = []): array\n {\n return $this->copperClient->createResourceFromObjectType($objectType)->find($objectId);\n }\n\n private function fetchCustomFieldData(Activity $activity, string $objectType): array\n {\n $payload = [];\n\n $fieldData = FieldData::where([\n 'object_id' => $activity->id,\n ])->whereHas('field', function ($query) use ($objectType) {\n $query->where('object_type', $objectType);\n })->get();\n\n foreach ($fieldData as $data) {\n // Add the field and value to the payload.\n $payload += [\n 'custom_field_definition_id' => $data->field->crm_provider_id,\n 'value' => $data->value,\n ];\n }\n\n return $payload;\n }\n\n public function generateProviderUrl(string $providerId, string $objectType): ?string\n {\n $baseUrl = 'https://app.prosperworks.com/companies/' . $this->config->crm_provider_id . '/app#/';\n switch ($objectType) {\n case 'lead':\n $url = $baseUrl . 'leads/' . $providerId;\n\n break;\n\n case 'account':\n $url = $baseUrl . 'organization/' . $providerId;\n\n break;\n\n case 'contact':\n $url = $baseUrl . 'contact/' . $providerId;\n\n break;\n\n case 'opportunity':\n $url = $baseUrl . 'deal/' . $providerId;\n\n break;\n\n default:\n case 'task':\n case 'activity':\n // Sadly we can't deeplink to these in Copper UI.\n $url = null;\n }\n\n return $url;\n }\n\n /**\n * Generate transcription for the activity.\n */\n private function generateTranscription(Activity $activity): string\n {\n if (! ($this->config->store_transcript)) {\n // If sending transcription to activity toggle is disabled\n return '';\n }\n\n return $this->transcriptionService\n ->findTranscriptionByActivity($activity)\n ->map(static function (array $transcriptionSegment): string {\n return $transcriptionSegment['formattedStartsAt'] . ' | ' . $transcriptionSegment['transcript'];\n })\n ->implode(PHP_EOL);\n }\n\n /**\n * @param array<string, mixed> $opportunityData\n *\n * @return int|string|bool|float|null\n */\n private function extractFieldValue(array $opportunityData, string $fieldName)\n {\n $fieldValue = $this->isStandardField($fieldName)\n ? $opportunityData[$fieldName]\n : $this->resolveCustomFieldFromOpportunityData($opportunityData, $fieldName);\n\n if (is_array($fieldValue)) {\n $fieldValue = implode(', ', $fieldValue);\n }\n\n return $fieldValue;\n }\n\n private function isStandardField(string $fieldName): bool\n {\n return array_key_exists($fieldName, self::STANDARD_OPPORTUNITY_FIELDS);\n }\n\n /**\n * @param array<string, mixed> $opportunityData\n *\n * @return string|int|float|bool|null|array<string, mixed>\n */\n private function resolveCustomFieldFromOpportunityData(array $opportunityData, string $fieldName)\n {\n $fieldData = array_filter(\n $opportunityData['custom_fields'],\n static fn ($field) => (string) $field['custom_field_definition_id'] === $fieldName\n );\n\n return array_pop($fieldData)['value'] ?? '';\n }\n\n private function isSupportedObjectType(string $objectType): bool\n {\n return $objectType !== self::UNSUPPORTED_OBJECT_TYPE;\n }\n\n private function syncStandardFields(): void\n {\n foreach (self::STANDARD_OPPORTUNITY_FIELDS as $fieldId => $fieldData) {\n $this->config->fields()->updateOrCreate([\n 'crm_provider_id' => mb_strimwidth($fieldId, 0, 128),\n 'object_type' => Field::OBJECT_OPPORTUNITY,\n ], [\n 'label' => mb_strimwidth($fieldData['label'], 0, Field::LABEL_MAX_LENGTH),\n 'type' => $fieldData['type'],\n ]);\n }\n\n $this->importStages([Stage::TYPE_OPPORTUNITY]);\n }\n\n private function requestWithExceptionHandling(\\Closure $apiRequest): iterable\n {\n try {\n return $apiRequest();\n } catch (\\Exception $exception) {\n if ($exception->getCode() === 403) {\n // Object might not be supported\n return [];\n }\n\n \\Sentry::captureException($exception);\n\n return [];\n }\n }\n\n public function verifyTaskExists(Activity $activity): bool\n {\n $crmProviderId = $activity->getCrmProviderId();\n $cacheKey = \"crm_task_exists:{$this->config->getId()}:$crmProviderId\";\n\n return Cache::remember($cacheKey, self::TASK_VERIFICATION_CACHE_TTL, function () use ($crmProviderId) {\n try {\n if ($this->copperClient === null) {\n return false;\n }\n\n $task = $this->copperClient->tasks()->find($crmProviderId);\n\n return ! empty($task);\n } catch (HttpNotFoundException) {\n // Task not found in CRM - this is expected and permanent\n $this->logger->info('[Copper] Task not found during verification', [\n 'task_id' => $crmProviderId,\n 'config_id' => $this->config->getId(),\n ]);\n\n return false;\n } catch (RequestFailedException $e) {\n // Handle 404 responses from Copper API\n if ($e->getCode() === 404) {\n $this->logger->info('[Copper] Task not found during verification (404)', [\n 'task_id' => $crmProviderId,\n 'config_id' => $this->config->getId(),\n ]);\n\n return false;\n }\n\n // Re-throw other Copper exceptions for retry\n throw $e;\n }\n // Let other exceptions (network errors, rate limits, etc.) bubble up for retry\n });\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":true,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Execute","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Explain Plan","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Browse Query History","depth":4,"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"View Parameters","depth":4,"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Open Query Execution Settings…","depth":4,"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"In-Editor Results","depth":4,"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Tx: Auto","depth":4,"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Cancel Running Statements","depth":4,"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Playground","depth":4,"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"jiminny","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.042220745,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"8","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"1","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"3","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"4","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"SELECT\n# DISTINCT\nopp.id as opp_id, opp.uuid, opp.name,\n# COUNT(dr.id) AS deal_risk_count,\n\n# dr.id,\n# cfd.value,\nusr.name AS owner_name,\nopp.value, opp.currency_code, opp.close_date, opp.remotely_created_at, opp.is_closed, opp.is_won,\nusr.uuid as owner_uuid,\nusr.photo_path as owner_photo,\nusr.id AS owner_id,\njt.name as owner_job,\nopp.stage_id, opp.stage_updated_at,\nacc.name AS acc_name, opp.stage_updated_at, acc.crm_provider_id AS acc_provider_id, opp.crm_provider_id AS opp_provider_id,\nrt.business_process_id AS pipeline_id\nFROM opportunities opp\nLEFT JOIN record_types rt ON opp.record_type_id = rt.id\nLEFT JOIN users usr ON opp.user_id = usr.id\nLEFT JOIN accounts acc ON opp.account_id = acc.id\nLEFT JOIN job_titles jt ON usr.job_title_id = jt.id\n\n# LEFT JOIN group_deal_risk_types gdrt ON gdrt.group_id = usr.group_id\n# LEFT JOIN deal_risks dr ON dr.opportunity_id = opp.id AND dr.group_deal_risk_type_id = gdrt.id\n\nLEFT JOIN crm_field_data cfd ON (cfd.object_id = opp.id and cfd.crm_field_id = 66814)\n\nWHERE opp.user_id IS NOT NULL\nAND opp.deleted_at IS NULL\nAND opp.is_closed = 1\nAND opp.is_won = 0\nAND opp.close_date >= '2024-01-01 00:00:00'\nAND opp.close_date <= '2024-12-31 23:59:59'\nAND usr.team_id = 1\n\n# and opp.id = 4823179\n\nGROUP BY opp.id, cfd.value\nORDER BY\n# cfd.value\n CAST(cfd.value AS UNSIGNED)\n# owner_name\n# (SELECT COUNT(dr.id)\n# FROM deal_risks dr\n# JOIN group_deal_risk_types gdrt ON dr.group_deal_risk_type_id = gdrt.id\n# WHERE dr.opportunity_id = opp.id\n# AND dr.is_active = 1\n# AND gdrt.is_enabled = 1)\nDESC\nLIMIT 25\n# OFFSET 0\n;\n\nselect * from crm_fields where crm_configuration_id = 1 and object_type = 'opportunity';\nselect * from crm_layout_entities where crm_layout_id = 1493;\n\nSELECT * FROM opportunities WHERE id = 4584045;\nSELECT * FROM crm_field_data WHERE object_id = 4584045;\n\nSELECT * FROM opportunities where team_id = 1 and crm_provider_id = '0061K00000lfb9IQAQ'\n\nSELECT\n opp.id as opportunity_id,\n opp.name,\n COUNT(dr.id) as risk_count\nFROM opportunities opp\nLEFT JOIN deal_risks dr ON dr.opportunity_id = opp.id\nWHERE opp.id IN (\n 6069574,8900938,9507638,9524799,9660490,9662824,9705675,9749758,4158460,4391812,6450439,4448422,5118945,5590675,4584045,7228149,9002408,9165534,9446720,9641778,9665149,9703344,9709280,5747948,4158491,6182565,6263970,5798120,5111315,4536978,6062352,6548383,6072095,6548225,4480986,5011422,6548381,8760540,8917554,9509986,9514392,9569009,9569011,9578490,9713604,5784397,7276612,7288405,8994421,9118219,9608148,9818911,4510535,6479693,5958049,6271674,6550448,4158331,4158483,6126571,6171615,6540943,4897466,5190896,5796182,5932762,8572433,8723698,8892697,9711001,9789264,4549188,6100831,6170064,6260799,6263653,6449936,6530871,6538978,7777651,8059269,8319918,8787049,8901150,9263153,9453207,9514738,9696700,9791062,5752018,6421452,7439134,7878923,9354763,9369285,9514396,9582506,9889949,9890216,4094311,4158495,4158496,6098128,5585661,3872564,6442149,5891604,6164746,6199593,6583474,6519684,9018490,9809006,4496897,5041324,5829430,6198319,6253504,6555763,7242914,7931055,8024125,8797814,8058559,8673347,8892695,8994420,9616219,9714970,9722004,9809439,9818918,6523177,8134147,9002915,9711422,9892713,9901719,9954210,9978435,5800810,6243518,6416114,6222251,6411974,6512456,5791953,6545606,9914780,5805540,6238986,6463838,6547680,9767049,9809437,9810885,9890855,6673493,9902036,4335521,6379871,6503799,6546077,8018765,9907556,9958433,9905855,9916179,9946741,9957877,5563344,6271838,6450815,7641128,7762567,7780592,8684810,8685786,8685787,8685788\n)\nGROUP BY opp.id;\n\nSELECT COUNT(dr.id)\n FROM deal_risks dr\n JOIN group_deal_risk_types gdrt ON dr.group_deal_risk_type_id = gdrt.id\n WHERE dr.opportunity_id = 5563344\n# AND gdrt.group_id = usr.group_id\n\n\n\nEXPLAIN SELECT\n opp.id as opp_id, opp.uuid, opp.name,\n\ncfd.value,\ncfv.sequence,\ncfv.value,\n# MAX(cfd.value) AS max_cfd_value,\nusr.name AS owner_name,\nopp.value, opp.currency_code, opp.close_date, opp.remotely_created_at, opp.is_closed, opp.is_won,\nusr.uuid as owner_uuid,\nusr.photo_path as owner_photo,\nusr.id AS owner_id,\njt.name as owner_job,\nopp.stage_id, opp.stage_updated_at,\nacc.name AS acc_name, opp.stage_updated_at, acc.crm_provider_id AS acc_provider_id, opp.crm_provider_id AS opp_provider_id,\nrt.business_process_id AS pipeline_id\nFROM opportunities opp\n\nLEFT JOIN record_types rt ON opp.record_type_id = rt.id\nLEFT JOIN users usr ON opp.user_id = usr.id\nLEFT JOIN accounts acc ON opp.account_id = acc.id\nLEFT JOIN job_titles jt ON usr.job_title_id = jt.id\n\nLEFT JOIN crm_field_data cfd ON cfd.id = (SELECT sub_cfd.id\n FROM crm_field_data sub_cfd\n WHERE sub_cfd.object_id = opp.id\n AND sub_cfd.crm_field_id = 66810\n ORDER BY sub_cfd.updated_at DESC\n LIMIT 1)\n\nLEFT JOIN crm_field_values cfv ON cfv.crm_field_id = 66810 AND cfv.value = cfd.value\n\nWHERE opp.user_id IS NOT NULL\nAND opp.deleted_at IS NULL\nAND opp.is_closed = 0\n# AND opp.is_closed = 1\n# AND opp.is_won = 0\nAND opp.close_date >= '2024-01-01 00:00:00'\nAND opp.close_date <= '2024-12-31 23:59:59'\nAND usr.team_id = 1\n\n# and opp.id = 4823179\n\n# GROUP BY opp.id\nORDER BY\n cfv.sequence DESC,\n# opp.name\n# owner_name\n cfd.value\n# CAST(cfd.value AS UNSIGNED)\n# CAST(MAX(cfd.value) AS UNSIGNED)\n# (SELECT COUNT(dr.id)\n# FROM deal_risks dr\n# JOIN group_deal_risk_types gdrt ON dr.group_deal_risk_type_id = gdrt.id\n# WHERE dr.opportunity_id = opp.id\n# AND dr.is_active = 1 AND gdrt.is_enabled = 1)\nDESC\n# LIMIT 75, 25\n;\n\n\n\nSELECT * FROM crm_field_values WHERE crm_field_id = 66810;\n\nSELECT f.id, f.crm_provider_id AS field_name, f.label, fd.object_id AS dealId, fd.value, fd.updated_at, fd.id, fd.created_at\nFROM crm_fields f\nINNER JOIN crm_field_data fd ON fd.crm_field_id = f.id\n WHERE (f.crm_configuration_id = 1)\n AND (f.object_type = 'opportunity')\n# AND (fd.object_id IN (4158331,4158483,4158495,4158496,6069574,6263970,6171615,6540943,6545606,5829430,6198319,6263653,6548381,7242914,8797814,7228149,7439134,7878923,8134147,9002915,9118219,9165534,9354763,9369285,9446720))\n AND (fd.object_id IN (4158460))\n AND (f.crm_provider_id IN ('ForecastCategoryName'))\n ORDER BY fd.object_id ASC, fd.updated_at DESC;\n\nselect * from crm_layouts where crm_configuration_id = 1;\nselect cf.* from crm_fields cf\njoin crm_layout_entities cle on cf.id = cle.crm_field_id\nwhere crm_layout_id = 1493;\n\nSELECT * FROM opportunities WHERE id = 4158460;\nSELECT * FROM crm_field_data WHERE object_id = 4158460;\n\nselect * from users where team_id = 1;\nSELECT * FROM users WHERE id = 7160;\nselect * from role_user where user_id = 3248;\n\nselect * from crm_field_values where crm_field_id = 66824;\n\nSELECT * FROM users WHERE id = 23470;\nselect * from crm_configurations;\n\n# ******************************************\nselect * from teams where id = 1038; # 23521, 966\nselect * from users where team_id = 1038;\nselect * from crm_configurations where id = 966;\nSELECT * FROM activities WHERE uuid_to_bin('e8dc7c34-31f6-4430-bd81-2d9d8f00dd07') = uuid; # 54965762\nSELECT * FROM activities WHERE uuid_to_bin('4b5727c0-69d1-428f-b8d9-b649055166e2') = uuid; # 54965764\nSELECT * FROM participants WHERE activity_id = 54965764;\n\nSELECT id, uuid, title, recording_state FROM activities WHERE uuid_to_bin('355f105d-5606-4379-b0c5-d91daf59f4ce') = uuid; # 54964075\nSELECT * FROM participants WHERE activity_id = 54964075;\nselect f.id, slug, title, tf.team_id from team_features tf JOIN features f on tf.feature_id = f.id\n where tf.team_id = 1038;\n\n\n# ****************************************** PD *************************\nselect * from teams where id = 1029;\nSELECT * FROM crm_configurations WHERE id = 957;\nSELECT * FROM activities WHERE uuid_to_bin('581a33cb-4343-44bd-ac0c-4a5cee71c5ec') = uuid; # 54963423\nSELECT * FROM activities WHERE uuid_to_bin('07238011-25fa-418e-838b-fb21e82b9ea2') = uuid; # 54963433\n\nSELECT * FROM activities WHERE uuid_to_bin('8b6bb2f7-e7a7-4aec-8806-fbfea7093ccd') = uuid; # 54963421\n\n# ****************************************** Close *************************\nselect * from teams where id = 1031;\nSELECT * FROM crm_configurations WHERE id = 959;\nSELECT * FROM activities WHERE uuid_to_bin('59c517e0-96c9-4dad-bfeb-b7bef72f8725') = uuid; # 54963517\n\n\nSELECT * FROM activities WHERE uuid_to_bin('07238011-25fa-418e-838b-fb21e82b9ea2') = uuid; # 54963433\n\nSELECT * FROM activities WHERE uuid_to_bin('d68311e3-ac34-49bd-bf04-88323a7e5352') = uuid; # 54966009\n\n# ****************************************** SF *************************\nSELECT * FROM activities WHERE uuid_to_bin('2bd4cfa3-4eb5-4c77-84a7-9ed46a21c988') = uuid;\n\nselect * from roles;\nselect * from permissions;\nselect * from permission_role where permission_id = 136;\n\nselect * from migrations order by id desc;\n\nselect * from teams where id IN (1, 1037);\nselect * from crm_layouts where crm_configuration_id = 1;\nSELECT * FROM crm_layout_entities WHERE crm_layout_id = 1493;\nSELECT * FROM crm_fields WHERE id IN (1652,1661,66799,66814,66821,66836,66843,66846,66864,84752,182306,323580,380378);\n\nselect * from features;\nselect * from team_features where feature_id = 33;\nselect * from opportunities;\n\nselect * from teams;\nSELECT\n CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,\n u.email,\n sa.*,\n t.owner_id FROM social_accounts sa\nJOIN users u on u.id = sa.sociable_id\nJOIN teams t on t.id = u.team_id\nWHERE u.team_id = 1052 and sa.provider = 'hubspot';\n\nSELECT * FROM accounts where id = 11512582;\nselect * from activities where account_id = 11512582;","depth":4,"on_screen":true,"value":"SELECT\n# DISTINCT\nopp.id as opp_id, opp.uuid, opp.name,\n# COUNT(dr.id) AS deal_risk_count,\n\n# dr.id,\n# cfd.value,\nusr.name AS owner_name,\nopp.value, opp.currency_code, opp.close_date, opp.remotely_created_at, opp.is_closed, opp.is_won,\nusr.uuid as owner_uuid,\nusr.photo_path as owner_photo,\nusr.id AS owner_id,\njt.name as owner_job,\nopp.stage_id, opp.stage_updated_at,\nacc.name AS acc_name, opp.stage_updated_at, acc.crm_provider_id AS acc_provider_id, opp.crm_provider_id AS opp_provider_id,\nrt.business_process_id AS pipeline_id\nFROM opportunities opp\nLEFT JOIN record_types rt ON opp.record_type_id = rt.id\nLEFT JOIN users usr ON opp.user_id = usr.id\nLEFT JOIN accounts acc ON opp.account_id = acc.id\nLEFT JOIN job_titles jt ON usr.job_title_id = jt.id\n\n# LEFT JOIN group_deal_risk_types gdrt ON gdrt.group_id = usr.group_id\n# LEFT JOIN deal_risks dr ON dr.opportunity_id = opp.id AND dr.group_deal_risk_type_id = gdrt.id\n\nLEFT JOIN crm_field_data cfd ON (cfd.object_id = opp.id and cfd.crm_field_id = 66814)\n\nWHERE opp.user_id IS NOT NULL\nAND opp.deleted_at IS NULL\nAND opp.is_closed = 1\nAND opp.is_won = 0\nAND opp.close_date >= '2024-01-01 00:00:00'\nAND opp.close_date <= '2024-12-31 23:59:59'\nAND usr.team_id = 1\n\n# and opp.id = 4823179\n\nGROUP BY opp.id, cfd.value\nORDER BY\n# cfd.value\n CAST(cfd.value AS UNSIGNED)\n# owner_name\n# (SELECT COUNT(dr.id)\n# FROM deal_risks dr\n# JOIN group_deal_risk_types gdrt ON dr.group_deal_risk_type_id = gdrt.id\n# WHERE dr.opportunity_id = opp.id\n# AND dr.is_active = 1\n# AND gdrt.is_enabled = 1)\nDESC\nLIMIT 25\n# OFFSET 0\n;\n\nselect * from crm_fields where crm_configuration_id = 1 and object_type = 'opportunity';\nselect * from crm_layout_entities where crm_layout_id = 1493;\n\nSELECT * FROM opportunities WHERE id = 4584045;\nSELECT * FROM crm_field_data WHERE object_id = 4584045;\n\nSELECT * FROM opportunities where team_id = 1 and crm_provider_id = '0061K00000lfb9IQAQ'\n\nSELECT\n opp.id as opportunity_id,\n opp.name,\n COUNT(dr.id) as risk_count\nFROM opportunities opp\nLEFT JOIN deal_risks dr ON dr.opportunity_id = opp.id\nWHERE opp.id IN (\n 6069574,8900938,9507638,9524799,9660490,9662824,9705675,9749758,4158460,4391812,6450439,4448422,5118945,5590675,4584045,7228149,9002408,9165534,9446720,9641778,9665149,9703344,9709280,5747948,4158491,6182565,6263970,5798120,5111315,4536978,6062352,6548383,6072095,6548225,4480986,5011422,6548381,8760540,8917554,9509986,9514392,9569009,9569011,9578490,9713604,5784397,7276612,7288405,8994421,9118219,9608148,9818911,4510535,6479693,5958049,6271674,6550448,4158331,4158483,6126571,6171615,6540943,4897466,5190896,5796182,5932762,8572433,8723698,8892697,9711001,9789264,4549188,6100831,6170064,6260799,6263653,6449936,6530871,6538978,7777651,8059269,8319918,8787049,8901150,9263153,9453207,9514738,9696700,9791062,5752018,6421452,7439134,7878923,9354763,9369285,9514396,9582506,9889949,9890216,4094311,4158495,4158496,6098128,5585661,3872564,6442149,5891604,6164746,6199593,6583474,6519684,9018490,9809006,4496897,5041324,5829430,6198319,6253504,6555763,7242914,7931055,8024125,8797814,8058559,8673347,8892695,8994420,9616219,9714970,9722004,9809439,9818918,6523177,8134147,9002915,9711422,9892713,9901719,9954210,9978435,5800810,6243518,6416114,6222251,6411974,6512456,5791953,6545606,9914780,5805540,6238986,6463838,6547680,9767049,9809437,9810885,9890855,6673493,9902036,4335521,6379871,6503799,6546077,8018765,9907556,9958433,9905855,9916179,9946741,9957877,5563344,6271838,6450815,7641128,7762567,7780592,8684810,8685786,8685787,8685788\n)\nGROUP BY opp.id;\n\nSELECT COUNT(dr.id)\n FROM deal_risks dr\n JOIN group_deal_risk_types gdrt ON dr.group_deal_risk_type_id = gdrt.id\n WHERE dr.opportunity_id = 5563344\n# AND gdrt.group_id = usr.group_id\n\n\n\nEXPLAIN SELECT\n opp.id as opp_id, opp.uuid, opp.name,\n\ncfd.value,\ncfv.sequence,\ncfv.value,\n# MAX(cfd.value) AS max_cfd_value,\nusr.name AS owner_name,\nopp.value, opp.currency_code, opp.close_date, opp.remotely_created_at, opp.is_closed, opp.is_won,\nusr.uuid as owner_uuid,\nusr.photo_path as owner_photo,\nusr.id AS owner_id,\njt.name as owner_job,\nopp.stage_id, opp.stage_updated_at,\nacc.name AS acc_name, opp.stage_updated_at, acc.crm_provider_id AS acc_provider_id, opp.crm_provider_id AS opp_provider_id,\nrt.business_process_id AS pipeline_id\nFROM opportunities opp\n\nLEFT JOIN record_types rt ON opp.record_type_id = rt.id\nLEFT JOIN users usr ON opp.user_id = usr.id\nLEFT JOIN accounts acc ON opp.account_id = acc.id\nLEFT JOIN job_titles jt ON usr.job_title_id = jt.id\n\nLEFT JOIN crm_field_data cfd ON cfd.id = (SELECT sub_cfd.id\n FROM crm_field_data sub_cfd\n WHERE sub_cfd.object_id = opp.id\n AND sub_cfd.crm_field_id = 66810\n ORDER BY sub_cfd.updated_at DESC\n LIMIT 1)\n\nLEFT JOIN crm_field_values cfv ON cfv.crm_field_id = 66810 AND cfv.value = cfd.value\n\nWHERE opp.user_id IS NOT NULL\nAND opp.deleted_at IS NULL\nAND opp.is_closed = 0\n# AND opp.is_closed = 1\n# AND opp.is_won = 0\nAND opp.close_date >= '2024-01-01 00:00:00'\nAND opp.close_date <= '2024-12-31 23:59:59'\nAND usr.team_id = 1\n\n# and opp.id = 4823179\n\n# GROUP BY opp.id\nORDER BY\n cfv.sequence DESC,\n# opp.name\n# owner_name\n cfd.value\n# CAST(cfd.value AS UNSIGNED)\n# CAST(MAX(cfd.value) AS UNSIGNED)\n# (SELECT COUNT(dr.id)\n# FROM deal_risks dr\n# JOIN group_deal_risk_types gdrt ON dr.group_deal_risk_type_id = gdrt.id\n# WHERE dr.opportunity_id = opp.id\n# AND dr.is_active = 1 AND gdrt.is_enabled = 1)\nDESC\n# LIMIT 75, 25\n;\n\n\n\nSELECT * FROM crm_field_values WHERE crm_field_id = 66810;\n\nSELECT f.id, f.crm_provider_id AS field_name, f.label, fd.object_id AS dealId, fd.value, fd.updated_at, fd.id, fd.created_at\nFROM crm_fields f\nINNER JOIN crm_field_data fd ON fd.crm_field_id = f.id\n WHERE (f.crm_configuration_id = 1)\n AND (f.object_type = 'opportunity')\n# AND (fd.object_id IN (4158331,4158483,4158495,4158496,6069574,6263970,6171615,6540943,6545606,5829430,6198319,6263653,6548381,7242914,8797814,7228149,7439134,7878923,8134147,9002915,9118219,9165534,9354763,9369285,9446720))\n AND (fd.object_id IN (4158460))\n AND (f.crm_provider_id IN ('ForecastCategoryName'))\n ORDER BY fd.object_id ASC, fd.updated_at DESC;\n\nselect * from crm_layouts where crm_configuration_id = 1;\nselect cf.* from crm_fields cf\njoin crm_layout_entities cle on cf.id = cle.crm_field_id\nwhere crm_layout_id = 1493;\n\nSELECT * FROM opportunities WHERE id = 4158460;\nSELECT * FROM crm_field_data WHERE object_id = 4158460;\n\nselect * from users where team_id = 1;\nSELECT * FROM users WHERE id = 7160;\nselect * from role_user where user_id = 3248;\n\nselect * from crm_field_values where crm_field_id = 66824;\n\nSELECT * FROM users WHERE id = 23470;\nselect * from crm_configurations;\n\n# ******************************************\nselect * from teams where id = 1038; # 23521, 966\nselect * from users where team_id = 1038;\nselect * from crm_configurations where id = 966;\nSELECT * FROM activities WHERE uuid_to_bin('e8dc7c34-31f6-4430-bd81-2d9d8f00dd07') = uuid; # 54965762\nSELECT * FROM activities WHERE uuid_to_bin('4b5727c0-69d1-428f-b8d9-b649055166e2') = uuid; # 54965764\nSELECT * FROM participants WHERE activity_id = 54965764;\n\nSELECT id, uuid, title, recording_state FROM activities WHERE uuid_to_bin('355f105d-5606-4379-b0c5-d91daf59f4ce') = uuid; # 54964075\nSELECT * FROM participants WHERE activity_id = 54964075;\nselect f.id, slug, title, tf.team_id from team_features tf JOIN features f on tf.feature_id = f.id\n where tf.team_id = 1038;\n\n\n# ****************************************** PD *************************\nselect * from teams where id = 1029;\nSELECT * FROM crm_configurations WHERE id = 957;\nSELECT * FROM activities WHERE uuid_to_bin('581a33cb-4343-44bd-ac0c-4a5cee71c5ec') = uuid; # 54963423\nSELECT * FROM activities WHERE uuid_to_bin('07238011-25fa-418e-838b-fb21e82b9ea2') = uuid; # 54963433\n\nSELECT * FROM activities WHERE uuid_to_bin('8b6bb2f7-e7a7-4aec-8806-fbfea7093ccd') = uuid; # 54963421\n\n# ****************************************** Close *************************\nselect * from teams where id = 1031;\nSELECT * FROM crm_configurations WHERE id = 959;\nSELECT * FROM activities WHERE uuid_to_bin('59c517e0-96c9-4dad-bfeb-b7bef72f8725') = uuid; # 54963517\n\n\nSELECT * FROM activities WHERE uuid_to_bin('07238011-25fa-418e-838b-fb21e82b9ea2') = uuid; # 54963433\n\nSELECT * FROM activities WHERE uuid_to_bin('d68311e3-ac34-49bd-bf04-88323a7e5352') = uuid; # 54966009\n\n# ****************************************** SF *************************\nSELECT * FROM activities WHERE uuid_to_bin('2bd4cfa3-4eb5-4c77-84a7-9ed46a21c988') = uuid;\n\nselect * from roles;\nselect * from permissions;\nselect * from permission_role where permission_id = 136;\n\nselect * from migrations order by id desc;\n\nselect * from teams where id IN (1, 1037);\nselect * from crm_layouts where crm_configuration_id = 1;\nSELECT * FROM crm_layout_entities WHERE crm_layout_id = 1493;\nSELECT * FROM crm_fields WHERE id IN (1652,1661,66799,66814,66821,66836,66843,66846,66864,84752,182306,323580,380378);\n\nselect * from features;\nselect * from team_features where feature_id = 33;\nselect * from opportunities;\n\nselect * from teams;\nSELECT\n CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,\n u.email,\n sa.*,\n t.owner_id FROM social_accounts sa\nJOIN users u on u.id = sa.sociable_id\nJOIN teams t on t.id = u.team_id\nWHERE u.team_id = 1052 and sa.provider = 'hubspot';\n\nSELECT * FROM accounts where id = 11512582;\nselect * from activities where account_id = 11512582;","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Project","depth":3,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Project","depth":3,"bounds":{"left":0.28224733,"top":1.0,"width":0.024268618,"height":-0.04788506},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"New File or Directory…","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Expand Selected","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Collapse All","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Options","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
8957564869086018857
|
-7959665055215941403
|
click
|
accessibility
|
NULL
|
Project: faVsco.js, menu
#12066 on JY-20725-handle Project: faVsco.js, menu
#12066 on JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
HandleHubspotRateLimitTest
Run 'HandleHubspotRateLimitTest'
Debug 'HandleHubspotRateLimitTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
11
72
1
4
Previous Highlighted Error
Next Highlighted Error
<?php
namespace Jiminny\Services\Crm\Copper;
use Carbon\Carbon;
use Exception;
use GuzzleHttp\Exception\ClientException;
use Illuminate\Support\Facades\Cache;
use Jiminny\Contracts\Services\Crm\LayoutManagementInterface;
use Jiminny\Contracts\Services\Crm\MatchCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\Provider\CopperInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityLookupInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityManipulationInterface;
use Jiminny\Contracts\Services\Crm\SettingsInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmMetadataInterface;
use Jiminny\Contracts\Services\Crm\VerifyTaskExistsInterface;
use Jiminny\Exceptions\CrmException;
use Jiminny\Exceptions\HttpNotFoundException;
use Jiminny\Models\Account;
use Jiminny\Models\Activity;
use Jiminny\Models\Contact;
use Jiminny\Models\Crm\BusinessProcess;
use Jiminny\Models\Crm\Configuration;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\FieldData;
use Jiminny\Models\Crm\FieldValue;
use Jiminny\Models\Crm\LayoutEntity;
use Jiminny\Models\Crm\Profile;
use Jiminny\Models\Lead;
use Jiminny\Models\Opportunity;
use Jiminny\Models\Playbook;
use Jiminny\Models\PlaybookCategory;
use Jiminny\Models\SocialAccount;
use Jiminny\Models\Stage;
use Jiminny\Models\User;
use Jiminny\Repositories\Crm\CrmEntityRepository;
use Jiminny\Repositories\Crm\FieldRepository;
use Jiminny\Repositories\Crm\ProfileRepository;
use Jiminny\Repositories\UserRepository;
use Jiminny\Services\Avatar\ProspectPhotoPathService;
use Jiminny\Services\Crm\BaseService;
use Jiminny\Services\Crm\Copper\Api\Client;
use Jiminny\Services\Crm\Copper\Api\ClientFactory;
use Jiminny\Services\Crm\Copper\Exception\RequestFailedException;
use Jiminny\Services\Crm\Helpers\FilterJoinedParticipants;
use Jiminny\Services\Crm\OpportunitySyncStrategyResolver;
use Jiminny\Services\Crm\Copper\Traits\SearchesCrmEntities;
use Jiminny\Utils\CurrencyFormatter;
use Jiminny\Utils\StringUtil;
class Service extends BaseService implements
CopperInterface,
SyncCrmEntitiesInterface,
SyncCrmMetadataInterface,
RemoteEntityManipulationInterface,
LayoutManagementInterface,
SettingsInterface,
MatchCrmEntitiesInterface,
RemoteEntityLookupInterface,
VerifyTaskExistsInterface
{
use SearchesCrmEntities;
private const int NOTE_BODY_MAX_LENGTH = 3000000;
private const int TASK_VERIFICATION_CACHE_TTL = 86400; // 1 day
private const array STANDARD_OPPORTUNITY_FIELDS = [
'monetary_value' => [
'label' => 'Value',
'available_on' => Field::OBJECT_OPPORTUNITY,
'type' => Field::TYPE_CURRENCY,
],
'pipeline_stage_id' => [
'label' => 'Stage',
'type' => Field::TYPE_PICKLIST,
'available_on' => Field::OBJECT_OPPORTUNITY,
],
'close_date' => [
'label' => 'Close date',
'type' => Field::TYPE_DATE,
'available_on' => Field::OBJECT_OPPORTUNITY,
],
'win_probability' => [
'label' => 'Win probability',
'type' => Field::TYPE_NUMBER,
'available_on' => Field::OBJECT_OPPORTUNITY,
],
];
private const string UNSUPPORTED_OBJECT_TYPE = 'unsupported';
private const string OPPORTUNITY_STATUS_OPEN = 'Open';
private const string OPPORTUNITY_IMPORT_FAILED_ERROR_MESSAGE = 'Opportunity import failed';
private const string RESOURCE_UPDATE_FAILED_ERROR_MESSAGE = 'Failed to update resource';
private FieldValueConverter $fieldValueConverter;
private ClientFactory $clientFactory;
private ?Client $copperClient = null;
public function __construct(
ClientFactory $clientFactory,
FieldValueConverter $fieldValueConverter,
private readonly UserRepository $userRepository,
private readonly ProspectPhotoPathService $prospectPhotoPathService,
private readonly CrmEntityRepository $crmEntityRepository
) {
parent::__construct();
$this->clientFactory = $clientFactory;
$this->fieldValueConverter = $fieldValueConverter;
}
public function getDisplayName(): string
{
return 'Copper';
}
/**
* Get the CRM entity repository (used by SearchesCrmEntities trait)
*/
protected function getCrmEntityRepository(): CrmEntityRepository
{
return $this->crmEntityRepository;
}
public function getMinimumApiVersion(): string
{
return Client::MIN_API_VERSION;
}
protected function getOAuthAccount(User $user): ?SocialAccount
{
// In this case, the Account Owner is always the connection for any API operations.
$owner = $user->team->owner;
return $owner->getSocialAccount(SocialAccount::PROVIDER_COPPER);
}
public function setUser(User $user): void
{
$this->configureSentryScope($user);
$this->team = $user->getTeam();
$this->teamService->setTeam($user->getTeam());
$this->setConfiguration($user->getTeam()->getCrmConfiguration());
// Reset state.
$this->profile = $user->getProfile();
if (! $this->profile instanceof Profile) {
// Try to fetch their profile, maybe it was not yet imported?
$this->profile = $this->getProfile($user);
}
}
public function setConfiguration(Configuration $configuration): void
{
$this->config = $configuration;
$this->copperClient = $this->clientFactory->create($configuration);
if ($this->team === null) {
$this->team = $configuration->getTeam();
}
}
public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string
{
return $this->fieldValueConverter->convertFromCrm($fieldType, $fieldValue);
}
private function mapObjectType(string $objectType): string
{
$objectType = strtolower($objectType);
switch ($objectType) {
case Field::OBJECT_TASK:
case Field::OBJECT_OPPORTUNITY:
case Field::OBJECT_LEAD:
$mappedType = $objectType;
break;
case Field::OBJECT_ACCOUNT:
$mappedType = 'company';
break;
case Field::OBJECT_CONTACT:
$mappedType = 'person';
break;
default:
$mappedType = self::UNSUPPORTED_OBJECT_TYPE;
break;
}
return $mappedType;
}
/**
* @param string $crmObject The name of the CRM object. i.e. Opportunity or Contact
*/
protected function getFields(string $crmObject): array
{
$mappedObjectType = $this->mapObjectType($crmObject);
if (! $this->isSupportedObjectType($mappedObjectType)) {
return [];
}
$customFields = $this->copperClient->fields()->all();
$filteredFields = array_filter(
$customFields,
static fn ($field) => in_array($mappedObjectType, $field['available_on'], true)
);
return array_map(
fn (array $property) => [
'name' => $property['id'],
'label' => $property['name'],
'type' => $this->convertFieldType($property['data_type']),
],
$filteredFields
);
}
/**
* @inheritdoc
*/
public function getDefaultFields(string $activityType): array
{
$fields = [];
if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {
$defaultFields = FieldDefinitions::defaultTaskFields();
// This lazy creates these fields if not already setup.
foreach ($defaultFields as $defaultField) {
$fields[] = $this->config->fields()->firstOrCreate($defaultField);
}
}
return $fields;
}
/**
* @inheritdoc
*/
public function getDefaultActivityField(string $activityType): Field
{
// Setup the activity field as the default Type.
$activityField = $this->config->fields()->where([
'crm_provider_id' => 'type',
'object_type' => $activityType,
])->first();
return $activityField;
}
/**
* @inheritdoc
*/
public function getSupportedPlaybookTypes(): array
{
return [Playbook::ACTIVITY_TYPE_TASK];
}
public function getDealInsightsFields(): array
{
return FieldDefinitions::dealInsightsFields();
}
protected function getDefaultFollowupLayoutFields(string $activityType): array
{
$fields = [];
$fieldRepo = app(FieldRepository::class);
$fieldFilters = FieldDefinitions::taskFollowupFieldsFilter();
foreach ($fieldFilters as $data) {
$field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $data);
// Only add the field if it is created, which it should be.
if ($field) {
$fields[] = $field;
}
}
return $fields;
}
public function syncFields(): void
{
$this->syncStandardFields();
$this->syncCustomFields();
}
public function syncField(Field $field): void
{
if ($field->getCrmProviderId() === 'type') {
/**
* This field is a special case, it is not a custom field and is not returned by the Copper API.
*
* The field is created manually during layout setup.
*
* @see getDefaultFields()
*
* There is a separate endpoint to get the activity types.
* @see importPicklistValues()
*/
return;
}
if ($field->getCrmProviderId() === 'pipeline_stage_id') {
// Not a custom type
return;
}
$crmField = $this->copperClient->fields()->find($field->getCrmProviderId());
$field->label = $crmField['name'];
$field->type = $this->convertFieldType($crmField['data_type']);
$field->save();
}
/**
* @param string $fieldType How the data should be input
*/
private function convertFieldType(string $fieldType): string
{
$fieldType = strtolower($fieldType);
switch ($fieldType) {
case 'dropdown':
$convertedType = Field::TYPE_PICKLIST;
break;
case 'string':
$convertedType = Field::TYPE_TEXT;
break;
case 'float':
case 'percentage':
$convertedType = Field::TYPE_NUMBER;
break;
case 'text':
$convertedType = Field::TYPE_TEXTAREA;
break;
case 'checkbox':
$convertedType = Field::TYPE_BOOLEAN;
break;
case Field::TYPE_DATE:
case Field::TYPE_CURRENCY:
case Field::TYPE_URL:
$convertedType = $fieldType;
break;
default:
$convertedType = Field::TYPE_UNSUPPORTED;
break;
}
return $convertedType;
}
/**
* @inheritdoc
*/
public function importPicklistValues(Field $field): array
{
$fieldValues = [];
if ($field->object_type === 'opportunity' && $field->crm_provider_id === 'pipeline_stage_id') {
return $this->importFieldValuesForDealInsights($field);
}
$values = [];
if ($field->object_type === Field::OBJECT_TASK && $field->crm_provider_id === 'type') {
$activityTypes = $this->copperClient->activityTypes()->all();
/**
* We ignore "system" activity types, disabled activity types and the "Note" activity type.
*/
$activityTypes = array_filter(
$activityTypes['user'],
fn (array $activityType) => ! $activityType['is_disabled'] && $activityType['id'] !== 0
);
// Import all activity values.
$i = 0;
foreach ($activityTypes as $activityType) {
$values[] = [
'value' => $activityType['id'],
'label' => mb_strimwidth($activityType['name'], 0, Field::LABEL_MAX_LENGTH),
'sequence' => $i++,
];
}
} else {
// We just check for the field in the custom definitions.
$crmField = $this->copperClient->fields()->find($field->crm_provider_id);
// Import all values.
foreach ($crmField['options'] as $option) {
$values[] = [
'value' => $option['id'],
'label' => mb_strimwidth($option['name'], 0, 191),
'sequence' => $option['rank'],
];
}
}
// Take the existing set of field values.
$fieldsToPurge = $field->values()->get()->pluck('value')->toArray();
foreach ($values as $value) {
// For all current values, upsert the field.
$value['value'] = substr($value['value'] ?? '', 0, 255);
$fieldValues[] = $field->values()->updateOrCreate([
'value' => $value['value'],
], $value);
// Remove this value from the ones we are going to purge.
$key = array_search($value['value'], $fieldsToPurge, false);
if ($key !== false) {
unset($fieldsToPurge[$key]);
}
}
// Delete the old values that are no longer used.
$field->values()->whereIn('value', $fieldsToPurge)->delete();
return $fieldValues;
}
/**
*
* @return FieldValue[]
*/
private function importFieldValuesForDealInsights(Field $field): array
{
$fieldValues = [];
$pipelines = $this->copperClient->pipelines()->all();
foreach ($pipelines as $pipeline) {
foreach ($pipeline['stages'] as $i => $dealStage) {
$fieldValues[] = $field->values()->updateOrCreate(
['value' => $dealStage['id']],
[
'label' => $dealStage['name'],
'sequence' => $i,
]
);
}
}
return $fieldValues;
}
/**
* @inheritdoc
*/
public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage
{
if ($types === null) {
$types = [Stage::TYPE_LEAD, Stage::TYPE_OPPORTUNITY];
}
$missingStage = null;
foreach ($types as $type) {
if ($type === Stage::TYPE_LEAD) {
$statuses = $this->requestWithExceptionHandling(
fn () => $this->copperClient->leadStatuses()->all()
);
if (count($statuses) > 0) {
$businessProcess = $this->config->businessProcesses()->updateOrCreate([
'crm_provider_id' => BusinessProcess::TYPE_LEAD,
], [
'team_id' => $this->team->id,
'name' => BusinessProcess::TYPE_LEAD,
'type' => BusinessProcess::TYPE_LEAD,
'is_selectable' => true,
]);
// A record type is really a clone of the business process, used to store which record uses which pipeline.
$this->config->recordTypes()->updateOrCreate([
'crm_provider_id' => BusinessProcess::TYPE_LEAD,
], [
'team_id' => $this->team->id,
'name' => BusinessProcess::TYPE_LEAD,
'is_selectable' => true,
'business_process_id' => $businessProcess->id ?? null,
]);
$leadStatuses = [];
foreach ($statuses as $status) {
// Upsert all statuses for the team.
$status = $this->config->stages()->updateOrCreate([
'crm_provider_id' => $status['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($status['name'], 0, 50),
'label' => mb_strimwidth($status['name'], 0, 191),
'type' => Stage::TYPE_LEAD,
'sequence' => $status['order'],
'is_selectable' => true,
]);
if ($missingStageName && $missingStageName === $status['id']) {
$missingStage = $status;
}
$leadStatuses[] = $status['id'];
}
$businessProcess->stages()->sync($leadStatuses);
}
} else {
// We create a business process to contain the pipeline, and later store all stages against it.
foreach ($this->copperClient->pipelines()->all() as $pipeline) {
$businessProcess = $this->config->businessProcesses()->updateOrCreate([
'crm_provider_id' => $pipeline['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($pipeline['name'], 0, 150),
'type' => BusinessProcess::TYPE_OPPORTUNITY,
'is_selectable' => true,
]);
// A record type is really a clone of the business process, used to store which record uses which pipeline.
$this->config->recordTypes()->updateOrCreate([
'crm_provider_id' => $pipeline['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($pipeline['name'], 0, 150),
'is_selectable' => true,
'business_process_id' => $businessProcess->id ?? null,
]);
$stages = [];
foreach ($pipeline['stages'] as $i => $dealStage) {
// Upsert all stages for the team.
$stage = $this->config->stages()->updateOrCreate([
'crm_provider_id' => $dealStage['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($dealStage['name'], 0, 50),
'label' => mb_strimwidth($dealStage['name'], 0, 191),
'type' => Stage::TYPE_OPPORTUNITY,
'sequence' => $i,
'is_selectable' => true,
'probability' => $dealStage['win_probability'],
]);
if ($missingStageName && $missingStageName === $dealStage['id']) {
$missingStage = $stage;
}
$stages[] = $stage['id'];
}
$businessProcess->stages()->sync($stages);
}
}
}
return $missingStage;
}
/**
* @inheritdoc
*/
public function syncLeads(Carbon $since, ?Carbon $to = null, ?string $crmProfileId = null): int
{
$syncCount = 0;
$cpLeads = $this->requestWithExceptionHandling(
fn () => $this->copperClient->leads()->paginate([
'minimum_modified_date' => $since->timestamp,
'sort_by' => 'date_modified',
])
);
foreach ($cpLeads as $cpLead) {
// Only sync if previously imported.
if ($this->hasLead($cpLead['id']) === false) {
$this->importLead($cpLead);
$syncCount++;
}
}
return $syncCount;
}
/**
* @inheritdoc
*/
public function syncLead(string $crmId): ?Lead
{
$cpLead = $this->requestWithExceptionHandling(
fn () => $this->copperClient->leads()->find($crmId)
);
if (\count($cpLead) === 0) {
return null;
}
return $this->importLead($cpLead);
}
/**
* @inheritdoc
*/
private function importLead($crmData): ?Lead
{
$countryCode = null;
if (! empty($crmData['address']) && isset($crmData['address']['country'])) {
$countryCode = $this->convertCountryNameToCode($crmData['address']['country']);
}
$mobilePhone = $parsedNumber = null;
if (! empty($crmData['phone_numbers'])) {
foreach ($crmData['phone_numbers'] as $phoneNumber) {
if ($phoneNumber['category'] === 'mobile') {
$mobilePhone = $this->buildContactMobilePhone($countryCode, $phoneNumber['number']);
} else {
$parsedNumber = $this->buildContactPhone($countryCode, $phoneNumber['number']);
}
}
}
$name = $crmData['name'];
if (empty($crmData['company_name'])) {
$company = 'Unknown';
} else {
$company = mb_strimwidth($crmData['company_name'], 0, 191);
}
$domain = null;
if (empty($crmData['websites']) === false) {
$domain = mb_strimwidth($crmData['websites'][0]['url'], 0, 191);
$domain = StringUtil::resolveDomain($domain);
}
$email = null;
if (empty($crmData['email']) === false) {
$email = mb_strimwidth($crmData['email']['email'], 0, 80);
}
$stage = $this->config
->stages()
->where('crm_provider_id', (string) $crmData['status_id'])
->where('type', Stage::TYPE_LEAD)
->first();
if ($stage === null) {
// Import it.
$stage = $this->importStages([Stage::TYPE_LEAD], $crmData['status_id']);
}
if (! $stage instanceof Stage) {
return null;
}
$profile = $this->config->profiles()->where('crm_provider_id', (string) $crmData['assignee_id'])->first();
$data = [
'team_id' => $this->team->id,
'user_id' => $profile ? $profile->user_id : null,
'owner_id' => $crmData['assignee_id'],
'name' => mb_strimwidth($name, 0, 191),
'company' => $company,
'domain' => $domain,
'title' => $crmData['title'] ? mb_strimwidth($crmData['title'], 0, 128) : null,
'email' => $email,
'phone' => $parsedNumber['phone'] ?? null,
'ext' => $parsedNumber['ext'] ?? null,
'mobile_phone' => $mobilePhone,
'stage_id' => $stage->id,
'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(
crmConfiguration: $this->config,
crmProviderId: $crmData['id'],
modelType: Lead::class,
fileName: $crmData['id'],
avatarText: $crmData['name']
),
'country_code' => $countryCode,
'remotely_created_at' => $crmData['date_created'],
];
return $this->config->leads()->updateOrCreate(['crm_provider_id' => (string) $crmData['id']], $data);
}
/**
* @inheritdoc
*/
public function syncAccounts(Carbon $since, ?Carbon $to = null): int
{
$syncCount = 0;
try {
$cpAccounts = $this->copperClient->accounts()->paginate([
'minimum_modified_date' => $since->timestamp,
'sort_by' => 'date_modified',
]);
foreach ($cpAccounts as $cpAccount) {
// Only sync if previously imported.
if ($this->hasAccount($cpAccount['id'])) {
$this->importAccount($cpAccount);
$syncCount++;
}
}
} catch (\Exception $exception) {
// Do nothing for now.
}
return $syncCount;
}
/**
* @inheritdoc
*/
public function syncAccount(string $crmId): ?Account
{
$cpAccount = $this->copperClient->accounts()->find($crmId);
if (empty($cpAccount)) {
return null;
}
return $this->importAccount($cpAccount);
}
/**
* @inheritdoc
*/
private function importAccount($crmData): Account
{
$countryCode = null;
if (empty($crmData['address']) === false && $crmData['address']['country']) {
$countryCode = $this->convertCountryNameToCode($crmData['address']['country']);
}
$name = $crmData['name'];
$parsedNumber = null;
foreach ($crmData['phone_numbers'] as $phoneNumber) {
$parsedNumber = parsePhoneNumber($countryCode, $phoneNumber['number']);
}
$domain = null;
if (empty($crmData['websites']) === false) {
$domain = mb_strimwidth($crmData['websites'][0]['url'], 0, 191);
$domain = StringUtil::resolveDomain($domain);
}
$profile = $this->config->profiles()->where('crm_provider_id', (string) $crmData['assignee_id'])->first();
$data = [
'team_id' => $this->team->id,
'user_id' => $profile ? $profile->user_id : null,
'owner_id' => $crmData['assignee_id'],
'name' => mb_strimwidth($name, 0, 191),
'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(
crmConfiguration: $this->config,
crmProviderId: $crmData['id'],
modelType: Account::class,
fileName: $crmData['id'],
avatarText: $crmData['name']
),
'country_code' => $countryCode,
'domain' => $domain,
'phone' => $parsedNumber['phone'] ?? null,
'ext' => $parsedNumber['ext'] ?? null,
'remotely_created_at' => $crmData['date_created'],
];
return $this->config->accounts()->updateOrCreate(['crm_provider_id' => (string) $crmData['id']], $data);
}
public function syncOpportunities(array $parameters, ?string $strategy = null): int
{
$strategyResolver = app(OpportunitySyncStrategyResolver::class, [
'client' => $this->copperClient,
]);
$strategies = $strategyResolver->getStrategies($this->config, $strategy);
$syncCount = 0;
try {
foreach ($strategies as $syncStrategy) {
foreach ($syncStrategy->fetchOpportunities($parameters) as $opportunityData) {
$this->importOpportunity($opportunityData);
$syncCount++;
}
}
} catch (\Exception $exception) {
// Do nothing for now.
$this->logger->error(self::OPPORTUNITY_IMPORT_FAILED_ERROR_MESSAGE, [
'error' => $exception->getMessage(),
]);
}
return $syncCount;
}
public function syncOpportunity(string $crmId): ?Opportunity
{
$strategyResolver = app(OpportunitySyncStrategyResolver::class, [
'client' => $this->copperClient,
]);
$strategy = $strategyResolver->resolve(
$this->config,
OpportunitySyncStrategyResolver::SINGLE_SYNC_OPPORTUNITY_STRATEGY
);
$parameters['crm_id'] = $crmId;
$cpOpportunity = $strategy->fetchOpportunities($parameters);
return $this->importOpportunity($cpOpportunity);
}
private function importOpportunity($crmData): ?Opportunity
{
$companyId = (string) $crmData['company_id'];
if (empty($companyId)) {
$this->logger->error(self::OPPORTUNITY_IMPORT_FAILED_ERROR_MESSAGE, [
'error' => 'No company id',
]);
return null;
}
$account = $this->config->accounts()->where('crm_provider_id', $companyId)->first();
if ($account === null) {
$account = $this->syncAccount($companyId);
}
$profile = $this->config->profiles()->where('crm_provider_id', (string) $crmData['assignee_id'])->first();
$businessProcess = $this->config
->businessProcesses()
->where('crm_provider_id', (string) $crmData['pipeline_id'])
->first();
if ($businessProcess === null) {
// It is possible for some deals to not be in a pipeline.
$this->logger->warning(self::OPPORTUNITY_IMPORT_FAILED_ERROR_MESSAGE, [
'error' => 'Missing pipeline',
]);
return null;
}
$stage = $businessProcess
->stages()
->where('crm_provider_id', (string) $crmData['pipeline_stage_id'])
->where('type', Stage::TYPE_OPPORTUNITY)
->first();
if ($stage === null) {
$this->logger->info('Stage not found, importing', [
'pipeline_id' => $crmData['pipeline_id'],
'stage_id' => $crmData['pipeline_stage_id'],
]);
$stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $crmData['pipeline_stage_id']);
}
$recordType = $businessProcess->recordTypes()->first();
$closeDate = null;
$isClosed = $crmData['status'] !== self::OPPORTUNITY_STATUS_OPEN;
if ($crmData['close_date']) {
$closeDate = Carbon::parse($crmData['close_date'])->format('Y-m-d');
}
$data = [
'team_id' => $this->team->getId(),
'account_id' => $account->id,
'user_id' => $profile->user_id ?? null,
'owner_id' => $crmData['assignee_id'],
'name' => mb_strimwidth($crmData['name'], 0, 128),
'value' => $crmData['monetary_value'],
'currency_code' => CurrencyFormatter::formatCode($crmData['monetary_unit']),
'close_date' => $closeDate,
'is_closed' => $isClosed,
'is_won' => $crmData['status'] === 'Won',
'stage_id' => $stage->id,
'record_type_id' => $recordType->id ?? null,
'remotely_created_at' => $crmData['date_created'],
];
/** @var Opportunity $opportunity */
$opportunity = $this->config
->opportunities()
->updateOrCreate(['crm_provider_id' => (string) $crmData['id']], $data)
;
// import external fields into crm_field_data if present
$dealInsightsFields = $this->getOpportunitySyncableFields();
$this->importOpportunityCrmFieldData($crmData, $dealInsightsFields, $opportunity->id);
return $opportunity;
}
/**
* @param array<string, mixed> $opportunityData
* @param string[] $dealInsightsFields
*/
public function importOpportunityCrmFieldData(
array $opportunityData,
array $dealInsightsFields,
int $opportunityId
): void {
foreach ($dealInsightsFields as $fieldName) {
$field = $this->config->fields()->with('entities')
->where('crm_provider_id', $fieldName)
->where('object_type', Field::OBJECT_OPPORTUNITY)
->first();
if (! $field instanceof Field) {
$this->logger->warning('Field not found', [
'field' => $fieldName,
'opportunity_id' => $opportunityId,
]);
continue;
}
$entity = $field->getEntities()->first();
if (! $entity instanceof LayoutEntity) {
continue;
}
$fieldValue = $this->extractFieldValue($opportunityData, $fieldName);
$normalizedValue = $this->normalizeValue($field->getType(), (string) $fieldValue, true);
$field->data()
->updateOrCreate([
'crm_layout_entity_id' => $entity->id,
'crm_field_id' => $field->id,
'object_type' => 'opportunity',
'object_id' => $opportunityId,
], ['value' => $normalizedValue])
;
}
}
/**
* @inheritdoc
*/
public function syncContacts(Carbon $since, ?Carbon $to = null): int
{
$syncCount = 0;
try {
$cpContacts = $this->copperClient->contacts()->paginate([
'minimum_interaction_date' => $since->timestamp,
'sort_by' => 'date_modified',
]);
foreach ($cpContacts as $cpContact) {
// Only sync if previously imported.
if ($this->hasContact($cpContact['id'])) {
$this->importContact($cpContact);
$syncCount++;
}
}
} catch (\Exception $exception) {
// Do nothing for now.
throw $exception;
}
return $syncCount;
}
/**
* @inheritdoc
*/
public function syncContact(string $crmId): ?Contact
{
$cpContact = $this->copperClient->contacts()->find($crmId);
if (empty($cpContact)) {
return null;
}
return $this->importContact($cpContact);
}
/**
* @inheritdoc
*/
private function importContact($crmData): Contact
{
$account = null;
if ($crmData['company_id']) {
$account = $this->team
->crm
->accounts()
->where('crm_provider_id', (string) $crmData['company_id'])
->first();
if ($account === null) {
$account = $this->syncAccount($crmData['company_id']);
}
}
$countryCode = null;
if (empty($crmData['address']) === false && $crmData['address']['country']) {
$countryCode = $this->convertCountryNameToCode($crmData['address']['country']);
}
$mobilePhone = $parsedNumber = null;
foreach ($crmData['phone_numbers'] as $phoneNumber) {
if ($phoneNumber['category'] === 'mobile') {
$mobilePhone = $this->buildContactMobilePhone($countryCode, $phoneNumber['number']);
} else {
$parsedNumber = $this->buildContactPhone($countryCode, $phoneNumber['number']);
}
}
$email = null;
if (empty($crmData['emails']) === false) {
$email = mb_strimwidth($crmData['emails'][0]['email'], 0, 80);
}
$profile = $this->config->profiles()->where('crm_provider_id', (string) $crmData['assignee_id'])->first();
$data = [
'team_id' => $this->team->id,
'account_id' => $account->id ?? null,
'user_id' => $profile ? $profile->user_id : null,
'owner_id' => $crmData['assignee_id'],
'name' => $crmData['name'],
'email' => $email,
'phone' => $parsedNumber['phone'] ?? null,
'mobile_phone' => $mobilePhone ?? null,
'ext' => $parsedNumber['ext'] ?? null,
'title' => $crmData['title'],
'country_code' => $countryCode,
'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(
crmConfiguration: $this->config,
crmProviderId: $crmData['id'],
modelType: Contact::class,
fileName: $crmData['id'],
avatarText: $crmData['name']
),
'remotely_created_at' => $crmData['date_created'],
];
return $this->config->contacts()->updateOrCreate(['crm_provider_id' => (string) $crmData['id']], $data);
}
private function buildContactPhone(?string $countryCode, ?string $number): ?array
{
if ($number) {
// Trim to our width and attempt to parse it.
$number = mb_strimwidth($number, 0, 25);
$parsedNumber = parsePhoneNumber($countryCode, $number);
} else {
$parsedNumber = [];
}
return $parsedNumber;
}
private function buildContactMobilePhone(?string $countryCode, ?string $number): ?string
{
return $number ? mb_strimwidth(phone_e164($countryCode, $number), 0, 25) : null;
}
/**
* @inheritdoc
*/
public function syncOrganization(): void
{
$organization = $this->copperClient->organisation()->all();
if ($this->config->crm_provider_id !== $organization['id']) {
$this->config->update(['crm_provider_id' => $organization['id']]);
}
}
private function syncCustomFields(): void
{
foreach ($this->getFieldTypes() as $fieldType) {
$currentFields = $this->getFields($fieldType);
foreach ($currentFields as $field) {
/** @var Field $field */
$field = $this->config->fields()->updateOrCreate([
'crm_provider_id' => $field['name'],
'object_type' => $fieldType,
], [
'label' => mb_strimwidth($field['label'], 0, Field::LABEL_MAX_LENGTH),
'type' => $field['type'],
]);
if ($field->getType() === Field::TYPE_PICKLIST) {
$this->importPicklistValues($field);
}
}
}
$this->config->save();
}
protected function getFieldTypes(): array
{
return [
parent::OBJECT_TASK,
parent::OBJECT_LEAD,
parent::OBJECT_ACCOUNT,
parent::OBJECT_CONTACT,
parent::OBJECT_OPPORTUNITY,
];
}
/**
* @inheritdoc
*/
public function syncProfiles(?User $userToSearch = null): ?Profile
{
$profiles = $this->copperClient->users()->all();
foreach ($profiles as $profile) {
$user = $this->userRepository->findByTeamAndEmailAddress($this->team, $profile['email'], onlyActive: true);
// Only import profiles for users we have locally.
if ($user) {
$profile = $user->crmProfile()->updateOrCreate([
'crm_configuration_id' => $this->config->id,
'crm_provider_id' => $profile['id'],
]);
if ($userToSearch && $userToSearch->id === $user->id) {
return $profile;
}
}
}
return null;
}
/**
* @inheritdoc
*/
public function syncProfileFields(): void
{
$fieldTypes = [
'task',
'lead',
'account',
'contact',
'opportunity',
];
foreach ($fieldTypes as $fieldType) {
try {
$currentFields = $this->getFields(ucfirst($fieldType));
$newFields = array_column($currentFields, 'name');
$changedFields = array_merge(
array_diff($this->profile->getFieldsAsArray($fieldType), $newFields),
array_diff($newFields, $this->profile->getFieldsAsArray($fieldType))
);
if (\count($changedFields) > 0) {
$fields = implode(',', $newFields);
$this->logger->debug(
ucfirst($fieldType)
. ' fields changed from '
. $this->profile->{$fieldType . '_fields'}
. ' to: '
. $fields
);
$this->profile->{$fieldType . '_fields'} = $fields;
}
} catch (\Exception $exception) {
$this->logger->error('No access to ' . $fieldType . ' object, skipping...');
continue;
}
}
$this->profile->save();
}
/**
* @inheritdoc
*/
public function find(string $name, array $scopes): array
{
$count = $this->limit ?? 20;
$offset = $this->offset ?? 0;
$scopeValues = array_map(fn ($scope) => is_string($scope) ? $scope : $scope->value, $scopes);
$cacheKey = sprintf('copper-search-%d-%s-%s-%d-%d', $this->team->id, $name, implode('-', $scopeValues), $count, $offset);
return Cache::remember($cacheKey, 300, function () use ($name, $scopes, $count, $offset) {
// Try local search first
$localResults = $this->searchLocally($name, $scopes, $count, $offset);
// If no local results, fall back to remote API search
if (empty($localResults)) {
return $this->searchRemotely($name, $scopes, $count, $offset);
}
return $localResults;
});
}
/**
* @inheritdoc
*/
public function findOpportunities(?string $crmAccountId, ?string $crmContactId, ?int $userId = null): array
{
$data = [];
$ownerData = [];
$ownerId = null;
$contact = null;
$account = null;
if ($crmAccountId) {
$account = $this->config->accounts()->where('crm_provider_id', $crmAccountId)->first();
if ($account === null) {
$account = $this->syncAccount($crmAccountId);
}
}
if ($crmContactId) {
$contact = $this->config->contacts()->where('crm_provider_id', $crmContactId)->first();
if ($contact === null) {
$contact = $this->syncContact($crmContactId);
}
}
if ($contact || $account) {
if ($contact && $account === null) {
$account = $contact->account;
}
if ($account === null) {
return [];
}
$params = [
'company_ids' => [$account->crm_provider_id],
'status_ids' => [0], // The integer values are 0, 1, 2, 3, for "Open", "Won", "Lost", and "Abandoned".
'sort_by' => 'date_modified',
'sort_direction' => 'desc',
];
switch ($this->config->opportunity_assignment_rule) {
case Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_UPDATED:
$params['sort_by'] = 'date_modified';
$params['sort_direction'] = 'desc';
break;
case Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_CREATED:
$params['sort_by'] = 'date_created';
$params['sort_direction'] = 'desc';
break;
case Configuration::OPP_ASSIGNMENT_ALL_OPEN_OLDEST_CREATED:
$params['sort_by'] = 'date_created';
$params['sort_direction'] = 'asc';
break;
case Configuration::OPP_ASSIGNMENT_ALL_RECENTLY_UPDATED:
$params['sort_by'] = 'date_modified';
$params['sort_direction'] = 'desc';
$params['status_ids'] = [0, 1, 2, 3];
}
$cpOpportunities = $this->copperClient->opportunities()->search($params);
if ($userId) {
$profileRepository = app(ProfileRepository::class);
$profile = $profileRepository->findProfileByUserId($this->config, $userId);
$ownerId = $profile instanceof Profile ? $profile->getCrmProviderId() : null;
}
foreach ($cpOpportunities as $cpOpportunity) {
$businessProcess = $this->config
->businessProcesses()
->where('crm_provider_id', (string) $cpOpportunity['pipeline_id'])
->first();
if ($businessProcess === null) {
// Import it.
$businessProcess = $this->importStages(
[Stage::TYPE_OPPORTUNITY],
$cpOpportunity['pipeline_stage_id']
);
}
$stage = $businessProcess
->stages()
->where('crm_provider_id', (string) $cpOpportunity['pipeline_stage_id'])
->where('type', Stage::TYPE_OPPORTUNITY)
->first();
if ($stage === null) {
// Import it.
$stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $cpOpportunity['pipeline_stage_id']);
}
$recordType = $businessProcess->recordTypes()->first();
$record = [
'crmId' => $cpOpportunity['id'],
'name' => $cpOpportunity['name'],
'value' => currency_format($cpOpportunity['monetary_value'], $cpOpportunity['monetary_unit']),
'won' => $cpOpportunity['status'] === 'Won',
'stage' => [
'id' => $stage->id_string,
'name' => $stage->name,
],
'recordType' => [
'id' => $recordType->id_string,
'name' => $recordType->name,
],
];
if ($ownerId && isset($cpOpportunity['assignee_id']) && $cpOpportunity['assignee_id'] === $ownerId) {
$ownerData[] = $record;
}
$data[] = $record;
}
}
if (! empty($ownerData)) {
return $ownerData;
}
return $data;
}
/**
* @inheritdoc
*/
public function getTasks(string $objectType, string $objectId, ?string $opportunityId): array
{
$data = $cpTasks = [];
switch ($objectType) {
case 'lead':
$cpTasks = $this->requestWithExceptionHandling(
fn () => $this->copperClient->leads()->relatedTasks($objectId)
);
break;
case 'account':
$cpTasks = $this->copperClient->accounts()->relatedTasks($objectId);
break;
case 'contact':
if ($opportunityId) {
$cpTasks = $this->copperClient->opportunities()->relatedTasks($opportunityId);
} elseif ($objectType === 'contact') {
$cpTasks = $this->copperClient->contacts()->relatedTasks($objectId);
}
break;
}
foreach ($cpTasks as $cpTask) {
// We only get the ID itself returned, so pull out the full object.
try {
$cpTask = $this->copperClient->tasks()->find($cpTask['id']);
} catch (Exception $exception) {
$this->logger->error('[Copper] Error fetching task', [
'resource_id' => $cpTask['id'],
'error' => $exception->getMessage(),
]);
continue;
}
// Only allow open tasks.
if ($cpTask['status'] === 'Open') {
$data[] = [
'crmId' => $cpTask['id'],
'subject' => $cpTask['name'],
'due' => $cpTask['due_date']
? Carbon::createFromTimestamp($cpTask['due_date'])->format('Y-m-d\TH:i:sO')
: null,
'type' => null,
];
}
}
return $data;
}
/**
* Try to find email address in CRM service
*
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
*}
*/
public function matchExactlyByEmail(string $email, ?int $userId = null): ?array
{
$cpObjectType = 'contact';
$cpObjects = $this->copperClient->contacts()->search([
'emails' => $email,
'sort_by' => 'date_modified',
'sort_direction' => 'desc',
]);
if (empty($cpObjects)) {
$cpObjectType = 'lead';
$cpObjects = $this->requestWithExceptionHandling(
fn () => $this->copperClient->leads()->search([
'emails' => ...
|
45363
|
NULL
|
NULL
|
NULL
|
|
45366
|
1625
|
20
|
2026-05-14T14:32:58.972558+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-14/1778 /Users/lukas/.screenpipe/data/data/2026-05-14/1778769178972_m1.jpg...
|
iTerm2
|
NULL
|
1
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
PhpStormFileEditViewNavigateCodeLaravelRefactorRun PhpStormFileEditViewNavigateCodeLaravelRefactorRunToolsGitWindowHelp•FV faVsco.jsv* #12066 on JY-20725-handle-HS-search-rate-limit k vabl19HandleHubspotRateLimitTest100% C8• Thu 14 May 17:32:58* :QProjectv= custom.logAutomatedReportGenerated.phpTrackAutomatedReportGeneratedEvent.phpPlaybackController.php© FieldDefinitions.phpUserAutomatedReportsController.phpPlanhatService.phpAutomatedReportResult.phpSendReportJob.php=laravel.log© FieldValueConverter.php• DeleteCrmEntityTrait.phpDeleteAccountJob.php© ImportActivityTypes.phpT WriteCrmTrait.phpDecorateActivity.phpSF [jiminny@localhost]© Service.php> D CrmObjects© Salesforce/Service.php© LogActivityTrait.phpPipedrive/Service.php© Close/Service.phpCopper/Service.php XA HS_local [jiminny@localho:W• DecorateActivity© PlainTextDecorateActivity.phpT ActivityPlaybookTrait.php© CrmHelperRepository.phpAccountController.php4 console [QAI PROD] X18© BaseDecorateActivity.php© PlainTextDecorateActivity.plT IntegrationAppTrait.php.env.stagingE.env© DetachActivityObject.phpRematchActivityOnCrmObjectDetach.php4 console [PROD]> C Dummy© MatchActivityCrmData.php© Client.php© HubspotPaginationService.php© HandleHubspotRateLimit.phpA console (EU]v Helpers52class Service extends BaseService implementsA11 A72 X1 X4DGo jiminnyT ActivityPlaybookTrait.php1664private function buildCallPayload(Activity $activity): Activity© Arraylterator.php1667if (Sactivity->crm_provider_id) {|084143 ×4 л215T ConnectionStateTrait.php1668•m_layout_id =ExtractSurround// E : Logged under the existing Task (not Activity).216© CrmHelperRepository.php2,1661,66799,661669SnewACtIvitylype = stn1s->getCrmType(Sactivity->category);217© FilterJoinedParticipants.php1670if ($newActivityType === null) {+ OpportunitySyncableFieldsT1671218Smessage = sprintf( format: '[Copper] invalid CRM Custom Activity Type: %s', $activity->cal219id = 33;v D Hubspot1672=• AccountSyncStrategy1673220throw new CrmException($message,code: 400);=> OJ Actions |1674221• ContactSyncStrategy1675=222$this->copperClient->tasks()->update($activity->crm_provider_id,> ODTO1676'status' => 'Completed',=223> D Fields= 224id THEN •(own1677'custom_activity_type_id' => SnewActivityType->value,> 0J Journal1678= 225|'details' => Sthis->generateActivityDescription(Sactivity),D Metadata=22616791):v C OpportunitySyncStrategy1680= 227} else {> D Concerns1681228if(Sthis-2getCrmType(Sactivitx-2categocy2m=nullnf© HubspotLastModifiedByF1682229Smessage = sprintf( format: '[Copper] Invalid CRM Activity Type: %s', Sactivity->category≥© HubspotLastModifiedCrehubspot':1683© HubspotLastModifiedCre1684throw new CrmException($message,231code: 400):. 232© HubspotLastModifiedOpe1685233 V© HubspotLastModifiedSyn= 11512582;1686=© HubspotSingleSyncStrate 1687if ($this->getCrmType($activity->category)->value === null) {234© HubspotSyncStrategyBa:1688$message = sprintf( format:'[Copper) InvalidCRM Activity Type: %s value', Sactivity->cate© HubspotWebhookBatchS1689v [ Pagination1690throw new CrmException($message,code: 400);© HubspotPaginationServic1691=A Daninstinnfanfin nhnWorkspace associated with branch 'JY-20725-handle-HS-search-rate-limit' has been restored // Rollback // Configure...(today 16:17)W Windsurf Teams1667:46 (33 chars)UTF-8Ca 4 spaces...
|
NULL
|
3556893718348779572
|
NULL
|
click
|
ocr
|
NULL
|
PhpStormFileEditViewNavigateCodeLaravelRefactorRun PhpStormFileEditViewNavigateCodeLaravelRefactorRunToolsGitWindowHelp•FV faVsco.jsv* #12066 on JY-20725-handle-HS-search-rate-limit k vabl19HandleHubspotRateLimitTest100% C8• Thu 14 May 17:32:58* :QProjectv= custom.logAutomatedReportGenerated.phpTrackAutomatedReportGeneratedEvent.phpPlaybackController.php© FieldDefinitions.phpUserAutomatedReportsController.phpPlanhatService.phpAutomatedReportResult.phpSendReportJob.php=laravel.log© FieldValueConverter.php• DeleteCrmEntityTrait.phpDeleteAccountJob.php© ImportActivityTypes.phpT WriteCrmTrait.phpDecorateActivity.phpSF [jiminny@localhost]© Service.php> D CrmObjects© Salesforce/Service.php© LogActivityTrait.phpPipedrive/Service.php© Close/Service.phpCopper/Service.php XA HS_local [jiminny@localho:W• DecorateActivity© PlainTextDecorateActivity.phpT ActivityPlaybookTrait.php© CrmHelperRepository.phpAccountController.php4 console [QAI PROD] X18© BaseDecorateActivity.php© PlainTextDecorateActivity.plT IntegrationAppTrait.php.env.stagingE.env© DetachActivityObject.phpRematchActivityOnCrmObjectDetach.php4 console [PROD]> C Dummy© MatchActivityCrmData.php© Client.php© HubspotPaginationService.php© HandleHubspotRateLimit.phpA console (EU]v Helpers52class Service extends BaseService implementsA11 A72 X1 X4DGo jiminnyT ActivityPlaybookTrait.php1664private function buildCallPayload(Activity $activity): Activity© Arraylterator.php1667if (Sactivity->crm_provider_id) {|084143 ×4 л215T ConnectionStateTrait.php1668•m_layout_id =ExtractSurround// E : Logged under the existing Task (not Activity).216© CrmHelperRepository.php2,1661,66799,661669SnewACtIvitylype = stn1s->getCrmType(Sactivity->category);217© FilterJoinedParticipants.php1670if ($newActivityType === null) {+ OpportunitySyncableFieldsT1671218Smessage = sprintf( format: '[Copper] invalid CRM Custom Activity Type: %s', $activity->cal219id = 33;v D Hubspot1672=• AccountSyncStrategy1673220throw new CrmException($message,code: 400);=> OJ Actions |1674221• ContactSyncStrategy1675=222$this->copperClient->tasks()->update($activity->crm_provider_id,> ODTO1676'status' => 'Completed',=223> D Fields= 224id THEN •(own1677'custom_activity_type_id' => SnewActivityType->value,> 0J Journal1678= 225|'details' => Sthis->generateActivityDescription(Sactivity),D Metadata=22616791):v C OpportunitySyncStrategy1680= 227} else {> D Concerns1681228if(Sthis-2getCrmType(Sactivitx-2categocy2m=nullnf© HubspotLastModifiedByF1682229Smessage = sprintf( format: '[Copper] Invalid CRM Activity Type: %s', Sactivity->category≥© HubspotLastModifiedCrehubspot':1683© HubspotLastModifiedCre1684throw new CrmException($message,231code: 400):. 232© HubspotLastModifiedOpe1685233 V© HubspotLastModifiedSyn= 11512582;1686=© HubspotSingleSyncStrate 1687if ($this->getCrmType($activity->category)->value === null) {234© HubspotSyncStrategyBa:1688$message = sprintf( format:'[Copper) InvalidCRM Activity Type: %s value', Sactivity->cate© HubspotWebhookBatchS1689v [ Pagination1690throw new CrmException($message,code: 400);© HubspotPaginationServic1691=A Daninstinnfanfin nhnWorkspace associated with branch 'JY-20725-handle-HS-search-rate-limit' has been restored // Rollback // Configure...(today 16:17)W Windsurf Teams1667:46 (33 chars)UTF-8Ca 4 spaces...
|
45364
|
NULL
|
NULL
|
NULL
|
|
45367
|
1626
|
13
|
2026-05-14T14:32:59.007462+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-14/1778 /Users/lukas/.screenpipe/data/data/2026-05-14/1778769179007_m2.jpg...
|
PhpStorm
|
faVsco.js – Copper/Service.php
|
1
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
ActivityFilesLaterJiminny... ~@ jiminny-x-integrat ActivityFilesLaterJiminny... ~@ jiminny-x-integrati& platform-inner-team© Channels# ai-chapter# alertsi backend# bugscontusion-cllnia# curiosity_lab# engineering# general# jiminny-bg# platform-tickets# product_launches# random# releases# sofia-office# support# thank-yous# the people of iimi..• Direct messages8. A...• A3 62P. Galya Dimitrova Mvasil VasilevA. Stefka Stoyanova%: Todor Stamatovf. Mario GeorgievP. Nikolay Ivanov2o James Graham "2. Stoyan Tanev. Steliyan Georgiev. Petko Kashinski*. Lukas Kovali...a: Apps® ToastS lira Gloud6 Huddle with Aneliya Angelova& R. Aneliya Angelova •• Messagest Add canvasur FilesAneliya Angelova 2:30 PMЛукаш, само при Сейлсфорс и Хубспот се синкваха активити типовете, когато се направи плейбук,нали?пои останалите скМі тоябва оъчно ла се въвелат.Lukas Kovalik 2:47 PMтряова да се за всички, някьде не се ли попьлвапри зохо май беше hardcoded но май и там си връщаха две категорииAneliya Angelova @ 4:00 PMЛукаш имаш ли време да се чуем за тестването на https://jiminny.atlassian.net/browse/JY-20725X Preview insackt[HubSpot] Optimise CRM rematctOpenReady for QA- MediumAA Aneliya AngelovaAs of today at 4:00 PM RetreshOpen in Jira+ SummariseLukas Kovalik 4:02 PMzean-iurwiniareelYou joined the huddle LIVE 4:06 PMAneliva Angelova is here toolAneliya Angelova 4:44 PM11512582Lukas Kovalik O 5:16 PMcurl --location "httns:ani.hubani.com/crm/v3/obiects/contacts/search--header 'Content-Type: application/json'\--header "Authorization: BearerC|Kvma?iMx7OINOM8kOFwrAowAcAkUAhIR24?A05D?xiVrIYMILuP6SA01Kwcmhr=n.v2 hytorDniMvDalYck-CMalovolNOM9kOFwrAUAcrkcaws-ThwRARIRAOF-ATFSAOFRAO-ROIEBAQUBEggBAQEBAYICFIґ7Au8O2Nwal9YW2CbCCxYvwngmSgNldTFSAFoAYABoAHAAeAA'\--data "{ "limit", 13'Message Aneliva Angelova+ AaIAl Notes: OffLeave~ (Q SearchDate ModifiedYesterdav at 13.36Yesterday at 13:36Yesterday at 13.30Yesterday at 13:34Yesterday at 13:34resterday at 15.5sYesterdav at 13:3kYesterday at 13:32Yesterday at 13.31Yesterday at 13:31Yesterday at 13:30resterday at 15.30Yesterdav at 13:29Yesterday at 13:29Yesterday at 13:28Yesterday at 13:28Yesterday at 13:27resterday at 13-40Yesterdav at 13:26Yesterday at 13:25Yesterday at 13:25Yesterday at 13:24Yesterday at 13:24Yesterday at 13.22Yesterdav at 13:21Yesterday at 13:20Yesterday at 13:19Yesterday at 13.18Yecterdav at 12:19Yesterday at 13-1/Yesterdav at 13:16Yesterday at 13:16Yesterday at 13:15Yecterdav at 12:1/Yesterday at 13:14Yesterday at 13.13Yecterdav at 13:10Yesterday at 13:12Yesterday at 13:11Yesterday at 13:11Yesterday at 13:10Yesterday at 13.10Yecterdav at 12:091Yesterday at 13:09Yesterdav at 13:0823K:MPEG-4 movie9KBMPEG-4 movie13 KB16 Kb17 KEMPEG-4 movieMPEG-4 movie29 KB6 KBMPE0"4 movie12 K:9 KBMPEG-4 movie7 KBMP2G-4 movie37 KBMPEG-4 movie10 KBI KDMPEG"4 movie8 KE9KBMPEG-4 movie8 KB72 KBMPEG-4 movie14 KBMPEG-4 movie13 KB9KBMPEG-4 movie18 KEMPEG-4 movie12 KBMPEG-4 movie10 KB16 K:MPEG-4 movie6 KBMPEG-4 movie12 KBMPEG-4 movie23 KBMPEG-4 movie8 KBMPEG-4 movie6 KBMPEG-4 movie16 K:11 KBMPEG-4 movie20 KBMPEG-4 movie21 KPMPEG-4 movie10 KB MPEG-4 movie7 KBMPEG-4 movie5 KB1MPEG-4 movie11 KBMPEG-4 movie26 KB111 KB102 KRMPEG-4 movieMDEG-A movie88 KB59 KBMPEG-4 movie08 KBIMPEG-A movie97 KB MPEG-4 movie66 KB44 KB02 KpMPEG-4 movieMPEG-4 movie78 KB50 KBMPEG-4 movie58 KГMPEG-A movid27 KBMPEG-4 movie7 KB12K8MPEG-4 movieFavouritesE jiminny© Recents* ApplicationsiCloudiCloud Drive228 Sync folderQ DXP4800PLUS-B5FA@ Network|• CRMI• Orange• Red• Yellow• Green• Purple•) All lags..lohlDownloadsNameLoom.pkgAlfred copv.alfredoreterencesB KeychronAssist-1.0.2 (1).dmgA Keychron Assist-1.0.2.dmgmazanoke-images-yWJo.zioPhotos-3-001.zip→mage U.loge1 Orioninstaller.dmaimage.jpg- image (2).1pc• ПО-22221726037035-004-001_ORGES.pdf• %D0%9F%D0%9E-22221726037035-004-001_archive.zipПO-22221726037035-004-001_archive (1).zipD report(2) xmlAltred copy2.altredoreterences05012026_0000000026574472_ SWIFT_OB70501260015890.pdf27022026_0000000026574472_SWIFT_OB72702260049200.pdf= 03042026 [CREDIT_CARD] SWIFT [CREDIT_CARD].001B reporti) xm=pdt.odipdf-1.pdfD pdf-2.pdf-pdf-5.pd1= ndf-1 ndipdf-3.pdfKoválik Family Tree.gedbitwarden export 20251031122528.isonKoválik Family Tree.zip*macOS Storage_Cleanup.mdal favicon icofirst_aid_notes_complete.docxrenortl2).esvconfig.ymlIteration run Search HS.postman_collection.jsonm licence hettertouchtoolMariusHosting Config.json1ooks-891a6503-bbb7-4b2b-9c3.csv•AlfredwPmazanoko.imnaoc.YWIfана Ковалик.jpg•искане даниел Ковалик..pa• Фактура Март Даниел Ковалик.jpс• Фактура Април Даниел Ковалик.jpg• Dhotac 2.001Q SearchKind00,4 MDinstdlle..dckage55.9 MBAlfred...ferences10,1 MBDisk Image10,1 MBDisk ImageIL MBLiP archive6,6 MBZIP archive2,5 MB PDF Document2,5 MBJreo lmage2.2 MBDisk Image2,2 MB PDF Document2 MBJPEG image1,9 MBJPEG imaqe192 KBPDF Document140 Kb148 KВ148 KB122 KB111 KB94 KBZIP archiveZIP archivelZIP archiveXML documentAlfred...ferencesPDF Document92 KBPDr DocumentPDF Document91 KB91 KB30 KB29 KBXML documentPDF Document28 KB28 KR28 KB27 KB14 KB11 KB6 KB6KB5KR4 KB3 KBPDF DocumentPDE DocumentDocumentJSONICSV DocumentZIP archiveMarkdo…..umentWindo...n imageword ..cumentCSV Document2 KBYAML document1 KBcSV Document928 byteshttlicence183 bytesZero butesJSONAlfred.. ferencesZero bytes1,9 MB1,8 MB17 MB1,7 MBFoldeColdorJPEG imageJPEG ImageJPEG imageJPEG imageColdo1 of 58 selected, 8.5 GB availab!i• Inu 14 May 1/.32:00Date AddedIs Mdl ZUzo dl 19:4530 Jan 2026 at 12:3617 Mar 2026 at 20:2717 Mar 2026 at 20:26Z3 Aor 2020 al 13:0229 Jan 2026 at 15:2019 Dec 2025 at 10:1619 Dec 2025 at 12:238 Aor 2026 at 20:3519 Dec 2025 at 10:2919 Dec 2025 at 12:1819 Dec 2025 at 12:4026 Mar 2026 at 11:2410 May 2026 at 13:5326 Mar 2026 at 11:2426 Mar 2026 at 11:2426 Mar 2026 at 11:2310 May 2026 at 14:3730 Jan 2026 at 12:3713 Feb 2026 at 11:54Z3 Apr 2026 at 13.0823 Aor 2026 at 13:0810 May 2026 at 13:5410 May 2026 at 14:3710 May 2026 at 13:4910 May 2026 at 13:5010 May 2026 at 13:5110 Mav 2026 at 12:51110 May 2026 at 13:5019 Dec 2025 at 11:3431 Oct 2025 at 12:2525 Nov 2025 at 17:596 Mar 2026 at 11.2224 Anr 2026 at 16:5220 Oct 2025 at 11:0218 Mar 2026 at 15:29• Mav 2026 at 11:09129 Oct 2025 at 19:329 May 2026 at 10:0418 Mar 2026 at 11:5510 May 2026 at 13:5712 Jun 2025 at 19:0430 Jan 2026 at 12:3630 Jan 2026 at 12:3616 Oct 2025 aт 16:0123 Apr 2026 at 13:0223 Apr 2026 at 13:0223 Apr 2026 at 13:0222 Anr 2026 at 12:0223 Apr 2026 at 13:0229 Jan 2026 at 15:20...
|
NULL
|
2977166443459692715
|
NULL
|
click
|
ocr
|
NULL
|
ActivityFilesLaterJiminny... ~@ jiminny-x-integrat ActivityFilesLaterJiminny... ~@ jiminny-x-integrati& platform-inner-team© Channels# ai-chapter# alertsi backend# bugscontusion-cllnia# curiosity_lab# engineering# general# jiminny-bg# platform-tickets# product_launches# random# releases# sofia-office# support# thank-yous# the people of iimi..• Direct messages8. A...• A3 62P. Galya Dimitrova Mvasil VasilevA. Stefka Stoyanova%: Todor Stamatovf. Mario GeorgievP. Nikolay Ivanov2o James Graham "2. Stoyan Tanev. Steliyan Georgiev. Petko Kashinski*. Lukas Kovali...a: Apps® ToastS lira Gloud6 Huddle with Aneliya Angelova& R. Aneliya Angelova •• Messagest Add canvasur FilesAneliya Angelova 2:30 PMЛукаш, само при Сейлсфорс и Хубспот се синкваха активити типовете, когато се направи плейбук,нали?пои останалите скМі тоябва оъчно ла се въвелат.Lukas Kovalik 2:47 PMтряова да се за всички, някьде не се ли попьлвапри зохо май беше hardcoded но май и там си връщаха две категорииAneliya Angelova @ 4:00 PMЛукаш имаш ли време да се чуем за тестването на https://jiminny.atlassian.net/browse/JY-20725X Preview insackt[HubSpot] Optimise CRM rematctOpenReady for QA- MediumAA Aneliya AngelovaAs of today at 4:00 PM RetreshOpen in Jira+ SummariseLukas Kovalik 4:02 PMzean-iurwiniareelYou joined the huddle LIVE 4:06 PMAneliva Angelova is here toolAneliya Angelova 4:44 PM11512582Lukas Kovalik O 5:16 PMcurl --location "httns:ani.hubani.com/crm/v3/obiects/contacts/search--header 'Content-Type: application/json'\--header "Authorization: BearerC|Kvma?iMx7OINOM8kOFwrAowAcAkUAhIR24?A05D?xiVrIYMILuP6SA01Kwcmhr=n.v2 hytorDniMvDalYck-CMalovolNOM9kOFwrAUAcrkcaws-ThwRARIRAOF-ATFSAOFRAO-ROIEBAQUBEggBAQEBAYICFIґ7Au8O2Nwal9YW2CbCCxYvwngmSgNldTFSAFoAYABoAHAAeAA'\--data "{ "limit", 13'Message Aneliva Angelova+ AaIAl Notes: OffLeave~ (Q SearchDate ModifiedYesterdav at 13.36Yesterday at 13:36Yesterday at 13.30Yesterday at 13:34Yesterday at 13:34resterday at 15.5sYesterdav at 13:3kYesterday at 13:32Yesterday at 13.31Yesterday at 13:31Yesterday at 13:30resterday at 15.30Yesterdav at 13:29Yesterday at 13:29Yesterday at 13:28Yesterday at 13:28Yesterday at 13:27resterday at 13-40Yesterdav at 13:26Yesterday at 13:25Yesterday at 13:25Yesterday at 13:24Yesterday at 13:24Yesterday at 13.22Yesterdav at 13:21Yesterday at 13:20Yesterday at 13:19Yesterday at 13.18Yecterdav at 12:19Yesterday at 13-1/Yesterdav at 13:16Yesterday at 13:16Yesterday at 13:15Yecterdav at 12:1/Yesterday at 13:14Yesterday at 13.13Yecterdav at 13:10Yesterday at 13:12Yesterday at 13:11Yesterday at 13:11Yesterday at 13:10Yesterday at 13.10Yecterdav at 12:091Yesterday at 13:09Yesterdav at 13:0823K:MPEG-4 movie9KBMPEG-4 movie13 KB16 Kb17 KEMPEG-4 movieMPEG-4 movie29 KB6 KBMPE0"4 movie12 K:9 KBMPEG-4 movie7 KBMP2G-4 movie37 KBMPEG-4 movie10 KBI KDMPEG"4 movie8 KE9KBMPEG-4 movie8 KB72 KBMPEG-4 movie14 KBMPEG-4 movie13 KB9KBMPEG-4 movie18 KEMPEG-4 movie12 KBMPEG-4 movie10 KB16 K:MPEG-4 movie6 KBMPEG-4 movie12 KBMPEG-4 movie23 KBMPEG-4 movie8 KBMPEG-4 movie6 KBMPEG-4 movie16 K:11 KBMPEG-4 movie20 KBMPEG-4 movie21 KPMPEG-4 movie10 KB MPEG-4 movie7 KBMPEG-4 movie5 KB1MPEG-4 movie11 KBMPEG-4 movie26 KB111 KB102 KRMPEG-4 movieMDEG-A movie88 KB59 KBMPEG-4 movie08 KBIMPEG-A movie97 KB MPEG-4 movie66 KB44 KB02 KpMPEG-4 movieMPEG-4 movie78 KB50 KBMPEG-4 movie58 KГMPEG-A movid27 KBMPEG-4 movie7 KB12K8MPEG-4 movieFavouritesE jiminny© Recents* ApplicationsiCloudiCloud Drive228 Sync folderQ DXP4800PLUS-B5FA@ Network|• CRMI• Orange• Red• Yellow• Green• Purple•) All lags..lohlDownloadsNameLoom.pkgAlfred copv.alfredoreterencesB KeychronAssist-1.0.2 (1).dmgA Keychron Assist-1.0.2.dmgmazanoke-images-yWJo.zioPhotos-3-001.zip→mage U.loge1 Orioninstaller.dmaimage.jpg- image (2).1pc• ПО-22221726037035-004-001_ORGES.pdf• %D0%9F%D0%9E-22221726037035-004-001_archive.zipПO-22221726037035-004-001_archive (1).zipD report(2) xmlAltred copy2.altredoreterences05012026_0000000026574472_ SWIFT_OB70501260015890.pdf27022026_0000000026574472_SWIFT_OB72702260049200.pdf= 03042026 [CREDIT_CARD] SWIFT [CREDIT_CARD].001B reporti) xm=pdt.odipdf-1.pdfD pdf-2.pdf-pdf-5.pd1= ndf-1 ndipdf-3.pdfKoválik Family Tree.gedbitwarden export 20251031122528.isonKoválik Family Tree.zip*macOS Storage_Cleanup.mdal favicon icofirst_aid_notes_complete.docxrenortl2).esvconfig.ymlIteration run Search HS.postman_collection.jsonm licence hettertouchtoolMariusHosting Config.json1ooks-891a6503-bbb7-4b2b-9c3.csv•AlfredwPmazanoko.imnaoc.YWIfана Ковалик.jpg•искане даниел Ковалик..pa• Фактура Март Даниел Ковалик.jpс• Фактура Април Даниел Ковалик.jpg• Dhotac 2.001Q SearchKind00,4 MDinstdlle..dckage55.9 MBAlfred...ferences10,1 MBDisk Image10,1 MBDisk ImageIL MBLiP archive6,6 MBZIP archive2,5 MB PDF Document2,5 MBJreo lmage2.2 MBDisk Image2,2 MB PDF Document2 MBJPEG image1,9 MBJPEG imaqe192 KBPDF Document140 Kb148 KВ148 KB122 KB111 KB94 KBZIP archiveZIP archivelZIP archiveXML documentAlfred...ferencesPDF Document92 KBPDr DocumentPDF Document91 KB91 KB30 KB29 KBXML documentPDF Document28 KB28 KR28 KB27 KB14 KB11 KB6 KB6KB5KR4 KB3 KBPDF DocumentPDE DocumentDocumentJSONICSV DocumentZIP archiveMarkdo…..umentWindo...n imageword ..cumentCSV Document2 KBYAML document1 KBcSV Document928 byteshttlicence183 bytesZero butesJSONAlfred.. ferencesZero bytes1,9 MB1,8 MB17 MB1,7 MBFoldeColdorJPEG imageJPEG ImageJPEG imageJPEG imageColdo1 of 58 selected, 8.5 GB availab!i• Inu 14 May 1/.32:00Date AddedIs Mdl ZUzo dl 19:4530 Jan 2026 at 12:3617 Mar 2026 at 20:2717 Mar 2026 at 20:26Z3 Aor 2020 al 13:0229 Jan 2026 at 15:2019 Dec 2025 at 10:1619 Dec 2025 at 12:238 Aor 2026 at 20:3519 Dec 2025 at 10:2919 Dec 2025 at 12:1819 Dec 2025 at 12:4026 Mar 2026 at 11:2410 May 2026 at 13:5326 Mar 2026 at 11:2426 Mar 2026 at 11:2426 Mar 2026 at 11:2310 May 2026 at 14:3730 Jan 2026 at 12:3713 Feb 2026 at 11:54Z3 Apr 2026 at 13.0823 Aor 2026 at 13:0810 May 2026 at 13:5410 May 2026 at 14:3710 May 2026 at 13:4910 May 2026 at 13:5010 May 2026 at 13:5110 Mav 2026 at 12:51110 May 2026 at 13:5019 Dec 2025 at 11:3431 Oct 2025 at 12:2525 Nov 2025 at 17:596 Mar 2026 at 11.2224 Anr 2026 at 16:5220 Oct 2025 at 11:0218 Mar 2026 at 15:29• Mav 2026 at 11:09129 Oct 2025 at 19:329 May 2026 at 10:0418 Mar 2026 at 11:5510 May 2026 at 13:5712 Jun 2025 at 19:0430 Jan 2026 at 12:3630 Jan 2026 at 12:3616 Oct 2025 aт 16:0123 Apr 2026 at 13:0223 Apr 2026 at 13:0223 Apr 2026 at 13:0222 Anr 2026 at 12:0223 Apr 2026 at 13:0229 Jan 2026 at 15:20...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
45368
|
1625
|
21
|
2026-05-14T14:33:00.930241+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-14/1778 /Users/lukas/.screenpipe/data/data/2026-05-14/1778769180930_m1.jpg...
|
iTerm2
|
NULL
|
1
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
PhpStormFileEditViewNavigateCodeLaravelRefactorRun PhpStormFileEditViewNavigateCodeLaravelRefactorRunToolsGitWindowHelp•FV faVsco.js* #12066 on JY-20725-handle-HS-search-rate-limit k vabl19HandleHubspotRateLimitTest100% C8• Thu 14 May 17:33:00* :QProjectv= custom.logAutomatedReportGenerated.phpTrackAutomatedReportGeneratedEvent.phpPlaybackController.php© FieldDefinitions.php© UserAutomatedReportsController.phpPlanhatService.phpAutomatedReportResult.phpSendReportJob.php=laravel.log© FieldValueConverter.php• DeleteCrmEntityTrait.phpDeleteAccountJob.php© ImportActivityTypes.phpT WriteCrmTrait.php© Service.php© DecorateActivity.phpSF [jiminny@localhost]> D CrmObjects© Salesforce/Service.php© LogActivityTrait.phpPipedrive/Service.php© Close/Service.phpCopper/Service.php XA HS_local [jiminny@localho:W• DecorateActivity© PlainTextDecorateActivity.phpT ActivityPlaybookTrait.php© CrmHelperRepository.phpAccountController.php4 console [QAI PROD] X18© BaseDecorateActivity.php© PlainTextDecorateActivity.plT IntegrationAppTrait.php.env.stagingE.env© DetachActivityObject.phpRematchActivityOnCrmObjectDetach.php4 console [PROD]> Dummy© MatchActivityCrmData.php© Client.php© HubspotPaginationService.php© HandleHubspotRateLimit.phpA console (EU]v Helpers52class Service extends BaseService implements811 472 ×1 ×4DGo jiminnyT ActivityPlaybookTrait.php1664private function buildCallPayload(Activity $activity): Activity© Arraylterator.php1667if (Sactivity->crm_provider_id) €084143 ×4 л215T ConnectionStateTrait.php•m_layout_id =1668// The activity should be logged under the existing Task (not Activity).216© CrmHelperRepository.php2,1661,66799,661669SnewActivityType = $this->getCrmType($activity->category);217© FilterJoinedParticipants.php1670if ($newActivityType === null) {+ OpportunitySyncableFieldsT2181671Smessage = sprintf( format: '[Copper] invalid CRM Custom Activity Type: %s', $activity->cal219v D Hubspot1672id = 33;=• AccountSyncStrategy1673220throw new CrmException($message,code: 400);=> @ Actions|1674221• ContactSyncStrategy1675=222$this->copperClient->tasks()->update($activity->crm_provider_id,> ODTO1676'status' => 'Completed',=223› Fields= 224id THEN(own1677'custom_activity_type_id' => $newActivityType-›value,> 0 Journal1678225'details' => Sthis->generateActivityDescription(Sactivity),D Metadata=22616791):v C OpportunitySyncStrategy1680= 227} else {> D Concerns1681if(Sthis-~getCrmType(Sactivitx-2category2mmmullnf228© HubspotLastModifiedByFCascade 8e XICommand &11682Smessage = sprintf( format: '[Copper) InvalidCRM Activi229© HubspotLastModifiedCre1683ategory2230hubspat":© HubspotLastModifiedCre1684throw new CrmException(Smessage,code: 400):231232HubspotLastModifiedOpe1685233 V© HubspotLastModifiedSyn= 11512582;1686=234© HubspotSingleSyncStrate 1687if ($this->getCrmType($activity->category)->value === null) {© HubspotSyncStrategyBa:1688$message = sprintf( format:'[Copper) InvalidCRM Activity Type: %s value', Sactivity->cate© HubspotWebhookBatchS1689v [ Pagination1690throw new CrmException($message,code:400):© HubspotPaginationServic1691A Daninstinnfanfin nhnWorkspace associated with branch 'JY-20725-handle-HS-search-rate-limit' has been restored // Rollback // Configure...(today 16:17)W Windsurf Teams1685:18 (246 chars, 4 line breaks)UTF-8Ca 4 spaces...
|
NULL
|
601328007992106341
|
NULL
|
visual_change
|
ocr
|
NULL
|
PhpStormFileEditViewNavigateCodeLaravelRefactorRun PhpStormFileEditViewNavigateCodeLaravelRefactorRunToolsGitWindowHelp•FV faVsco.js* #12066 on JY-20725-handle-HS-search-rate-limit k vabl19HandleHubspotRateLimitTest100% C8• Thu 14 May 17:33:00* :QProjectv= custom.logAutomatedReportGenerated.phpTrackAutomatedReportGeneratedEvent.phpPlaybackController.php© FieldDefinitions.php© UserAutomatedReportsController.phpPlanhatService.phpAutomatedReportResult.phpSendReportJob.php=laravel.log© FieldValueConverter.php• DeleteCrmEntityTrait.phpDeleteAccountJob.php© ImportActivityTypes.phpT WriteCrmTrait.php© Service.php© DecorateActivity.phpSF [jiminny@localhost]> D CrmObjects© Salesforce/Service.php© LogActivityTrait.phpPipedrive/Service.php© Close/Service.phpCopper/Service.php XA HS_local [jiminny@localho:W• DecorateActivity© PlainTextDecorateActivity.phpT ActivityPlaybookTrait.php© CrmHelperRepository.phpAccountController.php4 console [QAI PROD] X18© BaseDecorateActivity.php© PlainTextDecorateActivity.plT IntegrationAppTrait.php.env.stagingE.env© DetachActivityObject.phpRematchActivityOnCrmObjectDetach.php4 console [PROD]> Dummy© MatchActivityCrmData.php© Client.php© HubspotPaginationService.php© HandleHubspotRateLimit.phpA console (EU]v Helpers52class Service extends BaseService implements811 472 ×1 ×4DGo jiminnyT ActivityPlaybookTrait.php1664private function buildCallPayload(Activity $activity): Activity© Arraylterator.php1667if (Sactivity->crm_provider_id) €084143 ×4 л215T ConnectionStateTrait.php•m_layout_id =1668// The activity should be logged under the existing Task (not Activity).216© CrmHelperRepository.php2,1661,66799,661669SnewActivityType = $this->getCrmType($activity->category);217© FilterJoinedParticipants.php1670if ($newActivityType === null) {+ OpportunitySyncableFieldsT2181671Smessage = sprintf( format: '[Copper] invalid CRM Custom Activity Type: %s', $activity->cal219v D Hubspot1672id = 33;=• AccountSyncStrategy1673220throw new CrmException($message,code: 400);=> @ Actions|1674221• ContactSyncStrategy1675=222$this->copperClient->tasks()->update($activity->crm_provider_id,> ODTO1676'status' => 'Completed',=223› Fields= 224id THEN(own1677'custom_activity_type_id' => $newActivityType-›value,> 0 Journal1678225'details' => Sthis->generateActivityDescription(Sactivity),D Metadata=22616791):v C OpportunitySyncStrategy1680= 227} else {> D Concerns1681if(Sthis-~getCrmType(Sactivitx-2category2mmmullnf228© HubspotLastModifiedByFCascade 8e XICommand &11682Smessage = sprintf( format: '[Copper) InvalidCRM Activi229© HubspotLastModifiedCre1683ategory2230hubspat":© HubspotLastModifiedCre1684throw new CrmException(Smessage,code: 400):231232HubspotLastModifiedOpe1685233 V© HubspotLastModifiedSyn= 11512582;1686=234© HubspotSingleSyncStrate 1687if ($this->getCrmType($activity->category)->value === null) {© HubspotSyncStrategyBa:1688$message = sprintf( format:'[Copper) InvalidCRM Activity Type: %s value', Sactivity->cate© HubspotWebhookBatchS1689v [ Pagination1690throw new CrmException($message,code:400):© HubspotPaginationServic1691A Daninstinnfanfin nhnWorkspace associated with branch 'JY-20725-handle-HS-search-rate-limit' has been restored // Rollback // Configure...(today 16:17)W Windsurf Teams1685:18 (246 chars, 4 line breaks)UTF-8Ca 4 spaces...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
45370
|
1626
|
14
|
2026-05-14T14:33:08.442631+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-14/1778 /Users/lukas/.screenpipe/data/data/2026-05-14/1778769188442_m2.jpg...
|
PhpStorm
|
faVsco.js – Copper/Service.php
|
1
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
#12066 on JY-20725-handle Project: faVsco.js, menu
#12066 on JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
HandleHubspotRateLimitTest...
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"bounds":{"left":0.2962101,"top":1.0,"width":0.03856383,"height":-0.019952059},"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"#12066 on JY-20725-handle-HS-search-rate-limit, menu","depth":5,"bounds":{"left":0.33477393,"top":1.0,"width":0.122340426,"height":-0.019952059},"on_screen":true,"help_text":"Pull request #12066 exists for current branch JY-20725-handle-HS-search-rate-limit, but local branch is out of sync with remote","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"bounds":{"left":0.5731383,"top":1.0,"width":0.011303191,"height":-0.019952059},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"HandleHubspotRateLimitTest","depth":6,"bounds":{"left":0.5884308,"top":1.0,"width":0.076130316,"height":-0.019952059},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
7058342144550237105
|
-7195274138713862717
|
click
|
hybrid
|
NULL
|
Project: faVsco.js, menu
#12066 on JY-20725-handle Project: faVsco.js, menu
#12066 on JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
HandleHubspotRateLimitTest
ActivityFilesLaterJiminny... ~@ jiminny-x-integrati& platform-inner-team© Channels# ai-chapter# alertsi backend# bugscontusion-cllnia# curiosity_lab# engineering# general# jiminny-bg# platform-tickets# product_launches# random# releases# sofia-office# support# thank-yous# the people of iimi..• Direct messages8. A...• A3 62P. Galya Dimitrova Mvasil VasilevA. Stefka Stoyanova%: Todor Stamatovf. Mario GeorgievP. Nikolay Ivanov2o James Graham "2. Stoyan Tanev. Steliyan Georgiev. Petko Kashinski*. Lukas Kovali...a: Apps® ToastS lira Gloud6 Huddle with Aneliya Angelova& R. Aneliya Angelova •• Messagest Add canvasur FilesAneliya Angelova 2:30 PMЛукаш, само при Сейлсфорс и Хубспот се синкваха активити типовете, когато се направи плейбук,нали?пои останалите скМі тоябва оъчно ла се въвелат.Lukas Kovalik 2:47 PMтряова да се за всички, някьде не се ли попьлвапри зохо май беше hardcoded но май и там си връщаха две категорииAneliya Angelova @ 4:00 PMЛукаш имаш ли време да се чуем за тестването на https://jiminny.atlassian.net/browse/JY-20725X Preview insackt[HubSpot] Optimise CRM rematctOpenReady for QA- MediumAA Aneliya AngelovaAs of today at 4:00 PM RetreshOpen in Jira+ SummariseLukas Kovalik 4:02 PMzean-iurwiniareelYou joined the huddle LIVE 4:06 PMAneliva Angelova is here toolAneliya Angelova 4:44 PM11512582Lukas Kovalik O 5:16 PMcurl --location "httns:ani.hubani.com/crm/v3/obiects/contacts/search--header 'Content-Type: application/json'\--header "Authorization: BearerC|Kvma?iMx7OINOM8kOFwrAowAcAkUAhIR24?A05D?xiVrIYMILuP6SA01Kwcmhr=n.v2 hytorDniMvDalYck-CMalovolNOM9kOFwrAUAcrkcaws-ThwRARIRAOF-ATFSAOFRAO-ROIEBAQUBEggBAQEBAYICFIґ7Au8O2Nwal9YW2CbCCxYvwngmSgNldTFSAFoAYABoAHAAeAA'\--data "{ "limit", 13'Message Aneliva Angelova+ AaIAl Notes: OffLeave~ (Q SearchDate ModifiedYesterdav at 13.36Yesterday at 13:36Yesterday at 13.30Yesterday at 13:34Yesterday at 13:34resterday at 15.5sYesterdav at 13:3kYesterday at 13:32Yesterday at 13.31Yesterday at 13:31Yesterday at 13:30resterday at 15.30Yesterdav at 13:29Yesterday at 13:29Yesterday at 13:28Yesterday at 13:28Yesterday at 13:27resterday at 13-40Yesterdav at 13:26Yesterday at 13:25Yesterday at 13:25Yesterday at 13:24Yesterday at 13:24Yesterday at 13.22Yesterdav at 13:21Yesterday at 13:20Yesterday at 13:19Yesterday at 13.18Yecterdav at 12:19Yesterday at 13-1/Yesterdav at 13:16Yesterday at 13:16Yesterday at 13:15Yecterdav at 12:1/Yesterday at 13:14Yesterday at 13.13Yecterdav at 13:10Yesterday at 13:12Yesterday at 13:11Yesterday at 13:11Yesterday at 13:10Yesterday at 13.10Yecterdav at 12:091Yesterday at 13:09Yesterdav at 13:0823K:MPEG-4 movie9KBMPEG-4 movie13 KB16 Kb17 KEMPEG-4 movieMPEG-4 movie29 KB6 KBMPE0"4 movie12 K:9 KBMPEG-4 movie7 KBMP2G-4 movie37 KBMPEG-4 movie10 KBI KDMPEG"4 movie8 KE9KBMPEG-4 movie8 KB72 KBMPEG-4 movie14 KBMPEG-4 movie13 KB9KBMPEG-4 movie18 KEMPEG-4 movie12 KBMPEG-4 movie10 KB16 K:MPEG-4 movie6 KBMPEG-4 movie12 KBMPEG-4 movie23 KBMPEG-4 movie8 KBMPEG-4 movie6 KBMPEG-4 movie16 K:11 KBMPEG-4 movie20 KBMPEG-4 movie21 KPMPEG-4 movie10 KB MPEG-4 movie7 KBMPEG-4 movie5 KB1MPEG-4 movie11 KBMPEG-4 movie26 KB111 KB102 KRMPEG-4 movieMDEG-A movie88 KB59 KBMPEG-4 movie08 KBIMPEG-A movie97 KB MPEG-4 movie66 KB44 KB02 KpMPEG-4 movieMPEG-4 movie78 KB50 KBMPEG-4 movie58 KГMPEG-A movid27 KBMPEG-4 movie7 KB12K8MPEG-4 movieFavouritesE jiminny© Recents* ApplicationsiCloudiCloud Drive228 Sync folderQ DXP4800PLUS-B5FA@ Network|• CRMI• Orange• Red• Yellow• Green• Purple•) All lags..lohlDownloadsNameLoom.pkgAlfred copv.alfredoreterencesB KeychronAssist-1.0.2 (1).dmgA Keychron Assist-1.0.2.dmgmazanoke-images-yWJo.zioPhotos-3-001.zip→mage U.loge1 Orioninstaller.dmaimage.jpg- image (2).1pc• ПО-22221726037035-004-001_ORGES.pdf• %D0%9F%D0%9E-22221726037035-004-001_archive.zipПO-22221726037035-004-001_archive (1).zipD report(2) xmlAltred copy2.altredoreterences05012026_0000000026574472_ SWIFT_OB70501260015890.pdf27022026_0000000026574472_SWIFT_OB72702260049200.pdf= 03042026 [CREDIT_CARD] SWIFT [CREDIT_CARD].001B reporti) xm=pdt.odipdf-1.pdfD pdf-2.pdf-pdf-5.pd1= ndf-1 ndipdf-3.pdfKoválik Family Tree.gedbitwarden export 20251031122528.isonKoválik Family Tree.zip*macOS Storage_Cleanup.mdal favicon icofirst_aid_notes_complete.docxrenortl2).esvconfig.ymlIteration run Search HS.postman_collection.jsonm licence hettertouchtoolMariusHosting Config.json1ooks-891a6503-bbb7-4b2b-9c3.csv•AlfredwPmazanoko.imnaoc.YWIfана Ковалик.jpg•искане даниел Ковалик..pa• Фактура Март Даниел Ковалик.jpс• Фактура Април Даниел Ковалик.jpg• Dhotac 2.001Q SearchKind00,4 MDinstdlle..dckage55.9 MBAlfred...ferences10,1 MBDisk Image10,1 MBDisk ImageIL MBLiP archive6,6 MBZIP archive2,5 MB PDF Document2,5 MBJreo lmage2.2 MBDisk Image2,2 MB PDF Document2 MBJPEG image1,9 MBJPEG imaqe192 KBPDF Document140 Kb148 KВ148 KB122 KB111 KB94 KBZIP archiveZIP archivelZIP archiveXML documentAlfred...ferencesPDF Document92 KBPDr DocumentPDF Document91 KB91 KB30 KB29 KBXML documentPDF Document28 KB28 KR28 KB27 KB14 KB11 KB6 KB6KB5KR4 KB3 KBPDF DocumentPDE DocumentDocumentJSONICSV DocumentZIP archiveMarkdo…..umentWindo...n imageword ..cumentCSV Document2 KBYAML document1 KBcSV Document928 byteshttlicence183 bytesZero butesJSONAlfred.. ferencesZero bytes1,9 MB1,8 MB17 MB1,7 MBFoldeColdorJPEG imageJPEG ImageJPEG imageJPEG imageColdo1 of 58 selected, 8.5 GB availab!i• Inu 14 Mаy 1/.33.01Date AddedIs Mdl ZUzo dl 19:4530 Jan 2026 at 12:3617 Mar 2026 at 20:2717 Mar 2026 at 20:26Z3 Aor 2020 al 13:0229 Jan 2026 at 15:2019 Dec 2025 at 10:1619 Dec 2025 at 12:238 Aor 2026 at 20:3519 Dec 2025 at 10:2919 Dec 2025 at 12:1819 Dec 2025 at 12:4026 Mar 2026 at 11:2410 May 2026 at 13:5326 Mar 2026 at 11:2426 Mar 2026 at 11:2426 Mar 2026 at 11:2310 May 2026 at 14:3730 Jan 2026 at 12:3713 Feb 2026 at 11:54Z3 Apr 2026 at 13.0823 Aor 2026 at 13:0810 May 2026 at 13:5410 May 2026 at 14:3710 May 2026 at 13:4910 May 2026 at 13:5010 May 2026 at 13:5110 Mav 2026 at 12:51110 May 2026 at 13:5019 Dec 2025 at 11:3431 Oct 2025 at 12:2525 Nov 2025 at 17:596 Mar 2026 at 11.2224 Anr 2026 at 16:5220 Oct 2025 at 11:0218 Mar 2026 at 15:29• Mav 2026 at 11:09129 Oct 2025 at 19:329 May 2026 at 10:0418 Mar 2026 at 11:5510 May 2026 at 13:5712 Jun 2025 at 19:0430 Jan 2026 at 12:3630 Jan 2026 at 12:3616 Oct 2025 aт 16:0123 Apr 2026 at 13:0223 Apr 2026 at 13:0223 Apr 2026 at 13:0222 Anr 2026 at 12:0223 Apr 2026 at 13:0229 Jan 2026 at 15:20...
|
45367
|
NULL
|
NULL
|
NULL
|
|
45369
|
1625
|
22
|
2026-05-14T14:33:08.460007+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-14/1778 /Users/lukas/.screenpipe/data/data/2026-05-14/1778769188460_m1.jpg...
|
PhpStorm
|
faVsco.js – Copper/Service.php
|
1
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
#12066 on JY-20725-handle Project: faVsco.js, menu
#12066 on JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
HandleHubspotRateLimitTest...
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"bounds":{"left":0.054166667,"top":0.027777778,"width":0.08055556,"height":0.035555556},"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"#12066 on JY-20725-handle-HS-search-rate-limit, menu","depth":5,"bounds":{"left":0.13472222,"top":0.027777778,"width":0.25555557,"height":0.035555556},"on_screen":true,"help_text":"Pull request #12066 exists for current branch JY-20725-handle-HS-search-rate-limit, but local branch is out of sync with remote","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"bounds":{"left":0.6326389,"top":0.027777778,"width":0.023611112,"height":0.035555556},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"HandleHubspotRateLimitTest","depth":6,"bounds":{"left":0.6645833,"top":0.027777778,"width":0.15902779,"height":0.035555556},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
7058342144550237105
|
-7195274138713862717
|
click
|
hybrid
|
NULL
|
Project: faVsco.js, menu
#12066 on JY-20725-handle Project: faVsco.js, menu
#12066 on JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
HandleHubspotRateLimitTest
PhpStormFileEditViewNavigateCodeLaravelRefactorRunToolsGitWindowHelp•FV faVsco.jsv* #12066 on JY-20725-handle-HS-search-rate-limit k vabl100% CHandleHubspotRateLimitTest8• Thu 14 May 17:33:08* :QProjectv:=custom.logAutomatedReportGenerated.phpTrackAutomatedReportGeneratedEvent.phpPlaybackController.php© FieldDefinitions.php© UserAutomatedReportsController.phpPlanhatService.phpAutomatedReportResult.phpSendReportJob.php=laravel.log© FieldValueConverter.php• DeleteCrmEntityTrait.phpDeleteAccountJob.php© ImportActivityTypes.phpT WriteCrmTrait.php© Service.php© DecorateActivity.phpSF [jiminny@localhost]> D CrmObjects© Salesforce/Service.php© LogActivityTrait.php© Pipedrive/Service.php© Close/Service.phpCopper/Service.php XA HS_local [jiminny@localho:W• DecorateActivity© PlainTextDecorateActivity.phpT ActivityPlaybookTrait.php© CrmHelperRepository.php© AccountController.php4 console [QAI PROD] X18© BaseDecorateActivity.php© PlainTextDecorateActivity.plT IntegrationAppTrait.php=.env.stagingE.env© DetachActivityObject.phpRematchActivityOnCrmObjectDetach.php4 console [PROD]> D Dummyv HelpersT ActivityPlaybookTrait.php521664© MatchActivityCrmData.php© Client.php© HubspotPaginationService.phpclass Service extends BaseService implementsprivatefunction buildCallPayload(Activity $activity): Activity© HandleHubspotRateLimit.phpA console (EU]811 472 M1 ×4© Arraylterator.phpT ConnectionStateTrait.php1674© CrmHelperRepository.php1675© FilterJoinedParticipants.php1676© OpportunitySyncableFieldsT 1677v D Hubspot1678• AccountSyncStrategy1679> OJ Actions |1680Sthis->copperClient->tasks()->update($activity->crm_provider_id, ['status' =>'Completed','custom_activity_type_id' => $newActivityType->value,'details' => $this-›generateActivityDescription(Sactivity),1):else{id = 33;• ContactSyncStrategy1681> ODTO1682if(Sthis->getCrmType(Sactivitx-2categacx2null2ntSmessage = sprintf( format: '[Copper] Invalid CRM Activity Type: %s', Sactivity->category->getName()) ; E223D Fields1683› Journal1684• Metadata1685v C OpportunitySyncStrategy1686> D Concerns1687© HubspotLastModifiedByf 1688© HubspotLastModifiedCre1689© HubspotLastModifiedCre1690HubspotLastModifiedOpe1691|© HubspotLastModifiedSyn16921693DGo jiminny084143 ×4 л215•m_layout_id =2162,1661,66799,66217218219=220221: 222= 224— 225=226Extract Surround / E :=2271t (scnis->getcrmiype(>activity->category)->value === nul) {228Smessage = sprintf( format: '[Copper] Invalid CRM Activity Type: %s value', $activity->category->getName 229= 230231t232233 V234id THEN(ownthrow new CrmException(Smessage,code: 400);HIhubspat":throw new CrmException(Smessage,code: 400);// Just createan activity.= 11512582;© HubspotSingleSyncStrate$cpActivity = $this->copperClient-›activities()->create([© HubspotSyncStrategyBa:1694'user_id' => $this->profile->crm_provider_id,© HubspotWebhookBatchS1695'activity_date' => $activity->scheduled_start_time->timestamp,v [ Pagination1696'parent' => $this->convertActivityAssociations(Sactivity),© HubspotPaginationServic 1697'type' => $this->convertActivityType($activity->category),A DaninationCanfin nhn1698'details= $this->qenerateActivityDescription(Sactivity)Workspace associated with branch 'JY-20725-handle-HS-search-rate-limit' has been restored // Rollback // Configure... (today 16:17)W Windsurf Teams1685:18 (246 chars, 4 line breaks)UTF-8Ca 4 spaces...
|
45368
|
NULL
|
NULL
|
NULL
|
|
45371
|
1625
|
23
|
2026-05-14T14:33:10.101263+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-14/1778 /Users/lukas/.screenpipe/data/data/2026-05-14/1778769190101_m1.jpg...
|
PhpStorm
|
faVsco.js – Copper/Service.php
|
1
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
PhpStormFileEditViewNavigateCodeLaravelRefactorRun PhpStormFileEditViewNavigateCodeLaravelRefactorRunToolsGitWindowHelplhl•FV faVsco.jsv#12066 on JY-20725-handle-HS-search-rate-limit k vHandleHubspotRateLimitTestv100% <8• Thu 14 May 17:33:09QProjectv= custom.logAutomatedReportGenerated.phpTrackAutomatedReportGeneratedEvent.phpPlaybackController.php© FieldDefinitions.phpUserAutomatedReportsController.phpPlanhatService.phpAutomatedReportResult.phpSendReportJob.php=laravel.log© FieldValueConverter.php• DeleteCrmEntityTrait.phpDeleteAccountJob.php© ImportActivityTypes.phpT WriteCrmTrait.php© Service.php© DecorateActivity.phpSF [jiminny@localhost]> D CrmObjects© Salesforce/Service.php© LogActivityTrait.phpPipedrive/Service.phpClose/Service.phpCopper/Service.php XA HS_local [jiminny@localho:W• DecorateActivity© PlainTextDecorateActivity.phpT ActivityPlaybookTrait.phpCrmHelperRepository.phpAccountController.php4 console [QAI PROD] X18© BaseDecorateActivity.php© PlainTextDecorateActivity.plT IntegrationAppTrait.php.env.stagingE.env© DetachActivityObject.phpRematchActivityOnCrmObjectDetach.php4 console [PROD]> D Dummy© HandleHubspotRateLimit.phpv Helpers52811 472 M1 ×4T ActivityPlaybookTrait.php1664© Arraylterator.php1681T ConnectionStateTrait.php1682© MatchActivityCrmData.php© Client.php© HubspotPaginationService.phpclass Service extends BaseService implementsprivate function buildCallPayload(Activity $activity): Activityif(Sthis->getCrmType(Sactivitx->category2mmnull2ntSmessage = sprintf( format:'[Copper] Invalid CRM Activity Type: %s', Sactivity->category->getNameO);A console (EU]© CrmHelperRepository.php1683© FilterJoinedParticipants.php1684+ OpportunitySyncableFieldsTthrow new CrmException(Smessage,code:400);1685v D Hubspot1686• AccountSyncStrategy1687> OJ Actions |1688if (Sthis->getCrmType($activity->category)->value === null) {$message = sprintf( format: '[Copper] Invalid CRM Activity Type: %s value', $activity->category->getName 222• ContactSyncStrategy1689> ODTO1690throw new CrmException(Smessage,code: 400):D Fields1691› O Journal1692D Metadata1693v C OpportunitySyncStrategy1694> D Concerns1695© HubspotLastModifiedByF1696© HubspotLastModifiedCre1697© HubspotLastModifiedCre1698// Just create an activity.$cpActivity = $this->copperClient->activities()->create(['user_id' = $this->profile->crm_provider_id,'activity_date' => $activity->scheduled_start_time->timestamp,'parent' => $this->convertActivityAssociations($activity),'type' => Sthis->convertActivityType($activity->category),'details' => $this->generateActivityDescription(Sactivity),© HubspotLastModifiedOpe16991):© HubspotLastModifiedSyn1700DGojiminny v084143 ×4 л215•m_layout_id =2162,1661,66799,66217218219id = 33;=220221=223= 224id THEN '(own225=226= 227228229= 230hubspot';2312322:233V= 11512582;234© HubspotSingleSyncStrate1701© HubspotSyncStrategyBa:1702Sactivity->crm_provider_id = $cpActivity['id']:Sactivity->saveO:© HubspotWebhookBatchS1703v [ Pagination1704ch (ClientException $exception) {© HubspotPaginationServic1705A DaninatinnCanfin nhn/Log all ercons to SentryWorkspace associated with branch 'JY-20725-handle-HS-search-rate-limit' has been restored // Rollback // Configure... (today 16:17)W Windsurf Teams1688:29 (8 chars)UTF-8Co 4 spaces...
|
NULL
|
-7097145309050108631
|
NULL
|
visual_change
|
ocr
|
NULL
|
PhpStormFileEditViewNavigateCodeLaravelRefactorRun PhpStormFileEditViewNavigateCodeLaravelRefactorRunToolsGitWindowHelplhl•FV faVsco.jsv#12066 on JY-20725-handle-HS-search-rate-limit k vHandleHubspotRateLimitTestv100% <8• Thu 14 May 17:33:09QProjectv= custom.logAutomatedReportGenerated.phpTrackAutomatedReportGeneratedEvent.phpPlaybackController.php© FieldDefinitions.phpUserAutomatedReportsController.phpPlanhatService.phpAutomatedReportResult.phpSendReportJob.php=laravel.log© FieldValueConverter.php• DeleteCrmEntityTrait.phpDeleteAccountJob.php© ImportActivityTypes.phpT WriteCrmTrait.php© Service.php© DecorateActivity.phpSF [jiminny@localhost]> D CrmObjects© Salesforce/Service.php© LogActivityTrait.phpPipedrive/Service.phpClose/Service.phpCopper/Service.php XA HS_local [jiminny@localho:W• DecorateActivity© PlainTextDecorateActivity.phpT ActivityPlaybookTrait.phpCrmHelperRepository.phpAccountController.php4 console [QAI PROD] X18© BaseDecorateActivity.php© PlainTextDecorateActivity.plT IntegrationAppTrait.php.env.stagingE.env© DetachActivityObject.phpRematchActivityOnCrmObjectDetach.php4 console [PROD]> D Dummy© HandleHubspotRateLimit.phpv Helpers52811 472 M1 ×4T ActivityPlaybookTrait.php1664© Arraylterator.php1681T ConnectionStateTrait.php1682© MatchActivityCrmData.php© Client.php© HubspotPaginationService.phpclass Service extends BaseService implementsprivate function buildCallPayload(Activity $activity): Activityif(Sthis->getCrmType(Sactivitx->category2mmnull2ntSmessage = sprintf( format:'[Copper] Invalid CRM Activity Type: %s', Sactivity->category->getNameO);A console (EU]© CrmHelperRepository.php1683© FilterJoinedParticipants.php1684+ OpportunitySyncableFieldsTthrow new CrmException(Smessage,code:400);1685v D Hubspot1686• AccountSyncStrategy1687> OJ Actions |1688if (Sthis->getCrmType($activity->category)->value === null) {$message = sprintf( format: '[Copper] Invalid CRM Activity Type: %s value', $activity->category->getName 222• ContactSyncStrategy1689> ODTO1690throw new CrmException(Smessage,code: 400):D Fields1691› O Journal1692D Metadata1693v C OpportunitySyncStrategy1694> D Concerns1695© HubspotLastModifiedByF1696© HubspotLastModifiedCre1697© HubspotLastModifiedCre1698// Just create an activity.$cpActivity = $this->copperClient->activities()->create(['user_id' = $this->profile->crm_provider_id,'activity_date' => $activity->scheduled_start_time->timestamp,'parent' => $this->convertActivityAssociations($activity),'type' => Sthis->convertActivityType($activity->category),'details' => $this->generateActivityDescription(Sactivity),© HubspotLastModifiedOpe16991):© HubspotLastModifiedSyn1700DGojiminny v084143 ×4 л215•m_layout_id =2162,1661,66799,66217218219id = 33;=220221=223= 224id THEN '(own225=226= 227228229= 230hubspot';2312322:233V= 11512582;234© HubspotSingleSyncStrate1701© HubspotSyncStrategyBa:1702Sactivity->crm_provider_id = $cpActivity['id']:Sactivity->saveO:© HubspotWebhookBatchS1703v [ Pagination1704ch (ClientException $exception) {© HubspotPaginationServic1705A DaninatinnCanfin nhn/Log all ercons to SentryWorkspace associated with branch 'JY-20725-handle-HS-search-rate-limit' has been restored // Rollback // Configure... (today 16:17)W Windsurf Teams1688:29 (8 chars)UTF-8Co 4 spaces...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
45372
|
1626
|
15
|
2026-05-14T14:33:15.868866+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-14/1778 /Users/lukas/.screenpipe/data/data/2026-05-14/1778769195868_m2.jpg...
|
PhpStorm
|
faVsco.js – Copper/Service.php
|
1
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
ActivityFilesLaterJiminny... ~@ jiminny-x-integrat ActivityFilesLaterJiminny... ~@ jiminny-x-integrati& platform-inner-team© Channels# ai-chapter# alertsi backend# bugscontusion-cllnia# curiosity_lab# engineering# general# jiminny-bg# platform-tickets# product_launches# random# releases# sofia-office# support# thank-yous# the people of iimi..• Direct messages8. A...• A3 62P. Galya Dimitrova Mvasil VasilevA. Stefka Stoyanova%: Todor Stamatovf. Mario GeorgievP. Nikolay Ivanov2o James Graham "2. Stoyan Tanev. Steliyan Georgiev. Petko Kashinski*. Lukas Kovali...a: Apps® ToastS lira Gloud6 Huddle with Aneliya Angelova& R. Aneliya Angelova •• Messagest Add canvasur FilesAneliya Angelova 2:30 PMЛукаш, само при Сейлсфорс и Хубспот се синкваха активити типовете, когато се направи плейбук,нали?пои останалите скМі тоябва оъчно ла се въвелат.Lukas Kovalik 2:47 PMтряова да се за всички, някьде не се ли попьлвапри зохо май беше hardcoded но май и там си връщаха две категорииAneliya Angelova @ 4:00 PMЛукаш имаш ли време да се чуем за тестването на https://jiminny.atlassian.net/browse/JY-20725X Preview insackt[HubSpot] Optimise CRM rematctC OpenReady for QA- MediumAA Aneliya AngelovaAs of today at 4:00 PM RetreshOpen in Jira+ SummariseLukas Kovalik 4:02 PMzean-iurwiniareelYou joined the huddle LIVE 4:06 PMAneliva Angelova is here toolAneliya Angelova 4:44 PM11512582Lukas Kovalik O 5:16 PMcurl --location "httns:ani.hubani.com/crm/v3/obiects/contacts/search--header 'Content-Type: application/json'\--header "Authorization: BearerC|Kvma?iMx7OINOM8kOFwrAowAcAkUAhIR24?A05D?xiVrIYMILuP6SA01Kwcmhr=n.v2 hytorDniMvDalYck-CMalovolNOM9kOFwrAUAcrkcaws-ThwRARIRAOF-ATFSAOFRAO-ROIEBAQUBEggBAQEBAYICFIґ7Au8O2Nwal9YW2CbCCxYvwngmSgNldTFSAFoAYABoAHAAeAA'\--data "{ "limit", 13'Message Aneliva Angelova+ AaIAl Notes: OffLeave~ (Q SearchDate ModifiedYesterday at 13:37Yesterday at 13.30Yesterday at 13:35resterady al 15.54Yesterdav at 13:34Yesterday at 13:33Yesterday at 13:33Yesterday at 13:32Yesterday at 13:32resterday at 15.3Yesterdav at 13:30Yesterday at 13:30Yesterday at 13:29Yesterday at 13:29Yesterday at 13:28Yesterday at 13.21Yesterday at 13:27Yesterday at 13:26Yesterday at 13:26Yesterday at 13:25Yesterday at 13.24Yecterdav at 12:22Yesterday at 13:22resterday at 13.2lYesterday at 13:20Yesterday at 13.19Yecterdav at 12:10Yesterday at 13:18resterday at 13.10Yesterday at 13:16Yesterday at 13:15Yesterday at 13.14Yesterday at 13:13Yesterday at 13:13Yesterdav at 13:12Yesterday at 13:12Yesterday at 13.11Yecterdav at 12:10Yesterday at 13:10Yesterdav at 13:09Yesterday at 13:0871 KBMPEG-4 movie23 KBMPEG-4 movieMPEG-4 movie13 KE16 KB17 KBMPEO"4 movie29 KBMPEG-4 movie6 KBMPEG-4 movie12 KB9KBMPEG-4 movie7 KBMPEG-4 movie8 KB37 KBMPEO-4 moVie10 K:MPEG-4 movie7 KBMPEG-4 movie8 KB9K:MPEG-4 movie8 KBMPEG-4 movie72 KB14 KbMPEG-4 movie13 KEMPEG-4 movie9 KBMPEG-4 movie18 KBMPEG-4 movie12 K:MPEG-4 movie10 KBMPEG-4 movie16 KB6 KBMPEG-4 movieMPEG-4 movie12 KBMPEG-4 movie23 KBMPEG-4 movie8 K:MPEG-4 movie6 KBMPEG-4 movie6 KB11 KBMPEG-4 movie11 KPMDEG-A movid20 KBMPEG-4 movie34 KBMPEG-4 movie10 KI.MPEG-4 movie7KBMPEG-4 movie5 KB11 KBMPEG-4 movie26 KBMPEG-4 movie111 KB102 KBMPEG-4 movie88 KEMPEG-A movid59 KBMPEG-4 movie98 KB97 K866 KBMPEG-4 movie.MPEG-4 movie44 KB93 KBMPEG-4 movie78 KPMPEG-A movie50 KBMPEG-4 movie58 KB27 KEMPEG-4 movie7KBMPEG-4 movie• 0FavouritesE jiminny© Recents* ApplicationsiCloudiCloud Drive228 Sync folderLocationsQ DXP4800PLUS-B5FA@ Network|• CRM• Orange• Red• Yellow• Green• Purple•) All lags..lohlDownloadsNameLoom.pkgAlfred copv.alfredoreterencesB KeychronAssist-1.0.2 (1).dmgA Keychron Assist-1.0.2.dmgmazanoke-images-yWJo.zioPhotos-3-001.zip→mage U.loge1 Orioninstaller.dma- image (2).1pc• ПО-22221726037035-004-001_ORGES.pdf• %D0%9F%D0%9E-22221726037035-004-001_archive.zipПO-22221726037035-004-001_archive (1).zipD report(2) xmlAltred copy2.altredoreterences05012026_0000000026574472_ SWIFT_OB70501260015890.pdf27022026_0000000026574472_SWIFT_OB72702260049200.pdf= 03042026 [CREDIT_CARD] SWIFT [CREDIT_CARD].001B reporti) xm=pdt.odipdf-1.pdfD pdf-2.pdf-pdf-5.pd1= ndf-1 ndipdf-3.pdfB Rovaix Famly Treo gedbitwarden export 20251031122528.isonKoválik Family Tree.zip*macOS Storage_Cleanup.mdal favicon icofirst_aid_notes_complete.docxconfig.ymlIteration run Search HS.postman_collection.json--report(1).csvm licence hettertouchtoalMariusHosting Config.json1ooks-891a6503-bbb7-4b2b-9c3.csv•Alfredmazanoke-images-YWJ6ана Ковалик.jpg•искане даниел Ковалик..pa• Фактура Март Даниел Ковалик.jрс• Фактура Април Даниел Ковалик.jpg• Dhotac 2.001Q SearchKind00,4 MDinstdlle..dckage55.9 MBAlfred...ferences10,1 MBDisk Image10,1 MBDisk ImageIL MBLiP archive6,6 MBZIP archive2,5 MB PDF Document2,5 MBJreo lmage2.2 MBDisk Image2,2 MB PDF Document2 MBJPEG image1,9 MBJPEG imaqe192 KBPDF Document140 Kb148 KВ148 KB122 KB111 KB94 KBZIP archiveZIP archivelZIP archiveXML documentAlfred...ferencesPDF Document92 KBPDr DocumentPDF Document91 KB91 KB30 KB29 KBXML documentPDF Document28 KB28 KR28 KB27 KB14 KB11 KB6 KB6KB5KR4 KB3 KBPDF DocumentPDE DocumentDocumentJSONICSV DocumentZIP archiveMarkdo…..umentWindo...n imageword ..cumentCSV Document2 KBYAML document1 KBcSV Document928 byteshttlicence183 bytesZero butesJSONAlfred.. ferencesZero bytes1,9 MB1,8 MB17 MB1,7 MBFoldeColdorJPEG imageJPEG ImageJPEG imageJPEG imageColdo1 of 58 selected, 8.5 GB availab!i• Inu 14 May 1/.33.10Date AddedIs Mdl ZUzo dl 19:4530 Jan 2026 at 12:3617 Mar 2026 at 20:2717 Mar 2026 at 20:26Z3 Aor 2020 al 13:0229 Jan 2026 at 15:2019 Dec 2025 at 10:1619 Dec 2025 at 12:238 Aor 2026 at 20:3519 Dec 2025 at 10:2919 Dec 2025 at 12:1819 Dec 2025 at 12:4026 Mar 2026 at 11:2410 May 2026 at 13:5326 Mar 2026 at 11:2426 Mar 2026 at 11:2426 Mar 2026 at 11:2310 May 2026 at 14:3730 Jan 2026 at 12:3713 Feb 2026 at 11:54Z3 Apr 2026 at 13.0823 Aor 2026 at 13:0810 May 2026 at 13:5410 May 2026 at 14:3710 May 2026 at 13:4910 May 2026 at 13:5010 May 2026 at 13:5110 Mav 2026 at 12:51110 May 2026 at 13:5019 Dec 2025 at 11:3431 Oct 2025 at 12:2525 Nov 2025 at 17:596 Mar 2026 at 11.2224 Anr 2026 at 16:5220 Oct 2025 at 11:0218 Mar 2026 at 15:29• Mav 2026 at 11:09129 Oct 2025 at 19:329 May 2026 at 10:0418 Mar 2026 at 11:5510 May 2026 at 13:5712 Jun 2025 at 19:0430 Jan 2026 at 12:3630 Jan 2026 at 12:3616 Oct 2025 aт 16:0123 Apr 2026 at 13:0223 Apr 2026 at 13:0223 Apr 2026 at 13:0222 Anr 2026 at 12:0223 Apr 2026 at 13:0229 Jan 2026 at 15:20...
|
NULL
|
-6205760700402177095
|
NULL
|
visual_change
|
ocr
|
NULL
|
ActivityFilesLaterJiminny... ~@ jiminny-x-integrat ActivityFilesLaterJiminny... ~@ jiminny-x-integrati& platform-inner-team© Channels# ai-chapter# alertsi backend# bugscontusion-cllnia# curiosity_lab# engineering# general# jiminny-bg# platform-tickets# product_launches# random# releases# sofia-office# support# thank-yous# the people of iimi..• Direct messages8. A...• A3 62P. Galya Dimitrova Mvasil VasilevA. Stefka Stoyanova%: Todor Stamatovf. Mario GeorgievP. Nikolay Ivanov2o James Graham "2. Stoyan Tanev. Steliyan Georgiev. Petko Kashinski*. Lukas Kovali...a: Apps® ToastS lira Gloud6 Huddle with Aneliya Angelova& R. Aneliya Angelova •• Messagest Add canvasur FilesAneliya Angelova 2:30 PMЛукаш, само при Сейлсфорс и Хубспот се синкваха активити типовете, когато се направи плейбук,нали?пои останалите скМі тоябва оъчно ла се въвелат.Lukas Kovalik 2:47 PMтряова да се за всички, някьде не се ли попьлвапри зохо май беше hardcoded но май и там си връщаха две категорииAneliya Angelova @ 4:00 PMЛукаш имаш ли време да се чуем за тестването на https://jiminny.atlassian.net/browse/JY-20725X Preview insackt[HubSpot] Optimise CRM rematctC OpenReady for QA- MediumAA Aneliya AngelovaAs of today at 4:00 PM RetreshOpen in Jira+ SummariseLukas Kovalik 4:02 PMzean-iurwiniareelYou joined the huddle LIVE 4:06 PMAneliva Angelova is here toolAneliya Angelova 4:44 PM11512582Lukas Kovalik O 5:16 PMcurl --location "httns:ani.hubani.com/crm/v3/obiects/contacts/search--header 'Content-Type: application/json'\--header "Authorization: BearerC|Kvma?iMx7OINOM8kOFwrAowAcAkUAhIR24?A05D?xiVrIYMILuP6SA01Kwcmhr=n.v2 hytorDniMvDalYck-CMalovolNOM9kOFwrAUAcrkcaws-ThwRARIRAOF-ATFSAOFRAO-ROIEBAQUBEggBAQEBAYICFIґ7Au8O2Nwal9YW2CbCCxYvwngmSgNldTFSAFoAYABoAHAAeAA'\--data "{ "limit", 13'Message Aneliva Angelova+ AaIAl Notes: OffLeave~ (Q SearchDate ModifiedYesterday at 13:37Yesterday at 13.30Yesterday at 13:35resterady al 15.54Yesterdav at 13:34Yesterday at 13:33Yesterday at 13:33Yesterday at 13:32Yesterday at 13:32resterday at 15.3Yesterdav at 13:30Yesterday at 13:30Yesterday at 13:29Yesterday at 13:29Yesterday at 13:28Yesterday at 13.21Yesterday at 13:27Yesterday at 13:26Yesterday at 13:26Yesterday at 13:25Yesterday at 13.24Yecterdav at 12:22Yesterday at 13:22resterday at 13.2lYesterday at 13:20Yesterday at 13.19Yecterdav at 12:10Yesterday at 13:18resterday at 13.10Yesterday at 13:16Yesterday at 13:15Yesterday at 13.14Yesterday at 13:13Yesterday at 13:13Yesterdav at 13:12Yesterday at 13:12Yesterday at 13.11Yecterdav at 12:10Yesterday at 13:10Yesterdav at 13:09Yesterday at 13:0871 KBMPEG-4 movie23 KBMPEG-4 movieMPEG-4 movie13 KE16 KB17 KBMPEO"4 movie29 KBMPEG-4 movie6 KBMPEG-4 movie12 KB9KBMPEG-4 movie7 KBMPEG-4 movie8 KB37 KBMPEO-4 moVie10 K:MPEG-4 movie7 KBMPEG-4 movie8 KB9K:MPEG-4 movie8 KBMPEG-4 movie72 KB14 KbMPEG-4 movie13 KEMPEG-4 movie9 KBMPEG-4 movie18 KBMPEG-4 movie12 K:MPEG-4 movie10 KBMPEG-4 movie16 KB6 KBMPEG-4 movieMPEG-4 movie12 KBMPEG-4 movie23 KBMPEG-4 movie8 K:MPEG-4 movie6 KBMPEG-4 movie6 KB11 KBMPEG-4 movie11 KPMDEG-A movid20 KBMPEG-4 movie34 KBMPEG-4 movie10 KI.MPEG-4 movie7KBMPEG-4 movie5 KB11 KBMPEG-4 movie26 KBMPEG-4 movie111 KB102 KBMPEG-4 movie88 KEMPEG-A movid59 KBMPEG-4 movie98 KB97 K866 KBMPEG-4 movie.MPEG-4 movie44 KB93 KBMPEG-4 movie78 KPMPEG-A movie50 KBMPEG-4 movie58 KB27 KEMPEG-4 movie7KBMPEG-4 movie• 0FavouritesE jiminny© Recents* ApplicationsiCloudiCloud Drive228 Sync folderLocationsQ DXP4800PLUS-B5FA@ Network|• CRM• Orange• Red• Yellow• Green• Purple•) All lags..lohlDownloadsNameLoom.pkgAlfred copv.alfredoreterencesB KeychronAssist-1.0.2 (1).dmgA Keychron Assist-1.0.2.dmgmazanoke-images-yWJo.zioPhotos-3-001.zip→mage U.loge1 Orioninstaller.dma- image (2).1pc• ПО-22221726037035-004-001_ORGES.pdf• %D0%9F%D0%9E-22221726037035-004-001_archive.zipПO-22221726037035-004-001_archive (1).zipD report(2) xmlAltred copy2.altredoreterences05012026_0000000026574472_ SWIFT_OB70501260015890.pdf27022026_0000000026574472_SWIFT_OB72702260049200.pdf= 03042026 [CREDIT_CARD] SWIFT [CREDIT_CARD].001B reporti) xm=pdt.odipdf-1.pdfD pdf-2.pdf-pdf-5.pd1= ndf-1 ndipdf-3.pdfB Rovaix Famly Treo gedbitwarden export 20251031122528.isonKoválik Family Tree.zip*macOS Storage_Cleanup.mdal favicon icofirst_aid_notes_complete.docxconfig.ymlIteration run Search HS.postman_collection.json--report(1).csvm licence hettertouchtoalMariusHosting Config.json1ooks-891a6503-bbb7-4b2b-9c3.csv•Alfredmazanoke-images-YWJ6ана Ковалик.jpg•искане даниел Ковалик..pa• Фактура Март Даниел Ковалик.jрс• Фактура Април Даниел Ковалик.jpg• Dhotac 2.001Q SearchKind00,4 MDinstdlle..dckage55.9 MBAlfred...ferences10,1 MBDisk Image10,1 MBDisk ImageIL MBLiP archive6,6 MBZIP archive2,5 MB PDF Document2,5 MBJreo lmage2.2 MBDisk Image2,2 MB PDF Document2 MBJPEG image1,9 MBJPEG imaqe192 KBPDF Document140 Kb148 KВ148 KB122 KB111 KB94 KBZIP archiveZIP archivelZIP archiveXML documentAlfred...ferencesPDF Document92 KBPDr DocumentPDF Document91 KB91 KB30 KB29 KBXML documentPDF Document28 KB28 KR28 KB27 KB14 KB11 KB6 KB6KB5KR4 KB3 KBPDF DocumentPDE DocumentDocumentJSONICSV DocumentZIP archiveMarkdo…..umentWindo...n imageword ..cumentCSV Document2 KBYAML document1 KBcSV Document928 byteshttlicence183 bytesZero butesJSONAlfred.. ferencesZero bytes1,9 MB1,8 MB17 MB1,7 MBFoldeColdorJPEG imageJPEG ImageJPEG imageJPEG imageColdo1 of 58 selected, 8.5 GB availab!i• Inu 14 May 1/.33.10Date AddedIs Mdl ZUzo dl 19:4530 Jan 2026 at 12:3617 Mar 2026 at 20:2717 Mar 2026 at 20:26Z3 Aor 2020 al 13:0229 Jan 2026 at 15:2019 Dec 2025 at 10:1619 Dec 2025 at 12:238 Aor 2026 at 20:3519 Dec 2025 at 10:2919 Dec 2025 at 12:1819 Dec 2025 at 12:4026 Mar 2026 at 11:2410 May 2026 at 13:5326 Mar 2026 at 11:2426 Mar 2026 at 11:2426 Mar 2026 at 11:2310 May 2026 at 14:3730 Jan 2026 at 12:3713 Feb 2026 at 11:54Z3 Apr 2026 at 13.0823 Aor 2026 at 13:0810 May 2026 at 13:5410 May 2026 at 14:3710 May 2026 at 13:4910 May 2026 at 13:5010 May 2026 at 13:5110 Mav 2026 at 12:51110 May 2026 at 13:5019 Dec 2025 at 11:3431 Oct 2025 at 12:2525 Nov 2025 at 17:596 Mar 2026 at 11.2224 Anr 2026 at 16:5220 Oct 2025 at 11:0218 Mar 2026 at 15:29• Mav 2026 at 11:09129 Oct 2025 at 19:329 May 2026 at 10:0418 Mar 2026 at 11:5510 May 2026 at 13:5712 Jun 2025 at 19:0430 Jan 2026 at 12:3630 Jan 2026 at 12:3616 Oct 2025 aт 16:0123 Apr 2026 at 13:0223 Apr 2026 at 13:0223 Apr 2026 at 13:0222 Anr 2026 at 12:0223 Apr 2026 at 13:0229 Jan 2026 at 15:20...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
45373
|
1626
|
16
|
2026-05-14T14:33:18.940443+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-14/1778 /Users/lukas/.screenpipe/data/data/2026-05-14/1778769198940_m2.jpg...
|
PhpStorm
|
faVsco.js – Copper/Service.php
|
1
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
Jiminny... ~ActivityFilesLater@ jiminny-x-integrat Jiminny... ~ActivityFilesLater@ jiminny-x-integrati• plattorm-inner-teamE Channels# ai-chapter# alertsi backend# bugscontusion-cllnia# curiosity lab# engineering# general#jiminny-bgac nlattorm-nckets# product launches# random# releases# sofia-officed sunport# thank-yous# the people of iimi... Direct messages• 8la 62% Galya Dimitrovavasil VasilevM Stefka StovanovaSg: Todor StamatovMario GeorgievNiudlay lanay. James Graham "* Stoyan Tanevo Stelivan Georgiev( Petko Kashinski*. Lukas Kovali...::: AnnsToastS lira Gloud6d Huddle with Aneliva Angelova* Aneliya Angelova &• Messagest Add canvasur FilesAneliya Angelova 2:30 PMЛукаш, само при Сейлсфорс и Хубспот се синкваха активити типовете, когато се направи плейбук,нали?пои останалите скМі тоябва оъчно ла се въвелат.Lukas Kovalik 2:47 PMтряова да се за всички, някъде не се ли попьлвапри зохо май беше hardcoded но май и там си връщаха две категорииAneliya Angelova 4:00 PMЛукаш имаш ли време да се чуем за тестването на https://jiminny.atlassian.net/browse/JY-20725X Preuiew in sact[HubSpotl Optimise CRM rematct(7 OpenRosdy for OA- MediumAA Aneliya AngelovaAs of today at 4:00 PM RetreshOpen in Jira* SummariseLukas Kovalik 4:02 PMzean-iurwiniareelYou joined the huddle (LIVE 4:06 PMAneliva Angelova is here toolAneliva Angelova 4:44 PN11512582Lukas Kovalik M 5.16 PNcurl --location "httns:ani.hubani.com/crm/v3/obiects/contacts/search--header 'Content-vne' annlication/ison'--header "Authorization: BearerC|Kvma?iMx7OINOM8kOFwrAowAcAkUAhIR24?A05D?xiVrIYMILuP6SA01Kwcmhr=n.v2 hytorDniMvDalYck-CMalovolNOM9kOFwrAUAcrkcaws-ThwRARIRAOF-ATFSAOFRAO-ROIEBAQUBEggBAQEBAYICFIr7Au8O2Nwal9YW2CbCCxYvwngmSgNldTFSAFoAYABoAHAAeAA'\--data "{ "limit", 13'Message Aneliva Angelova+ AaIđ 0Pmv Q SearchDate ModifiedYesterday at 13.3Yesterday at 13:36Yesterday at 13:36Yesterday at 13.30Yesterdav at 13:35Yesterday at 13:34Yesterday at 13:34Yesterdav at 13:3kYesterday at 13:33Yesterday at 13.32Yesterdav at 13:31resterday at 15:30Yesterdav at 13:30Yesterday at 13:29Yesterday at 13:29Yesterday at 13:28Yecterdav at 12:28resterday at 13-21Yesterdav at 13:26Yesterday at 13:26Yesterday at 13:25Yesterday at 13:25Voctorday at 12:01Yesterday at 13.23Yesterdav at 13:22resterady al 15.clYesterday at 13:20Yesterday at 13.19Yecterdav at 12:19Yesterdav at 13:18Yesterday at 13-1/Yesterdav at 13:17Yesterday at 13:16Yesterday at 13:15Yecterdav at 12:15Yesterday at 13.14Yecterdav at 13:10Yesterday at 13:13Yesterday at 13:12Yoctorday at 12:11Yesterday at 13.10Yecterdav at 12:10Yesterdav at 13:09Yesterdav at 13:08v Size23 KB9 KB13 Kb16 KE17 KB29 KB16 K:12 KB9 KEIKBn8 KBI37 KB10 KB7 KE8 KB9 KB8 KB172 KR14 KB13 KB9 KE18 KB12 KB10 KBI16 KB6 KB6KB12 KE23 KB8 KB16 K:11 KB20 KP34 KB10 KB7 K:5 KB11 KB26 KBMPEG-4 movieMPEG-4 movieMPEG-4 movieMPEG-4 movieMPE0"4 movieMDSG-A movieMP2G-4 movieMPEG-4 movieMPEG"4 movieMPEG-4 movieMoEeh movioMPEG-4 movieMPEG-1 movieMPEG-4 movieMPEG-4 movieMPEG-4 movieMPEG-4 movieMDEG.A movicMPEG-4 movieMPEG-4 movieMPEG-4 movieMPEG-4 movieMDEC A movidMPEG-4 movieMDEG-A movieMPEG-4 movieMPEG-4 movieMDEC.A moviaMPEG-4 movie111 KC102 KB88 KBMDEG-A moviaMPEG-4 movie59 KB1MPEG-A movie98 KB MPEG-4 movie97 KB66 K3MPEG-4 movieMDEG.A movioAAKP93 KB/8 KBMPEG-4 movie50 KгMDEG-A movid58 KBMPEG-4 movie27 KB7KBMPEG-4 movieAl Notes: OffLeave• • CFavourites• jiminny• Recents* ApplicationsiCloud• iCloud Drive992 Svnc tolde0 DXP4800PLUS-B5F49 Networl• CRMI• Orange• Red|• Yellow• Green• Purple•) All lags..IhlDownloadsNameLoom.pkgAlfred copv.alfredoreterences=KeychronAssist-1.0.2 (1).dmeKeychronAssist-1.0.2.dmgmazanoke-images-yWJo.zioPhotos-3-001.zip• Transcript.pd→mage U.loge1 Orioninstaller.dma_image.jpg- image (2).1pc• ПO-22221726037035-004-001_ORGES.pdf%D0%9F%D0%9E-22221726037035-004-001 archive.zipПO-22221726037035-004-001_archive (1).zip• repon 4).XmAltred copy2.altredoreterencesСE 060209С О000000026571172 CWICT 0Р70501260015900 ndt= 27022026_0000000026574472_ SWIFT_ [IBAN].pdf= 03042026 [CREDIT_CARD] SWIFT [CREDIT_CARD].001reportxmmi=pdt.odiB pdf-1.pdfD pdf-2.pdf-pdf-5.pd1= ndf-1 ndipdf-3.pdfKoválik Family Tree.gedbitwarden export 20251031122528.isonlKoválik Family Tree.zip*macOS Storage_Cleanup.mdal favicon icofirst aid_notes_complete.docxrenortl2).esv1 config.vmlIteration run Search HS.postman_collection.jsonm licence bettertouchtoalMariusHosting Config.isonnokc.901a6502.6667.A62h-062 ccu•AlfredwPmazanoko.imnaoc.YWIfана Ковалик.jpg•искане даниел Ковалик..pg• Фактура Март Даниел Ковалик.jрс• Фактура Април Даниел Ковалик iро• Dhotac 2.001Q SearchKind00,4 MD55.9 MBinstdlle..dckageAlfred...ferences10,1 MB10,1 MBIL MBDisk ImageDisk ImageLiP archive6.6 MBZIp archive2,5 MB PDF Document2,5 MBJreo lmage2.2 MBDisk Image2,2 MB PDF Document2 MBJPEG image1,9 MBJPEG imaqe192 KBPDE Document140 KbZIP archive148 KВ148 KB122 KBZIP archivelZIP archiveXML document111 KBAlfred...ferences94 KBDDE Documont92 KBPDr DocumentK:PDF Document91 KB91 KBXML document30 KBn0kpDDE Nocumont28 KBPDF Document28 KRPDE Document28 KB27 KBDocument14 KB11 KB6 KB6KBJSONINCV NacumontZIP archiveMarkdo…..ument5KRWindo...n image4 KB3 KBword ..cumentCSV Document2 KBVAMI dosumon1 KB029 buteccSV Documenthttlicence183 bytesZero butesJSONAlfrod foronso!Zero bytesFolde1.9 MB1,8 MB17 MB1,7 MBColdorJPEG imageJPEG ImageIPEG imadeJPEG imageColdo1 of 58 selected, 8.49 GB availabld• Inu 14 May 1/.33.10Date AddedIs Mdl ZUzo dl 19:4530 Jan 2026 at 12:3617 Mar 2026 at 20:2717 Mar 2026 at 20:26Z3 Aor 2020 al 13:0229 ,Jan 2026 at 15:2019 Dec 2025 at 10:1619 Dec 2025 at 12:238 Aor 2026 at 20:3519 Dec 2025 at 10:2919 Dec 2025 at 12:1819 Dec 2025 at 12:4026 Mar 2026 at 11:2410 May 2026 at 13:5326 Mar 2026 at 11:2426 Mar 2026 at 11:2426 Mar 2026 at 11:2310 May 2026 at 14:3730 Jan 2026 at 12:3712 Goh 2006 9t 11:5 A1Z3 Apr 2026 at 13.0823 Aor 2026 at 13:0810 May 2026 at 13:5410 May 2026 at 14:3710 May 2026 at 13:4910 May 2026 at 13:5010 May 2026 at 13:5110 Mav 2026 at 12:51110 May 2026 at 13:5019 Dec 2025 at 11:3431 Oct 2025 at 12:2525 Nau 2025 г+ 17:506 Mar 2026 at 11.2224 Anr 2026 at 16:5220 Oct 2025 at 11:0218 Mar 2026 at 15:29• Mav 2026 at 11:09129 Oct 2025 at 19:329 May 2026 at 10:0419 Mar 2026 at 11:55|10 May 2026 at 13:5712 Jun 2025 at 19:0430 Jan 2026 at 12:362616 Oct 2025 aт 16:0122 Aar 2026 at 12:0223 Apr 2026 at 13:0223 Apr 2026 at 13:0222 Anr 2026 at 12:0223 Apr 2026 at 13:0229 Jan 2026 at 15:20...
|
NULL
|
-2134816872056375722
|
NULL
|
visual_change
|
ocr
|
NULL
|
Jiminny... ~ActivityFilesLater@ jiminny-x-integrat Jiminny... ~ActivityFilesLater@ jiminny-x-integrati• plattorm-inner-teamE Channels# ai-chapter# alertsi backend# bugscontusion-cllnia# curiosity lab# engineering# general#jiminny-bgac nlattorm-nckets# product launches# random# releases# sofia-officed sunport# thank-yous# the people of iimi... Direct messages• 8la 62% Galya Dimitrovavasil VasilevM Stefka StovanovaSg: Todor StamatovMario GeorgievNiudlay lanay. James Graham "* Stoyan Tanevo Stelivan Georgiev( Petko Kashinski*. Lukas Kovali...::: AnnsToastS lira Gloud6d Huddle with Aneliva Angelova* Aneliya Angelova &• Messagest Add canvasur FilesAneliya Angelova 2:30 PMЛукаш, само при Сейлсфорс и Хубспот се синкваха активити типовете, когато се направи плейбук,нали?пои останалите скМі тоябва оъчно ла се въвелат.Lukas Kovalik 2:47 PMтряова да се за всички, някъде не се ли попьлвапри зохо май беше hardcoded но май и там си връщаха две категорииAneliya Angelova 4:00 PMЛукаш имаш ли време да се чуем за тестването на https://jiminny.atlassian.net/browse/JY-20725X Preuiew in sact[HubSpotl Optimise CRM rematct(7 OpenRosdy for OA- MediumAA Aneliya AngelovaAs of today at 4:00 PM RetreshOpen in Jira* SummariseLukas Kovalik 4:02 PMzean-iurwiniareelYou joined the huddle (LIVE 4:06 PMAneliva Angelova is here toolAneliva Angelova 4:44 PN11512582Lukas Kovalik M 5.16 PNcurl --location "httns:ani.hubani.com/crm/v3/obiects/contacts/search--header 'Content-vne' annlication/ison'--header "Authorization: BearerC|Kvma?iMx7OINOM8kOFwrAowAcAkUAhIR24?A05D?xiVrIYMILuP6SA01Kwcmhr=n.v2 hytorDniMvDalYck-CMalovolNOM9kOFwrAUAcrkcaws-ThwRARIRAOF-ATFSAOFRAO-ROIEBAQUBEggBAQEBAYICFIr7Au8O2Nwal9YW2CbCCxYvwngmSgNldTFSAFoAYABoAHAAeAA'\--data "{ "limit", 13'Message Aneliva Angelova+ AaIđ 0Pmv Q SearchDate ModifiedYesterday at 13.3Yesterday at 13:36Yesterday at 13:36Yesterday at 13.30Yesterdav at 13:35Yesterday at 13:34Yesterday at 13:34Yesterdav at 13:3kYesterday at 13:33Yesterday at 13.32Yesterdav at 13:31resterday at 15:30Yesterdav at 13:30Yesterday at 13:29Yesterday at 13:29Yesterday at 13:28Yecterdav at 12:28resterday at 13-21Yesterdav at 13:26Yesterday at 13:26Yesterday at 13:25Yesterday at 13:25Voctorday at 12:01Yesterday at 13.23Yesterdav at 13:22resterady al 15.clYesterday at 13:20Yesterday at 13.19Yecterdav at 12:19Yesterdav at 13:18Yesterday at 13-1/Yesterdav at 13:17Yesterday at 13:16Yesterday at 13:15Yecterdav at 12:15Yesterday at 13.14Yecterdav at 13:10Yesterday at 13:13Yesterday at 13:12Yoctorday at 12:11Yesterday at 13.10Yecterdav at 12:10Yesterdav at 13:09Yesterdav at 13:08v Size23 KB9 KB13 Kb16 KE17 KB29 KB16 K:12 KB9 KEIKBn8 KBI37 KB10 KB7 KE8 KB9 KB8 KB172 KR14 KB13 KB9 KE18 KB12 KB10 KBI16 KB6 KB6KB12 KE23 KB8 KB16 K:11 KB20 KP34 KB10 KB7 K:5 KB11 KB26 KBMPEG-4 movieMPEG-4 movieMPEG-4 movieMPEG-4 movieMPE0"4 movieMDSG-A movieMP2G-4 movieMPEG-4 movieMPEG"4 movieMPEG-4 movieMoEeh movioMPEG-4 movieMPEG-1 movieMPEG-4 movieMPEG-4 movieMPEG-4 movieMPEG-4 movieMDEG.A movicMPEG-4 movieMPEG-4 movieMPEG-4 movieMPEG-4 movieMDEC A movidMPEG-4 movieMDEG-A movieMPEG-4 movieMPEG-4 movieMDEC.A moviaMPEG-4 movie111 KC102 KB88 KBMDEG-A moviaMPEG-4 movie59 KB1MPEG-A movie98 KB MPEG-4 movie97 KB66 K3MPEG-4 movieMDEG.A movioAAKP93 KB/8 KBMPEG-4 movie50 KгMDEG-A movid58 KBMPEG-4 movie27 KB7KBMPEG-4 movieAl Notes: OffLeave• • CFavourites• jiminny• Recents* ApplicationsiCloud• iCloud Drive992 Svnc tolde0 DXP4800PLUS-B5F49 Networl• CRMI• Orange• Red|• Yellow• Green• Purple•) All lags..IhlDownloadsNameLoom.pkgAlfred copv.alfredoreterences=KeychronAssist-1.0.2 (1).dmeKeychronAssist-1.0.2.dmgmazanoke-images-yWJo.zioPhotos-3-001.zip• Transcript.pd→mage U.loge1 Orioninstaller.dma_image.jpg- image (2).1pc• ПO-22221726037035-004-001_ORGES.pdf%D0%9F%D0%9E-22221726037035-004-001 archive.zipПO-22221726037035-004-001_archive (1).zip• repon 4).XmAltred copy2.altredoreterencesСE 060209С О000000026571172 CWICT 0Р70501260015900 ndt= 27022026_0000000026574472_ SWIFT_ [IBAN].pdf= 03042026 [CREDIT_CARD] SWIFT [CREDIT_CARD].001reportxmmi=pdt.odiB pdf-1.pdfD pdf-2.pdf-pdf-5.pd1= ndf-1 ndipdf-3.pdfKoválik Family Tree.gedbitwarden export 20251031122528.isonlKoválik Family Tree.zip*macOS Storage_Cleanup.mdal favicon icofirst aid_notes_complete.docxrenortl2).esv1 config.vmlIteration run Search HS.postman_collection.jsonm licence bettertouchtoalMariusHosting Config.isonnokc.901a6502.6667.A62h-062 ccu•AlfredwPmazanoko.imnaoc.YWIfана Ковалик.jpg•искане даниел Ковалик..pg• Фактура Март Даниел Ковалик.jрс• Фактура Април Даниел Ковалик iро• Dhotac 2.001Q SearchKind00,4 MD55.9 MBinstdlle..dckageAlfred...ferences10,1 MB10,1 MBIL MBDisk ImageDisk ImageLiP archive6.6 MBZIp archive2,5 MB PDF Document2,5 MBJreo lmage2.2 MBDisk Image2,2 MB PDF Document2 MBJPEG image1,9 MBJPEG imaqe192 KBPDE Document140 KbZIP archive148 KВ148 KB122 KBZIP archivelZIP archiveXML document111 KBAlfred...ferences94 KBDDE Documont92 KBPDr DocumentK:PDF Document91 KB91 KBXML document30 KBn0kpDDE Nocumont28 KBPDF Document28 KRPDE Document28 KB27 KBDocument14 KB11 KB6 KB6KBJSONINCV NacumontZIP archiveMarkdo…..ument5KRWindo...n image4 KB3 KBword ..cumentCSV Document2 KBVAMI dosumon1 KB029 buteccSV Documenthttlicence183 bytesZero butesJSONAlfrod foronso!Zero bytesFolde1.9 MB1,8 MB17 MB1,7 MBColdorJPEG imageJPEG ImageIPEG imadeJPEG imageColdo1 of 58 selected, 8.49 GB availabld• Inu 14 May 1/.33.10Date AddedIs Mdl ZUzo dl 19:4530 Jan 2026 at 12:3617 Mar 2026 at 20:2717 Mar 2026 at 20:26Z3 Aor 2020 al 13:0229 ,Jan 2026 at 15:2019 Dec 2025 at 10:1619 Dec 2025 at 12:238 Aor 2026 at 20:3519 Dec 2025 at 10:2919 Dec 2025 at 12:1819 Dec 2025 at 12:4026 Mar 2026 at 11:2410 May 2026 at 13:5326 Mar 2026 at 11:2426 Mar 2026 at 11:2426 Mar 2026 at 11:2310 May 2026 at 14:3730 Jan 2026 at 12:3712 Goh 2006 9t 11:5 A1Z3 Apr 2026 at 13.0823 Aor 2026 at 13:0810 May 2026 at 13:5410 May 2026 at 14:3710 May 2026 at 13:4910 May 2026 at 13:5010 May 2026 at 13:5110 Mav 2026 at 12:51110 May 2026 at 13:5019 Dec 2025 at 11:3431 Oct 2025 at 12:2525 Nau 2025 г+ 17:506 Mar 2026 at 11.2224 Anr 2026 at 16:5220 Oct 2025 at 11:0218 Mar 2026 at 15:29• Mav 2026 at 11:09129 Oct 2025 at 19:329 May 2026 at 10:0419 Mar 2026 at 11:55|10 May 2026 at 13:5712 Jun 2025 at 19:0430 Jan 2026 at 12:362616 Oct 2025 aт 16:0122 Aar 2026 at 12:0223 Apr 2026 at 13:0223 Apr 2026 at 13:0222 Anr 2026 at 12:0223 Apr 2026 at 13:0229 Jan 2026 at 15:20...
|
45372
|
NULL
|
NULL
|
NULL
|
|
45374
|
1625
|
24
|
2026-05-14T14:33:21.074066+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-14/1778 /Users/lukas/.screenpipe/data/data/2026-05-14/1778769201074_m1.jpg...
|
PhpStorm
|
faVsco.js – Copper/Service.php
|
1
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
PhpStormFileEditViewNavigateCodeLaravelRefactorRun PhpStormFileEditViewNavigateCodeLaravelRefactorRunToolsGitWindowHelpabl100% C•FV faVsco.jsv* #12066 on JY-20725-handle-HS-search-rate-limit k vHandleHubspotRateLimitTest v8• Thu 14 May 17:33:20* :QProjectv:=custom.logAutomatedReportGenerated.phpTrackAutomatedReportGeneratedEvent.phpPlaybackController.php© FieldDefinitions.phpUserAutomatedReportsController.phpPlanhatService.phpAutomatedReportResult.phpSendReportJob.php=laravel.log© FieldValueConverter.php• DeleteCrmEntityTrait.phpDeleteAccountJob.php© ImportActivityTypes.phpT WriteCrmTrait.php© Service.php© DecorateActivity.phpSF [jiminny@localhost]> D CrmObjects© Salesforce/Service.php© LogActivityTrait.phpPipedrive/Service.php© Close/Service.phpCopper/Service.php XA HS_local [jiminny@localho:W• DecorateActivity© PlainTextDecorateActivity.phpT ActivityPlaybookTrait.php© CrmHelperRepository.phpAccountController.php4 console [QAI PROD] X18© BaseDecorateActivity.php© PlainTextDecorateActivity.plT IntegrationAppTrait.php.env.stagingE.env© DetachActivityObject.phpRematchActivityOnCrmObjectDetach.php4 console [PROD]> D Dummyv Helpers52T ActivityPlaybookTrait.php1664© Arraylterator.php1678© MatchActivityCrmData.php© Client.php© HubspotPaginationService.phpclass Service extends BaseService implementsprivate function buildCallPayload(Activity $activity): Activityaetarts'= $tnis->generateActIvityuescription(sactivity),© HandleHubspotRateLimit.phpA console (EU]811 472 M1 м4 лT ConnectionStateTrait.php1679© CrmHelperRepository.php16801):• else {© FilterJoinedParticipants.php1681+ OpportunitySyncableFieldsT1682v D Hubspot1683• AccountSyncStrategy1684DGo jiminny084143 ×4 л215•m_layout_id =2162,1661, 66799,66217itn(Sthis-2getCrmType(SactivitX->categacV2mmm0ulVnt218$message = sprintf( format: '[Copper] Invalid CRM Activity Type: %s', $activity->category->getName()):_* 219=220=221.id = 33;throw new CrmException(Smessage,code: 400);> OJ Actions |1685• ContactSyncStrategy1686> ODTO1687D Fields1688› O Journal1689D Metadata1698code: 400):v C OpportunitySyncStrategy1691› D Concerns1692© HubspotLastModifiedByF 1693© HubspotLastModifiedCre 1694© HubspotLastModifiedCre 1695© HubspotLastModifiedOpe 1696© HubspotLastModifiedSyn 169711698if (Sthis->getCrmType($activity->category)->value === null) {=223$messagel = sprintf( format: '[Copper] Invalid CRM Activity Type: %s value', $activity->category->getName224225Extract Surround //tnrow new Crmexception(smessage,=226=227228229=: 230|231232233V=234id THEN •(own© HubspotSingleSyncStrate// Just create an activity.$cpActivity = $this->copperClient-›activities()->create(['user_id' => $this->profile->crm_provider_id,'activity_date' => $activity->scheduled_start_time->timestamp,'parent' => $this->convertActivityAssociations(Sactivity),'type' => $this->convertActivityType(Sactivity->category),'details' => $this->generateActivityDescription(Sactivity),hubspot":-= 11512582;© HubspotSyncStrategyBa: 16991):© HubspotWebhookBatchS1700v [ Pagination1701• HubspotPaginationServic 1702Sactivity->crm_provider_id = $cpActivity['id'];Sactivity->save):A DaninatinnCanfin nhnWorkspace associated with branch 'JY-20725-handle-HS-search-rate-limit' has been restored // Rollback // Configure... (today 16:17)W Windsurf Teams1688:29 (8 chars)UTF-8Ca 4 spaces...
|
NULL
|
-1341745411075778780
|
NULL
|
click
|
ocr
|
NULL
|
PhpStormFileEditViewNavigateCodeLaravelRefactorRun PhpStormFileEditViewNavigateCodeLaravelRefactorRunToolsGitWindowHelpabl100% C•FV faVsco.jsv* #12066 on JY-20725-handle-HS-search-rate-limit k vHandleHubspotRateLimitTest v8• Thu 14 May 17:33:20* :QProjectv:=custom.logAutomatedReportGenerated.phpTrackAutomatedReportGeneratedEvent.phpPlaybackController.php© FieldDefinitions.phpUserAutomatedReportsController.phpPlanhatService.phpAutomatedReportResult.phpSendReportJob.php=laravel.log© FieldValueConverter.php• DeleteCrmEntityTrait.phpDeleteAccountJob.php© ImportActivityTypes.phpT WriteCrmTrait.php© Service.php© DecorateActivity.phpSF [jiminny@localhost]> D CrmObjects© Salesforce/Service.php© LogActivityTrait.phpPipedrive/Service.php© Close/Service.phpCopper/Service.php XA HS_local [jiminny@localho:W• DecorateActivity© PlainTextDecorateActivity.phpT ActivityPlaybookTrait.php© CrmHelperRepository.phpAccountController.php4 console [QAI PROD] X18© BaseDecorateActivity.php© PlainTextDecorateActivity.plT IntegrationAppTrait.php.env.stagingE.env© DetachActivityObject.phpRematchActivityOnCrmObjectDetach.php4 console [PROD]> D Dummyv Helpers52T ActivityPlaybookTrait.php1664© Arraylterator.php1678© MatchActivityCrmData.php© Client.php© HubspotPaginationService.phpclass Service extends BaseService implementsprivate function buildCallPayload(Activity $activity): Activityaetarts'= $tnis->generateActIvityuescription(sactivity),© HandleHubspotRateLimit.phpA console (EU]811 472 M1 м4 лT ConnectionStateTrait.php1679© CrmHelperRepository.php16801):• else {© FilterJoinedParticipants.php1681+ OpportunitySyncableFieldsT1682v D Hubspot1683• AccountSyncStrategy1684DGo jiminny084143 ×4 л215•m_layout_id =2162,1661, 66799,66217itn(Sthis-2getCrmType(SactivitX->categacV2mmm0ulVnt218$message = sprintf( format: '[Copper] Invalid CRM Activity Type: %s', $activity->category->getName()):_* 219=220=221.id = 33;throw new CrmException(Smessage,code: 400);> OJ Actions |1685• ContactSyncStrategy1686> ODTO1687D Fields1688› O Journal1689D Metadata1698code: 400):v C OpportunitySyncStrategy1691› D Concerns1692© HubspotLastModifiedByF 1693© HubspotLastModifiedCre 1694© HubspotLastModifiedCre 1695© HubspotLastModifiedOpe 1696© HubspotLastModifiedSyn 169711698if (Sthis->getCrmType($activity->category)->value === null) {=223$messagel = sprintf( format: '[Copper] Invalid CRM Activity Type: %s value', $activity->category->getName224225Extract Surround //tnrow new Crmexception(smessage,=226=227228229=: 230|231232233V=234id THEN •(own© HubspotSingleSyncStrate// Just create an activity.$cpActivity = $this->copperClient-›activities()->create(['user_id' => $this->profile->crm_provider_id,'activity_date' => $activity->scheduled_start_time->timestamp,'parent' => $this->convertActivityAssociations(Sactivity),'type' => $this->convertActivityType(Sactivity->category),'details' => $this->generateActivityDescription(Sactivity),hubspot":-= 11512582;© HubspotSyncStrategyBa: 16991):© HubspotWebhookBatchS1700v [ Pagination1701• HubspotPaginationServic 1702Sactivity->crm_provider_id = $cpActivity['id'];Sactivity->save):A DaninatinnCanfin nhnWorkspace associated with branch 'JY-20725-handle-HS-search-rate-limit' has been restored // Rollback // Configure... (today 16:17)W Windsurf Teams1688:29 (8 chars)UTF-8Ca 4 spaces...
|
45371
|
NULL
|
NULL
|
NULL
|
|
45375
|
1625
|
25
|
2026-05-14T14:33:24.529049+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-14/1778 /Users/lukas/.screenpipe/data/data/2026-05-14/1778769204529_m1.jpg...
|
PhpStorm
|
faVsco.js – Copper/Service.php
|
1
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
#12066 on JY-20725-handle Project: faVsco.js, menu
#12066 on JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
HandleHubspotRateLimitTest
Run 'HandleHubspotRateLimitTest'
Debug 'HandleHubspotRateLimitTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
11
72
1
4
Previous Highlighted Error
Next Highlighted Error
<?php
namespace Jiminny\Services\Crm\Copper;
use Carbon\Carbon;
use Exception;
use GuzzleHttp\Exception\ClientException;
use Illuminate\Support\Facades\Cache;
use Jiminny\Contracts\Services\Crm\LayoutManagementInterface;
use Jiminny\Contracts\Services\Crm\MatchCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\Provider\CopperInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityLookupInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityManipulationInterface;
use Jiminny\Contracts\Services\Crm\SettingsInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmMetadataInterface;
use Jiminny\Contracts\Services\Crm\VerifyTaskExistsInterface;
use Jiminny\Exceptions\CrmException;
use Jiminny\Exceptions\HttpNotFoundException;
use Jiminny\Models\Account;
use Jiminny\Models\Activity;
use Jiminny\Models\Contact;
use Jiminny\Models\Crm\BusinessProcess;
use Jiminny\Models\Crm\Configuration;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\FieldData;
use Jiminny\Models\Crm\FieldValue;
use Jiminny\Models\Crm\LayoutEntity;
use Jiminny\Models\Crm\Profile;
use Jiminny\Models\Lead;
use Jiminny\Models\Opportunity;
use Jiminny\Models\Playbook;
use Jiminny\Models\PlaybookCategory;
use Jiminny\Models\SocialAccount;
use Jiminny\Models\Stage;
use Jiminny\Models\User;
use Jiminny\Repositories\Crm\CrmEntityRepository;
use Jiminny\Repositories\Crm\FieldRepository;
use Jiminny\Repositories\Crm\ProfileRepository;
use Jiminny\Repositories\UserRepository;
use Jiminny\Services\Avatar\ProspectPhotoPathService;
use Jiminny\Services\Crm\BaseService;
use Jiminny\Services\Crm\Copper\Api\Client;
use Jiminny\Services\Crm\Copper\Api\ClientFactory;
use Jiminny\Services\Crm\Copper\Exception\RequestFailedException;
use Jiminny\Services\Crm\Helpers\FilterJoinedParticipants;
use Jiminny\Services\Crm\OpportunitySyncStrategyResolver;
use Jiminny\Services\Crm\Copper\Traits\SearchesCrmEntities;
use Jiminny\Utils\CurrencyFormatter;
use Jiminny\Utils\StringUtil;
class Service extends BaseService implements
CopperInterface,
SyncCrmEntitiesInterface,
SyncCrmMetadataInterface,
RemoteEntityManipulationInterface,
LayoutManagementInterface,
SettingsInterface,
MatchCrmEntitiesInterface,
RemoteEntityLookupInterface,
VerifyTaskExistsInterface
{
use SearchesCrmEntities;
private const int NOTE_BODY_MAX_LENGTH = 3000000;
private const int TASK_VERIFICATION_CACHE_TTL = 86400; // 1 day
private const array STANDARD_OPPORTUNITY_FIELDS = [
'monetary_value' => [
'label' => 'Value',
'available_on' => Field::OBJECT_OPPORTUNITY,
'type' => Field::TYPE_CURRENCY,
],
'pipeline_stage_id' => [
'label' => 'Stage',
'type' => Field::TYPE_PICKLIST,
'available_on' => Field::OBJECT_OPPORTUNITY,
],
'close_date' => [
'label' => 'Close date',
'type' => Field::TYPE_DATE,
'available_on' => Field::OBJECT_OPPORTUNITY,
],
'win_probability' => [
'label' => 'Win probability',
'type' => Field::TYPE_NUMBER,
'available_on' => Field::OBJECT_OPPORTUNITY,
],
];
private const string UNSUPPORTED_OBJECT_TYPE = 'unsupported';
private const string OPPORTUNITY_STATUS_OPEN = 'Open';
private const string OPPORTUNITY_IMPORT_FAILED_ERROR_MESSAGE = 'Opportunity import failed';
private const string RESOURCE_UPDATE_FAILED_ERROR_MESSAGE = 'Failed to update resource';
private FieldValueConverter $fieldValueConverter;
private ClientFactory $clientFactory;
private ?Client $copperClient = null;
public function __construct(
ClientFactory $clientFactory,
FieldValueConverter $fieldValueConverter,
private readonly UserRepository $userRepository,
private readonly ProspectPhotoPathService $prospectPhotoPathService,
private readonly CrmEntityRepository $crmEntityRepository
) {
parent::__construct();
$this->clientFactory = $clientFactory;
$this->fieldValueConverter = $fieldValueConverter;
}
public function getDisplayName(): string
{
return 'Copper';
}
/**
* Get the CRM entity repository (used by SearchesCrmEntities trait)
*/
protected function getCrmEntityRepository(): CrmEntityRepository
{
return $this->crmEntityRepository;
}
public function getMinimumApiVersion(): string
{
return Client::MIN_API_VERSION;
}
protected function getOAuthAccount(User $user): ?SocialAccount
{
// In this case, the Account Owner is always the connection for any API operations.
$owner = $user->team->owner;
return $owner->getSocialAccount(SocialAccount::PROVIDER_COPPER);
}
public function setUser(User $user): void
{
$this->configureSentryScope($user);
$this->team = $user->getTeam();
$this->teamService->setTeam($user->getTeam());
$this->setConfiguration($user->getTeam()->getCrmConfiguration());
// Reset state.
$this->profile = $user->getProfile();
if (! $this->profile instanceof Profile) {
// Try to fetch their profile, maybe it was not yet imported?
$this->profile = $this->getProfile($user);
}
}
public function setConfiguration(Configuration $configuration): void
{
$this->config = $configuration;
$this->copperClient = $this->clientFactory->create($configuration);
if ($this->team === null) {
$this->team = $configuration->getTeam();
}
}
public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string
{
return $this->fieldValueConverter->convertFromCrm($fieldType, $fieldValue);
}
private function mapObjectType(string $objectType): string
{
$objectType = strtolower($objectType);
switch ($objectType) {
case Field::OBJECT_TASK:
case Field::OBJECT_OPPORTUNITY:
case Field::OBJECT_LEAD:
$mappedType = $objectType;
break;
case Field::OBJECT_ACCOUNT:
$mappedType = 'company';
break;
case Field::OBJECT_CONTACT:
$mappedType = 'person';
break;
default:
$mappedType = self::UNSUPPORTED_OBJECT_TYPE;
break;
}
return $mappedType;
}
/**
* @param string $crmObject The name of the CRM object. i.e. Opportunity or Contact
*/
protected function getFields(string $crmObject): array
{
$mappedObjectType = $this->mapObjectType($crmObject);
if (! $this->isSupportedObjectType($mappedObjectType)) {
return [];
}
$customFields = $this->copperClient->fields()->all();
$filteredFields = array_filter(
$customFields,
static fn ($field) => in_array($mappedObjectType, $field['available_on'], true)
);
return array_map(
fn (array $property) => [
'name' => $property['id'],
'label' => $property['name'],
'type' => $this->convertFieldType($property['data_type']),
],
$filteredFields
);
}
/**
* @inheritdoc
*/
public function getDefaultFields(string $activityType): array
{
$fields = [];
if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {
$defaultFields = FieldDefinitions::defaultTaskFields();
// This lazy creates these fields if not already setup.
foreach ($defaultFields as $defaultField) {
$fields[] = $this->config->fields()->firstOrCreate($defaultField);
}
}
return $fields;
}
/**
* @inheritdoc
*/
public function getDefaultActivityField(string $activityType): Field
{
// Setup the activity field as the default Type.
$activityField = $this->config->fields()->where([
'crm_provider_id' => 'type',
'object_type' => $activityType,
])->first();
return $activityField;
}
/**
* @inheritdoc
*/
public function getSupportedPlaybookTypes(): array
{
return [Playbook::ACTIVITY_TYPE_TASK];
}
public function getDealInsightsFields(): array
{
return FieldDefinitions::dealInsightsFields();
}
protected function getDefaultFollowupLayoutFields(string $activityType): array
{
$fields = [];
$fieldRepo = app(FieldRepository::class);
$fieldFilters = FieldDefinitions::taskFollowupFieldsFilter();
foreach ($fieldFilters as $data) {
$field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $data);
// Only add the field if it is created, which it should be.
if ($field) {
$fields[] = $field;
}
}
return $fields;
}
public function syncFields(): void
{
$this->syncStandardFields();
$this->syncCustomFields();
}
public function syncField(Field $field): void
{
if ($field->getCrmProviderId() === 'type') {
/**
* This field is a special case, it is not a custom field and is not returned by the Copper API.
*
* The field is created manually during layout setup.
*
* @see getDefaultFields()
*
* There is a separate endpoint to get the activity types.
* @see importPicklistValues()
*/
return;
}
if ($field->getCrmProviderId() === 'pipeline_stage_id') {
// Not a custom type
return;
}
$crmField = $this->copperClient->fields()->find($field->getCrmProviderId());
$field->label = $crmField['name'];
$field->type = $this->convertFieldType($crmField['data_type']);
$field->save();
}
/**
* @param string $fieldType How the data should be input
*/
private function convertFieldType(string $fieldType): string
{
$fieldType = strtolower($fieldType);
switch ($fieldType) {
case 'dropdown':
$convertedType = Field::TYPE_PICKLIST;
break;
case 'string':
$convertedType = Field::TYPE_TEXT;
break;
case 'float':
case 'percentage':
$convertedType = Field::TYPE_NUMBER;
break;
case 'text':
$convertedType = Field::TYPE_TEXTAREA;
break;
case 'checkbox':
$convertedType = Field::TYPE_BOOLEAN;
break;
case Field::TYPE_DATE:
case Field::TYPE_CURRENCY:
case Field::TYPE_URL:
$convertedType = $fieldType;
break;
default:
$convertedType = Field::TYPE_UNSUPPORTED;
break;
}
return $convertedType;
}
/**
* @inheritdoc
*/
public function importPicklistValues(Field $field): array
{
$fieldValues = [];
if ($field->object_type === 'opportunity' && $field->crm_provider_id === 'pipeline_stage_id') {
return $this->importFieldValuesForDealInsights($field);
}
$values = [];
if ($field->object_type === Field::OBJECT_TASK && $field->crm_provider_id === 'type') {
$activityTypes = $this->copperClient->activityTypes()->all();
/**
* We ignore "system" activity types, disabled activity types and the "Note" activity type.
*/
$activityTypes = array_filter(
$activityTypes['user'],
fn (array $activityType) => ! $activityType['is_disabled'] && $activityType['id'] !== 0
);
// Import all activity values.
$i = 0;
foreach ($activityTypes as $activityType) {
$values[] = [
'value' => $activityType['id'],
'label' => mb_strimwidth($activityType['name'], 0, Field::LABEL_MAX_LENGTH),
'sequence' => $i++,
];
}
} else {
// We just check for the field in the custom definitions.
$crmField = $this->copperClient->fields()->find($field->crm_provider_id);
// Import all values.
foreach ($crmField['options'] as $option) {
$values[] = [
'value' => $option['id'],
'label' => mb_strimwidth($option['name'], 0, 191),
'sequence' => $option['rank'],
];
}
}
// Take the existing set of field values.
$fieldsToPurge = $field->values()->get()->pluck('value')->toArray();
foreach ($values as $value) {
// For all current values, upsert the field.
$value['value'] = substr($value['value'] ?? '', 0, 255);
$fieldValues[] = $field->values()->updateOrCreate([
'value' => $value['value'],
], $value);
// Remove this value from the ones we are going to purge.
$key = array_search($value['value'], $fieldsToPurge, false);
if ($key !== false) {
unset($fieldsToPurge[$key]);
}
}
// Delete the old values that are no longer used.
$field->values()->whereIn('value', $fieldsToPurge)->delete();
return $fieldValues;
}
/**
*
* @return FieldValue[]
*/
private function importFieldValuesForDealInsights(Field $field): array
{
$fieldValues = [];
$pipelines = $this->copperClient->pipelines()->all();
foreach ($pipelines as $pipeline) {
foreach ($pipeline['stages'] as $i => $dealStage) {
$fieldValues[] = $field->values()->updateOrCreate(
['value' => $dealStage['id']],
[
'label' => $dealStage['name'],
'sequence' => $i,
]
);
}
}
return $fieldValues;
}
/**
* @inheritdoc
*/
public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage
{
if ($types === null) {
$types = [Stage::TYPE_LEAD, Stage::TYPE_OPPORTUNITY];
}
$missingStage = null;
foreach ($types as $type) {
if ($type === Stage::TYPE_LEAD) {
$statuses = $this->requestWithExceptionHandling(
fn () => $this->copperClient->leadStatuses()->all()
);
if (count($statuses) > 0) {
$businessProcess = $this->config->businessProcesses()->updateOrCreate([
'crm_provider_id' => BusinessProcess::TYPE_LEAD,
], [
'team_id' => $this->team->id,
'name' => BusinessProcess::TYPE_LEAD,
'type' => BusinessProcess::TYPE_LEAD,
'is_selectable' => true,
]);
// A record type is really a clone of the business process, used to store which record uses which pipeline.
$this->config->recordTypes()->updateOrCreate([
'crm_provider_id' => BusinessProcess::TYPE_LEAD,
], [
'team_id' => $this->team->id,
'name' => BusinessProcess::TYPE_LEAD,
'is_selectable' => true,
'business_process_id' => $businessProcess->id ?? null,
]);
$leadStatuses = [];
foreach ($statuses as $status) {
// Upsert all statuses for the team.
$status = $this->config->stages()->updateOrCreate([
'crm_provider_id' => $status['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($status['name'], 0, 50),
'label' => mb_strimwidth($status['name'], 0, 191),
'type' => Stage::TYPE_LEAD,
'sequence' => $status['order'],
'is_selectable' => true,
]);
if ($missingStageName && $missingStageName === $status['id']) {
$missingStage = $status;
}
$leadStatuses[] = $status['id'];
}
$businessProcess->stages()->sync($leadStatuses);
}
} else {
// We create a business process to contain the pipeline, and later store all stages against it.
foreach ($this->copperClient->pipelines()->all() as $pipeline) {
$businessProcess = $this->config->businessProcesses()->updateOrCreate([
'crm_provider_id' => $pipeline['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($pipeline['name'], 0, 150),
'type' => BusinessProcess::TYPE_OPPORTUNITY,
'is_selectable' => true,
]);
// A record type is really a clone of the business process, used to store which record uses which pipeline.
$this->config->recordTypes()->updateOrCreate([
'crm_provider_id' => $pipeline['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($pipeline['name'], 0, 150),
'is_selectable' => true,
'business_process_id' => $businessProcess->id ?? null,
]);
$stages = [];
foreach ($pipeline['stages'] as $i => $dealStage) {
// Upsert all stages for the team.
$stage = $this->config->stages()->updateOrCreate([
'crm_provider_id' => $dealStage['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($dealStage['name'], 0, 50),
'label' => mb_strimwidth($dealStage['name'], 0, 191),
'type' => Stage::TYPE_OPPORTUNITY,
'sequence' => $i,
'is_selectable' => true,
'probability' => $dealStage['win_probability'],
]);
if ($missingStageName && $missingStageName === $dealStage['id']) {
$missingStage = $stage;
}
$stages[] = $stage['id'];
}
$businessProcess->stages()->sync($stages);
}
}
}
return $missingStage;
}
/**
* @inheritdoc
*/
public function syncLeads(Carbon $since, ?Carbon $to = null, ?string $crmProfileId = null): int
{
$syncCount = 0;
$cpLeads = $this->requestWithExceptionHandling(
fn () => $this->copperClient->leads()->paginate([
'minimum_modified_date' => $since->timestamp,
'sort_by' => 'date_modified',
])
);
foreach ($cpLeads as $cpLead) {
// Only sync if previously imported.
if ($this->hasLead($cpLead['id']) === false) {
$this->importLead($cpLead);
$syncCount++;
}
}
return $syncCount;
}
/**
* @inheritdoc
*/
public function syncLead(string $crmId): ?Lead
{
$cpLead = $this->requestWithExceptionHandling(
fn () => $this->copperClient->leads()->find($crmId)
);
if (\count($cpLead) === 0) {
return null;
}
return $this->importLead($cpLead);
}
/**
* @inheritdoc
*/
private function importLead($crmData): ?Lead
{
$countryCode = null;
if (! empty($crmData['address']) && isset($crmData['address']['country'])) {
$countryCode = $this->convertCountryNameToCode($crmData['address']['country']);
}
$mobilePhone = $parsedNumber = null;
if (! empty($crmData['phone_numbers'])) {
foreach ($crmData['phone_numbers'] as $phoneNumber) {
if ($phoneNumber['category'] === 'mobile') {
$mobilePhone = $this->buildContactMobilePhone($countryCode, $phoneNumber['number']);
} else {
$parsedNumber = $this->buildContactPhone($countryCode, $phoneNumber['number']);
}
}
}
$name = $crmData['name'];
if (empty($crmData['company_name'])) {
$company = 'Unknown';
} else {
$company = mb_strimwidth($crmData['company_name'], 0, 191);
}
$domain = null;
if (empty($crmData['websites']) === false) {
$domain = mb_strimwidth($crmData['websites'][0]['url'], 0, 191);
$domain = StringUtil::resolveDomain($domain);
}
$email = null;
if (empty($crmData['email']) === false) {
$email = mb_strimwidth($crmData['email']['email'], 0, 80);
}
$stage = $this->config
->stages()
->where('crm_provider_id', (string) $crmData['status_id'])
->where('type', Stage::TYPE_LEAD)
->first();
if ($stage === null) {
// Import it.
$stage = $this->importStages([Stage::TYPE_LEAD], $crmData['status_id']);
}
if (! $stage instanceof Stage) {
return null;
}
$profile = $this->config->profiles()->where('crm_provider_id', (string) $crmData['assignee_id'])->first();
$data = [
'team_id' => $this->team->id,
'user_id' => $profile ? $profile->user_id : null,
'owner_id' => $crmData['assignee_id'],
'name' => mb_strimwidth($name, 0, 191),
'company' => $company,
'domain' => $domain,
'title' => $crmData['title'] ? mb_strimwidth($crmData['title'], 0, 128) : null,
'email' => $email,
'phone' => $parsedNumber['phone'] ?? null,
'ext' => $parsedNumber['ext'] ?? null,
'mobile_phone' => $mobilePhone,
'stage_id' => $stage->id,
'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(
crmConfiguration: $this->config,
crmProviderId: $crmData['id'],
modelType: Lead::class,
fileName: $crmData['id'],
avatarText: $crmData['name']
),
'country_code' => $countryCode,
'remotely_created_at' => $crmData['date_created'],
];
return $this->config->leads()->updateOrCreate(['crm_provider_id' => (string) $crmData['id']], $data);
}
/**
* @inheritdoc
*/
public function syncAccounts(Carbon $since, ?Carbon $to = null): int
{
$syncCount = 0;
try {
$cpAccounts = $this->copperClient->accounts()->paginate([
'minimum_modified_date' => $since->timestamp,
'sort_by' => 'date_modified',
]);
foreach ($cpAccounts as $cpAccount) {
// Only sync if previously imported.
if ($this->hasAccount($cpAccount['id'])) {
$this->importAccount($cpAccount);
$syncCount++;
}
}
} catch (\Exception $exception) {
// Do nothing for now.
}
return $syncCount;
}
/**
* @inheritdoc
*/
public function syncAccount(string $crmId): ?Account
{
$cpAccount = $this->copperClient->accounts()->find($crmId);
if (empty($cpAccount)) {
return null;
}
return $this->importAccount($cpAccount);
}
/**
* @inheritdoc
*/
private function importAccount($crmData): Account
{
$countryCode = null;
if (empty($crmData['address']) === false && $crmData['address']['country']) {
$countryCode = $this->convertCountryNameToCode($crmData['address']['country']);
}
$name = $crmData['name'];
$parsedNumber = null;
foreach ($crmData['phone_numbers'] as $phoneNumber) {
$parsedNumber = parsePhoneNumber($countryCode, $phoneNumber['number']);
}
$domain = null;
if (empty($crmData['websites']) === false) {
$domain = mb_strimwidth($crmData['websites'][0]['url'], 0, 191);
$domain = StringUtil::resolveDomain($domain);
}
$profile = $this->config->profiles()->where('crm_provider_id', (string) $crmData['assignee_id'])->first();
$data = [
'team_id' => $this->team->id,
'user_id' => $profile ? $profile->user_id : null,
'owner_id' => $crmData['assignee_id'],
'name' => mb_strimwidth($name, 0, 191),
'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(
crmConfiguration: $this->config,
crmProviderId: $crmData['id'],
modelType: Account::class,
fileName: $crmData['id'],
avatarText: $crmData['name']
),
'country_code' => $countryCode,
'domain' => $domain,
'phone' => $parsedNumber['phone'] ?? null,
'ext' => $parsedNumber['ext'] ?? null,
'remotely_created_at' => $crmData['date_created'],
];
return $this->config->accounts()->updateOrCreate(['crm_provider_id' => (string) $crmData['id']], $data);
}
public function syncOpportunities(array $parameters, ?string $strategy = null): int
{
$strategyResolver = app(OpportunitySyncStrategyResolver::class, [
'client' => $this->copperClient,
]);
$strategies = $strategyResolver->getStrategies($this->config, $strategy);
$syncCount = 0;
try {
foreach ($strategies as $syncStrategy) {
foreach ($syncStrategy->fetchOpportunities($parameters) as $opportunityData) {
$this->importOpportunity($opportunityData);
$syncCount++;
}
}
} catch (\Exception $exception) {
// Do nothing for now.
$this->logger->error(self::OPPORTUNITY_IMPORT_FAILED_ERROR_MESSAGE, [
'error' => $exception->getMessage(),
]);
}
return $syncCount;
}
public function syncOpportunity(string $crmId): ?Opportunity
{
$strategyResolver = app(OpportunitySyncStrategyResolver::class, [
'client' => $this->copperClient,
]);
$strategy = $strategyResolver->resolve(
$this->config,
OpportunitySyncStrategyResolver::SINGLE_SYNC_OPPORTUNITY_STRATEGY
);
$parameters['crm_id'] = $crmId;
$cpOpportunity = $strategy->fetchOpportunities($parameters);
return $this->importOpportunity($cpOpportunity);
}
private function importOpportunity($crmData): ?Opportunity
{
$companyId = (string) $crmData['company_id'];
if (empty($companyId)) {
$this->logger->error(self::OPPORTUNITY_IMPORT_FAILED_ERROR_MESSAGE, [
'error' => 'No company id',
]);
return null;
}
$account = $this->config->accounts()->where('crm_provider_id', $companyId)->first();
if ($account === null) {
$account = $this->syncAccount($companyId);
}
$profile = $this->config->profiles()->where('crm_provider_id', (string) $crmData['assignee_id'])->first();
$businessProcess = $this->config
->businessProcesses()
->where('crm_provider_id', (string) $crmData['pipeline_id'])
->first();
if ($businessProcess === null) {
// It is possible for some deals to not be in a pipeline.
$this->logger->warning(self::OPPORTUNITY_IMPORT_FAILED_ERROR_MESSAGE, [
'error' => 'Missing pipeline',
]);
return null;
}
$stage = $businessProcess
->stages()
->where('crm_provider_id', (string) $crmData['pipeline_stage_id'])
->where('type', Stage::TYPE_OPPORTUNITY)
->first();
if ($stage === null) {
$this->logger->info('Stage not found, importing', [
'pipeline_id' => $crmData['pipeline_id'],
'stage_id' => $crmData['pipeline_stage_id'],
]);
$stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $crmData['pipeline_stage_id']);
}
$recordType = $businessProcess->recordTypes()->first();
$closeDate = null;
$isClosed = $crmData['status'] !== self::OPPORTUNITY_STATUS_OPEN;
if ($crmData['close_date']) {
$closeDate = Carbon::parse($crmData['close_date'])->format('Y-m-d');
}
$data = [
'team_id' => $this->team->getId(),
'account_id' => $account->id,
'user_id' => $profile->user_id ?? null,
'owner_id' => $crmData['assignee_id'],
'name' => mb_strimwidth($crmData['name'], 0, 128),
'value' => $crmData['monetary_value'],
'currency_code' => CurrencyFormatter::formatCode($crmData['monetary_unit']),
'close_date' => $closeDate,
'is_closed' => $isClosed,
'is_won' => $crmData['status'] === 'Won',
'stage_id' => $stage->id,
'record_type_id' => $recordType->id ?? null,
'remotely_created_at' => $crmData['date_created'],
];
/** @var Opportunity $opportunity */
$opportunity = $this->config
->opportunities()
->updateOrCreate(['crm_provider_id' => (string) $crmData['id']], $data)
;
// import external fields into crm_field_data if present
$dealInsightsFields = $this->getOpportunitySyncableFields();
$this->importOpportunityCrmFieldData($crmData, $dealInsightsFields, $opportunity->id);
return $opportunity;
}
/**
* @param array<string, mixed> $opportunityData
* @param string[] $dealInsightsFields
*/
public function importOpportunityCrmFieldData(
array $opportunityData,
array $dealInsightsFields,
int $opportunityId
): void {
foreach ($dealInsightsFields as $fieldName) {
$field = $this->config->fields()->with('entities')
->where('crm_provider_id', $fieldName)
->where('object_type', Field::OBJECT_OPPORTUNITY)
->first();
if (! $field instanceof Field) {
$this->logger->warning('Field not found', [
'field' => $fieldName,
'opportunity_id' => $opportunityId,
]);
continue;
}
$entity = $field->getEntities()->first();
if (! $entity instanceof LayoutEntity) {
continue;
}
$fieldValue = $this->extractFieldValue($opportunityData, $fieldName);
$normalizedValue = $this->normalizeValue($field->getType(), (string) $fieldValue, true);
$field->data()
->updateOrCreate([
'crm_layout_entity_id' => $entity->id,
'crm_field_id' => $field->id,
'object_type' => 'opportunity',
'object_id' => $opportunityId,
], ['value' => $normalizedValue])
;
}
}
/**
* @inheritdoc
*/
public function syncContacts(Carbon $since, ?Carbon $to = null): int
{
$syncCount = 0;
try {
$cpContacts = $this->copperClient->contacts()->paginate([
'minimum_interaction_date' => $since->timestamp,
'sort_by' => 'date_modified',
]);
foreach ($cpContacts as $cpContact) {
// Only sync if previously imported.
if ($this->hasContact($cpContact['id'])) {
$this->importContact($cpContact);
$syncCount++;
}
}
} catch (\Exception $exception) {
// Do nothing for now.
throw $exception;
}
return $syncCount;
}
/**
* @inheritdoc
*/
public function syncContact(string $crmId): ?Contact
{
$cpContact = $this->copperClient->contacts()->find($crmId);
if (empty($cpContact)) {
return null;
}
return $this->importContact($cpContact);
}
/**
* @inheritdoc
*/
private function importContact($crmData): Contact
{
$account = null;
if ($crmData['company_id']) {
$account = $this->team
->crm
->accounts()
->where('crm_provider_id', (string) $crmData['company_id'])
->first();
if ($account === null) {
$account = $this->syncAccount($crmData['company_id']);
}
}
$countryCode = null;
if (empty($crmData['address']) === false && $crmData['address']['country']) {
$countryCode = $this->convertCountryNameToCode($crmData['address']['country']);
}
$mobilePhone = $parsedNumber = null;
foreach ($crmData['phone_numbers'] as $phoneNumber) {
if ($phoneNumber['category'] === 'mobile') {
$mobilePhone = $this->buildContactMobilePhone($countryCode, $phoneNumber['number']);
} else {
$parsedNumber = $this->buildContactPhone($countryCode, $phoneNumber['number']);
}
}
$email = null;
if (empty($crmData['emails']) === false) {
$email = mb_strimwidth($crmData['emails'][0]['email'], 0, 80);
}
$profile = $this->config->profiles()->where('crm_provider_id', (string) $crmData['assignee_id'])->first();
$data = [
'team_id' => $this->team->id,
'account_id' => $account->id ?? null,
'user_id' => $profile ? $profile->user_id : null,
'owner_id' => $crmData['assignee_id'],
'name' => $crmData['name'],
'email' => $email,
'phone' => $parsedNumber['phone'] ?? null,
'mobile_phone' => $mobilePhone ?? null,
'ext' => $parsedNumber['ext'] ?? null,
'title' => $crmData['title'],
'country_code' => $countryCode,
'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(
crmConfiguration: $this->config,
crmProviderId: $crmData['id'],
modelType: Contact::class,
fileName: $crmData['id'],
avatarText: $crmData['name']
),
'remotely_created_at' => $crmData['date_created'],
];
return $this->config->contacts()->updateOrCreate(['crm_provider_id' => (string) $crmData['id']], $data);
}
private function buildContactPhone(?string $countryCode, ?string $number): ?array
{
if ($number) {
// Trim to our width and attempt to parse it.
$number = mb_strimwidth($number, 0, 25);
$parsedNumber = parsePhoneNumber($countryCode, $number);
} else {
$parsedNumber = [];
}
return $parsedNumber;
}
private function buildContactMobilePhone(?string $countryCode, ?string $number): ?string
{
return $number ? mb_strimwidth(phone_e164($countryCode, $number), 0, 25) : null;
}
/**
* @inheritdoc
*/
public function syncOrganization(): void
{
$organization = $this->copperClient->organisation()->all();
if ($this->config->crm_provider_id !== $organization['id']) {
$this->config->update(['crm_provider_id' => $organization['id']]);
}
}
private function syncCustomFields(): void
{
foreach ($this->getFieldTypes() as $fieldType) {
$currentFields = $this->getFields($fieldType);
foreach ($currentFields as $field) {
/** @var Field $field */
$field = $this->config->fields()->updateOrCreate([
'crm_provider_id' => $field['name'],
'object_type' => $fieldType,
], [
'label' => mb_strimwidth($field['label'], 0, Field::LABEL_MAX_LENGTH),
'type' => $field['type'],
]);
if ($field->getType() === Field::TYPE_PICKLIST) {
$this->importPicklistValues($field);
}
}
}
$this->config->save();
}
protected function getFieldTypes(): array
{
return [
parent::OBJECT_TASK,
parent::OBJECT_LEAD,
parent::OBJECT_ACCOUNT,
parent::OBJECT_CONTACT,
parent::OBJECT_OPPORTUNITY,
];
}
/**
* @inheritdoc
*/
public function syncProfiles(?User $userToSearch = null): ?Profile
{
$profiles = $this->copperClient->users()->all();
foreach ($profiles as $profile) {
$user = $this->userRepository->findByTeamAndEmailAddress($this->team, $profile['email'], onlyActive: true);
// Only import profiles for users we have locally.
if ($user) {
$profile = $user->crmProfile()->updateOrCreate([
'crm_configuration_id' => $this->config->id,
'crm_provider_id' => $profile['id'],
]);
if ($userToSearch && $userToSearch->id === $user->id) {
return $profile;
}
}
}
return null;
}
/**
* @inheritdoc
*/
public function syncProfileFields(): void
{
$fieldTypes = [
'task',
'lead',
'account',
'contact',
'opportunity',
];
foreach ($fieldTypes as $fieldType) {
try {
$currentFields = $this->getFields(ucfirst($fieldType));
$newFields = array_column($currentFields, 'name');
$changedFields = array_merge(
array_diff($this->profile->getFieldsAsArray($fieldType), $newFields),
array_diff($newFields, $this->profile->getFieldsAsArray($fieldType))
);
if (\count($changedFields) > 0) {
$fields = implode(',', $newFields);
$this->logger->debug(
ucfirst($fieldType)
. ' fields changed from '
. $this->profile->{$fieldType . '_fields'}
. ' to: '
. $fields
);
$this->profile->{$fieldType . '_fields'} = $fields;
}
} catch (\Exception $exception) {
$this->logger->error('No access to ' . $fieldType . ' object, skipping...');
continue;
}
}
$this->profile->save();
}
/**
* @inheritdoc
*/
public function find(string $name, array $scopes): array
{
$count = $this->limit ?? 20;
$offset = $this->offset ?? 0;
$scopeValues = array_map(fn ($scope) => is_string($scope) ? $scope : $scope->value, $scopes);
$cacheKey = sprintf('copper-search-%d-%s-%s-%d-%d', $this->team->id, $name, implode('-', $scopeValues), $count, $offset);
return Cache::remember($cacheKey, 300, function () use ($name, $scopes, $count, $offset) {
// Try local search first
$localResults = $this->searchLocally($name, $scopes, $count, $offset);
// If no local results, fall back to remote API search
if (empty($localResults)) {
return $this->searchRemotely($name, $scopes, $count, $offset);
}
return $localResults;
});
}
/**
* @inheritdoc
*/
public function findOpportunities(?string $crmAccountId, ?string $crmContactId, ?int $userId = null): array
{
$data = [];
$ownerData = [];
$ownerId = null;
$contact = null;
$account = null;
if ($crmAccountId) {
$account = $this->config->accounts()->where('crm_provider_id', $crmAccountId)->first();
if ($account === null) {
$account = $this->syncAccount($crmAccountId);
}
}
if ($crmContactId) {
$contact = $this->config->contacts()->where('crm_provider_id', $crmContactId)->first();
if ($contact === null) {
$contact = $this->syncContact($crmContactId);
}
}
if ($contact || $account) {
if ($contact && $account === null) {
$account = $contact->account;
}
if ($account === null) {
return [];
}
$params = [
'company_ids' => [$account->crm_provider_id],
'status_ids' => [0], // The integer values are 0, 1, 2, 3, for "Open", "Won", "Lost", and "Abandoned".
'sort_by' => 'date_modified',
'sort_direction' => 'desc',
];
switch ($this->config->opportunity_assignment_rule) {
case Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_UPDATED:
$params['sort_by'] = 'date_modified';
$params['sort_direction'] = 'desc';
break;
case Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_CREATED:
$params['sort_by'] = 'date_created';
$params['sort_direction'] = 'desc';
break;
case Configuration::OPP_ASSIGNMENT_ALL_OPEN_OLDEST_CREATED:
$params['sort_by'] = 'date_created';
$params['sort_direction'] = 'asc';
break;
case Configuration::OPP_ASSIGNMENT_ALL_RECENTLY_UPDATED:
$params['sort_by'] = 'date_modified';
$params['sort_direction'] = 'desc';
$params['status_ids'] = [0, 1, 2, 3];
}
$cpOpportunities = $this->copperClient->opportunities()->search($params);
if ($userId) {
$profileRepository = app(ProfileRepository::class);
$profile = $profileRepository->findProfileByUserId($this->config, $userId);
$ownerId = $profile instanceof Profile ? $profile->getCrmProviderId() : null;
}
foreach ($cpOpportunities as $cpOpportunity) {
$businessProcess = $this->config
->businessProcesses()
->where('crm_provider_id', (string) $cpOpportunity['pipeline_id'])
->first();
if ($businessProcess === null) {
// Import it.
$businessProcess = $this->importStages(
[Stage::TYPE_OPPORTUNITY],
$cpOpportunity['pipeline_stage_id']
);
}
$stage = $businessProcess
->stages()
->where('crm_provider_id', (string) $cpOpportunity['pipeline_stage_id'])
->where('type', Stage::TYPE_OPPORTUNITY)
->first();
if ($stage === null) {
// Import it.
$stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $cpOpportunity['pipeline_stage_id']);
}
$recordType = $businessProcess->recordTypes()->first();
$record = [
'crmId' => $cpOpportunity['id'],
'name' => $cpOpportunity['name'],
'value' => currency_format($cpOpportunity['monetary_value'], $cpOpportunity['monetary_unit']),
'won' => $cpOpportunity['status'] === 'Won',
'stage' => [
'id' => $stage->id_string,
'name' => $stage->name,
],
'recordType' => [
'id' => $recordType->id_string,
'name' => $recordType->name,
],
];
if ($ownerId && isset($cpOpportunity['assignee_id']) && $cpOpportunity['assignee_id'] === $ownerId) {
$ownerData[] = $record;
}
$data[] = $record;
}
}
if (! empty($ownerData)) {
return $ownerData;
}
return $data;
}
/**
* @inheritdoc
*/
public function getTasks(string $objectType, string $objectId, ?string $opportunityId): array
{
$data = $cpTasks = [];
switch ($objectType) {
case 'lead':
$cpTasks = $this->requestWithExceptionHandling(
fn () => $this->copperClient->leads()->relatedTasks($objectId)
);
break;
case 'account':
$cpTasks = $this->copperClient->accounts()->relatedTasks($objectId);
break;
case 'contact':
if ($opportunityId) {
$cpTasks = $this->copperClient->opportunities()->relatedTasks($opportunityId);
} elseif ($objectType === 'contact') {
$cpTasks = $this->copperClient->contacts()->relatedTasks($objectId);
}
break;
}
foreach ($cpTasks as $cpTask) {
// We only get the ID itself returned, so pull out the full object.
try {
$cpTask = $this->copperClient->tasks()->find($cpTask['id']);
} catch (Exception $exception) {
$this->logger->error('[Copper] Error fetching task', [
'resource_id' => $cpTask['id'],
'error' => $exception->getMessage(),
]);
continue;
}
// Only allow open tasks.
if ($cpTask['status'] === 'Open') {
$data[] = [
'crmId' => $cpTask['id'],
'subject' => $cpTask['name'],
'due' => $cpTask['due_date']
? Carbon::createFromTimestamp($cpTask['due_date'])->format('Y-m-d\TH:i:sO')
: null,
'type' => null,
];
}
}
return $data;
}
/**
* Try to find email address in CRM service
*
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
*}
*/
public function matchExactlyByEmail(string $email, ?int $userId = null): ?array
{
$cpObjectType = 'contact';
$cpObjects = $this->copperClient->contacts()->search([
'emails' => $email,
'sort_by' => 'date_modified',
'sort_direction' => 'desc',
]);
if (empty($cpObjects)) {
$cpObjectType = 'lead';
$cpObjects = $this->requestWithExceptionHandling(
fn () => $this->copperClient->leads()->search([
'emails' => ...
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"bounds":{"left":0.054166667,"top":0.027777778,"width":0.08055556,"height":0.035555556},"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"#12066 on JY-20725-handle-HS-search-rate-limit, menu","depth":5,"bounds":{"left":0.13472222,"top":0.027777778,"width":0.25555557,"height":0.035555556},"on_screen":true,"help_text":"Pull request #12066 exists for current branch JY-20725-handle-HS-search-rate-limit, but local branch is out of sync with remote","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"bounds":{"left":0.6326389,"top":0.027777778,"width":0.023611112,"height":0.035555556},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"HandleHubspotRateLimitTest","depth":6,"bounds":{"left":0.6645833,"top":0.027777778,"width":0.15902779,"height":0.035555556},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'HandleHubspotRateLimitTest'","depth":6,"bounds":{"left":0.82361114,"top":0.027777778,"width":0.023611112,"height":0.035555556},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'HandleHubspotRateLimitTest'","depth":6,"bounds":{"left":0.8472222,"top":0.027777778,"width":0.023611112,"height":0.035555556},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"bounds":{"left":0.87083334,"top":0.027777778,"width":0.023611112,"height":0.035555556},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"bounds":{"left":0.9291667,"top":0.027777778,"width":0.023611112,"height":0.035555556},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"bounds":{"left":0.9527778,"top":0.027777778,"width":0.023611112,"height":0.035555556},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"IDE and Project Settings","depth":5,"bounds":{"left":0.9763889,"top":0.027777778,"width":0.023611112,"height":0.035555556},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.088194445,"height":0.027777778},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"11","depth":4,"bounds":{"left":0.7340278,"top":0.3122222,"width":0.01875,"height":0.02111111},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"72","depth":4,"bounds":{"left":0.7569444,"top":0.3122222,"width":0.020833334,"height":0.02111111},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"1","depth":4,"bounds":{"left":0.78194445,"top":0.3122222,"width":0.015277778,"height":0.02111111},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"4","depth":4,"bounds":{"left":0.80138886,"top":0.3122222,"width":0.016666668,"height":0.02111111},"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"bounds":{"left":0.8215278,"top":0.31,"width":0.015277778,"height":0.025555555},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"bounds":{"left":0.8368056,"top":0.31,"width":0.014583333,"height":0.025555555},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\nnamespace Jiminny\\Services\\Crm\\Copper;\n\nuse Carbon\\Carbon;\nuse Exception;\nuse GuzzleHttp\\Exception\\ClientException;\nuse Illuminate\\Support\\Facades\\Cache;\nuse Jiminny\\Contracts\\Services\\Crm\\LayoutManagementInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\MatchCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\Provider\\CopperInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityLookupInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityManipulationInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SettingsInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmMetadataInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\VerifyTaskExistsInterface;\nuse Jiminny\\Exceptions\\CrmException;\nuse Jiminny\\Exceptions\\HttpNotFoundException;\nuse Jiminny\\Models\\Account;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Contact;\nuse Jiminny\\Models\\Crm\\BusinessProcess;\nuse Jiminny\\Models\\Crm\\Configuration;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Models\\Crm\\FieldData;\nuse Jiminny\\Models\\Crm\\FieldValue;\nuse Jiminny\\Models\\Crm\\LayoutEntity;\nuse Jiminny\\Models\\Crm\\Profile;\nuse Jiminny\\Models\\Lead;\nuse Jiminny\\Models\\Opportunity;\nuse Jiminny\\Models\\Playbook;\nuse Jiminny\\Models\\PlaybookCategory;\nuse Jiminny\\Models\\SocialAccount;\nuse Jiminny\\Models\\Stage;\nuse Jiminny\\Models\\User;\nuse Jiminny\\Repositories\\Crm\\CrmEntityRepository;\nuse Jiminny\\Repositories\\Crm\\FieldRepository;\nuse Jiminny\\Repositories\\Crm\\ProfileRepository;\nuse Jiminny\\Repositories\\UserRepository;\nuse Jiminny\\Services\\Avatar\\ProspectPhotoPathService;\nuse Jiminny\\Services\\Crm\\BaseService;\nuse Jiminny\\Services\\Crm\\Copper\\Api\\Client;\nuse Jiminny\\Services\\Crm\\Copper\\Api\\ClientFactory;\nuse Jiminny\\Services\\Crm\\Copper\\Exception\\RequestFailedException;\nuse Jiminny\\Services\\Crm\\Helpers\\FilterJoinedParticipants;\nuse Jiminny\\Services\\Crm\\OpportunitySyncStrategyResolver;\nuse Jiminny\\Services\\Crm\\Copper\\Traits\\SearchesCrmEntities;\nuse Jiminny\\Utils\\CurrencyFormatter;\nuse Jiminny\\Utils\\StringUtil;\n\nclass Service extends BaseService implements\n CopperInterface,\n SyncCrmEntitiesInterface,\n SyncCrmMetadataInterface,\n RemoteEntityManipulationInterface,\n LayoutManagementInterface,\n SettingsInterface,\n MatchCrmEntitiesInterface,\n RemoteEntityLookupInterface,\n VerifyTaskExistsInterface\n{\n use SearchesCrmEntities;\n private const int NOTE_BODY_MAX_LENGTH = 3000000;\n\n private const int TASK_VERIFICATION_CACHE_TTL = 86400; // 1 day\n\n private const array STANDARD_OPPORTUNITY_FIELDS = [\n 'monetary_value' => [\n 'label' => 'Value',\n 'available_on' => Field::OBJECT_OPPORTUNITY,\n 'type' => Field::TYPE_CURRENCY,\n ],\n 'pipeline_stage_id' => [\n 'label' => 'Stage',\n 'type' => Field::TYPE_PICKLIST,\n 'available_on' => Field::OBJECT_OPPORTUNITY,\n ],\n 'close_date' => [\n 'label' => 'Close date',\n 'type' => Field::TYPE_DATE,\n 'available_on' => Field::OBJECT_OPPORTUNITY,\n ],\n 'win_probability' => [\n 'label' => 'Win probability',\n 'type' => Field::TYPE_NUMBER,\n 'available_on' => Field::OBJECT_OPPORTUNITY,\n ],\n ];\n private const string UNSUPPORTED_OBJECT_TYPE = 'unsupported';\n private const string OPPORTUNITY_STATUS_OPEN = 'Open';\n private const string OPPORTUNITY_IMPORT_FAILED_ERROR_MESSAGE = 'Opportunity import failed';\n private const string RESOURCE_UPDATE_FAILED_ERROR_MESSAGE = 'Failed to update resource';\n\n private FieldValueConverter $fieldValueConverter;\n private ClientFactory $clientFactory;\n private ?Client $copperClient = null;\n\n public function __construct(\n ClientFactory $clientFactory,\n FieldValueConverter $fieldValueConverter,\n private readonly UserRepository $userRepository,\n private readonly ProspectPhotoPathService $prospectPhotoPathService,\n private readonly CrmEntityRepository $crmEntityRepository\n ) {\n parent::__construct();\n\n $this->clientFactory = $clientFactory;\n $this->fieldValueConverter = $fieldValueConverter;\n }\n\n public function getDisplayName(): string\n {\n return 'Copper';\n }\n\n /**\n * Get the CRM entity repository (used by SearchesCrmEntities trait)\n */\n protected function getCrmEntityRepository(): CrmEntityRepository\n {\n return $this->crmEntityRepository;\n }\n\n public function getMinimumApiVersion(): string\n {\n return Client::MIN_API_VERSION;\n }\n\n protected function getOAuthAccount(User $user): ?SocialAccount\n {\n // In this case, the Account Owner is always the connection for any API operations.\n $owner = $user->team->owner;\n\n return $owner->getSocialAccount(SocialAccount::PROVIDER_COPPER);\n }\n\n public function setUser(User $user): void\n {\n $this->configureSentryScope($user);\n\n $this->team = $user->getTeam();\n $this->teamService->setTeam($user->getTeam());\n $this->setConfiguration($user->getTeam()->getCrmConfiguration());\n\n // Reset state.\n $this->profile = $user->getProfile();\n\n if (! $this->profile instanceof Profile) {\n // Try to fetch their profile, maybe it was not yet imported?\n $this->profile = $this->getProfile($user);\n }\n }\n\n public function setConfiguration(Configuration $configuration): void\n {\n $this->config = $configuration;\n $this->copperClient = $this->clientFactory->create($configuration);\n\n if ($this->team === null) {\n $this->team = $configuration->getTeam();\n }\n }\n\n public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string\n {\n return $this->fieldValueConverter->convertFromCrm($fieldType, $fieldValue);\n }\n\n private function mapObjectType(string $objectType): string\n {\n $objectType = strtolower($objectType);\n\n switch ($objectType) {\n case Field::OBJECT_TASK:\n case Field::OBJECT_OPPORTUNITY:\n case Field::OBJECT_LEAD:\n $mappedType = $objectType;\n\n break;\n case Field::OBJECT_ACCOUNT:\n $mappedType = 'company';\n\n break;\n case Field::OBJECT_CONTACT:\n $mappedType = 'person';\n\n break;\n default:\n $mappedType = self::UNSUPPORTED_OBJECT_TYPE;\n\n break;\n }\n\n return $mappedType;\n }\n\n /**\n * @param string $crmObject The name of the CRM object. i.e. Opportunity or Contact\n */\n protected function getFields(string $crmObject): array\n {\n $mappedObjectType = $this->mapObjectType($crmObject);\n\n if (! $this->isSupportedObjectType($mappedObjectType)) {\n return [];\n }\n\n $customFields = $this->copperClient->fields()->all();\n\n $filteredFields = array_filter(\n $customFields,\n static fn ($field) => in_array($mappedObjectType, $field['available_on'], true)\n );\n\n return array_map(\n fn (array $property) => [\n 'name' => $property['id'],\n 'label' => $property['name'],\n 'type' => $this->convertFieldType($property['data_type']),\n ],\n $filteredFields\n );\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultFields(string $activityType): array\n {\n $fields = [];\n\n if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {\n $defaultFields = FieldDefinitions::defaultTaskFields();\n\n // This lazy creates these fields if not already setup.\n foreach ($defaultFields as $defaultField) {\n $fields[] = $this->config->fields()->firstOrCreate($defaultField);\n }\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultActivityField(string $activityType): Field\n {\n // Setup the activity field as the default Type.\n $activityField = $this->config->fields()->where([\n 'crm_provider_id' => 'type',\n 'object_type' => $activityType,\n ])->first();\n\n return $activityField;\n }\n\n /**\n * @inheritdoc\n */\n public function getSupportedPlaybookTypes(): array\n {\n return [Playbook::ACTIVITY_TYPE_TASK];\n }\n\n public function getDealInsightsFields(): array\n {\n return FieldDefinitions::dealInsightsFields();\n }\n\n protected function getDefaultFollowupLayoutFields(string $activityType): array\n {\n $fields = [];\n $fieldRepo = app(FieldRepository::class);\n $fieldFilters = FieldDefinitions::taskFollowupFieldsFilter();\n\n foreach ($fieldFilters as $data) {\n $field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $data);\n\n // Only add the field if it is created, which it should be.\n if ($field) {\n $fields[] = $field;\n }\n }\n\n return $fields;\n }\n\n public function syncFields(): void\n {\n $this->syncStandardFields();\n $this->syncCustomFields();\n }\n\n public function syncField(Field $field): void\n {\n if ($field->getCrmProviderId() === 'type') {\n /**\n * This field is a special case, it is not a custom field and is not returned by the Copper API.\n *\n * The field is created manually during layout setup.\n *\n * @see getDefaultFields()\n *\n * There is a separate endpoint to get the activity types.\n * @see importPicklistValues()\n */\n return;\n }\n\n if ($field->getCrmProviderId() === 'pipeline_stage_id') {\n // Not a custom type\n return;\n }\n\n $crmField = $this->copperClient->fields()->find($field->getCrmProviderId());\n $field->label = $crmField['name'];\n $field->type = $this->convertFieldType($crmField['data_type']);\n\n $field->save();\n }\n\n /**\n * @param string $fieldType How the data should be input\n */\n private function convertFieldType(string $fieldType): string\n {\n $fieldType = strtolower($fieldType);\n\n switch ($fieldType) {\n case 'dropdown':\n $convertedType = Field::TYPE_PICKLIST;\n\n break;\n case 'string':\n $convertedType = Field::TYPE_TEXT;\n\n break;\n case 'float':\n case 'percentage':\n $convertedType = Field::TYPE_NUMBER;\n\n break;\n case 'text':\n $convertedType = Field::TYPE_TEXTAREA;\n\n break;\n case 'checkbox':\n $convertedType = Field::TYPE_BOOLEAN;\n\n break;\n case Field::TYPE_DATE:\n case Field::TYPE_CURRENCY:\n case Field::TYPE_URL:\n $convertedType = $fieldType;\n\n break;\n default:\n $convertedType = Field::TYPE_UNSUPPORTED;\n\n break;\n }\n\n return $convertedType;\n }\n\n /**\n * @inheritdoc\n */\n public function importPicklistValues(Field $field): array\n {\n $fieldValues = [];\n\n if ($field->object_type === 'opportunity' && $field->crm_provider_id === 'pipeline_stage_id') {\n return $this->importFieldValuesForDealInsights($field);\n }\n\n $values = [];\n\n if ($field->object_type === Field::OBJECT_TASK && $field->crm_provider_id === 'type') {\n $activityTypes = $this->copperClient->activityTypes()->all();\n\n /**\n * We ignore \"system\" activity types, disabled activity types and the \"Note\" activity type.\n */\n $activityTypes = array_filter(\n $activityTypes['user'],\n fn (array $activityType) => ! $activityType['is_disabled'] && $activityType['id'] !== 0\n );\n\n // Import all activity values.\n $i = 0;\n foreach ($activityTypes as $activityType) {\n $values[] = [\n 'value' => $activityType['id'],\n 'label' => mb_strimwidth($activityType['name'], 0, Field::LABEL_MAX_LENGTH),\n 'sequence' => $i++,\n ];\n }\n } else {\n // We just check for the field in the custom definitions.\n $crmField = $this->copperClient->fields()->find($field->crm_provider_id);\n\n // Import all values.\n foreach ($crmField['options'] as $option) {\n $values[] = [\n 'value' => $option['id'],\n 'label' => mb_strimwidth($option['name'], 0, 191),\n 'sequence' => $option['rank'],\n ];\n }\n }\n\n // Take the existing set of field values.\n $fieldsToPurge = $field->values()->get()->pluck('value')->toArray();\n\n foreach ($values as $value) {\n // For all current values, upsert the field.\n $value['value'] = substr($value['value'] ?? '', 0, 255);\n $fieldValues[] = $field->values()->updateOrCreate([\n 'value' => $value['value'],\n ], $value);\n\n // Remove this value from the ones we are going to purge.\n $key = array_search($value['value'], $fieldsToPurge, false);\n if ($key !== false) {\n unset($fieldsToPurge[$key]);\n }\n }\n\n // Delete the old values that are no longer used.\n $field->values()->whereIn('value', $fieldsToPurge)->delete();\n\n return $fieldValues;\n }\n\n /**\n *\n * @return FieldValue[]\n */\n private function importFieldValuesForDealInsights(Field $field): array\n {\n $fieldValues = [];\n $pipelines = $this->copperClient->pipelines()->all();\n\n foreach ($pipelines as $pipeline) {\n foreach ($pipeline['stages'] as $i => $dealStage) {\n $fieldValues[] = $field->values()->updateOrCreate(\n ['value' => $dealStage['id']],\n [\n 'label' => $dealStage['name'],\n 'sequence' => $i,\n ]\n );\n }\n }\n\n return $fieldValues;\n }\n\n /**\n * @inheritdoc\n */\n public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage\n {\n if ($types === null) {\n $types = [Stage::TYPE_LEAD, Stage::TYPE_OPPORTUNITY];\n }\n $missingStage = null;\n\n foreach ($types as $type) {\n if ($type === Stage::TYPE_LEAD) {\n $statuses = $this->requestWithExceptionHandling(\n fn () => $this->copperClient->leadStatuses()->all()\n );\n\n if (count($statuses) > 0) {\n $businessProcess = $this->config->businessProcesses()->updateOrCreate([\n 'crm_provider_id' => BusinessProcess::TYPE_LEAD,\n ], [\n 'team_id' => $this->team->id,\n 'name' => BusinessProcess::TYPE_LEAD,\n 'type' => BusinessProcess::TYPE_LEAD,\n 'is_selectable' => true,\n ]);\n\n // A record type is really a clone of the business process, used to store which record uses which pipeline.\n $this->config->recordTypes()->updateOrCreate([\n 'crm_provider_id' => BusinessProcess::TYPE_LEAD,\n ], [\n 'team_id' => $this->team->id,\n 'name' => BusinessProcess::TYPE_LEAD,\n 'is_selectable' => true,\n 'business_process_id' => $businessProcess->id ?? null,\n ]);\n\n $leadStatuses = [];\n foreach ($statuses as $status) {\n // Upsert all statuses for the team.\n $status = $this->config->stages()->updateOrCreate([\n 'crm_provider_id' => $status['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($status['name'], 0, 50),\n 'label' => mb_strimwidth($status['name'], 0, 191),\n 'type' => Stage::TYPE_LEAD,\n 'sequence' => $status['order'],\n 'is_selectable' => true,\n ]);\n\n if ($missingStageName && $missingStageName === $status['id']) {\n $missingStage = $status;\n }\n\n $leadStatuses[] = $status['id'];\n }\n\n $businessProcess->stages()->sync($leadStatuses);\n }\n } else {\n // We create a business process to contain the pipeline, and later store all stages against it.\n foreach ($this->copperClient->pipelines()->all() as $pipeline) {\n $businessProcess = $this->config->businessProcesses()->updateOrCreate([\n 'crm_provider_id' => $pipeline['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($pipeline['name'], 0, 150),\n 'type' => BusinessProcess::TYPE_OPPORTUNITY,\n 'is_selectable' => true,\n ]);\n\n // A record type is really a clone of the business process, used to store which record uses which pipeline.\n $this->config->recordTypes()->updateOrCreate([\n 'crm_provider_id' => $pipeline['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($pipeline['name'], 0, 150),\n 'is_selectable' => true,\n 'business_process_id' => $businessProcess->id ?? null,\n ]);\n\n $stages = [];\n\n foreach ($pipeline['stages'] as $i => $dealStage) {\n // Upsert all stages for the team.\n $stage = $this->config->stages()->updateOrCreate([\n 'crm_provider_id' => $dealStage['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($dealStage['name'], 0, 50),\n 'label' => mb_strimwidth($dealStage['name'], 0, 191),\n 'type' => Stage::TYPE_OPPORTUNITY,\n 'sequence' => $i,\n 'is_selectable' => true,\n 'probability' => $dealStage['win_probability'],\n ]);\n\n if ($missingStageName && $missingStageName === $dealStage['id']) {\n $missingStage = $stage;\n }\n\n $stages[] = $stage['id'];\n }\n\n $businessProcess->stages()->sync($stages);\n }\n }\n }\n\n return $missingStage;\n }\n\n /**\n * @inheritdoc\n */\n public function syncLeads(Carbon $since, ?Carbon $to = null, ?string $crmProfileId = null): int\n {\n $syncCount = 0;\n\n $cpLeads = $this->requestWithExceptionHandling(\n fn () => $this->copperClient->leads()->paginate([\n 'minimum_modified_date' => $since->timestamp,\n 'sort_by' => 'date_modified',\n ])\n );\n\n foreach ($cpLeads as $cpLead) {\n // Only sync if previously imported.\n if ($this->hasLead($cpLead['id']) === false) {\n $this->importLead($cpLead);\n $syncCount++;\n }\n }\n\n return $syncCount;\n }\n\n /**\n * @inheritdoc\n */\n public function syncLead(string $crmId): ?Lead\n {\n $cpLead = $this->requestWithExceptionHandling(\n fn () => $this->copperClient->leads()->find($crmId)\n );\n\n if (\\count($cpLead) === 0) {\n return null;\n }\n\n return $this->importLead($cpLead);\n }\n\n /**\n * @inheritdoc\n */\n private function importLead($crmData): ?Lead\n {\n $countryCode = null;\n if (! empty($crmData['address']) && isset($crmData['address']['country'])) {\n $countryCode = $this->convertCountryNameToCode($crmData['address']['country']);\n }\n\n $mobilePhone = $parsedNumber = null;\n if (! empty($crmData['phone_numbers'])) {\n foreach ($crmData['phone_numbers'] as $phoneNumber) {\n if ($phoneNumber['category'] === 'mobile') {\n $mobilePhone = $this->buildContactMobilePhone($countryCode, $phoneNumber['number']);\n } else {\n $parsedNumber = $this->buildContactPhone($countryCode, $phoneNumber['number']);\n }\n }\n }\n\n $name = $crmData['name'];\n\n if (empty($crmData['company_name'])) {\n $company = 'Unknown';\n } else {\n $company = mb_strimwidth($crmData['company_name'], 0, 191);\n }\n\n $domain = null;\n if (empty($crmData['websites']) === false) {\n $domain = mb_strimwidth($crmData['websites'][0]['url'], 0, 191);\n $domain = StringUtil::resolveDomain($domain);\n }\n\n $email = null;\n if (empty($crmData['email']) === false) {\n $email = mb_strimwidth($crmData['email']['email'], 0, 80);\n }\n\n $stage = $this->config\n ->stages()\n ->where('crm_provider_id', (string) $crmData['status_id'])\n ->where('type', Stage::TYPE_LEAD)\n ->first();\n\n if ($stage === null) {\n // Import it.\n $stage = $this->importStages([Stage::TYPE_LEAD], $crmData['status_id']);\n }\n\n if (! $stage instanceof Stage) {\n return null;\n }\n\n $profile = $this->config->profiles()->where('crm_provider_id', (string) $crmData['assignee_id'])->first();\n\n $data = [\n 'team_id' => $this->team->id,\n 'user_id' => $profile ? $profile->user_id : null,\n 'owner_id' => $crmData['assignee_id'],\n 'name' => mb_strimwidth($name, 0, 191),\n 'company' => $company,\n 'domain' => $domain,\n 'title' => $crmData['title'] ? mb_strimwidth($crmData['title'], 0, 128) : null,\n 'email' => $email,\n 'phone' => $parsedNumber['phone'] ?? null,\n 'ext' => $parsedNumber['ext'] ?? null,\n 'mobile_phone' => $mobilePhone,\n 'stage_id' => $stage->id,\n 'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(\n crmConfiguration: $this->config,\n crmProviderId: $crmData['id'],\n modelType: Lead::class,\n fileName: $crmData['id'],\n avatarText: $crmData['name']\n ),\n 'country_code' => $countryCode,\n 'remotely_created_at' => $crmData['date_created'],\n ];\n\n return $this->config->leads()->updateOrCreate(['crm_provider_id' => (string) $crmData['id']], $data);\n }\n\n /**\n * @inheritdoc\n */\n public function syncAccounts(Carbon $since, ?Carbon $to = null): int\n {\n $syncCount = 0;\n\n try {\n $cpAccounts = $this->copperClient->accounts()->paginate([\n 'minimum_modified_date' => $since->timestamp,\n 'sort_by' => 'date_modified',\n ]);\n\n foreach ($cpAccounts as $cpAccount) {\n // Only sync if previously imported.\n if ($this->hasAccount($cpAccount['id'])) {\n $this->importAccount($cpAccount);\n $syncCount++;\n }\n }\n } catch (\\Exception $exception) {\n // Do nothing for now.\n }\n\n return $syncCount;\n }\n\n /**\n * @inheritdoc\n */\n public function syncAccount(string $crmId): ?Account\n {\n $cpAccount = $this->copperClient->accounts()->find($crmId);\n\n if (empty($cpAccount)) {\n return null;\n }\n\n return $this->importAccount($cpAccount);\n }\n\n /**\n * @inheritdoc\n */\n private function importAccount($crmData): Account\n {\n $countryCode = null;\n if (empty($crmData['address']) === false && $crmData['address']['country']) {\n $countryCode = $this->convertCountryNameToCode($crmData['address']['country']);\n }\n\n $name = $crmData['name'];\n\n $parsedNumber = null;\n foreach ($crmData['phone_numbers'] as $phoneNumber) {\n $parsedNumber = parsePhoneNumber($countryCode, $phoneNumber['number']);\n }\n\n $domain = null;\n if (empty($crmData['websites']) === false) {\n $domain = mb_strimwidth($crmData['websites'][0]['url'], 0, 191);\n $domain = StringUtil::resolveDomain($domain);\n }\n\n $profile = $this->config->profiles()->where('crm_provider_id', (string) $crmData['assignee_id'])->first();\n\n $data = [\n 'team_id' => $this->team->id,\n 'user_id' => $profile ? $profile->user_id : null,\n 'owner_id' => $crmData['assignee_id'],\n 'name' => mb_strimwidth($name, 0, 191),\n 'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(\n crmConfiguration: $this->config,\n crmProviderId: $crmData['id'],\n modelType: Account::class,\n fileName: $crmData['id'],\n avatarText: $crmData['name']\n ),\n 'country_code' => $countryCode,\n 'domain' => $domain,\n 'phone' => $parsedNumber['phone'] ?? null,\n 'ext' => $parsedNumber['ext'] ?? null,\n 'remotely_created_at' => $crmData['date_created'],\n ];\n\n return $this->config->accounts()->updateOrCreate(['crm_provider_id' => (string) $crmData['id']], $data);\n }\n\n public function syncOpportunities(array $parameters, ?string $strategy = null): int\n {\n $strategyResolver = app(OpportunitySyncStrategyResolver::class, [\n 'client' => $this->copperClient,\n ]);\n $strategies = $strategyResolver->getStrategies($this->config, $strategy);\n\n $syncCount = 0;\n\n try {\n foreach ($strategies as $syncStrategy) {\n foreach ($syncStrategy->fetchOpportunities($parameters) as $opportunityData) {\n $this->importOpportunity($opportunityData);\n $syncCount++;\n }\n }\n } catch (\\Exception $exception) {\n // Do nothing for now.\n $this->logger->error(self::OPPORTUNITY_IMPORT_FAILED_ERROR_MESSAGE, [\n 'error' => $exception->getMessage(),\n ]);\n }\n\n return $syncCount;\n }\n\n public function syncOpportunity(string $crmId): ?Opportunity\n {\n $strategyResolver = app(OpportunitySyncStrategyResolver::class, [\n 'client' => $this->copperClient,\n ]);\n $strategy = $strategyResolver->resolve(\n $this->config,\n OpportunitySyncStrategyResolver::SINGLE_SYNC_OPPORTUNITY_STRATEGY\n );\n\n $parameters['crm_id'] = $crmId;\n\n $cpOpportunity = $strategy->fetchOpportunities($parameters);\n\n return $this->importOpportunity($cpOpportunity);\n }\n\n private function importOpportunity($crmData): ?Opportunity\n {\n $companyId = (string) $crmData['company_id'];\n\n if (empty($companyId)) {\n $this->logger->error(self::OPPORTUNITY_IMPORT_FAILED_ERROR_MESSAGE, [\n 'error' => 'No company id',\n ]);\n\n return null;\n }\n\n $account = $this->config->accounts()->where('crm_provider_id', $companyId)->first();\n\n if ($account === null) {\n $account = $this->syncAccount($companyId);\n }\n\n $profile = $this->config->profiles()->where('crm_provider_id', (string) $crmData['assignee_id'])->first();\n\n $businessProcess = $this->config\n ->businessProcesses()\n ->where('crm_provider_id', (string) $crmData['pipeline_id'])\n ->first();\n\n if ($businessProcess === null) {\n // It is possible for some deals to not be in a pipeline.\n $this->logger->warning(self::OPPORTUNITY_IMPORT_FAILED_ERROR_MESSAGE, [\n 'error' => 'Missing pipeline',\n ]);\n\n return null;\n }\n\n $stage = $businessProcess\n ->stages()\n ->where('crm_provider_id', (string) $crmData['pipeline_stage_id'])\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->first();\n\n if ($stage === null) {\n $this->logger->info('Stage not found, importing', [\n 'pipeline_id' => $crmData['pipeline_id'],\n 'stage_id' => $crmData['pipeline_stage_id'],\n ]);\n\n $stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $crmData['pipeline_stage_id']);\n }\n\n $recordType = $businessProcess->recordTypes()->first();\n $closeDate = null;\n $isClosed = $crmData['status'] !== self::OPPORTUNITY_STATUS_OPEN;\n\n if ($crmData['close_date']) {\n $closeDate = Carbon::parse($crmData['close_date'])->format('Y-m-d');\n }\n\n $data = [\n 'team_id' => $this->team->getId(),\n 'account_id' => $account->id,\n 'user_id' => $profile->user_id ?? null,\n 'owner_id' => $crmData['assignee_id'],\n 'name' => mb_strimwidth($crmData['name'], 0, 128),\n 'value' => $crmData['monetary_value'],\n 'currency_code' => CurrencyFormatter::formatCode($crmData['monetary_unit']),\n 'close_date' => $closeDate,\n 'is_closed' => $isClosed,\n 'is_won' => $crmData['status'] === 'Won',\n 'stage_id' => $stage->id,\n 'record_type_id' => $recordType->id ?? null,\n 'remotely_created_at' => $crmData['date_created'],\n ];\n\n /** @var Opportunity $opportunity */\n $opportunity = $this->config\n ->opportunities()\n ->updateOrCreate(['crm_provider_id' => (string) $crmData['id']], $data)\n ;\n\n // import external fields into crm_field_data if present\n $dealInsightsFields = $this->getOpportunitySyncableFields();\n $this->importOpportunityCrmFieldData($crmData, $dealInsightsFields, $opportunity->id);\n\n return $opportunity;\n }\n\n /**\n * @param array<string, mixed> $opportunityData\n * @param string[] $dealInsightsFields\n */\n public function importOpportunityCrmFieldData(\n array $opportunityData,\n array $dealInsightsFields,\n int $opportunityId\n ): void {\n foreach ($dealInsightsFields as $fieldName) {\n $field = $this->config->fields()->with('entities')\n ->where('crm_provider_id', $fieldName)\n ->where('object_type', Field::OBJECT_OPPORTUNITY)\n ->first();\n\n if (! $field instanceof Field) {\n $this->logger->warning('Field not found', [\n 'field' => $fieldName,\n 'opportunity_id' => $opportunityId,\n ]);\n\n continue;\n }\n\n $entity = $field->getEntities()->first();\n\n if (! $entity instanceof LayoutEntity) {\n continue;\n }\n\n $fieldValue = $this->extractFieldValue($opportunityData, $fieldName);\n $normalizedValue = $this->normalizeValue($field->getType(), (string) $fieldValue, true);\n\n $field->data()\n ->updateOrCreate([\n 'crm_layout_entity_id' => $entity->id,\n 'crm_field_id' => $field->id,\n 'object_type' => 'opportunity',\n 'object_id' => $opportunityId,\n ], ['value' => $normalizedValue])\n ;\n }\n }\n\n /**\n * @inheritdoc\n */\n public function syncContacts(Carbon $since, ?Carbon $to = null): int\n {\n $syncCount = 0;\n\n try {\n $cpContacts = $this->copperClient->contacts()->paginate([\n 'minimum_interaction_date' => $since->timestamp,\n 'sort_by' => 'date_modified',\n ]);\n\n foreach ($cpContacts as $cpContact) {\n // Only sync if previously imported.\n if ($this->hasContact($cpContact['id'])) {\n $this->importContact($cpContact);\n $syncCount++;\n }\n }\n } catch (\\Exception $exception) {\n // Do nothing for now.\n throw $exception;\n }\n\n return $syncCount;\n }\n\n /**\n * @inheritdoc\n */\n public function syncContact(string $crmId): ?Contact\n {\n $cpContact = $this->copperClient->contacts()->find($crmId);\n\n if (empty($cpContact)) {\n return null;\n }\n\n return $this->importContact($cpContact);\n }\n\n /**\n * @inheritdoc\n */\n private function importContact($crmData): Contact\n {\n $account = null;\n if ($crmData['company_id']) {\n $account = $this->team\n ->crm\n ->accounts()\n ->where('crm_provider_id', (string) $crmData['company_id'])\n ->first();\n\n if ($account === null) {\n $account = $this->syncAccount($crmData['company_id']);\n }\n }\n\n $countryCode = null;\n if (empty($crmData['address']) === false && $crmData['address']['country']) {\n $countryCode = $this->convertCountryNameToCode($crmData['address']['country']);\n }\n\n $mobilePhone = $parsedNumber = null;\n foreach ($crmData['phone_numbers'] as $phoneNumber) {\n if ($phoneNumber['category'] === 'mobile') {\n $mobilePhone = $this->buildContactMobilePhone($countryCode, $phoneNumber['number']);\n } else {\n $parsedNumber = $this->buildContactPhone($countryCode, $phoneNumber['number']);\n }\n }\n\n $email = null;\n if (empty($crmData['emails']) === false) {\n $email = mb_strimwidth($crmData['emails'][0]['email'], 0, 80);\n }\n\n $profile = $this->config->profiles()->where('crm_provider_id', (string) $crmData['assignee_id'])->first();\n\n $data = [\n 'team_id' => $this->team->id,\n 'account_id' => $account->id ?? null,\n 'user_id' => $profile ? $profile->user_id : null,\n 'owner_id' => $crmData['assignee_id'],\n 'name' => $crmData['name'],\n 'email' => $email,\n 'phone' => $parsedNumber['phone'] ?? null,\n 'mobile_phone' => $mobilePhone ?? null,\n 'ext' => $parsedNumber['ext'] ?? null,\n 'title' => $crmData['title'],\n 'country_code' => $countryCode,\n 'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(\n crmConfiguration: $this->config,\n crmProviderId: $crmData['id'],\n modelType: Contact::class,\n fileName: $crmData['id'],\n avatarText: $crmData['name']\n ),\n 'remotely_created_at' => $crmData['date_created'],\n ];\n\n return $this->config->contacts()->updateOrCreate(['crm_provider_id' => (string) $crmData['id']], $data);\n }\n\n private function buildContactPhone(?string $countryCode, ?string $number): ?array\n {\n if ($number) {\n // Trim to our width and attempt to parse it.\n $number = mb_strimwidth($number, 0, 25);\n $parsedNumber = parsePhoneNumber($countryCode, $number);\n } else {\n $parsedNumber = [];\n }\n\n return $parsedNumber;\n }\n\n private function buildContactMobilePhone(?string $countryCode, ?string $number): ?string\n {\n return $number ? mb_strimwidth(phone_e164($countryCode, $number), 0, 25) : null;\n }\n\n /**\n * @inheritdoc\n */\n public function syncOrganization(): void\n {\n $organization = $this->copperClient->organisation()->all();\n\n if ($this->config->crm_provider_id !== $organization['id']) {\n $this->config->update(['crm_provider_id' => $organization['id']]);\n }\n }\n\n private function syncCustomFields(): void\n {\n foreach ($this->getFieldTypes() as $fieldType) {\n $currentFields = $this->getFields($fieldType);\n\n foreach ($currentFields as $field) {\n /** @var Field $field */\n $field = $this->config->fields()->updateOrCreate([\n 'crm_provider_id' => $field['name'],\n 'object_type' => $fieldType,\n ], [\n 'label' => mb_strimwidth($field['label'], 0, Field::LABEL_MAX_LENGTH),\n 'type' => $field['type'],\n ]);\n\n if ($field->getType() === Field::TYPE_PICKLIST) {\n $this->importPicklistValues($field);\n }\n }\n }\n\n $this->config->save();\n }\n\n protected function getFieldTypes(): array\n {\n return [\n parent::OBJECT_TASK,\n parent::OBJECT_LEAD,\n parent::OBJECT_ACCOUNT,\n parent::OBJECT_CONTACT,\n parent::OBJECT_OPPORTUNITY,\n ];\n }\n\n /**\n * @inheritdoc\n */\n public function syncProfiles(?User $userToSearch = null): ?Profile\n {\n $profiles = $this->copperClient->users()->all();\n\n foreach ($profiles as $profile) {\n $user = $this->userRepository->findByTeamAndEmailAddress($this->team, $profile['email'], onlyActive: true);\n\n // Only import profiles for users we have locally.\n if ($user) {\n $profile = $user->crmProfile()->updateOrCreate([\n 'crm_configuration_id' => $this->config->id,\n 'crm_provider_id' => $profile['id'],\n ]);\n\n if ($userToSearch && $userToSearch->id === $user->id) {\n return $profile;\n }\n }\n }\n\n return null;\n }\n\n /**\n * @inheritdoc\n */\n public function syncProfileFields(): void\n {\n $fieldTypes = [\n 'task',\n 'lead',\n 'account',\n 'contact',\n 'opportunity',\n ];\n\n foreach ($fieldTypes as $fieldType) {\n try {\n $currentFields = $this->getFields(ucfirst($fieldType));\n\n $newFields = array_column($currentFields, 'name');\n $changedFields = array_merge(\n array_diff($this->profile->getFieldsAsArray($fieldType), $newFields),\n array_diff($newFields, $this->profile->getFieldsAsArray($fieldType))\n );\n\n if (\\count($changedFields) > 0) {\n $fields = implode(',', $newFields);\n $this->logger->debug(\n ucfirst($fieldType)\n . ' fields changed from '\n . $this->profile->{$fieldType . '_fields'}\n . ' to: '\n . $fields\n );\n\n $this->profile->{$fieldType . '_fields'} = $fields;\n }\n } catch (\\Exception $exception) {\n $this->logger->error('No access to ' . $fieldType . ' object, skipping...');\n\n continue;\n }\n }\n\n $this->profile->save();\n }\n\n /**\n * @inheritdoc\n */\n public function find(string $name, array $scopes): array\n {\n $count = $this->limit ?? 20;\n $offset = $this->offset ?? 0;\n\n $scopeValues = array_map(fn ($scope) => is_string($scope) ? $scope : $scope->value, $scopes);\n $cacheKey = sprintf('copper-search-%d-%s-%s-%d-%d', $this->team->id, $name, implode('-', $scopeValues), $count, $offset);\n\n return Cache::remember($cacheKey, 300, function () use ($name, $scopes, $count, $offset) {\n // Try local search first\n $localResults = $this->searchLocally($name, $scopes, $count, $offset);\n\n // If no local results, fall back to remote API search\n if (empty($localResults)) {\n return $this->searchRemotely($name, $scopes, $count, $offset);\n }\n\n return $localResults;\n });\n }\n\n /**\n * @inheritdoc\n */\n public function findOpportunities(?string $crmAccountId, ?string $crmContactId, ?int $userId = null): array\n {\n $data = [];\n $ownerData = [];\n $ownerId = null;\n $contact = null;\n $account = null;\n\n if ($crmAccountId) {\n $account = $this->config->accounts()->where('crm_provider_id', $crmAccountId)->first();\n\n if ($account === null) {\n $account = $this->syncAccount($crmAccountId);\n }\n }\n\n if ($crmContactId) {\n $contact = $this->config->contacts()->where('crm_provider_id', $crmContactId)->first();\n\n if ($contact === null) {\n $contact = $this->syncContact($crmContactId);\n }\n }\n\n if ($contact || $account) {\n if ($contact && $account === null) {\n $account = $contact->account;\n }\n\n if ($account === null) {\n return [];\n }\n\n $params = [\n 'company_ids' => [$account->crm_provider_id],\n 'status_ids' => [0], // The integer values are 0, 1, 2, 3, for \"Open\", \"Won\", \"Lost\", and \"Abandoned\".\n 'sort_by' => 'date_modified',\n 'sort_direction' => 'desc',\n ];\n\n switch ($this->config->opportunity_assignment_rule) {\n case Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_UPDATED:\n $params['sort_by'] = 'date_modified';\n $params['sort_direction'] = 'desc';\n\n break;\n\n case Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_CREATED:\n $params['sort_by'] = 'date_created';\n $params['sort_direction'] = 'desc';\n\n break;\n\n case Configuration::OPP_ASSIGNMENT_ALL_OPEN_OLDEST_CREATED:\n $params['sort_by'] = 'date_created';\n $params['sort_direction'] = 'asc';\n\n break;\n\n case Configuration::OPP_ASSIGNMENT_ALL_RECENTLY_UPDATED:\n $params['sort_by'] = 'date_modified';\n $params['sort_direction'] = 'desc';\n $params['status_ids'] = [0, 1, 2, 3];\n }\n\n $cpOpportunities = $this->copperClient->opportunities()->search($params);\n\n if ($userId) {\n $profileRepository = app(ProfileRepository::class);\n $profile = $profileRepository->findProfileByUserId($this->config, $userId);\n\n $ownerId = $profile instanceof Profile ? $profile->getCrmProviderId() : null;\n }\n\n foreach ($cpOpportunities as $cpOpportunity) {\n $businessProcess = $this->config\n ->businessProcesses()\n ->where('crm_provider_id', (string) $cpOpportunity['pipeline_id'])\n ->first();\n\n if ($businessProcess === null) {\n // Import it.\n $businessProcess = $this->importStages(\n [Stage::TYPE_OPPORTUNITY],\n $cpOpportunity['pipeline_stage_id']\n );\n }\n\n $stage = $businessProcess\n ->stages()\n ->where('crm_provider_id', (string) $cpOpportunity['pipeline_stage_id'])\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->first();\n\n if ($stage === null) {\n // Import it.\n $stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $cpOpportunity['pipeline_stage_id']);\n }\n\n $recordType = $businessProcess->recordTypes()->first();\n\n $record = [\n 'crmId' => $cpOpportunity['id'],\n 'name' => $cpOpportunity['name'],\n 'value' => currency_format($cpOpportunity['monetary_value'], $cpOpportunity['monetary_unit']),\n 'won' => $cpOpportunity['status'] === 'Won',\n 'stage' => [\n 'id' => $stage->id_string,\n 'name' => $stage->name,\n ],\n 'recordType' => [\n 'id' => $recordType->id_string,\n 'name' => $recordType->name,\n ],\n ];\n\n if ($ownerId && isset($cpOpportunity['assignee_id']) && $cpOpportunity['assignee_id'] === $ownerId) {\n $ownerData[] = $record;\n }\n\n $data[] = $record;\n }\n }\n\n if (! empty($ownerData)) {\n return $ownerData;\n }\n\n return $data;\n }\n\n /**\n * @inheritdoc\n */\n public function getTasks(string $objectType, string $objectId, ?string $opportunityId): array\n {\n $data = $cpTasks = [];\n\n switch ($objectType) {\n case 'lead':\n $cpTasks = $this->requestWithExceptionHandling(\n fn () => $this->copperClient->leads()->relatedTasks($objectId)\n );\n\n break;\n\n case 'account':\n $cpTasks = $this->copperClient->accounts()->relatedTasks($objectId);\n\n break;\n case 'contact':\n if ($opportunityId) {\n $cpTasks = $this->copperClient->opportunities()->relatedTasks($opportunityId);\n } elseif ($objectType === 'contact') {\n $cpTasks = $this->copperClient->contacts()->relatedTasks($objectId);\n }\n\n break;\n }\n\n foreach ($cpTasks as $cpTask) {\n // We only get the ID itself returned, so pull out the full object.\n try {\n $cpTask = $this->copperClient->tasks()->find($cpTask['id']);\n } catch (Exception $exception) {\n $this->logger->error('[Copper] Error fetching task', [\n 'resource_id' => $cpTask['id'],\n 'error' => $exception->getMessage(),\n ]);\n\n continue;\n }\n\n // Only allow open tasks.\n if ($cpTask['status'] === 'Open') {\n $data[] = [\n 'crmId' => $cpTask['id'],\n 'subject' => $cpTask['name'],\n 'due' => $cpTask['due_date']\n ? Carbon::createFromTimestamp($cpTask['due_date'])->format('Y-m-d\\TH:i:sO')\n : null,\n 'type' => null,\n ];\n }\n }\n\n return $data;\n }\n\n /**\n * Try to find email address in CRM service\n *\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n *}\n */\n public function matchExactlyByEmail(string $email, ?int $userId = null): ?array\n {\n $cpObjectType = 'contact';\n\n $cpObjects = $this->copperClient->contacts()->search([\n 'emails' => $email,\n 'sort_by' => 'date_modified',\n 'sort_direction' => 'desc',\n ]);\n\n if (empty($cpObjects)) {\n $cpObjectType = 'lead';\n $cpObjects = $this->requestWithExceptionHandling(\n fn () => $this->copperClient->leads()->search([\n 'emails' => $email,\n 'sort_by' => 'date_modified',\n 'sort_direction' => 'desc',\n ])\n );\n }\n\n if (empty($cpObjects)) {\n return null;\n }\n\n // Search returns an array of results, but we only want the first\n $cpObject = reset($cpObjects);\n $data = $this->convertCrmData($cpObject, $cpObjectType, $userId);\n\n return ! empty(array_filter($data)) ? $data : null;\n }\n\n /**\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n public function matchByPhone(string $phone, ?string $rawPhoneNumber = null, ?int $userId = null): ?array\n {\n // Check if the user is internal.\n $teamMember = $this->team->users()->where('phone', $phone)->exists();\n\n // Skip the attendee if internal.\n if ($teamMember === false) {\n $cpObjectType = 'contact';\n $cpObject = $this->copperClient->contacts()->search([\n 'phone_number' => $phone,\n 'sort_by' => 'date_modified',\n 'sort_direction' => 'desc',\n ]);\n\n if (\\count($cpObject) === 0) {\n $cpObjectType = 'lead';\n $cpObject = $this->requestWithExceptionHandling(\n fn () => $this->copperClient->leads()->search([\n 'phone_number' => $phone,\n 'sort_by' => 'date_modified',\n 'sort_direction' => 'desc',\n ])\n );\n }\n\n if (empty($cpObject)) {\n return null;\n }\n\n $cpObject = reset($cpObject);\n $data = $this->convertCrmData($cpObject, $cpObjectType, $userId);\n\n return ! empty(array_filter($data)) ? $data : null;\n }\n\n return null;\n }\n\n /**\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n public function matchByName(string $name, ?int $userId = null): ?array\n {\n // Don't waste time searching for single character strings.\n if (\\strlen($name) <= 1) {\n return null;\n }\n\n $result = \\Cache::remember($this->team->id . $name, 60, function () use ($name, $userId) {\n $cpObjectType = 'contact';\n $cpObject = $this->copperClient->contacts()->search([\n 'name' => $name,\n 'sort_by' => 'date_modified',\n 'sort_direction' => 'desc',\n ]);\n\n if (\\count($cpObject) === 0) {\n $cpObjectType = 'lead';\n $cpObject = $this->requestWithExceptionHandling(\n fn () => $this->copperClient->leads()->search([\n 'name' => $name,\n 'sort_by' => 'date_modified',\n 'sort_direction' => 'desc',\n ])\n );\n }\n\n if (! empty($cpObject)) {\n $cpObject = reset($cpObject);\n }\n\n $data = $this->convertCrmData($cpObject, $cpObjectType, $userId);\n\n return ! empty(array_filter($data)) ? $data : false;\n });\n\n return is_array($result) ? $result : null;\n }\n\n private function convertCrmData(array $object, string $objectType, ?int $userId = null): array\n {\n $lead = null;\n $contact = null;\n $opportunity = null;\n $account = null;\n $stage = null;\n $countryCode = null;\n\n if ($objectType === 'lead') {\n $lead = $this->importLead($object);\n\n // Lead might not be imported if the Stage is null for example.\n if ($lead) {\n $countryCode = $lead->country_code;\n $stage = $lead->stage;\n }\n } else {\n $contact = $this->importContact($object);\n $account = $contact->account;\n\n if ($contact->country_code) {\n $countryCode = $contact->country_code;\n } elseif ($account) {\n $countryCode = $account->country_code;\n }\n\n try {\n $cpOpportunities = $this->findOpportunities(\n $account?->crm_provider_id,\n $contact->crm_provider_id,\n $userId,\n );\n\n if (! empty($cpOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($cpOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n } catch (\\Exception $e) {\n // Nothing to see here.\n }\n }\n\n return [\n $lead,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n }\n\n public function saveActivity(Activity $activity): Activity\n {\n switch ($activity->type) {\n case Activity::TYPE_CONFERENCE:\n case Activity::TYPE_SOFTPHONE:\n case Activity::TYPE_SOFTPHONE_INBOUND:\n $activity = $this->buildCallPayload($activity);\n\n break;\n case Activity::TYPE_SMS_INBOUND:\n case Activity::TYPE_SMS_OUTBOUND:\n $activity = $this->buildTextMessagePayload($activity);\n\n break;\n }\n\n return $activity;\n }\n\n private function convertActivityAssociations(Activity $activity): array\n {\n if ($activity->lead) {\n $type = 'lead';\n $id = $activity->lead->crm_provider_id;\n } elseif ($activity->contact) {\n $type = 'person';\n $id = $activity->contact->crm_provider_id;\n } elseif ($activity->opportunity) {\n $type = 'opportunity';\n $id = $activity->opportunity->crm_provider_id;\n } else {\n $type = 'company';\n $id = $activity->account->crm_provider_id;\n }\n\n return [\n 'type' => $type,\n 'id' => $id,\n ];\n }\n\n private function convertActivityType(PlaybookCategory $activityType): array\n {\n return [\n 'category' => 'user',\n 'id' => $this->getCrmType($activityType)->value,\n ];\n }\n\n /**\n * @throws CrmException\n */\n private function buildCallPayload(Activity $activity): Activity\n {\n try {\n if ($activity->crm_provider_id) {\n // The activity should be logged under the existing Task (not Activity).\n $newActivityType = $this->getCrmType($activity->category);\n if ($newActivityType === null) {\n $message = sprintf('[Copper] invalid CRM Custom Activity Type: %s', $activity->category);\n\n throw new CrmException($message, 400);\n }\n $this->copperClient->tasks()->update($activity->crm_provider_id, [\n 'status' => 'Completed',\n 'custom_activity_type_id' => $newActivityType->value,\n 'details' => $this->generateActivityDescription($activity),\n ]);\n } else {\n if ($this->getCrmType($activity->category) === null) {\n $message = sprintf('[Copper] Invalid CRM Activity Type: %s', $activity->category->getName());\n\n throw new CrmException($message, 400);\n }\n\n if ($this->getCrmType($activity->category)->value === null) {\n $message = sprintf('[Copper] Invalid CRM Activity Type: %s value', $activity->category->getName());\n\n throw new CrmException($message, 400);\n }\n // Just create an activity.\n $cpActivity = $this->copperClient->activities()->create([\n 'user_id' => $this->profile->crm_provider_id,\n 'activity_date' => $activity->scheduled_start_time->timestamp,\n 'parent' => $this->convertActivityAssociations($activity),\n 'type' => $this->convertActivityType($activity->category),\n 'details' => $this->generateActivityDescription($activity),\n ]);\n\n $activity->crm_provider_id = $cpActivity['id'];\n $activity->save();\n }\n } catch (ClientException $exception) {\n //Log all errors to Sentry\n \\Sentry::captureException($exception);\n\n $response = $exception->getResponse();\n\n $responseBody = $response->getBody();\n $message = $responseBody;\n $errorCode = $response->getStatusCode();\n\n $jsonResponse = json_decode($responseBody, true);\n if (isset($jsonResponse[0]['message'])) {\n $message = $jsonResponse[0]['message'];\n }\n\n throw new CrmException($message, $errorCode);\n }\n\n return $activity;\n }\n\n private function buildTextMessagePayload(Activity $activity): Activity\n {\n if ($this->getCrmType($activity->category) === null) {\n $message = sprintf('[Copper] Invalid CRM Activity Type: %s', $activity->category->getName());\n\n throw new CrmException($message, 400);\n }\n\n if ($this->getCrmType($activity->category)->value === null) {\n $message = sprintf('[Copper] Invalid CRM Activity Type: %s value', $activity->category->getName());\n\n throw new CrmException($message, 400);\n }\n\n $cpActivity = $this->copperClient->activities()->create([\n 'user_id' => $this->profile->crm_provider_id,\n 'activity_date' => $activity->created_at->timestamp,\n 'parent' => $this->convertActivityAssociations($activity),\n 'type' => $this->convertActivityType($activity->category),\n 'details' => $this->generateActivityDescription($activity),\n ]);\n\n $activity->crm_provider_id = $cpActivity['id'];\n $activity->save();\n\n return $activity;\n }\n\n private function generateActivityDescription(Activity $activity): string\n {\n $description = '';\n\n switch ($activity->type) {\n case Activity::TYPE_SOFTPHONE:\n case Activity::TYPE_SOFTPHONE_INBOUND:\n case Activity::TYPE_CONFERENCE:\n\n $description = 'Jiminny Call Summary' . PHP_EOL . PHP_EOL;\n $description .= 'Call Duration: ' . $activity->duration_for_humans . PHP_EOL . PHP_EOL;\n\n // Tell them which number was dialed.\n if ($activity->to) {\n $description .= 'Dialed: ' . $activity->to->phone_number . PHP_EOL . PHP_EOL;\n }\n\n if ($activity->hasReasonCodeBotKicked()) {\n $description .= 'Notetaker removed from this meeting' . PHP_EOL . PHP_EOL;\n // When we fix the state to be Activity::RECORDING_RECORDED as it should be this can change.\n } elseif ($activity->hasReasonCodeNotCompliant()) {\n $description .= 'Notetaker did not join due to recording consent not being provided by attendees' . PHP_EOL . PHP_EOL;\n } elseif ($activity->canReviewActivity()) {\n $playbackUrl = $activity->user->team->partner->getPlaybackUrl($activity);\n $description .= 'Review Activity:' . PHP_EOL . $playbackUrl . PHP_EOL . PHP_EOL;\n }\n\n if ($activity->getCrmType() === Activity::TYPE_CONFERENCE) {\n $description .= 'Attendees:'\n . PHP_EOL\n . (new FilterJoinedParticipants())->toString($activity);\n }\n\n if (\\count($activity->notes) > 0) {\n $description .= PHP_EOL . PHP_EOL . 'Notes: ' . PHP_EOL;\n\n foreach ($activity->notes as $note) {\n $nTime = (int) ceil($note->time);\n $time = ($nTime > 3600) ? gmdate('H:i:s', $nTime) : gmdate('i:s', $nTime);\n $description .= $time . ' ' . $note->note . PHP_EOL;\n }\n }\n\n // Get all private messages.\n $messages = $activity->messages()\n ->where('is_private', 1)\n ->orderBy('created_at', 'asc');\n\n if ($messages->count() > 0) {\n $description .= PHP_EOL . PHP_EOL . 'Coaching Chat: ' . PHP_EOL;\n\n foreach ($messages->get() as $message) {\n $description .= $message->participant->name . ': ' . $message->message . PHP_EOL;\n }\n }\n\n // Get all public messages.\n $messages = $activity->messages()\n ->where('is_private', 0)\n ->orderBy('created_at', 'asc');\n\n if ($messages->count() > 0) {\n $description .= PHP_EOL . PHP_EOL . 'Customer Chat: ' . PHP_EOL;\n\n foreach ($messages->get() as $message) {\n $description .= $message->participant->name . ': ' . $message->message . PHP_EOL;\n }\n }\n\n if ($activity->summary) {\n $description .= PHP_EOL . PHP_EOL . 'Summary: ' . PHP_EOL . $activity->summary;\n }\n\n break;\n\n case Activity::TYPE_SMS_INBOUND:\n case Activity::TYPE_SMS_OUTBOUND:\n $description = $activity->description;\n\n break;\n }\n\n return $description;\n }\n\n public function saveFollowupActivity(Activity $activity, array $fields): ?string\n {\n // This is the user provided activity subject field.\n if (empty($fields['type'])) {\n return null;\n }\n\n $reminder = null;\n if (empty($fields['reminder_date']) === false) {\n $formatReminder = Carbon::createFromFormat('Y-m-d\\TH:i:s\\Z', $fields['reminder_date']);\n $reminder = $formatReminder->getTimestamp();\n }\n\n $due = null;\n if (empty($fields['due_date']) === false) {\n $formatDue = Carbon::createFromFormat('Y-m-d\\TH:i:s\\Z', $fields['due_date']);\n $due = $formatDue->getTimestamp();\n }\n\n $data = [\n 'custom_activity_type_id' => $fields['type'],\n 'status' => 'Open',\n 'assignee_id' => $this->profile->crm_provider_id,\n 'due_date' => $due,\n 'details' => $fields['details'] ?? '',\n 'reminder_date' => $reminder,\n 'name' => $fields['name'] ?? 'Follow-up',\n 'related_resource' => $this->convertActivityAssociations($activity),\n 'custom_fields' => $this->fetchCustomFieldData($activity, Field::OBJECT_TASK),\n ];\n\n // We don't actually create a corresponding activity object on our side yet.\n return $this->copperClient->tasks()->create($data)['id'];\n }\n\n /**\n * Store transcripts as note.\n *\n * @throws \\Exception\n */\n public function createTranscriptNotes(Activity $activity): void\n {\n // Generate activity transcription.\n $transcriptionData = $this->generateTranscription($activity);\n\n // Truncate Notes with max notes length because transcription text could be very long.\n $transcripts = mb_strimwidth($transcriptionData, 0, static::NOTE_BODY_MAX_LENGTH);\n\n $data = [\n 'user_id' => $this->profile->crm_provider_id,\n 'type' => [\n 'category' => 'user',\n 'id' => 0, // This creates a note.\n ],\n 'activity_date' => time(),\n 'details' => $transcripts,\n 'parent' => $this->convertActivityAssociations($activity),\n ];\n\n $cpActivity = $this->copperClient->activities()->create($data);\n\n // Store CRM Activity ID in transcription.\n $transcription = $activity->getTranscription();\n $transcription->crm_activity_id = $cpActivity['id'];\n $transcription->save();\n }\n\n /**\n * @inheritdoc\n */\n public function updateStage($crmObject, Stage $stage): void\n {\n if ($crmObject instanceof Lead) {\n $this->requestWithExceptionHandling(\n fn () => $this->copperClient->leads()->update($crmObject->crm_provider_id, [\n 'status_id' => $stage->crm_provider_id,\n ])\n );\n } else {\n $this->copperClient->opportunities()->update($crmObject->crm_provider_id, [\n 'pipeline_stage_id' => $stage->crm_provider_id,\n ]);\n }\n }\n\n public function prepareValueForUpdate(array $params): array\n {\n return [$params['fieldName'] => $params['fieldValue']];\n }\n\n /*\n * @inheritdoc\n */\n public function updateRecord(string $objectType, string $objectId, array $data, array $headers = []): void\n {\n $preparedData = $data;\n\n if ($objectType === Field::OBJECT_OPPORTUNITY) {\n $preparedData = $this->prepareOpportunityFieldsForUpdate($data);\n }\n\n try {\n $this->copperClient\n ->createResourceFromObjectType($objectType)\n ->update($objectId, $preparedData)\n ;\n } catch (Exception $e) {\n $this->logger->error(self::RESOURCE_UPDATE_FAILED_ERROR_MESSAGE, [\n 'resource_id' => $objectId,\n 'data' => $preparedData,\n 'error' => $e->getMessage(),\n ]);\n\n throw new CrmException(self::RESOURCE_UPDATE_FAILED_ERROR_MESSAGE, $e->getCode(), $e);\n }\n }\n\n /**\n * @param array<string,string> $fieldData\n *\n * @throws CrmException\n *\n * @return array<string,array<int,array<string,bool|float|int|string|null>>|bool|float|int|string|null>\n */\n private function prepareOpportunityFieldsForUpdate(array $fieldData): array\n {\n $preparedData = [];\n $customFields = [];\n\n foreach ($fieldData as $fieldName => $fieldValue) {\n $field = $this->config\n ->fields()\n ->where('crm_provider_id', $fieldName)\n ->first();\n\n if (! $field instanceof Field) {\n throw new CrmException('Field not found');\n }\n\n $convertedValue = $this->fieldValueConverter->convertToCrm($field->getType(), $fieldValue);\n\n if ($this->isStandardField($fieldName)) {\n $preparedData[$fieldName] = $convertedValue;\n\n continue;\n }\n\n $customFields[] = [\n 'custom_field_definition_id' => $fieldName,\n 'value' => $convertedValue,\n ];\n }\n\n if (! empty($customFields)) {\n $preparedData['custom_fields'] = $customFields;\n }\n\n return $preparedData;\n }\n\n /*\n * @inheritdoc\n */\n public function getRecord(string $objectType, string $objectId, array $fields = []): array\n {\n return $this->copperClient->createResourceFromObjectType($objectType)->find($objectId);\n }\n\n private function fetchCustomFieldData(Activity $activity, string $objectType): array\n {\n $payload = [];\n\n $fieldData = FieldData::where([\n 'object_id' => $activity->id,\n ])->whereHas('field', function ($query) use ($objectType) {\n $query->where('object_type', $objectType);\n })->get();\n\n foreach ($fieldData as $data) {\n // Add the field and value to the payload.\n $payload += [\n 'custom_field_definition_id' => $data->field->crm_provider_id,\n 'value' => $data->value,\n ];\n }\n\n return $payload;\n }\n\n public function generateProviderUrl(string $providerId, string $objectType): ?string\n {\n $baseUrl = 'https://app.prosperworks.com/companies/' . $this->config->crm_provider_id . '/app#/';\n switch ($objectType) {\n case 'lead':\n $url = $baseUrl . 'leads/' . $providerId;\n\n break;\n\n case 'account':\n $url = $baseUrl . 'organization/' . $providerId;\n\n break;\n\n case 'contact':\n $url = $baseUrl . 'contact/' . $providerId;\n\n break;\n\n case 'opportunity':\n $url = $baseUrl . 'deal/' . $providerId;\n\n break;\n\n default:\n case 'task':\n case 'activity':\n // Sadly we can't deeplink to these in Copper UI.\n $url = null;\n }\n\n return $url;\n }\n\n /**\n * Generate transcription for the activity.\n */\n private function generateTranscription(Activity $activity): string\n {\n if (! ($this->config->store_transcript)) {\n // If sending transcription to activity toggle is disabled\n return '';\n }\n\n return $this->transcriptionService\n ->findTranscriptionByActivity($activity)\n ->map(static function (array $transcriptionSegment): string {\n return $transcriptionSegment['formattedStartsAt'] . ' | ' . $transcriptionSegment['transcript'];\n })\n ->implode(PHP_EOL);\n }\n\n /**\n * @param array<string, mixed> $opportunityData\n *\n * @return int|string|bool|float|null\n */\n private function extractFieldValue(array $opportunityData, string $fieldName)\n {\n $fieldValue = $this->isStandardField($fieldName)\n ? $opportunityData[$fieldName]\n : $this->resolveCustomFieldFromOpportunityData($opportunityData, $fieldName);\n\n if (is_array($fieldValue)) {\n $fieldValue = implode(', ', $fieldValue);\n }\n\n return $fieldValue;\n }\n\n private function isStandardField(string $fieldName): bool\n {\n return array_key_exists($fieldName, self::STANDARD_OPPORTUNITY_FIELDS);\n }\n\n /**\n * @param array<string, mixed> $opportunityData\n *\n * @return string|int|float|bool|null|array<string, mixed>\n */\n private function resolveCustomFieldFromOpportunityData(array $opportunityData, string $fieldName)\n {\n $fieldData = array_filter(\n $opportunityData['custom_fields'],\n static fn ($field) => (string) $field['custom_field_definition_id'] === $fieldName\n );\n\n return array_pop($fieldData)['value'] ?? '';\n }\n\n private function isSupportedObjectType(string $objectType): bool\n {\n return $objectType !== self::UNSUPPORTED_OBJECT_TYPE;\n }\n\n private function syncStandardFields(): void\n {\n foreach (self::STANDARD_OPPORTUNITY_FIELDS as $fieldId => $fieldData) {\n $this->config->fields()->updateOrCreate([\n 'crm_provider_id' => mb_strimwidth($fieldId, 0, 128),\n 'object_type' => Field::OBJECT_OPPORTUNITY,\n ], [\n 'label' => mb_strimwidth($fieldData['label'], 0, Field::LABEL_MAX_LENGTH),\n 'type' => $fieldData['type'],\n ]);\n }\n\n $this->importStages([Stage::TYPE_OPPORTUNITY]);\n }\n\n private function requestWithExceptionHandling(\\Closure $apiRequest): iterable\n {\n try {\n return $apiRequest();\n } catch (\\Exception $exception) {\n if ($exception->getCode() === 403) {\n // Object might not be supported\n return [];\n }\n\n \\Sentry::captureException($exception);\n\n return [];\n }\n }\n\n public function verifyTaskExists(Activity $activity): bool\n {\n $crmProviderId = $activity->getCrmProviderId();\n $cacheKey = \"crm_task_exists:{$this->config->getId()}:$crmProviderId\";\n\n return Cache::remember($cacheKey, self::TASK_VERIFICATION_CACHE_TTL, function () use ($crmProviderId) {\n try {\n if ($this->copperClient === null) {\n return false;\n }\n\n $task = $this->copperClient->tasks()->find($crmProviderId);\n\n return ! empty($task);\n } catch (HttpNotFoundException) {\n // Task not found in CRM - this is expected and permanent\n $this->logger->info('[Copper] Task not found during verification', [\n 'task_id' => $crmProviderId,\n 'config_id' => $this->config->getId(),\n ]);\n\n return false;\n } catch (RequestFailedException $e) {\n // Handle 404 responses from Copper API\n if ($e->getCode() === 404) {\n $this->logger->info('[Copper] Task not found during verification (404)', [\n 'task_id' => $crmProviderId,\n 'config_id' => $this->config->getId(),\n ]);\n\n return false;\n }\n\n // Re-throw other Copper exceptions for retry\n throw $e;\n }\n // Let other exceptions (network errors, rate limits, etc.) bubble up for retry\n });\n }\n}","depth":4,"on_screen":true,"value":"<?php\n\nnamespace Jiminny\\Services\\Crm\\Copper;\n\nuse Carbon\\Carbon;\nuse Exception;\nuse GuzzleHttp\\Exception\\ClientException;\nuse Illuminate\\Support\\Facades\\Cache;\nuse Jiminny\\Contracts\\Services\\Crm\\LayoutManagementInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\MatchCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\Provider\\CopperInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityLookupInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityManipulationInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SettingsInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmMetadataInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\VerifyTaskExistsInterface;\nuse Jiminny\\Exceptions\\CrmException;\nuse Jiminny\\Exceptions\\HttpNotFoundException;\nuse Jiminny\\Models\\Account;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Contact;\nuse Jiminny\\Models\\Crm\\BusinessProcess;\nuse Jiminny\\Models\\Crm\\Configuration;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Models\\Crm\\FieldData;\nuse Jiminny\\Models\\Crm\\FieldValue;\nuse Jiminny\\Models\\Crm\\LayoutEntity;\nuse Jiminny\\Models\\Crm\\Profile;\nuse Jiminny\\Models\\Lead;\nuse Jiminny\\Models\\Opportunity;\nuse Jiminny\\Models\\Playbook;\nuse Jiminny\\Models\\PlaybookCategory;\nuse Jiminny\\Models\\SocialAccount;\nuse Jiminny\\Models\\Stage;\nuse Jiminny\\Models\\User;\nuse Jiminny\\Repositories\\Crm\\CrmEntityRepository;\nuse Jiminny\\Repositories\\Crm\\FieldRepository;\nuse Jiminny\\Repositories\\Crm\\ProfileRepository;\nuse Jiminny\\Repositories\\UserRepository;\nuse Jiminny\\Services\\Avatar\\ProspectPhotoPathService;\nuse Jiminny\\Services\\Crm\\BaseService;\nuse Jiminny\\Services\\Crm\\Copper\\Api\\Client;\nuse Jiminny\\Services\\Crm\\Copper\\Api\\ClientFactory;\nuse Jiminny\\Services\\Crm\\Copper\\Exception\\RequestFailedException;\nuse Jiminny\\Services\\Crm\\Helpers\\FilterJoinedParticipants;\nuse Jiminny\\Services\\Crm\\OpportunitySyncStrategyResolver;\nuse Jiminny\\Services\\Crm\\Copper\\Traits\\SearchesCrmEntities;\nuse Jiminny\\Utils\\CurrencyFormatter;\nuse Jiminny\\Utils\\StringUtil;\n\nclass Service extends BaseService implements\n CopperInterface,\n SyncCrmEntitiesInterface,\n SyncCrmMetadataInterface,\n RemoteEntityManipulationInterface,\n LayoutManagementInterface,\n SettingsInterface,\n MatchCrmEntitiesInterface,\n RemoteEntityLookupInterface,\n VerifyTaskExistsInterface\n{\n use SearchesCrmEntities;\n private const int NOTE_BODY_MAX_LENGTH = 3000000;\n\n private const int TASK_VERIFICATION_CACHE_TTL = 86400; // 1 day\n\n private const array STANDARD_OPPORTUNITY_FIELDS = [\n 'monetary_value' => [\n 'label' => 'Value',\n 'available_on' => Field::OBJECT_OPPORTUNITY,\n 'type' => Field::TYPE_CURRENCY,\n ],\n 'pipeline_stage_id' => [\n 'label' => 'Stage',\n 'type' => Field::TYPE_PICKLIST,\n 'available_on' => Field::OBJECT_OPPORTUNITY,\n ],\n 'close_date' => [\n 'label' => 'Close date',\n 'type' => Field::TYPE_DATE,\n 'available_on' => Field::OBJECT_OPPORTUNITY,\n ],\n 'win_probability' => [\n 'label' => 'Win probability',\n 'type' => Field::TYPE_NUMBER,\n 'available_on' => Field::OBJECT_OPPORTUNITY,\n ],\n ];\n private const string UNSUPPORTED_OBJECT_TYPE = 'unsupported';\n private const string OPPORTUNITY_STATUS_OPEN = 'Open';\n private const string OPPORTUNITY_IMPORT_FAILED_ERROR_MESSAGE = 'Opportunity import failed';\n private const string RESOURCE_UPDATE_FAILED_ERROR_MESSAGE = 'Failed to update resource';\n\n private FieldValueConverter $fieldValueConverter;\n private ClientFactory $clientFactory;\n private ?Client $copperClient = null;\n\n public function __construct(\n ClientFactory $clientFactory,\n FieldValueConverter $fieldValueConverter,\n private readonly UserRepository $userRepository,\n private readonly ProspectPhotoPathService $prospectPhotoPathService,\n private readonly CrmEntityRepository $crmEntityRepository\n ) {\n parent::__construct();\n\n $this->clientFactory = $clientFactory;\n $this->fieldValueConverter = $fieldValueConverter;\n }\n\n public function getDisplayName(): string\n {\n return 'Copper';\n }\n\n /**\n * Get the CRM entity repository (used by SearchesCrmEntities trait)\n */\n protected function getCrmEntityRepository(): CrmEntityRepository\n {\n return $this->crmEntityRepository;\n }\n\n public function getMinimumApiVersion(): string\n {\n return Client::MIN_API_VERSION;\n }\n\n protected function getOAuthAccount(User $user): ?SocialAccount\n {\n // In this case, the Account Owner is always the connection for any API operations.\n $owner = $user->team->owner;\n\n return $owner->getSocialAccount(SocialAccount::PROVIDER_COPPER);\n }\n\n public function setUser(User $user): void\n {\n $this->configureSentryScope($user);\n\n $this->team = $user->getTeam();\n $this->teamService->setTeam($user->getTeam());\n $this->setConfiguration($user->getTeam()->getCrmConfiguration());\n\n // Reset state.\n $this->profile = $user->getProfile();\n\n if (! $this->profile instanceof Profile) {\n // Try to fetch their profile, maybe it was not yet imported?\n $this->profile = $this->getProfile($user);\n }\n }\n\n public function setConfiguration(Configuration $configuration): void\n {\n $this->config = $configuration;\n $this->copperClient = $this->clientFactory->create($configuration);\n\n if ($this->team === null) {\n $this->team = $configuration->getTeam();\n }\n }\n\n public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string\n {\n return $this->fieldValueConverter->convertFromCrm($fieldType, $fieldValue);\n }\n\n private function mapObjectType(string $objectType): string\n {\n $objectType = strtolower($objectType);\n\n switch ($objectType) {\n case Field::OBJECT_TASK:\n case Field::OBJECT_OPPORTUNITY:\n case Field::OBJECT_LEAD:\n $mappedType = $objectType;\n\n break;\n case Field::OBJECT_ACCOUNT:\n $mappedType = 'company';\n\n break;\n case Field::OBJECT_CONTACT:\n $mappedType = 'person';\n\n break;\n default:\n $mappedType = self::UNSUPPORTED_OBJECT_TYPE;\n\n break;\n }\n\n return $mappedType;\n }\n\n /**\n * @param string $crmObject The name of the CRM object. i.e. Opportunity or Contact\n */\n protected function getFields(string $crmObject): array\n {\n $mappedObjectType = $this->mapObjectType($crmObject);\n\n if (! $this->isSupportedObjectType($mappedObjectType)) {\n return [];\n }\n\n $customFields = $this->copperClient->fields()->all();\n\n $filteredFields = array_filter(\n $customFields,\n static fn ($field) => in_array($mappedObjectType, $field['available_on'], true)\n );\n\n return array_map(\n fn (array $property) => [\n 'name' => $property['id'],\n 'label' => $property['name'],\n 'type' => $this->convertFieldType($property['data_type']),\n ],\n $filteredFields\n );\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultFields(string $activityType): array\n {\n $fields = [];\n\n if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {\n $defaultFields = FieldDefinitions::defaultTaskFields();\n\n // This lazy creates these fields if not already setup.\n foreach ($defaultFields as $defaultField) {\n $fields[] = $this->config->fields()->firstOrCreate($defaultField);\n }\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultActivityField(string $activityType): Field\n {\n // Setup the activity field as the default Type.\n $activityField = $this->config->fields()->where([\n 'crm_provider_id' => 'type',\n 'object_type' => $activityType,\n ])->first();\n\n return $activityField;\n }\n\n /**\n * @inheritdoc\n */\n public function getSupportedPlaybookTypes(): array\n {\n return [Playbook::ACTIVITY_TYPE_TASK];\n }\n\n public function getDealInsightsFields(): array\n {\n return FieldDefinitions::dealInsightsFields();\n }\n\n protected function getDefaultFollowupLayoutFields(string $activityType): array\n {\n $fields = [];\n $fieldRepo = app(FieldRepository::class);\n $fieldFilters = FieldDefinitions::taskFollowupFieldsFilter();\n\n foreach ($fieldFilters as $data) {\n $field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $data);\n\n // Only add the field if it is created, which it should be.\n if ($field) {\n $fields[] = $field;\n }\n }\n\n return $fields;\n }\n\n public function syncFields(): void\n {\n $this->syncStandardFields();\n $this->syncCustomFields();\n }\n\n public function syncField(Field $field): void\n {\n if ($field->getCrmProviderId() === 'type') {\n /**\n * This field is a special case, it is not a custom field and is not returned by the Copper API.\n *\n * The field is created manually during layout setup.\n *\n * @see getDefaultFields()\n *\n * There is a separate endpoint to get the activity types.\n * @see importPicklistValues()\n */\n return;\n }\n\n if ($field->getCrmProviderId() === 'pipeline_stage_id') {\n // Not a custom type\n return;\n }\n\n $crmField = $this->copperClient->fields()->find($field->getCrmProviderId());\n $field->label = $crmField['name'];\n $field->type = $this->convertFieldType($crmField['data_type']);\n\n $field->save();\n }\n\n /**\n * @param string $fieldType How the data should be input\n */\n private function convertFieldType(string $fieldType): string\n {\n $fieldType = strtolower($fieldType);\n\n switch ($fieldType) {\n case 'dropdown':\n $convertedType = Field::TYPE_PICKLIST;\n\n break;\n case 'string':\n $convertedType = Field::TYPE_TEXT;\n\n break;\n case 'float':\n case 'percentage':\n $convertedType = Field::TYPE_NUMBER;\n\n break;\n case 'text':\n $convertedType = Field::TYPE_TEXTAREA;\n\n break;\n case 'checkbox':\n $convertedType = Field::TYPE_BOOLEAN;\n\n break;\n case Field::TYPE_DATE:\n case Field::TYPE_CURRENCY:\n case Field::TYPE_URL:\n $convertedType = $fieldType;\n\n break;\n default:\n $convertedType = Field::TYPE_UNSUPPORTED;\n\n break;\n }\n\n return $convertedType;\n }\n\n /**\n * @inheritdoc\n */\n public function importPicklistValues(Field $field): array\n {\n $fieldValues = [];\n\n if ($field->object_type === 'opportunity' && $field->crm_provider_id === 'pipeline_stage_id') {\n return $this->importFieldValuesForDealInsights($field);\n }\n\n $values = [];\n\n if ($field->object_type === Field::OBJECT_TASK && $field->crm_provider_id === 'type') {\n $activityTypes = $this->copperClient->activityTypes()->all();\n\n /**\n * We ignore \"system\" activity types, disabled activity types and the \"Note\" activity type.\n */\n $activityTypes = array_filter(\n $activityTypes['user'],\n fn (array $activityType) => ! $activityType['is_disabled'] && $activityType['id'] !== 0\n );\n\n // Import all activity values.\n $i = 0;\n foreach ($activityTypes as $activityType) {\n $values[] = [\n 'value' => $activityType['id'],\n 'label' => mb_strimwidth($activityType['name'], 0, Field::LABEL_MAX_LENGTH),\n 'sequence' => $i++,\n ];\n }\n } else {\n // We just check for the field in the custom definitions.\n $crmField = $this->copperClient->fields()->find($field->crm_provider_id);\n\n // Import all values.\n foreach ($crmField['options'] as $option) {\n $values[] = [\n 'value' => $option['id'],\n 'label' => mb_strimwidth($option['name'], 0, 191),\n 'sequence' => $option['rank'],\n ];\n }\n }\n\n // Take the existing set of field values.\n $fieldsToPurge = $field->values()->get()->pluck('value')->toArray();\n\n foreach ($values as $value) {\n // For all current values, upsert the field.\n $value['value'] = substr($value['value'] ?? '', 0, 255);\n $fieldValues[] = $field->values()->updateOrCreate([\n 'value' => $value['value'],\n ], $value);\n\n // Remove this value from the ones we are going to purge.\n $key = array_search($value['value'], $fieldsToPurge, false);\n if ($key !== false) {\n unset($fieldsToPurge[$key]);\n }\n }\n\n // Delete the old values that are no longer used.\n $field->values()->whereIn('value', $fieldsToPurge)->delete();\n\n return $fieldValues;\n }\n\n /**\n *\n * @return FieldValue[]\n */\n private function importFieldValuesForDealInsights(Field $field): array\n {\n $fieldValues = [];\n $pipelines = $this->copperClient->pipelines()->all();\n\n foreach ($pipelines as $pipeline) {\n foreach ($pipeline['stages'] as $i => $dealStage) {\n $fieldValues[] = $field->values()->updateOrCreate(\n ['value' => $dealStage['id']],\n [\n 'label' => $dealStage['name'],\n 'sequence' => $i,\n ]\n );\n }\n }\n\n return $fieldValues;\n }\n\n /**\n * @inheritdoc\n */\n public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage\n {\n if ($types === null) {\n $types = [Stage::TYPE_LEAD, Stage::TYPE_OPPORTUNITY];\n }\n $missingStage = null;\n\n foreach ($types as $type) {\n if ($type === Stage::TYPE_LEAD) {\n $statuses = $this->requestWithExceptionHandling(\n fn () => $this->copperClient->leadStatuses()->all()\n );\n\n if (count($statuses) > 0) {\n $businessProcess = $this->config->businessProcesses()->updateOrCreate([\n 'crm_provider_id' => BusinessProcess::TYPE_LEAD,\n ], [\n 'team_id' => $this->team->id,\n 'name' => BusinessProcess::TYPE_LEAD,\n 'type' => BusinessProcess::TYPE_LEAD,\n 'is_selectable' => true,\n ]);\n\n // A record type is really a clone of the business process, used to store which record uses which pipeline.\n $this->config->recordTypes()->updateOrCreate([\n 'crm_provider_id' => BusinessProcess::TYPE_LEAD,\n ], [\n 'team_id' => $this->team->id,\n 'name' => BusinessProcess::TYPE_LEAD,\n 'is_selectable' => true,\n 'business_process_id' => $businessProcess->id ?? null,\n ]);\n\n $leadStatuses = [];\n foreach ($statuses as $status) {\n // Upsert all statuses for the team.\n $status = $this->config->stages()->updateOrCreate([\n 'crm_provider_id' => $status['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($status['name'], 0, 50),\n 'label' => mb_strimwidth($status['name'], 0, 191),\n 'type' => Stage::TYPE_LEAD,\n 'sequence' => $status['order'],\n 'is_selectable' => true,\n ]);\n\n if ($missingStageName && $missingStageName === $status['id']) {\n $missingStage = $status;\n }\n\n $leadStatuses[] = $status['id'];\n }\n\n $businessProcess->stages()->sync($leadStatuses);\n }\n } else {\n // We create a business process to contain the pipeline, and later store all stages against it.\n foreach ($this->copperClient->pipelines()->all() as $pipeline) {\n $businessProcess = $this->config->businessProcesses()->updateOrCreate([\n 'crm_provider_id' => $pipeline['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($pipeline['name'], 0, 150),\n 'type' => BusinessProcess::TYPE_OPPORTUNITY,\n 'is_selectable' => true,\n ]);\n\n // A record type is really a clone of the business process, used to store which record uses which pipeline.\n $this->config->recordTypes()->updateOrCreate([\n 'crm_provider_id' => $pipeline['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($pipeline['name'], 0, 150),\n 'is_selectable' => true,\n 'business_process_id' => $businessProcess->id ?? null,\n ]);\n\n $stages = [];\n\n foreach ($pipeline['stages'] as $i => $dealStage) {\n // Upsert all stages for the team.\n $stage = $this->config->stages()->updateOrCreate([\n 'crm_provider_id' => $dealStage['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($dealStage['name'], 0, 50),\n 'label' => mb_strimwidth($dealStage['name'], 0, 191),\n 'type' => Stage::TYPE_OPPORTUNITY,\n 'sequence' => $i,\n 'is_selectable' => true,\n 'probability' => $dealStage['win_probability'],\n ]);\n\n if ($missingStageName && $missingStageName === $dealStage['id']) {\n $missingStage = $stage;\n }\n\n $stages[] = $stage['id'];\n }\n\n $businessProcess->stages()->sync($stages);\n }\n }\n }\n\n return $missingStage;\n }\n\n /**\n * @inheritdoc\n */\n public function syncLeads(Carbon $since, ?Carbon $to = null, ?string $crmProfileId = null): int\n {\n $syncCount = 0;\n\n $cpLeads = $this->requestWithExceptionHandling(\n fn () => $this->copperClient->leads()->paginate([\n 'minimum_modified_date' => $since->timestamp,\n 'sort_by' => 'date_modified',\n ])\n );\n\n foreach ($cpLeads as $cpLead) {\n // Only sync if previously imported.\n if ($this->hasLead($cpLead['id']) === false) {\n $this->importLead($cpLead);\n $syncCount++;\n }\n }\n\n return $syncCount;\n }\n\n /**\n * @inheritdoc\n */\n public function syncLead(string $crmId): ?Lead\n {\n $cpLead = $this->requestWithExceptionHandling(\n fn () => $this->copperClient->leads()->find($crmId)\n );\n\n if (\\count($cpLead) === 0) {\n return null;\n }\n\n return $this->importLead($cpLead);\n }\n\n /**\n * @inheritdoc\n */\n private function importLead($crmData): ?Lead\n {\n $countryCode = null;\n if (! empty($crmData['address']) && isset($crmData['address']['country'])) {\n $countryCode = $this->convertCountryNameToCode($crmData['address']['country']);\n }\n\n $mobilePhone = $parsedNumber = null;\n if (! empty($crmData['phone_numbers'])) {\n foreach ($crmData['phone_numbers'] as $phoneNumber) {\n if ($phoneNumber['category'] === 'mobile') {\n $mobilePhone = $this->buildContactMobilePhone($countryCode, $phoneNumber['number']);\n } else {\n $parsedNumber = $this->buildContactPhone($countryCode, $phoneNumber['number']);\n }\n }\n }\n\n $name = $crmData['name'];\n\n if (empty($crmData['company_name'])) {\n $company = 'Unknown';\n } else {\n $company = mb_strimwidth($crmData['company_name'], 0, 191);\n }\n\n $domain = null;\n if (empty($crmData['websites']) === false) {\n $domain = mb_strimwidth($crmData['websites'][0]['url'], 0, 191);\n $domain = StringUtil::resolveDomain($domain);\n }\n\n $email = null;\n if (empty($crmData['email']) === false) {\n $email = mb_strimwidth($crmData['email']['email'], 0, 80);\n }\n\n $stage = $this->config\n ->stages()\n ->where('crm_provider_id', (string) $crmData['status_id'])\n ->where('type', Stage::TYPE_LEAD)\n ->first();\n\n if ($stage === null) {\n // Import it.\n $stage = $this->importStages([Stage::TYPE_LEAD], $crmData['status_id']);\n }\n\n if (! $stage instanceof Stage) {\n return null;\n }\n\n $profile = $this->config->profiles()->where('crm_provider_id', (string) $crmData['assignee_id'])->first();\n\n $data = [\n 'team_id' => $this->team->id,\n 'user_id' => $profile ? $profile->user_id : null,\n 'owner_id' => $crmData['assignee_id'],\n 'name' => mb_strimwidth($name, 0, 191),\n 'company' => $company,\n 'domain' => $domain,\n 'title' => $crmData['title'] ? mb_strimwidth($crmData['title'], 0, 128) : null,\n 'email' => $email,\n 'phone' => $parsedNumber['phone'] ?? null,\n 'ext' => $parsedNumber['ext'] ?? null,\n 'mobile_phone' => $mobilePhone,\n 'stage_id' => $stage->id,\n 'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(\n crmConfiguration: $this->config,\n crmProviderId: $crmData['id'],\n modelType: Lead::class,\n fileName: $crmData['id'],\n avatarText: $crmData['name']\n ),\n 'country_code' => $countryCode,\n 'remotely_created_at' => $crmData['date_created'],\n ];\n\n return $this->config->leads()->updateOrCreate(['crm_provider_id' => (string) $crmData['id']], $data);\n }\n\n /**\n * @inheritdoc\n */\n public function syncAccounts(Carbon $since, ?Carbon $to = null): int\n {\n $syncCount = 0;\n\n try {\n $cpAccounts = $this->copperClient->accounts()->paginate([\n 'minimum_modified_date' => $since->timestamp,\n 'sort_by' => 'date_modified',\n ]);\n\n foreach ($cpAccounts as $cpAccount) {\n // Only sync if previously imported.\n if ($this->hasAccount($cpAccount['id'])) {\n $this->importAccount($cpAccount);\n $syncCount++;\n }\n }\n } catch (\\Exception $exception) {\n // Do nothing for now.\n }\n\n return $syncCount;\n }\n\n /**\n * @inheritdoc\n */\n public function syncAccount(string $crmId): ?Account\n {\n $cpAccount = $this->copperClient->accounts()->find($crmId);\n\n if (empty($cpAccount)) {\n return null;\n }\n\n return $this->importAccount($cpAccount);\n }\n\n /**\n * @inheritdoc\n */\n private function importAccount($crmData): Account\n {\n $countryCode = null;\n if (empty($crmData['address']) === false && $crmData['address']['country']) {\n $countryCode = $this->convertCountryNameToCode($crmData['address']['country']);\n }\n\n $name = $crmData['name'];\n\n $parsedNumber = null;\n foreach ($crmData['phone_numbers'] as $phoneNumber) {\n $parsedNumber = parsePhoneNumber($countryCode, $phoneNumber['number']);\n }\n\n $domain = null;\n if (empty($crmData['websites']) === false) {\n $domain = mb_strimwidth($crmData['websites'][0]['url'], 0, 191);\n $domain = StringUtil::resolveDomain($domain);\n }\n\n $profile = $this->config->profiles()->where('crm_provider_id', (string) $crmData['assignee_id'])->first();\n\n $data = [\n 'team_id' => $this->team->id,\n 'user_id' => $profile ? $profile->user_id : null,\n 'owner_id' => $crmData['assignee_id'],\n 'name' => mb_strimwidth($name, 0, 191),\n 'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(\n crmConfiguration: $this->config,\n crmProviderId: $crmData['id'],\n modelType: Account::class,\n fileName: $crmData['id'],\n avatarText: $crmData['name']\n ),\n 'country_code' => $countryCode,\n 'domain' => $domain,\n 'phone' => $parsedNumber['phone'] ?? null,\n 'ext' => $parsedNumber['ext'] ?? null,\n 'remotely_created_at' => $crmData['date_created'],\n ];\n\n return $this->config->accounts()->updateOrCreate(['crm_provider_id' => (string) $crmData['id']], $data);\n }\n\n public function syncOpportunities(array $parameters, ?string $strategy = null): int\n {\n $strategyResolver = app(OpportunitySyncStrategyResolver::class, [\n 'client' => $this->copperClient,\n ]);\n $strategies = $strategyResolver->getStrategies($this->config, $strategy);\n\n $syncCount = 0;\n\n try {\n foreach ($strategies as $syncStrategy) {\n foreach ($syncStrategy->fetchOpportunities($parameters) as $opportunityData) {\n $this->importOpportunity($opportunityData);\n $syncCount++;\n }\n }\n } catch (\\Exception $exception) {\n // Do nothing for now.\n $this->logger->error(self::OPPORTUNITY_IMPORT_FAILED_ERROR_MESSAGE, [\n 'error' => $exception->getMessage(),\n ]);\n }\n\n return $syncCount;\n }\n\n public function syncOpportunity(string $crmId): ?Opportunity\n {\n $strategyResolver = app(OpportunitySyncStrategyResolver::class, [\n 'client' => $this->copperClient,\n ]);\n $strategy = $strategyResolver->resolve(\n $this->config,\n OpportunitySyncStrategyResolver::SINGLE_SYNC_OPPORTUNITY_STRATEGY\n );\n\n $parameters['crm_id'] = $crmId;\n\n $cpOpportunity = $strategy->fetchOpportunities($parameters);\n\n return $this->importOpportunity($cpOpportunity);\n }\n\n private function importOpportunity($crmData): ?Opportunity\n {\n $companyId = (string) $crmData['company_id'];\n\n if (empty($companyId)) {\n $this->logger->error(self::OPPORTUNITY_IMPORT_FAILED_ERROR_MESSAGE, [\n 'error' => 'No company id',\n ]);\n\n return null;\n }\n\n $account = $this->config->accounts()->where('crm_provider_id', $companyId)->first();\n\n if ($account === null) {\n $account = $this->syncAccount($companyId);\n }\n\n $profile = $this->config->profiles()->where('crm_provider_id', (string) $crmData['assignee_id'])->first();\n\n $businessProcess = $this->config\n ->businessProcesses()\n ->where('crm_provider_id', (string) $crmData['pipeline_id'])\n ->first();\n\n if ($businessProcess === null) {\n // It is possible for some deals to not be in a pipeline.\n $this->logger->warning(self::OPPORTUNITY_IMPORT_FAILED_ERROR_MESSAGE, [\n 'error' => 'Missing pipeline',\n ]);\n\n return null;\n }\n\n $stage = $businessProcess\n ->stages()\n ->where('crm_provider_id', (string) $crmData['pipeline_stage_id'])\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->first();\n\n if ($stage === null) {\n $this->logger->info('Stage not found, importing', [\n 'pipeline_id' => $crmData['pipeline_id'],\n 'stage_id' => $crmData['pipeline_stage_id'],\n ]);\n\n $stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $crmData['pipeline_stage_id']);\n }\n\n $recordType = $businessProcess->recordTypes()->first();\n $closeDate = null;\n $isClosed = $crmData['status'] !== self::OPPORTUNITY_STATUS_OPEN;\n\n if ($crmData['close_date']) {\n $closeDate = Carbon::parse($crmData['close_date'])->format('Y-m-d');\n }\n\n $data = [\n 'team_id' => $this->team->getId(),\n 'account_id' => $account->id,\n 'user_id' => $profile->user_id ?? null,\n 'owner_id' => $crmData['assignee_id'],\n 'name' => mb_strimwidth($crmData['name'], 0, 128),\n 'value' => $crmData['monetary_value'],\n 'currency_code' => CurrencyFormatter::formatCode($crmData['monetary_unit']),\n 'close_date' => $closeDate,\n 'is_closed' => $isClosed,\n 'is_won' => $crmData['status'] === 'Won',\n 'stage_id' => $stage->id,\n 'record_type_id' => $recordType->id ?? null,\n 'remotely_created_at' => $crmData['date_created'],\n ];\n\n /** @var Opportunity $opportunity */\n $opportunity = $this->config\n ->opportunities()\n ->updateOrCreate(['crm_provider_id' => (string) $crmData['id']], $data)\n ;\n\n // import external fields into crm_field_data if present\n $dealInsightsFields = $this->getOpportunitySyncableFields();\n $this->importOpportunityCrmFieldData($crmData, $dealInsightsFields, $opportunity->id);\n\n return $opportunity;\n }\n\n /**\n * @param array<string, mixed> $opportunityData\n * @param string[] $dealInsightsFields\n */\n public function importOpportunityCrmFieldData(\n array $opportunityData,\n array $dealInsightsFields,\n int $opportunityId\n ): void {\n foreach ($dealInsightsFields as $fieldName) {\n $field = $this->config->fields()->with('entities')\n ->where('crm_provider_id', $fieldName)\n ->where('object_type', Field::OBJECT_OPPORTUNITY)\n ->first();\n\n if (! $field instanceof Field) {\n $this->logger->warning('Field not found', [\n 'field' => $fieldName,\n 'opportunity_id' => $opportunityId,\n ]);\n\n continue;\n }\n\n $entity = $field->getEntities()->first();\n\n if (! $entity instanceof LayoutEntity) {\n continue;\n }\n\n $fieldValue = $this->extractFieldValue($opportunityData, $fieldName);\n $normalizedValue = $this->normalizeValue($field->getType(), (string) $fieldValue, true);\n\n $field->data()\n ->updateOrCreate([\n 'crm_layout_entity_id' => $entity->id,\n 'crm_field_id' => $field->id,\n 'object_type' => 'opportunity',\n 'object_id' => $opportunityId,\n ], ['value' => $normalizedValue])\n ;\n }\n }\n\n /**\n * @inheritdoc\n */\n public function syncContacts(Carbon $since, ?Carbon $to = null): int\n {\n $syncCount = 0;\n\n try {\n $cpContacts = $this->copperClient->contacts()->paginate([\n 'minimum_interaction_date' => $since->timestamp,\n 'sort_by' => 'date_modified',\n ]);\n\n foreach ($cpContacts as $cpContact) {\n // Only sync if previously imported.\n if ($this->hasContact($cpContact['id'])) {\n $this->importContact($cpContact);\n $syncCount++;\n }\n }\n } catch (\\Exception $exception) {\n // Do nothing for now.\n throw $exception;\n }\n\n return $syncCount;\n }\n\n /**\n * @inheritdoc\n */\n public function syncContact(string $crmId): ?Contact\n {\n $cpContact = $this->copperClient->contacts()->find($crmId);\n\n if (empty($cpContact)) {\n return null;\n }\n\n return $this->importContact($cpContact);\n }\n\n /**\n * @inheritdoc\n */\n private function importContact($crmData): Contact\n {\n $account = null;\n if ($crmData['company_id']) {\n $account = $this->team\n ->crm\n ->accounts()\n ->where('crm_provider_id', (string) $crmData['company_id'])\n ->first();\n\n if ($account === null) {\n $account = $this->syncAccount($crmData['company_id']);\n }\n }\n\n $countryCode = null;\n if (empty($crmData['address']) === false && $crmData['address']['country']) {\n $countryCode = $this->convertCountryNameToCode($crmData['address']['country']);\n }\n\n $mobilePhone = $parsedNumber = null;\n foreach ($crmData['phone_numbers'] as $phoneNumber) {\n if ($phoneNumber['category'] === 'mobile') {\n $mobilePhone = $this->buildContactMobilePhone($countryCode, $phoneNumber['number']);\n } else {\n $parsedNumber = $this->buildContactPhone($countryCode, $phoneNumber['number']);\n }\n }\n\n $email = null;\n if (empty($crmData['emails']) === false) {\n $email = mb_strimwidth($crmData['emails'][0]['email'], 0, 80);\n }\n\n $profile = $this->config->profiles()->where('crm_provider_id', (string) $crmData['assignee_id'])->first();\n\n $data = [\n 'team_id' => $this->team->id,\n 'account_id' => $account->id ?? null,\n 'user_id' => $profile ? $profile->user_id : null,\n 'owner_id' => $crmData['assignee_id'],\n 'name' => $crmData['name'],\n 'email' => $email,\n 'phone' => $parsedNumber['phone'] ?? null,\n 'mobile_phone' => $mobilePhone ?? null,\n 'ext' => $parsedNumber['ext'] ?? null,\n 'title' => $crmData['title'],\n 'country_code' => $countryCode,\n 'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(\n crmConfiguration: $this->config,\n crmProviderId: $crmData['id'],\n modelType: Contact::class,\n fileName: $crmData['id'],\n avatarText: $crmData['name']\n ),\n 'remotely_created_at' => $crmData['date_created'],\n ];\n\n return $this->config->contacts()->updateOrCreate(['crm_provider_id' => (string) $crmData['id']], $data);\n }\n\n private function buildContactPhone(?string $countryCode, ?string $number): ?array\n {\n if ($number) {\n // Trim to our width and attempt to parse it.\n $number = mb_strimwidth($number, 0, 25);\n $parsedNumber = parsePhoneNumber($countryCode, $number);\n } else {\n $parsedNumber = [];\n }\n\n return $parsedNumber;\n }\n\n private function buildContactMobilePhone(?string $countryCode, ?string $number): ?string\n {\n return $number ? mb_strimwidth(phone_e164($countryCode, $number), 0, 25) : null;\n }\n\n /**\n * @inheritdoc\n */\n public function syncOrganization(): void\n {\n $organization = $this->copperClient->organisation()->all();\n\n if ($this->config->crm_provider_id !== $organization['id']) {\n $this->config->update(['crm_provider_id' => $organization['id']]);\n }\n }\n\n private function syncCustomFields(): void\n {\n foreach ($this->getFieldTypes() as $fieldType) {\n $currentFields = $this->getFields($fieldType);\n\n foreach ($currentFields as $field) {\n /** @var Field $field */\n $field = $this->config->fields()->updateOrCreate([\n 'crm_provider_id' => $field['name'],\n 'object_type' => $fieldType,\n ], [\n 'label' => mb_strimwidth($field['label'], 0, Field::LABEL_MAX_LENGTH),\n 'type' => $field['type'],\n ]);\n\n if ($field->getType() === Field::TYPE_PICKLIST) {\n $this->importPicklistValues($field);\n }\n }\n }\n\n $this->config->save();\n }\n\n protected function getFieldTypes(): array\n {\n return [\n parent::OBJECT_TASK,\n parent::OBJECT_LEAD,\n parent::OBJECT_ACCOUNT,\n parent::OBJECT_CONTACT,\n parent::OBJECT_OPPORTUNITY,\n ];\n }\n\n /**\n * @inheritdoc\n */\n public function syncProfiles(?User $userToSearch = null): ?Profile\n {\n $profiles = $this->copperClient->users()->all();\n\n foreach ($profiles as $profile) {\n $user = $this->userRepository->findByTeamAndEmailAddress($this->team, $profile['email'], onlyActive: true);\n\n // Only import profiles for users we have locally.\n if ($user) {\n $profile = $user->crmProfile()->updateOrCreate([\n 'crm_configuration_id' => $this->config->id,\n 'crm_provider_id' => $profile['id'],\n ]);\n\n if ($userToSearch && $userToSearch->id === $user->id) {\n return $profile;\n }\n }\n }\n\n return null;\n }\n\n /**\n * @inheritdoc\n */\n public function syncProfileFields(): void\n {\n $fieldTypes = [\n 'task',\n 'lead',\n 'account',\n 'contact',\n 'opportunity',\n ];\n\n foreach ($fieldTypes as $fieldType) {\n try {\n $currentFields = $this->getFields(ucfirst($fieldType));\n\n $newFields = array_column($currentFields, 'name');\n $changedFields = array_merge(\n array_diff($this->profile->getFieldsAsArray($fieldType), $newFields),\n array_diff($newFields, $this->profile->getFieldsAsArray($fieldType))\n );\n\n if (\\count($changedFields) > 0) {\n $fields = implode(',', $newFields);\n $this->logger->debug(\n ucfirst($fieldType)\n . ' fields changed from '\n . $this->profile->{$fieldType . '_fields'}\n . ' to: '\n . $fields\n );\n\n $this->profile->{$fieldType . '_fields'} = $fields;\n }\n } catch (\\Exception $exception) {\n $this->logger->error('No access to ' . $fieldType . ' object, skipping...');\n\n continue;\n }\n }\n\n $this->profile->save();\n }\n\n /**\n * @inheritdoc\n */\n public function find(string $name, array $scopes): array\n {\n $count = $this->limit ?? 20;\n $offset = $this->offset ?? 0;\n\n $scopeValues = array_map(fn ($scope) => is_string($scope) ? $scope : $scope->value, $scopes);\n $cacheKey = sprintf('copper-search-%d-%s-%s-%d-%d', $this->team->id, $name, implode('-', $scopeValues), $count, $offset);\n\n return Cache::remember($cacheKey, 300, function () use ($name, $scopes, $count, $offset) {\n // Try local search first\n $localResults = $this->searchLocally($name, $scopes, $count, $offset);\n\n // If no local results, fall back to remote API search\n if (empty($localResults)) {\n return $this->searchRemotely($name, $scopes, $count, $offset);\n }\n\n return $localResults;\n });\n }\n\n /**\n * @inheritdoc\n */\n public function findOpportunities(?string $crmAccountId, ?string $crmContactId, ?int $userId = null): array\n {\n $data = [];\n $ownerData = [];\n $ownerId = null;\n $contact = null;\n $account = null;\n\n if ($crmAccountId) {\n $account = $this->config->accounts()->where('crm_provider_id', $crmAccountId)->first();\n\n if ($account === null) {\n $account = $this->syncAccount($crmAccountId);\n }\n }\n\n if ($crmContactId) {\n $contact = $this->config->contacts()->where('crm_provider_id', $crmContactId)->first();\n\n if ($contact === null) {\n $contact = $this->syncContact($crmContactId);\n }\n }\n\n if ($contact || $account) {\n if ($contact && $account === null) {\n $account = $contact->account;\n }\n\n if ($account === null) {\n return [];\n }\n\n $params = [\n 'company_ids' => [$account->crm_provider_id],\n 'status_ids' => [0], // The integer values are 0, 1, 2, 3, for \"Open\", \"Won\", \"Lost\", and \"Abandoned\".\n 'sort_by' => 'date_modified',\n 'sort_direction' => 'desc',\n ];\n\n switch ($this->config->opportunity_assignment_rule) {\n case Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_UPDATED:\n $params['sort_by'] = 'date_modified';\n $params['sort_direction'] = 'desc';\n\n break;\n\n case Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_CREATED:\n $params['sort_by'] = 'date_created';\n $params['sort_direction'] = 'desc';\n\n break;\n\n case Configuration::OPP_ASSIGNMENT_ALL_OPEN_OLDEST_CREATED:\n $params['sort_by'] = 'date_created';\n $params['sort_direction'] = 'asc';\n\n break;\n\n case Configuration::OPP_ASSIGNMENT_ALL_RECENTLY_UPDATED:\n $params['sort_by'] = 'date_modified';\n $params['sort_direction'] = 'desc';\n $params['status_ids'] = [0, 1, 2, 3];\n }\n\n $cpOpportunities = $this->copperClient->opportunities()->search($params);\n\n if ($userId) {\n $profileRepository = app(ProfileRepository::class);\n $profile = $profileRepository->findProfileByUserId($this->config, $userId);\n\n $ownerId = $profile instanceof Profile ? $profile->getCrmProviderId() : null;\n }\n\n foreach ($cpOpportunities as $cpOpportunity) {\n $businessProcess = $this->config\n ->businessProcesses()\n ->where('crm_provider_id', (string) $cpOpportunity['pipeline_id'])\n ->first();\n\n if ($businessProcess === null) {\n // Import it.\n $businessProcess = $this->importStages(\n [Stage::TYPE_OPPORTUNITY],\n $cpOpportunity['pipeline_stage_id']\n );\n }\n\n $stage = $businessProcess\n ->stages()\n ->where('crm_provider_id', (string) $cpOpportunity['pipeline_stage_id'])\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->first();\n\n if ($stage === null) {\n // Import it.\n $stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $cpOpportunity['pipeline_stage_id']);\n }\n\n $recordType = $businessProcess->recordTypes()->first();\n\n $record = [\n 'crmId' => $cpOpportunity['id'],\n 'name' => $cpOpportunity['name'],\n 'value' => currency_format($cpOpportunity['monetary_value'], $cpOpportunity['monetary_unit']),\n 'won' => $cpOpportunity['status'] === 'Won',\n 'stage' => [\n 'id' => $stage->id_string,\n 'name' => $stage->name,\n ],\n 'recordType' => [\n 'id' => $recordType->id_string,\n 'name' => $recordType->name,\n ],\n ];\n\n if ($ownerId && isset($cpOpportunity['assignee_id']) && $cpOpportunity['assignee_id'] === $ownerId) {\n $ownerData[] = $record;\n }\n\n $data[] = $record;\n }\n }\n\n if (! empty($ownerData)) {\n return $ownerData;\n }\n\n return $data;\n }\n\n /**\n * @inheritdoc\n */\n public function getTasks(string $objectType, string $objectId, ?string $opportunityId): array\n {\n $data = $cpTasks = [];\n\n switch ($objectType) {\n case 'lead':\n $cpTasks = $this->requestWithExceptionHandling(\n fn () => $this->copperClient->leads()->relatedTasks($objectId)\n );\n\n break;\n\n case 'account':\n $cpTasks = $this->copperClient->accounts()->relatedTasks($objectId);\n\n break;\n case 'contact':\n if ($opportunityId) {\n $cpTasks = $this->copperClient->opportunities()->relatedTasks($opportunityId);\n } elseif ($objectType === 'contact') {\n $cpTasks = $this->copperClient->contacts()->relatedTasks($objectId);\n }\n\n break;\n }\n\n foreach ($cpTasks as $cpTask) {\n // We only get the ID itself returned, so pull out the full object.\n try {\n $cpTask = $this->copperClient->tasks()->find($cpTask['id']);\n } catch (Exception $exception) {\n $this->logger->error('[Copper] Error fetching task', [\n 'resource_id' => $cpTask['id'],\n 'error' => $exception->getMessage(),\n ]);\n\n continue;\n }\n\n // Only allow open tasks.\n if ($cpTask['status'] === 'Open') {\n $data[] = [\n 'crmId' => $cpTask['id'],\n 'subject' => $cpTask['name'],\n 'due' => $cpTask['due_date']\n ? Carbon::createFromTimestamp($cpTask['due_date'])->format('Y-m-d\\TH:i:sO')\n : null,\n 'type' => null,\n ];\n }\n }\n\n return $data;\n }\n\n /**\n * Try to find email address in CRM service\n *\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n *}\n */\n public function matchExactlyByEmail(string $email, ?int $userId = null): ?array\n {\n $cpObjectType = 'contact';\n\n $cpObjects = $this->copperClient->contacts()->search([\n 'emails' => $email,\n 'sort_by' => 'date_modified',\n 'sort_direction' => 'desc',\n ]);\n\n if (empty($cpObjects)) {\n $cpObjectType = 'lead';\n $cpObjects = $this->requestWithExceptionHandling(\n fn () => $this->copperClient->leads()->search([\n 'emails' => $email,\n 'sort_by' => 'date_modified',\n 'sort_direction' => 'desc',\n ])\n );\n }\n\n if (empty($cpObjects)) {\n return null;\n }\n\n // Search returns an array of results, but we only want the first\n $cpObject = reset($cpObjects);\n $data = $this->convertCrmData($cpObject, $cpObjectType, $userId);\n\n return ! empty(array_filter($data)) ? $data : null;\n }\n\n /**\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n public function matchByPhone(string $phone, ?string $rawPhoneNumber = null, ?int $userId = null): ?array\n {\n // Check if the user is internal.\n $teamMember = $this->team->users()->where('phone', $phone)->exists();\n\n // Skip the attendee if internal.\n if ($teamMember === false) {\n $cpObjectType = 'contact';\n $cpObject = $this->copperClient->contacts()->search([\n 'phone_number' => $phone,\n 'sort_by' => 'date_modified',\n 'sort_direction' => 'desc',\n ]);\n\n if (\\count($cpObject) === 0) {\n $cpObjectType = 'lead';\n $cpObject = $this->requestWithExceptionHandling(\n fn () => $this->copperClient->leads()->search([\n 'phone_number' => $phone,\n 'sort_by' => 'date_modified',\n 'sort_direction' => 'desc',\n ])\n );\n }\n\n if (empty($cpObject)) {\n return null;\n }\n\n $cpObject = reset($cpObject);\n $data = $this->convertCrmData($cpObject, $cpObjectType, $userId);\n\n return ! empty(array_filter($data)) ? $data : null;\n }\n\n return null;\n }\n\n /**\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n public function matchByName(string $name, ?int $userId = null): ?array\n {\n // Don't waste time searching for single character strings.\n if (\\strlen($name) <= 1) {\n return null;\n }\n\n $result = \\Cache::remember($this->team->id . $name, 60, function () use ($name, $userId) {\n $cpObjectType = 'contact';\n $cpObject = $this->copperClient->contacts()->search([\n 'name' => $name,\n 'sort_by' => 'date_modified',\n 'sort_direction' => 'desc',\n ]);\n\n if (\\count($cpObject) === 0) {\n $cpObjectType = 'lead';\n $cpObject = $this->requestWithExceptionHandling(\n fn () => $this->copperClient->leads()->search([\n 'name' => $name,\n 'sort_by' => 'date_modified',\n 'sort_direction' => 'desc',\n ])\n );\n }\n\n if (! empty($cpObject)) {\n $cpObject = reset($cpObject);\n }\n\n $data = $this->convertCrmData($cpObject, $cpObjectType, $userId);\n\n return ! empty(array_filter($data)) ? $data : false;\n });\n\n return is_array($result) ? $result : null;\n }\n\n private function convertCrmData(array $object, string $objectType, ?int $userId = null): array\n {\n $lead = null;\n $contact = null;\n $opportunity = null;\n $account = null;\n $stage = null;\n $countryCode = null;\n\n if ($objectType === 'lead') {\n $lead = $this->importLead($object);\n\n // Lead might not be imported if the Stage is null for example.\n if ($lead) {\n $countryCode = $lead->country_code;\n $stage = $lead->stage;\n }\n } else {\n $contact = $this->importContact($object);\n $account = $contact->account;\n\n if ($contact->country_code) {\n $countryCode = $contact->country_code;\n } elseif ($account) {\n $countryCode = $account->country_code;\n }\n\n try {\n $cpOpportunities = $this->findOpportunities(\n $account?->crm_provider_id,\n $contact->crm_provider_id,\n $userId,\n );\n\n if (! empty($cpOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($cpOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n } catch (\\Exception $e) {\n // Nothing to see here.\n }\n }\n\n return [\n $lead,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n }\n\n public function saveActivity(Activity $activity): Activity\n {\n switch ($activity->type) {\n case Activity::TYPE_CONFERENCE:\n case Activity::TYPE_SOFTPHONE:\n case Activity::TYPE_SOFTPHONE_INBOUND:\n $activity = $this->buildCallPayload($activity);\n\n break;\n case Activity::TYPE_SMS_INBOUND:\n case Activity::TYPE_SMS_OUTBOUND:\n $activity = $this->buildTextMessagePayload($activity);\n\n break;\n }\n\n return $activity;\n }\n\n private function convertActivityAssociations(Activity $activity): array\n {\n if ($activity->lead) {\n $type = 'lead';\n $id = $activity->lead->crm_provider_id;\n } elseif ($activity->contact) {\n $type = 'person';\n $id = $activity->contact->crm_provider_id;\n } elseif ($activity->opportunity) {\n $type = 'opportunity';\n $id = $activity->opportunity->crm_provider_id;\n } else {\n $type = 'company';\n $id = $activity->account->crm_provider_id;\n }\n\n return [\n 'type' => $type,\n 'id' => $id,\n ];\n }\n\n private function convertActivityType(PlaybookCategory $activityType): array\n {\n return [\n 'category' => 'user',\n 'id' => $this->getCrmType($activityType)->value,\n ];\n }\n\n /**\n * @throws CrmException\n */\n private function buildCallPayload(Activity $activity): Activity\n {\n try {\n if ($activity->crm_provider_id) {\n // The activity should be logged under the existing Task (not Activity).\n $newActivityType = $this->getCrmType($activity->category);\n if ($newActivityType === null) {\n $message = sprintf('[Copper] invalid CRM Custom Activity Type: %s', $activity->category);\n\n throw new CrmException($message, 400);\n }\n $this->copperClient->tasks()->update($activity->crm_provider_id, [\n 'status' => 'Completed',\n 'custom_activity_type_id' => $newActivityType->value,\n 'details' => $this->generateActivityDescription($activity),\n ]);\n } else {\n if ($this->getCrmType($activity->category) === null) {\n $message = sprintf('[Copper] Invalid CRM Activity Type: %s', $activity->category->getName());\n\n throw new CrmException($message, 400);\n }\n\n if ($this->getCrmType($activity->category)->value === null) {\n $message = sprintf('[Copper] Invalid CRM Activity Type: %s value', $activity->category->getName());\n\n throw new CrmException($message, 400);\n }\n // Just create an activity.\n $cpActivity = $this->copperClient->activities()->create([\n 'user_id' => $this->profile->crm_provider_id,\n 'activity_date' => $activity->scheduled_start_time->timestamp,\n 'parent' => $this->convertActivityAssociations($activity),\n 'type' => $this->convertActivityType($activity->category),\n 'details' => $this->generateActivityDescription($activity),\n ]);\n\n $activity->crm_provider_id = $cpActivity['id'];\n $activity->save();\n }\n } catch (ClientException $exception) {\n //Log all errors to Sentry\n \\Sentry::captureException($exception);\n\n $response = $exception->getResponse();\n\n $responseBody = $response->getBody();\n $message = $responseBody;\n $errorCode = $response->getStatusCode();\n\n $jsonResponse = json_decode($responseBody, true);\n if (isset($jsonResponse[0]['message'])) {\n $message = $jsonResponse[0]['message'];\n }\n\n throw new CrmException($message, $errorCode);\n }\n\n return $activity;\n }\n\n private function buildTextMessagePayload(Activity $activity): Activity\n {\n if ($this->getCrmType($activity->category) === null) {\n $message = sprintf('[Copper] Invalid CRM Activity Type: %s', $activity->category->getName());\n\n throw new CrmException($message, 400);\n }\n\n if ($this->getCrmType($activity->category)->value === null) {\n $message = sprintf('[Copper] Invalid CRM Activity Type: %s value', $activity->category->getName());\n\n throw new CrmException($message, 400);\n }\n\n $cpActivity = $this->copperClient->activities()->create([\n 'user_id' => $this->profile->crm_provider_id,\n 'activity_date' => $activity->created_at->timestamp,\n 'parent' => $this->convertActivityAssociations($activity),\n 'type' => $this->convertActivityType($activity->category),\n 'details' => $this->generateActivityDescription($activity),\n ]);\n\n $activity->crm_provider_id = $cpActivity['id'];\n $activity->save();\n\n return $activity;\n }\n\n private function generateActivityDescription(Activity $activity): string\n {\n $description = '';\n\n switch ($activity->type) {\n case Activity::TYPE_SOFTPHONE:\n case Activity::TYPE_SOFTPHONE_INBOUND:\n case Activity::TYPE_CONFERENCE:\n\n $description = 'Jiminny Call Summary' . PHP_EOL . PHP_EOL;\n $description .= 'Call Duration: ' . $activity->duration_for_humans . PHP_EOL . PHP_EOL;\n\n // Tell them which number was dialed.\n if ($activity->to) {\n $description .= 'Dialed: ' . $activity->to->phone_number . PHP_EOL . PHP_EOL;\n }\n\n if ($activity->hasReasonCodeBotKicked()) {\n $description .= 'Notetaker removed from this meeting' . PHP_EOL . PHP_EOL;\n // When we fix the state to be Activity::RECORDING_RECORDED as it should be this can change.\n } elseif ($activity->hasReasonCodeNotCompliant()) {\n $description .= 'Notetaker did not join due to recording consent not being provided by attendees' . PHP_EOL . PHP_EOL;\n } elseif ($activity->canReviewActivity()) {\n $playbackUrl = $activity->user->team->partner->getPlaybackUrl($activity);\n $description .= 'Review Activity:' . PHP_EOL . $playbackUrl . PHP_EOL . PHP_EOL;\n }\n\n if ($activity->getCrmType() === Activity::TYPE_CONFERENCE) {\n $description .= 'Attendees:'\n . PHP_EOL\n . (new FilterJoinedParticipants())->toString($activity);\n }\n\n if (\\count($activity->notes) > 0) {\n $description .= PHP_EOL . PHP_EOL . 'Notes: ' . PHP_EOL;\n\n foreach ($activity->notes as $note) {\n $nTime = (int) ceil($note->time);\n $time = ($nTime > 3600) ? gmdate('H:i:s', $nTime) : gmdate('i:s', $nTime);\n $description .= $time . ' ' . $note->note . PHP_EOL;\n }\n }\n\n // Get all private messages.\n $messages = $activity->messages()\n ->where('is_private', 1)\n ->orderBy('created_at', 'asc');\n\n if ($messages->count() > 0) {\n $description .= PHP_EOL . PHP_EOL . 'Coaching Chat: ' . PHP_EOL;\n\n foreach ($messages->get() as $message) {\n $description .= $message->participant->name . ': ' . $message->message . PHP_EOL;\n }\n }\n\n // Get all public messages.\n $messages = $activity->messages()\n ->where('is_private', 0)\n ->orderBy('created_at', 'asc');\n\n if ($messages->count() > 0) {\n $description .= PHP_EOL . PHP_EOL . 'Customer Chat: ' . PHP_EOL;\n\n foreach ($messages->get() as $message) {\n $description .= $message->participant->name . ': ' . $message->message . PHP_EOL;\n }\n }\n\n if ($activity->summary) {\n $description .= PHP_EOL . PHP_EOL . 'Summary: ' . PHP_EOL . $activity->summary;\n }\n\n break;\n\n case Activity::TYPE_SMS_INBOUND:\n case Activity::TYPE_SMS_OUTBOUND:\n $description = $activity->description;\n\n break;\n }\n\n return $description;\n }\n\n public function saveFollowupActivity(Activity $activity, array $fields): ?string\n {\n // This is the user provided activity subject field.\n if (empty($fields['type'])) {\n return null;\n }\n\n $reminder = null;\n if (empty($fields['reminder_date']) === false) {\n $formatReminder = Carbon::createFromFormat('Y-m-d\\TH:i:s\\Z', $fields['reminder_date']);\n $reminder = $formatReminder->getTimestamp();\n }\n\n $due = null;\n if (empty($fields['due_date']) === false) {\n $formatDue = Carbon::createFromFormat('Y-m-d\\TH:i:s\\Z', $fields['due_date']);\n $due = $formatDue->getTimestamp();\n }\n\n $data = [\n 'custom_activity_type_id' => $fields['type'],\n 'status' => 'Open',\n 'assignee_id' => $this->profile->crm_provider_id,\n 'due_date' => $due,\n 'details' => $fields['details'] ?? '',\n 'reminder_date' => $reminder,\n 'name' => $fields['name'] ?? 'Follow-up',\n 'related_resource' => $this->convertActivityAssociations($activity),\n 'custom_fields' => $this->fetchCustomFieldData($activity, Field::OBJECT_TASK),\n ];\n\n // We don't actually create a corresponding activity object on our side yet.\n return $this->copperClient->tasks()->create($data)['id'];\n }\n\n /**\n * Store transcripts as note.\n *\n * @throws \\Exception\n */\n public function createTranscriptNotes(Activity $activity): void\n {\n // Generate activity transcription.\n $transcriptionData = $this->generateTranscription($activity);\n\n // Truncate Notes with max notes length because transcription text could be very long.\n $transcripts = mb_strimwidth($transcriptionData, 0, static::NOTE_BODY_MAX_LENGTH);\n\n $data = [\n 'user_id' => $this->profile->crm_provider_id,\n 'type' => [\n 'category' => 'user',\n 'id' => 0, // This creates a note.\n ],\n 'activity_date' => time(),\n 'details' => $transcripts,\n 'parent' => $this->convertActivityAssociations($activity),\n ];\n\n $cpActivity = $this->copperClient->activities()->create($data);\n\n // Store CRM Activity ID in transcription.\n $transcription = $activity->getTranscription();\n $transcription->crm_activity_id = $cpActivity['id'];\n $transcription->save();\n }\n\n /**\n * @inheritdoc\n */\n public function updateStage($crmObject, Stage $stage): void\n {\n if ($crmObject instanceof Lead) {\n $this->requestWithExceptionHandling(\n fn () => $this->copperClient->leads()->update($crmObject->crm_provider_id, [\n 'status_id' => $stage->crm_provider_id,\n ])\n );\n } else {\n $this->copperClient->opportunities()->update($crmObject->crm_provider_id, [\n 'pipeline_stage_id' => $stage->crm_provider_id,\n ]);\n }\n }\n\n public function prepareValueForUpdate(array $params): array\n {\n return [$params['fieldName'] => $params['fieldValue']];\n }\n\n /*\n * @inheritdoc\n */\n public function updateRecord(string $objectType, string $objectId, array $data, array $headers = []): void\n {\n $preparedData = $data;\n\n if ($objectType === Field::OBJECT_OPPORTUNITY) {\n $preparedData = $this->prepareOpportunityFieldsForUpdate($data);\n }\n\n try {\n $this->copperClient\n ->createResourceFromObjectType($objectType)\n ->update($objectId, $preparedData)\n ;\n } catch (Exception $e) {\n $this->logger->error(self::RESOURCE_UPDATE_FAILED_ERROR_MESSAGE, [\n 'resource_id' => $objectId,\n 'data' => $preparedData,\n 'error' => $e->getMessage(),\n ]);\n\n throw new CrmException(self::RESOURCE_UPDATE_FAILED_ERROR_MESSAGE, $e->getCode(), $e);\n }\n }\n\n /**\n * @param array<string,string> $fieldData\n *\n * @throws CrmException\n *\n * @return array<string,array<int,array<string,bool|float|int|string|null>>|bool|float|int|string|null>\n */\n private function prepareOpportunityFieldsForUpdate(array $fieldData): array\n {\n $preparedData = [];\n $customFields = [];\n\n foreach ($fieldData as $fieldName => $fieldValue) {\n $field = $this->config\n ->fields()\n ->where('crm_provider_id', $fieldName)\n ->first();\n\n if (! $field instanceof Field) {\n throw new CrmException('Field not found');\n }\n\n $convertedValue = $this->fieldValueConverter->convertToCrm($field->getType(), $fieldValue);\n\n if ($this->isStandardField($fieldName)) {\n $preparedData[$fieldName] = $convertedValue;\n\n continue;\n }\n\n $customFields[] = [\n 'custom_field_definition_id' => $fieldName,\n 'value' => $convertedValue,\n ];\n }\n\n if (! empty($customFields)) {\n $preparedData['custom_fields'] = $customFields;\n }\n\n return $preparedData;\n }\n\n /*\n * @inheritdoc\n */\n public function getRecord(string $objectType, string $objectId, array $fields = []): array\n {\n return $this->copperClient->createResourceFromObjectType($objectType)->find($objectId);\n }\n\n private function fetchCustomFieldData(Activity $activity, string $objectType): array\n {\n $payload = [];\n\n $fieldData = FieldData::where([\n 'object_id' => $activity->id,\n ])->whereHas('field', function ($query) use ($objectType) {\n $query->where('object_type', $objectType);\n })->get();\n\n foreach ($fieldData as $data) {\n // Add the field and value to the payload.\n $payload += [\n 'custom_field_definition_id' => $data->field->crm_provider_id,\n 'value' => $data->value,\n ];\n }\n\n return $payload;\n }\n\n public function generateProviderUrl(string $providerId, string $objectType): ?string\n {\n $baseUrl = 'https://app.prosperworks.com/companies/' . $this->config->crm_provider_id . '/app#/';\n switch ($objectType) {\n case 'lead':\n $url = $baseUrl . 'leads/' . $providerId;\n\n break;\n\n case 'account':\n $url = $baseUrl . 'organization/' . $providerId;\n\n break;\n\n case 'contact':\n $url = $baseUrl . 'contact/' . $providerId;\n\n break;\n\n case 'opportunity':\n $url = $baseUrl . 'deal/' . $providerId;\n\n break;\n\n default:\n case 'task':\n case 'activity':\n // Sadly we can't deeplink to these in Copper UI.\n $url = null;\n }\n\n return $url;\n }\n\n /**\n * Generate transcription for the activity.\n */\n private function generateTranscription(Activity $activity): string\n {\n if (! ($this->config->store_transcript)) {\n // If sending transcription to activity toggle is disabled\n return '';\n }\n\n return $this->transcriptionService\n ->findTranscriptionByActivity($activity)\n ->map(static function (array $transcriptionSegment): string {\n return $transcriptionSegment['formattedStartsAt'] . ' | ' . $transcriptionSegment['transcript'];\n })\n ->implode(PHP_EOL);\n }\n\n /**\n * @param array<string, mixed> $opportunityData\n *\n * @return int|string|bool|float|null\n */\n private function extractFieldValue(array $opportunityData, string $fieldName)\n {\n $fieldValue = $this->isStandardField($fieldName)\n ? $opportunityData[$fieldName]\n : $this->resolveCustomFieldFromOpportunityData($opportunityData, $fieldName);\n\n if (is_array($fieldValue)) {\n $fieldValue = implode(', ', $fieldValue);\n }\n\n return $fieldValue;\n }\n\n private function isStandardField(string $fieldName): bool\n {\n return array_key_exists($fieldName, self::STANDARD_OPPORTUNITY_FIELDS);\n }\n\n /**\n * @param array<string, mixed> $opportunityData\n *\n * @return string|int|float|bool|null|array<string, mixed>\n */\n private function resolveCustomFieldFromOpportunityData(array $opportunityData, string $fieldName)\n {\n $fieldData = array_filter(\n $opportunityData['custom_fields'],\n static fn ($field) => (string) $field['custom_field_definition_id'] === $fieldName\n );\n\n return array_pop($fieldData)['value'] ?? '';\n }\n\n private function isSupportedObjectType(string $objectType): bool\n {\n return $objectType !== self::UNSUPPORTED_OBJECT_TYPE;\n }\n\n private function syncStandardFields(): void\n {\n foreach (self::STANDARD_OPPORTUNITY_FIELDS as $fieldId => $fieldData) {\n $this->config->fields()->updateOrCreate([\n 'crm_provider_id' => mb_strimwidth($fieldId, 0, 128),\n 'object_type' => Field::OBJECT_OPPORTUNITY,\n ], [\n 'label' => mb_strimwidth($fieldData['label'], 0, Field::LABEL_MAX_LENGTH),\n 'type' => $fieldData['type'],\n ]);\n }\n\n $this->importStages([Stage::TYPE_OPPORTUNITY]);\n }\n\n private function requestWithExceptionHandling(\\Closure $apiRequest): iterable\n {\n try {\n return $apiRequest();\n } catch (\\Exception $exception) {\n if ($exception->getCode() === 403) {\n // Object might not be supported\n return [];\n }\n\n \\Sentry::captureException($exception);\n\n return [];\n }\n }\n\n public function verifyTaskExists(Activity $activity): bool\n {\n $crmProviderId = $activity->getCrmProviderId();\n $cacheKey = \"crm_task_exists:{$this->config->getId()}:$crmProviderId\";\n\n return Cache::remember($cacheKey, self::TASK_VERIFICATION_CACHE_TTL, function () use ($crmProviderId) {\n try {\n if ($this->copperClient === null) {\n return false;\n }\n\n $task = $this->copperClient->tasks()->find($crmProviderId);\n\n return ! empty($task);\n } catch (HttpNotFoundException) {\n // Task not found in CRM - this is expected and permanent\n $this->logger->info('[Copper] Task not found during verification', [\n 'task_id' => $crmProviderId,\n 'config_id' => $this->config->getId(),\n ]);\n\n return false;\n } catch (RequestFailedException $e) {\n // Handle 404 responses from Copper API\n if ($e->getCode() === 404) {\n $this->logger->info('[Copper] Task not found during verification (404)', [\n 'task_id' => $crmProviderId,\n 'config_id' => $this->config->getId(),\n ]);\n\n return false;\n }\n\n // Re-throw other Copper exceptions for retry\n throw $e;\n }\n // Let other exceptions (network errors, rate limits, etc.) bubble up for retry\n });\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":true,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Execute","depth":4,"bounds":{"left":0.85486114,"top":0.31,"width":0.018055556,"height":0.026666667},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Explain Plan","depth":4,"bounds":{"left":0.87291664,"top":0.31,"width":0.018055556,"height":0.026666667},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Browse Query History","depth":4,"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"View Parameters","depth":4,"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Open Query Execution Settings…","depth":4,"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"In-Editor Results","depth":4,"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Tx: Auto","depth":4,"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Cancel Running Statements","depth":4,"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Playground","depth":4,"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"jiminny","depth":4,"bounds":{"left":0.9138889,"top":0.31,"width":0.059027776,"height":0.026666667},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.088194445,"height":0.027777778},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"8","depth":4,"bounds":{"left":0.8645833,"top":0.34444445,"width":0.016666668,"height":0.02111111},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"1","depth":4,"bounds":{"left":0.8854167,"top":0.34444445,"width":0.015277778,"height":0.02111111},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"3","depth":4,"bounds":{"left":0.9048611,"top":0.34444445,"width":0.016666668,"height":0.02111111},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"4","depth":4,"bounds":{"left":0.92569447,"top":0.34444445,"width":0.016666668,"height":0.02111111},"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"bounds":{"left":0.9458333,"top":0.3422222,"width":0.015277778,"height":0.025555555},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"bounds":{"left":0.9611111,"top":0.3422222,"width":0.014583333,"height":0.025555555},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"SELECT\n# DISTINCT\nopp.id as opp_id, opp.uuid, opp.name,\n# COUNT(dr.id) AS deal_risk_count,\n\n# dr.id,\n# cfd.value,\nusr.name AS owner_name,\nopp.value, opp.currency_code, opp.close_date, opp.remotely_created_at, opp.is_closed, opp.is_won,\nusr.uuid as owner_uuid,\nusr.photo_path as owner_photo,\nusr.id AS owner_id,\njt.name as owner_job,\nopp.stage_id, opp.stage_updated_at,\nacc.name AS acc_name, opp.stage_updated_at, acc.crm_provider_id AS acc_provider_id, opp.crm_provider_id AS opp_provider_id,\nrt.business_process_id AS pipeline_id\nFROM opportunities opp\nLEFT JOIN record_types rt ON opp.record_type_id = rt.id\nLEFT JOIN users usr ON opp.user_id = usr.id\nLEFT JOIN accounts acc ON opp.account_id = acc.id\nLEFT JOIN job_titles jt ON usr.job_title_id = jt.id\n\n# LEFT JOIN group_deal_risk_types gdrt ON gdrt.group_id = usr.group_id\n# LEFT JOIN deal_risks dr ON dr.opportunity_id = opp.id AND dr.group_deal_risk_type_id = gdrt.id\n\nLEFT JOIN crm_field_data cfd ON (cfd.object_id = opp.id and cfd.crm_field_id = 66814)\n\nWHERE opp.user_id IS NOT NULL\nAND opp.deleted_at IS NULL\nAND opp.is_closed = 1\nAND opp.is_won = 0\nAND opp.close_date >= '2024-01-01 00:00:00'\nAND opp.close_date <= '2024-12-31 23:59:59'\nAND usr.team_id = 1\n\n# and opp.id = 4823179\n\nGROUP BY opp.id, cfd.value\nORDER BY\n# cfd.value\n CAST(cfd.value AS UNSIGNED)\n# owner_name\n# (SELECT COUNT(dr.id)\n# FROM deal_risks dr\n# JOIN group_deal_risk_types gdrt ON dr.group_deal_risk_type_id = gdrt.id\n# WHERE dr.opportunity_id = opp.id\n# AND dr.is_active = 1\n# AND gdrt.is_enabled = 1)\nDESC\nLIMIT 25\n# OFFSET 0\n;\n\nselect * from crm_fields where crm_configuration_id = 1 and object_type = 'opportunity';\nselect * from crm_layout_entities where crm_layout_id = 1493;\n\nSELECT * FROM opportunities WHERE id = 4584045;\nSELECT * FROM crm_field_data WHERE object_id = 4584045;\n\nSELECT * FROM opportunities where team_id = 1 and crm_provider_id = '0061K00000lfb9IQAQ'\n\nSELECT\n opp.id as opportunity_id,\n opp.name,\n COUNT(dr.id) as risk_count\nFROM opportunities opp\nLEFT JOIN deal_risks dr ON dr.opportunity_id = opp.id\nWHERE opp.id IN (\n 6069574,8900938,9507638,9524799,9660490,9662824,9705675,9749758,4158460,4391812,6450439,4448422,5118945,5590675,4584045,7228149,9002408,9165534,9446720,9641778,9665149,9703344,9709280,5747948,4158491,6182565,6263970,5798120,5111315,4536978,6062352,6548383,6072095,6548225,4480986,5011422,6548381,8760540,8917554,9509986,9514392,9569009,9569011,9578490,9713604,5784397,7276612,7288405,8994421,9118219,9608148,9818911,4510535,6479693,5958049,6271674,6550448,4158331,4158483,6126571,6171615,6540943,4897466,5190896,5796182,5932762,8572433,8723698,8892697,9711001,9789264,4549188,6100831,6170064,6260799,6263653,6449936,6530871,6538978,7777651,8059269,8319918,8787049,8901150,9263153,9453207,9514738,9696700,9791062,5752018,6421452,7439134,7878923,9354763,9369285,9514396,9582506,9889949,9890216,4094311,4158495,4158496,6098128,5585661,3872564,6442149,5891604,6164746,6199593,6583474,6519684,9018490,9809006,4496897,5041324,5829430,6198319,6253504,6555763,7242914,7931055,8024125,8797814,8058559,8673347,8892695,8994420,9616219,9714970,9722004,9809439,9818918,6523177,8134147,9002915,9711422,9892713,9901719,9954210,9978435,5800810,6243518,6416114,6222251,6411974,6512456,5791953,6545606,9914780,5805540,6238986,6463838,6547680,9767049,9809437,9810885,9890855,6673493,9902036,4335521,6379871,6503799,6546077,8018765,9907556,9958433,9905855,9916179,9946741,9957877,5563344,6271838,6450815,7641128,7762567,7780592,8684810,8685786,8685787,8685788\n)\nGROUP BY opp.id;\n\nSELECT COUNT(dr.id)\n FROM deal_risks dr\n JOIN group_deal_risk_types gdrt ON dr.group_deal_risk_type_id = gdrt.id\n WHERE dr.opportunity_id = 5563344\n# AND gdrt.group_id = usr.group_id\n\n\n\nEXPLAIN SELECT\n opp.id as opp_id, opp.uuid, opp.name,\n\ncfd.value,\ncfv.sequence,\ncfv.value,\n# MAX(cfd.value) AS max_cfd_value,\nusr.name AS owner_name,\nopp.value, opp.currency_code, opp.close_date, opp.remotely_created_at, opp.is_closed, opp.is_won,\nusr.uuid as owner_uuid,\nusr.photo_path as owner_photo,\nusr.id AS owner_id,\njt.name as owner_job,\nopp.stage_id, opp.stage_updated_at,\nacc.name AS acc_name, opp.stage_updated_at, acc.crm_provider_id AS acc_provider_id, opp.crm_provider_id AS opp_provider_id,\nrt.business_process_id AS pipeline_id\nFROM opportunities opp\n\nLEFT JOIN record_types rt ON opp.record_type_id = rt.id\nLEFT JOIN users usr ON opp.user_id = usr.id\nLEFT JOIN accounts acc ON opp.account_id = acc.id\nLEFT JOIN job_titles jt ON usr.job_title_id = jt.id\n\nLEFT JOIN crm_field_data cfd ON cfd.id = (SELECT sub_cfd.id\n FROM crm_field_data sub_cfd\n WHERE sub_cfd.object_id = opp.id\n AND sub_cfd.crm_field_id = 66810\n ORDER BY sub_cfd.updated_at DESC\n LIMIT 1)\n\nLEFT JOIN crm_field_values cfv ON cfv.crm_field_id = 66810 AND cfv.value = cfd.value\n\nWHERE opp.user_id IS NOT NULL\nAND opp.deleted_at IS NULL\nAND opp.is_closed = 0\n# AND opp.is_closed = 1\n# AND opp.is_won = 0\nAND opp.close_date >= '2024-01-01 00:00:00'\nAND opp.close_date <= '2024-12-31 23:59:59'\nAND usr.team_id = 1\n\n# and opp.id = 4823179\n\n# GROUP BY opp.id\nORDER BY\n cfv.sequence DESC,\n# opp.name\n# owner_name\n cfd.value\n# CAST(cfd.value AS UNSIGNED)\n# CAST(MAX(cfd.value) AS UNSIGNED)\n# (SELECT COUNT(dr.id)\n# FROM deal_risks dr\n# JOIN group_deal_risk_types gdrt ON dr.group_deal_risk_type_id = gdrt.id\n# WHERE dr.opportunity_id = opp.id\n# AND dr.is_active = 1 AND gdrt.is_enabled = 1)\nDESC\n# LIMIT 75, 25\n;\n\n\n\nSELECT * FROM crm_field_values WHERE crm_field_id = 66810;\n\nSELECT f.id, f.crm_provider_id AS field_name, f.label, fd.object_id AS dealId, fd.value, fd.updated_at, fd.id, fd.created_at\nFROM crm_fields f\nINNER JOIN crm_field_data fd ON fd.crm_field_id = f.id\n WHERE (f.crm_configuration_id = 1)\n AND (f.object_type = 'opportunity')\n# AND (fd.object_id IN (4158331,4158483,4158495,4158496,6069574,6263970,6171615,6540943,6545606,5829430,6198319,6263653,6548381,7242914,8797814,7228149,7439134,7878923,8134147,9002915,9118219,9165534,9354763,9369285,9446720))\n AND (fd.object_id IN (4158460))\n AND (f.crm_provider_id IN ('ForecastCategoryName'))\n ORDER BY fd.object_id ASC, fd.updated_at DESC;\n\nselect * from crm_layouts where crm_configuration_id = 1;\nselect cf.* from crm_fields cf\njoin crm_layout_entities cle on cf.id = cle.crm_field_id\nwhere crm_layout_id = 1493;\n\nSELECT * FROM opportunities WHERE id = 4158460;\nSELECT * FROM crm_field_data WHERE object_id = 4158460;\n\nselect * from users where team_id = 1;\nSELECT * FROM users WHERE id = 7160;\nselect * from role_user where user_id = 3248;\n\nselect * from crm_field_values where crm_field_id = 66824;\n\nSELECT * FROM users WHERE id = 23470;\nselect * from crm_configurations;\n\n# ******************************************\nselect * from teams where id = 1038; # 23521, 966\nselect * from users where team_id = 1038;\nselect * from crm_configurations where id = 966;\nSELECT * FROM activities WHERE uuid_to_bin('e8dc7c34-31f6-4430-bd81-2d9d8f00dd07') = uuid; # 54965762\nSELECT * FROM activities WHERE uuid_to_bin('4b5727c0-69d1-428f-b8d9-b649055166e2') = uuid; # 54965764\nSELECT * FROM participants WHERE activity_id = 54965764;\n\nSELECT id, uuid, title, recording_state FROM activities WHERE uuid_to_bin('355f105d-5606-4379-b0c5-d91daf59f4ce') = uuid; # 54964075\nSELECT * FROM participants WHERE activity_id = 54964075;\nselect f.id, slug, title, tf.team_id from team_features tf JOIN features f on tf.feature_id = f.id\n where tf.team_id = 1038;\n\n\n# ****************************************** PD *************************\nselect * from teams where id = 1029;\nSELECT * FROM crm_configurations WHERE id = 957;\nSELECT * FROM activities WHERE uuid_to_bin('581a33cb-4343-44bd-ac0c-4a5cee71c5ec') = uuid; # 54963423\nSELECT * FROM activities WHERE uuid_to_bin('07238011-25fa-418e-838b-fb21e82b9ea2') = uuid; # 54963433\n\nSELECT * FROM activities WHERE uuid_to_bin('8b6bb2f7-e7a7-4aec-8806-fbfea7093ccd') = uuid; # 54963421\n\n# ****************************************** Close *************************\nselect * from teams where id = 1031;\nSELECT * FROM crm_configurations WHERE id = 959;\nSELECT * FROM activities WHERE uuid_to_bin('59c517e0-96c9-4dad-bfeb-b7bef72f8725') = uuid; # 54963517\n\n\nSELECT * FROM activities WHERE uuid_to_bin('07238011-25fa-418e-838b-fb21e82b9ea2') = uuid; # 54963433\n\nSELECT * FROM activities WHERE uuid_to_bin('d68311e3-ac34-49bd-bf04-88323a7e5352') = uuid; # 54966009\n\n# ****************************************** SF *************************\nSELECT * FROM activities WHERE uuid_to_bin('2bd4cfa3-4eb5-4c77-84a7-9ed46a21c988') = uuid;\n\nselect * from roles;\nselect * from permissions;\nselect * from permission_role where permission_id = 136;\n\nselect * from migrations order by id desc;\n\nselect * from teams where id IN (1, 1037);\nselect * from crm_layouts where crm_configuration_id = 1;\nSELECT * FROM crm_layout_entities WHERE crm_layout_id = 1493;\nSELECT * FROM crm_fields WHERE id IN (1652,1661,66799,66814,66821,66836,66843,66846,66864,84752,182306,323580,380378);\n\nselect * from features;\nselect * from team_features where feature_id = 33;\nselect * from opportunities;\n\nselect * from teams;\nSELECT\n CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,\n u.email,\n sa.*,\n t.owner_id FROM social_accounts sa\nJOIN users u on u.id = sa.sociable_id\nJOIN teams t on t.id = u.team_id\nWHERE u.team_id = 1052 and sa.provider = 'hubspot';\n\nSELECT * FROM accounts where id = 11512582;\nselect * from activities where account_id = 11512582;","depth":4,"on_screen":true,"value":"SELECT\n# DISTINCT\nopp.id as opp_id, opp.uuid, opp.name,\n# COUNT(dr.id) AS deal_risk_count,\n\n# dr.id,\n# cfd.value,\nusr.name AS owner_name,\nopp.value, opp.currency_code, opp.close_date, opp.remotely_created_at, opp.is_closed, opp.is_won,\nusr.uuid as owner_uuid,\nusr.photo_path as owner_photo,\nusr.id AS owner_id,\njt.name as owner_job,\nopp.stage_id, opp.stage_updated_at,\nacc.name AS acc_name, opp.stage_updated_at, acc.crm_provider_id AS acc_provider_id, opp.crm_provider_id AS opp_provider_id,\nrt.business_process_id AS pipeline_id\nFROM opportunities opp\nLEFT JOIN record_types rt ON opp.record_type_id = rt.id\nLEFT JOIN users usr ON opp.user_id = usr.id\nLEFT JOIN accounts acc ON opp.account_id = acc.id\nLEFT JOIN job_titles jt ON usr.job_title_id = jt.id\n\n# LEFT JOIN group_deal_risk_types gdrt ON gdrt.group_id = usr.group_id\n# LEFT JOIN deal_risks dr ON dr.opportunity_id = opp.id AND dr.group_deal_risk_type_id = gdrt.id\n\nLEFT JOIN crm_field_data cfd ON (cfd.object_id = opp.id and cfd.crm_field_id = 66814)\n\nWHERE opp.user_id IS NOT NULL\nAND opp.deleted_at IS NULL\nAND opp.is_closed = 1\nAND opp.is_won = 0\nAND opp.close_date >= '2024-01-01 00:00:00'\nAND opp.close_date <= '2024-12-31 23:59:59'\nAND usr.team_id = 1\n\n# and opp.id = 4823179\n\nGROUP BY opp.id, cfd.value\nORDER BY\n# cfd.value\n CAST(cfd.value AS UNSIGNED)\n# owner_name\n# (SELECT COUNT(dr.id)\n# FROM deal_risks dr\n# JOIN group_deal_risk_types gdrt ON dr.group_deal_risk_type_id = gdrt.id\n# WHERE dr.opportunity_id = opp.id\n# AND dr.is_active = 1\n# AND gdrt.is_enabled = 1)\nDESC\nLIMIT 25\n# OFFSET 0\n;\n\nselect * from crm_fields where crm_configuration_id = 1 and object_type = 'opportunity';\nselect * from crm_layout_entities where crm_layout_id = 1493;\n\nSELECT * FROM opportunities WHERE id = 4584045;\nSELECT * FROM crm_field_data WHERE object_id = 4584045;\n\nSELECT * FROM opportunities where team_id = 1 and crm_provider_id = '0061K00000lfb9IQAQ'\n\nSELECT\n opp.id as opportunity_id,\n opp.name,\n COUNT(dr.id) as risk_count\nFROM opportunities opp\nLEFT JOIN deal_risks dr ON dr.opportunity_id = opp.id\nWHERE opp.id IN (\n 6069574,8900938,9507638,9524799,9660490,9662824,9705675,9749758,4158460,4391812,6450439,4448422,5118945,5590675,4584045,7228149,9002408,9165534,9446720,9641778,9665149,9703344,9709280,5747948,4158491,6182565,6263970,5798120,5111315,4536978,6062352,6548383,6072095,6548225,4480986,5011422,6548381,8760540,8917554,9509986,9514392,9569009,9569011,9578490,9713604,5784397,7276612,7288405,8994421,9118219,9608148,9818911,4510535,6479693,5958049,6271674,6550448,4158331,4158483,6126571,6171615,6540943,4897466,5190896,5796182,5932762,8572433,8723698,8892697,9711001,9789264,4549188,6100831,6170064,6260799,6263653,6449936,6530871,6538978,7777651,8059269,8319918,8787049,8901150,9263153,9453207,9514738,9696700,9791062,5752018,6421452,7439134,7878923,9354763,9369285,9514396,9582506,9889949,9890216,4094311,4158495,4158496,6098128,5585661,3872564,6442149,5891604,6164746,6199593,6583474,6519684,9018490,9809006,4496897,5041324,5829430,6198319,6253504,6555763,7242914,7931055,8024125,8797814,8058559,8673347,8892695,8994420,9616219,9714970,9722004,9809439,9818918,6523177,8134147,9002915,9711422,9892713,9901719,9954210,9978435,5800810,6243518,6416114,6222251,6411974,6512456,5791953,6545606,9914780,5805540,6238986,6463838,6547680,9767049,9809437,9810885,9890855,6673493,9902036,4335521,6379871,6503799,6546077,8018765,9907556,9958433,9905855,9916179,9946741,9957877,5563344,6271838,6450815,7641128,7762567,7780592,8684810,8685786,8685787,8685788\n)\nGROUP BY opp.id;\n\nSELECT COUNT(dr.id)\n FROM deal_risks dr\n JOIN group_deal_risk_types gdrt ON dr.group_deal_risk_type_id = gdrt.id\n WHERE dr.opportunity_id = 5563344\n# AND gdrt.group_id = usr.group_id\n\n\n\nEXPLAIN SELECT\n opp.id as opp_id, opp.uuid, opp.name,\n\ncfd.value,\ncfv.sequence,\ncfv.value,\n# MAX(cfd.value) AS max_cfd_value,\nusr.name AS owner_name,\nopp.value, opp.currency_code, opp.close_date, opp.remotely_created_at, opp.is_closed, opp.is_won,\nusr.uuid as owner_uuid,\nusr.photo_path as owner_photo,\nusr.id AS owner_id,\njt.name as owner_job,\nopp.stage_id, opp.stage_updated_at,\nacc.name AS acc_name, opp.stage_updated_at, acc.crm_provider_id AS acc_provider_id, opp.crm_provider_id AS opp_provider_id,\nrt.business_process_id AS pipeline_id\nFROM opportunities opp\n\nLEFT JOIN record_types rt ON opp.record_type_id = rt.id\nLEFT JOIN users usr ON opp.user_id = usr.id\nLEFT JOIN accounts acc ON opp.account_id = acc.id\nLEFT JOIN job_titles jt ON usr.job_title_id = jt.id\n\nLEFT JOIN crm_field_data cfd ON cfd.id = (SELECT sub_cfd.id\n FROM crm_field_data sub_cfd\n WHERE sub_cfd.object_id = opp.id\n AND sub_cfd.crm_field_id = 66810\n ORDER BY sub_cfd.updated_at DESC\n LIMIT 1)\n\nLEFT JOIN crm_field_values cfv ON cfv.crm_field_id = 66810 AND cfv.value = cfd.value\n\nWHERE opp.user_id IS NOT NULL\nAND opp.deleted_at IS NULL\nAND opp.is_closed = 0\n# AND opp.is_closed = 1\n# AND opp.is_won = 0\nAND opp.close_date >= '2024-01-01 00:00:00'\nAND opp.close_date <= '2024-12-31 23:59:59'\nAND usr.team_id = 1\n\n# and opp.id = 4823179\n\n# GROUP BY opp.id\nORDER BY\n cfv.sequence DESC,\n# opp.name\n# owner_name\n cfd.value\n# CAST(cfd.value AS UNSIGNED)\n# CAST(MAX(cfd.value) AS UNSIGNED)\n# (SELECT COUNT(dr.id)\n# FROM deal_risks dr\n# JOIN group_deal_risk_types gdrt ON dr.group_deal_risk_type_id = gdrt.id\n# WHERE dr.opportunity_id = opp.id\n# AND dr.is_active = 1 AND gdrt.is_enabled = 1)\nDESC\n# LIMIT 75, 25\n;\n\n\n\nSELECT * FROM crm_field_values WHERE crm_field_id = 66810;\n\nSELECT f.id, f.crm_provider_id AS field_name, f.label, fd.object_id AS dealId, fd.value, fd.updated_at, fd.id, fd.created_at\nFROM crm_fields f\nINNER JOIN crm_field_data fd ON fd.crm_field_id = f.id\n WHERE (f.crm_configuration_id = 1)\n AND (f.object_type = 'opportunity')\n# AND (fd.object_id IN (4158331,4158483,4158495,4158496,6069574,6263970,6171615,6540943,6545606,5829430,6198319,6263653,6548381,7242914,8797814,7228149,7439134,7878923,8134147,9002915,9118219,9165534,9354763,9369285,9446720))\n AND (fd.object_id IN (4158460))\n AND (f.crm_provider_id IN ('ForecastCategoryName'))\n ORDER BY fd.object_id ASC, fd.updated_at DESC;\n\nselect * from crm_layouts where crm_configuration_id = 1;\nselect cf.* from crm_fields cf\njoin crm_layout_entities cle on cf.id = cle.crm_field_id\nwhere crm_layout_id = 1493;\n\nSELECT * FROM opportunities WHERE id = 4158460;\nSELECT * FROM crm_field_data WHERE object_id = 4158460;\n\nselect * from users where team_id = 1;\nSELECT * FROM users WHERE id = 7160;\nselect * from role_user where user_id = 3248;\n\nselect * from crm_field_values where crm_field_id = 66824;\n\nSELECT * FROM users WHERE id = 23470;\nselect * from crm_configurations;\n\n# ******************************************\nselect * from teams where id = 1038; # 23521, 966\nselect * from users where team_id = 1038;\nselect * from crm_configurations where id = 966;\nSELECT * FROM activities WHERE uuid_to_bin('e8dc7c34-31f6-4430-bd81-2d9d8f00dd07') = uuid; # 54965762\nSELECT * FROM activities WHERE uuid_to_bin('4b5727c0-69d1-428f-b8d9-b649055166e2') = uuid; # 54965764\nSELECT * FROM participants WHERE activity_id = 54965764;\n\nSELECT id, uuid, title, recording_state FROM activities WHERE uuid_to_bin('355f105d-5606-4379-b0c5-d91daf59f4ce') = uuid; # 54964075\nSELECT * FROM participants WHERE activity_id = 54964075;\nselect f.id, slug, title, tf.team_id from team_features tf JOIN features f on tf.feature_id = f.id\n where tf.team_id = 1038;\n\n\n# ****************************************** PD *************************\nselect * from teams where id = 1029;\nSELECT * FROM crm_configurations WHERE id = 957;\nSELECT * FROM activities WHERE uuid_to_bin('581a33cb-4343-44bd-ac0c-4a5cee71c5ec') = uuid; # 54963423\nSELECT * FROM activities WHERE uuid_to_bin('07238011-25fa-418e-838b-fb21e82b9ea2') = uuid; # 54963433\n\nSELECT * FROM activities WHERE uuid_to_bin('8b6bb2f7-e7a7-4aec-8806-fbfea7093ccd') = uuid; # 54963421\n\n# ****************************************** Close *************************\nselect * from teams where id = 1031;\nSELECT * FROM crm_configurations WHERE id = 959;\nSELECT * FROM activities WHERE uuid_to_bin('59c517e0-96c9-4dad-bfeb-b7bef72f8725') = uuid; # 54963517\n\n\nSELECT * FROM activities WHERE uuid_to_bin('07238011-25fa-418e-838b-fb21e82b9ea2') = uuid; # 54963433\n\nSELECT * FROM activities WHERE uuid_to_bin('d68311e3-ac34-49bd-bf04-88323a7e5352') = uuid; # 54966009\n\n# ****************************************** SF *************************\nSELECT * FROM activities WHERE uuid_to_bin('2bd4cfa3-4eb5-4c77-84a7-9ed46a21c988') = uuid;\n\nselect * from roles;\nselect * from permissions;\nselect * from permission_role where permission_id = 136;\n\nselect * from migrations order by id desc;\n\nselect * from teams where id IN (1, 1037);\nselect * from crm_layouts where crm_configuration_id = 1;\nSELECT * FROM crm_layout_entities WHERE crm_layout_id = 1493;\nSELECT * FROM crm_fields WHERE id IN (1652,1661,66799,66814,66821,66836,66843,66846,66864,84752,182306,323580,380378);\n\nselect * from features;\nselect * from team_features where feature_id = 33;\nselect * from opportunities;\n\nselect * from teams;\nSELECT\n CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,\n u.email,\n sa.*,\n t.owner_id FROM social_accounts sa\nJOIN users u on u.id = sa.sociable_id\nJOIN teams t on t.id = u.team_id\nWHERE u.team_id = 1052 and sa.provider = 'hubspot';\n\nSELECT * FROM accounts where id = 11512582;\nselect * from activities where account_id = 11512582;","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Project","depth":3,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Project","depth":3,"bounds":{"left":0.025,"top":0.06666667,"width":0.050694443,"height":0.034444444},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"New File or Directory…","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Expand Selected","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Collapse All","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Options","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
8957564869086018857
|
-7959665055215941403
|
click
|
accessibility
|
NULL
|
Project: faVsco.js, menu
#12066 on JY-20725-handle Project: faVsco.js, menu
#12066 on JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
HandleHubspotRateLimitTest
Run 'HandleHubspotRateLimitTest'
Debug 'HandleHubspotRateLimitTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
11
72
1
4
Previous Highlighted Error
Next Highlighted Error
<?php
namespace Jiminny\Services\Crm\Copper;
use Carbon\Carbon;
use Exception;
use GuzzleHttp\Exception\ClientException;
use Illuminate\Support\Facades\Cache;
use Jiminny\Contracts\Services\Crm\LayoutManagementInterface;
use Jiminny\Contracts\Services\Crm\MatchCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\Provider\CopperInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityLookupInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityManipulationInterface;
use Jiminny\Contracts\Services\Crm\SettingsInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmMetadataInterface;
use Jiminny\Contracts\Services\Crm\VerifyTaskExistsInterface;
use Jiminny\Exceptions\CrmException;
use Jiminny\Exceptions\HttpNotFoundException;
use Jiminny\Models\Account;
use Jiminny\Models\Activity;
use Jiminny\Models\Contact;
use Jiminny\Models\Crm\BusinessProcess;
use Jiminny\Models\Crm\Configuration;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\FieldData;
use Jiminny\Models\Crm\FieldValue;
use Jiminny\Models\Crm\LayoutEntity;
use Jiminny\Models\Crm\Profile;
use Jiminny\Models\Lead;
use Jiminny\Models\Opportunity;
use Jiminny\Models\Playbook;
use Jiminny\Models\PlaybookCategory;
use Jiminny\Models\SocialAccount;
use Jiminny\Models\Stage;
use Jiminny\Models\User;
use Jiminny\Repositories\Crm\CrmEntityRepository;
use Jiminny\Repositories\Crm\FieldRepository;
use Jiminny\Repositories\Crm\ProfileRepository;
use Jiminny\Repositories\UserRepository;
use Jiminny\Services\Avatar\ProspectPhotoPathService;
use Jiminny\Services\Crm\BaseService;
use Jiminny\Services\Crm\Copper\Api\Client;
use Jiminny\Services\Crm\Copper\Api\ClientFactory;
use Jiminny\Services\Crm\Copper\Exception\RequestFailedException;
use Jiminny\Services\Crm\Helpers\FilterJoinedParticipants;
use Jiminny\Services\Crm\OpportunitySyncStrategyResolver;
use Jiminny\Services\Crm\Copper\Traits\SearchesCrmEntities;
use Jiminny\Utils\CurrencyFormatter;
use Jiminny\Utils\StringUtil;
class Service extends BaseService implements
CopperInterface,
SyncCrmEntitiesInterface,
SyncCrmMetadataInterface,
RemoteEntityManipulationInterface,
LayoutManagementInterface,
SettingsInterface,
MatchCrmEntitiesInterface,
RemoteEntityLookupInterface,
VerifyTaskExistsInterface
{
use SearchesCrmEntities;
private const int NOTE_BODY_MAX_LENGTH = 3000000;
private const int TASK_VERIFICATION_CACHE_TTL = 86400; // 1 day
private const array STANDARD_OPPORTUNITY_FIELDS = [
'monetary_value' => [
'label' => 'Value',
'available_on' => Field::OBJECT_OPPORTUNITY,
'type' => Field::TYPE_CURRENCY,
],
'pipeline_stage_id' => [
'label' => 'Stage',
'type' => Field::TYPE_PICKLIST,
'available_on' => Field::OBJECT_OPPORTUNITY,
],
'close_date' => [
'label' => 'Close date',
'type' => Field::TYPE_DATE,
'available_on' => Field::OBJECT_OPPORTUNITY,
],
'win_probability' => [
'label' => 'Win probability',
'type' => Field::TYPE_NUMBER,
'available_on' => Field::OBJECT_OPPORTUNITY,
],
];
private const string UNSUPPORTED_OBJECT_TYPE = 'unsupported';
private const string OPPORTUNITY_STATUS_OPEN = 'Open';
private const string OPPORTUNITY_IMPORT_FAILED_ERROR_MESSAGE = 'Opportunity import failed';
private const string RESOURCE_UPDATE_FAILED_ERROR_MESSAGE = 'Failed to update resource';
private FieldValueConverter $fieldValueConverter;
private ClientFactory $clientFactory;
private ?Client $copperClient = null;
public function __construct(
ClientFactory $clientFactory,
FieldValueConverter $fieldValueConverter,
private readonly UserRepository $userRepository,
private readonly ProspectPhotoPathService $prospectPhotoPathService,
private readonly CrmEntityRepository $crmEntityRepository
) {
parent::__construct();
$this->clientFactory = $clientFactory;
$this->fieldValueConverter = $fieldValueConverter;
}
public function getDisplayName(): string
{
return 'Copper';
}
/**
* Get the CRM entity repository (used by SearchesCrmEntities trait)
*/
protected function getCrmEntityRepository(): CrmEntityRepository
{
return $this->crmEntityRepository;
}
public function getMinimumApiVersion(): string
{
return Client::MIN_API_VERSION;
}
protected function getOAuthAccount(User $user): ?SocialAccount
{
// In this case, the Account Owner is always the connection for any API operations.
$owner = $user->team->owner;
return $owner->getSocialAccount(SocialAccount::PROVIDER_COPPER);
}
public function setUser(User $user): void
{
$this->configureSentryScope($user);
$this->team = $user->getTeam();
$this->teamService->setTeam($user->getTeam());
$this->setConfiguration($user->getTeam()->getCrmConfiguration());
// Reset state.
$this->profile = $user->getProfile();
if (! $this->profile instanceof Profile) {
// Try to fetch their profile, maybe it was not yet imported?
$this->profile = $this->getProfile($user);
}
}
public function setConfiguration(Configuration $configuration): void
{
$this->config = $configuration;
$this->copperClient = $this->clientFactory->create($configuration);
if ($this->team === null) {
$this->team = $configuration->getTeam();
}
}
public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string
{
return $this->fieldValueConverter->convertFromCrm($fieldType, $fieldValue);
}
private function mapObjectType(string $objectType): string
{
$objectType = strtolower($objectType);
switch ($objectType) {
case Field::OBJECT_TASK:
case Field::OBJECT_OPPORTUNITY:
case Field::OBJECT_LEAD:
$mappedType = $objectType;
break;
case Field::OBJECT_ACCOUNT:
$mappedType = 'company';
break;
case Field::OBJECT_CONTACT:
$mappedType = 'person';
break;
default:
$mappedType = self::UNSUPPORTED_OBJECT_TYPE;
break;
}
return $mappedType;
}
/**
* @param string $crmObject The name of the CRM object. i.e. Opportunity or Contact
*/
protected function getFields(string $crmObject): array
{
$mappedObjectType = $this->mapObjectType($crmObject);
if (! $this->isSupportedObjectType($mappedObjectType)) {
return [];
}
$customFields = $this->copperClient->fields()->all();
$filteredFields = array_filter(
$customFields,
static fn ($field) => in_array($mappedObjectType, $field['available_on'], true)
);
return array_map(
fn (array $property) => [
'name' => $property['id'],
'label' => $property['name'],
'type' => $this->convertFieldType($property['data_type']),
],
$filteredFields
);
}
/**
* @inheritdoc
*/
public function getDefaultFields(string $activityType): array
{
$fields = [];
if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {
$defaultFields = FieldDefinitions::defaultTaskFields();
// This lazy creates these fields if not already setup.
foreach ($defaultFields as $defaultField) {
$fields[] = $this->config->fields()->firstOrCreate($defaultField);
}
}
return $fields;
}
/**
* @inheritdoc
*/
public function getDefaultActivityField(string $activityType): Field
{
// Setup the activity field as the default Type.
$activityField = $this->config->fields()->where([
'crm_provider_id' => 'type',
'object_type' => $activityType,
])->first();
return $activityField;
}
/**
* @inheritdoc
*/
public function getSupportedPlaybookTypes(): array
{
return [Playbook::ACTIVITY_TYPE_TASK];
}
public function getDealInsightsFields(): array
{
return FieldDefinitions::dealInsightsFields();
}
protected function getDefaultFollowupLayoutFields(string $activityType): array
{
$fields = [];
$fieldRepo = app(FieldRepository::class);
$fieldFilters = FieldDefinitions::taskFollowupFieldsFilter();
foreach ($fieldFilters as $data) {
$field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $data);
// Only add the field if it is created, which it should be.
if ($field) {
$fields[] = $field;
}
}
return $fields;
}
public function syncFields(): void
{
$this->syncStandardFields();
$this->syncCustomFields();
}
public function syncField(Field $field): void
{
if ($field->getCrmProviderId() === 'type') {
/**
* This field is a special case, it is not a custom field and is not returned by the Copper API.
*
* The field is created manually during layout setup.
*
* @see getDefaultFields()
*
* There is a separate endpoint to get the activity types.
* @see importPicklistValues()
*/
return;
}
if ($field->getCrmProviderId() === 'pipeline_stage_id') {
// Not a custom type
return;
}
$crmField = $this->copperClient->fields()->find($field->getCrmProviderId());
$field->label = $crmField['name'];
$field->type = $this->convertFieldType($crmField['data_type']);
$field->save();
}
/**
* @param string $fieldType How the data should be input
*/
private function convertFieldType(string $fieldType): string
{
$fieldType = strtolower($fieldType);
switch ($fieldType) {
case 'dropdown':
$convertedType = Field::TYPE_PICKLIST;
break;
case 'string':
$convertedType = Field::TYPE_TEXT;
break;
case 'float':
case 'percentage':
$convertedType = Field::TYPE_NUMBER;
break;
case 'text':
$convertedType = Field::TYPE_TEXTAREA;
break;
case 'checkbox':
$convertedType = Field::TYPE_BOOLEAN;
break;
case Field::TYPE_DATE:
case Field::TYPE_CURRENCY:
case Field::TYPE_URL:
$convertedType = $fieldType;
break;
default:
$convertedType = Field::TYPE_UNSUPPORTED;
break;
}
return $convertedType;
}
/**
* @inheritdoc
*/
public function importPicklistValues(Field $field): array
{
$fieldValues = [];
if ($field->object_type === 'opportunity' && $field->crm_provider_id === 'pipeline_stage_id') {
return $this->importFieldValuesForDealInsights($field);
}
$values = [];
if ($field->object_type === Field::OBJECT_TASK && $field->crm_provider_id === 'type') {
$activityTypes = $this->copperClient->activityTypes()->all();
/**
* We ignore "system" activity types, disabled activity types and the "Note" activity type.
*/
$activityTypes = array_filter(
$activityTypes['user'],
fn (array $activityType) => ! $activityType['is_disabled'] && $activityType['id'] !== 0
);
// Import all activity values.
$i = 0;
foreach ($activityTypes as $activityType) {
$values[] = [
'value' => $activityType['id'],
'label' => mb_strimwidth($activityType['name'], 0, Field::LABEL_MAX_LENGTH),
'sequence' => $i++,
];
}
} else {
// We just check for the field in the custom definitions.
$crmField = $this->copperClient->fields()->find($field->crm_provider_id);
// Import all values.
foreach ($crmField['options'] as $option) {
$values[] = [
'value' => $option['id'],
'label' => mb_strimwidth($option['name'], 0, 191),
'sequence' => $option['rank'],
];
}
}
// Take the existing set of field values.
$fieldsToPurge = $field->values()->get()->pluck('value')->toArray();
foreach ($values as $value) {
// For all current values, upsert the field.
$value['value'] = substr($value['value'] ?? '', 0, 255);
$fieldValues[] = $field->values()->updateOrCreate([
'value' => $value['value'],
], $value);
// Remove this value from the ones we are going to purge.
$key = array_search($value['value'], $fieldsToPurge, false);
if ($key !== false) {
unset($fieldsToPurge[$key]);
}
}
// Delete the old values that are no longer used.
$field->values()->whereIn('value', $fieldsToPurge)->delete();
return $fieldValues;
}
/**
*
* @return FieldValue[]
*/
private function importFieldValuesForDealInsights(Field $field): array
{
$fieldValues = [];
$pipelines = $this->copperClient->pipelines()->all();
foreach ($pipelines as $pipeline) {
foreach ($pipeline['stages'] as $i => $dealStage) {
$fieldValues[] = $field->values()->updateOrCreate(
['value' => $dealStage['id']],
[
'label' => $dealStage['name'],
'sequence' => $i,
]
);
}
}
return $fieldValues;
}
/**
* @inheritdoc
*/
public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage
{
if ($types === null) {
$types = [Stage::TYPE_LEAD, Stage::TYPE_OPPORTUNITY];
}
$missingStage = null;
foreach ($types as $type) {
if ($type === Stage::TYPE_LEAD) {
$statuses = $this->requestWithExceptionHandling(
fn () => $this->copperClient->leadStatuses()->all()
);
if (count($statuses) > 0) {
$businessProcess = $this->config->businessProcesses()->updateOrCreate([
'crm_provider_id' => BusinessProcess::TYPE_LEAD,
], [
'team_id' => $this->team->id,
'name' => BusinessProcess::TYPE_LEAD,
'type' => BusinessProcess::TYPE_LEAD,
'is_selectable' => true,
]);
// A record type is really a clone of the business process, used to store which record uses which pipeline.
$this->config->recordTypes()->updateOrCreate([
'crm_provider_id' => BusinessProcess::TYPE_LEAD,
], [
'team_id' => $this->team->id,
'name' => BusinessProcess::TYPE_LEAD,
'is_selectable' => true,
'business_process_id' => $businessProcess->id ?? null,
]);
$leadStatuses = [];
foreach ($statuses as $status) {
// Upsert all statuses for the team.
$status = $this->config->stages()->updateOrCreate([
'crm_provider_id' => $status['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($status['name'], 0, 50),
'label' => mb_strimwidth($status['name'], 0, 191),
'type' => Stage::TYPE_LEAD,
'sequence' => $status['order'],
'is_selectable' => true,
]);
if ($missingStageName && $missingStageName === $status['id']) {
$missingStage = $status;
}
$leadStatuses[] = $status['id'];
}
$businessProcess->stages()->sync($leadStatuses);
}
} else {
// We create a business process to contain the pipeline, and later store all stages against it.
foreach ($this->copperClient->pipelines()->all() as $pipeline) {
$businessProcess = $this->config->businessProcesses()->updateOrCreate([
'crm_provider_id' => $pipeline['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($pipeline['name'], 0, 150),
'type' => BusinessProcess::TYPE_OPPORTUNITY,
'is_selectable' => true,
]);
// A record type is really a clone of the business process, used to store which record uses which pipeline.
$this->config->recordTypes()->updateOrCreate([
'crm_provider_id' => $pipeline['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($pipeline['name'], 0, 150),
'is_selectable' => true,
'business_process_id' => $businessProcess->id ?? null,
]);
$stages = [];
foreach ($pipeline['stages'] as $i => $dealStage) {
// Upsert all stages for the team.
$stage = $this->config->stages()->updateOrCreate([
'crm_provider_id' => $dealStage['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($dealStage['name'], 0, 50),
'label' => mb_strimwidth($dealStage['name'], 0, 191),
'type' => Stage::TYPE_OPPORTUNITY,
'sequence' => $i,
'is_selectable' => true,
'probability' => $dealStage['win_probability'],
]);
if ($missingStageName && $missingStageName === $dealStage['id']) {
$missingStage = $stage;
}
$stages[] = $stage['id'];
}
$businessProcess->stages()->sync($stages);
}
}
}
return $missingStage;
}
/**
* @inheritdoc
*/
public function syncLeads(Carbon $since, ?Carbon $to = null, ?string $crmProfileId = null): int
{
$syncCount = 0;
$cpLeads = $this->requestWithExceptionHandling(
fn () => $this->copperClient->leads()->paginate([
'minimum_modified_date' => $since->timestamp,
'sort_by' => 'date_modified',
])
);
foreach ($cpLeads as $cpLead) {
// Only sync if previously imported.
if ($this->hasLead($cpLead['id']) === false) {
$this->importLead($cpLead);
$syncCount++;
}
}
return $syncCount;
}
/**
* @inheritdoc
*/
public function syncLead(string $crmId): ?Lead
{
$cpLead = $this->requestWithExceptionHandling(
fn () => $this->copperClient->leads()->find($crmId)
);
if (\count($cpLead) === 0) {
return null;
}
return $this->importLead($cpLead);
}
/**
* @inheritdoc
*/
private function importLead($crmData): ?Lead
{
$countryCode = null;
if (! empty($crmData['address']) && isset($crmData['address']['country'])) {
$countryCode = $this->convertCountryNameToCode($crmData['address']['country']);
}
$mobilePhone = $parsedNumber = null;
if (! empty($crmData['phone_numbers'])) {
foreach ($crmData['phone_numbers'] as $phoneNumber) {
if ($phoneNumber['category'] === 'mobile') {
$mobilePhone = $this->buildContactMobilePhone($countryCode, $phoneNumber['number']);
} else {
$parsedNumber = $this->buildContactPhone($countryCode, $phoneNumber['number']);
}
}
}
$name = $crmData['name'];
if (empty($crmData['company_name'])) {
$company = 'Unknown';
} else {
$company = mb_strimwidth($crmData['company_name'], 0, 191);
}
$domain = null;
if (empty($crmData['websites']) === false) {
$domain = mb_strimwidth($crmData['websites'][0]['url'], 0, 191);
$domain = StringUtil::resolveDomain($domain);
}
$email = null;
if (empty($crmData['email']) === false) {
$email = mb_strimwidth($crmData['email']['email'], 0, 80);
}
$stage = $this->config
->stages()
->where('crm_provider_id', (string) $crmData['status_id'])
->where('type', Stage::TYPE_LEAD)
->first();
if ($stage === null) {
// Import it.
$stage = $this->importStages([Stage::TYPE_LEAD], $crmData['status_id']);
}
if (! $stage instanceof Stage) {
return null;
}
$profile = $this->config->profiles()->where('crm_provider_id', (string) $crmData['assignee_id'])->first();
$data = [
'team_id' => $this->team->id,
'user_id' => $profile ? $profile->user_id : null,
'owner_id' => $crmData['assignee_id'],
'name' => mb_strimwidth($name, 0, 191),
'company' => $company,
'domain' => $domain,
'title' => $crmData['title'] ? mb_strimwidth($crmData['title'], 0, 128) : null,
'email' => $email,
'phone' => $parsedNumber['phone'] ?? null,
'ext' => $parsedNumber['ext'] ?? null,
'mobile_phone' => $mobilePhone,
'stage_id' => $stage->id,
'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(
crmConfiguration: $this->config,
crmProviderId: $crmData['id'],
modelType: Lead::class,
fileName: $crmData['id'],
avatarText: $crmData['name']
),
'country_code' => $countryCode,
'remotely_created_at' => $crmData['date_created'],
];
return $this->config->leads()->updateOrCreate(['crm_provider_id' => (string) $crmData['id']], $data);
}
/**
* @inheritdoc
*/
public function syncAccounts(Carbon $since, ?Carbon $to = null): int
{
$syncCount = 0;
try {
$cpAccounts = $this->copperClient->accounts()->paginate([
'minimum_modified_date' => $since->timestamp,
'sort_by' => 'date_modified',
]);
foreach ($cpAccounts as $cpAccount) {
// Only sync if previously imported.
if ($this->hasAccount($cpAccount['id'])) {
$this->importAccount($cpAccount);
$syncCount++;
}
}
} catch (\Exception $exception) {
// Do nothing for now.
}
return $syncCount;
}
/**
* @inheritdoc
*/
public function syncAccount(string $crmId): ?Account
{
$cpAccount = $this->copperClient->accounts()->find($crmId);
if (empty($cpAccount)) {
return null;
}
return $this->importAccount($cpAccount);
}
/**
* @inheritdoc
*/
private function importAccount($crmData): Account
{
$countryCode = null;
if (empty($crmData['address']) === false && $crmData['address']['country']) {
$countryCode = $this->convertCountryNameToCode($crmData['address']['country']);
}
$name = $crmData['name'];
$parsedNumber = null;
foreach ($crmData['phone_numbers'] as $phoneNumber) {
$parsedNumber = parsePhoneNumber($countryCode, $phoneNumber['number']);
}
$domain = null;
if (empty($crmData['websites']) === false) {
$domain = mb_strimwidth($crmData['websites'][0]['url'], 0, 191);
$domain = StringUtil::resolveDomain($domain);
}
$profile = $this->config->profiles()->where('crm_provider_id', (string) $crmData['assignee_id'])->first();
$data = [
'team_id' => $this->team->id,
'user_id' => $profile ? $profile->user_id : null,
'owner_id' => $crmData['assignee_id'],
'name' => mb_strimwidth($name, 0, 191),
'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(
crmConfiguration: $this->config,
crmProviderId: $crmData['id'],
modelType: Account::class,
fileName: $crmData['id'],
avatarText: $crmData['name']
),
'country_code' => $countryCode,
'domain' => $domain,
'phone' => $parsedNumber['phone'] ?? null,
'ext' => $parsedNumber['ext'] ?? null,
'remotely_created_at' => $crmData['date_created'],
];
return $this->config->accounts()->updateOrCreate(['crm_provider_id' => (string) $crmData['id']], $data);
}
public function syncOpportunities(array $parameters, ?string $strategy = null): int
{
$strategyResolver = app(OpportunitySyncStrategyResolver::class, [
'client' => $this->copperClient,
]);
$strategies = $strategyResolver->getStrategies($this->config, $strategy);
$syncCount = 0;
try {
foreach ($strategies as $syncStrategy) {
foreach ($syncStrategy->fetchOpportunities($parameters) as $opportunityData) {
$this->importOpportunity($opportunityData);
$syncCount++;
}
}
} catch (\Exception $exception) {
// Do nothing for now.
$this->logger->error(self::OPPORTUNITY_IMPORT_FAILED_ERROR_MESSAGE, [
'error' => $exception->getMessage(),
]);
}
return $syncCount;
}
public function syncOpportunity(string $crmId): ?Opportunity
{
$strategyResolver = app(OpportunitySyncStrategyResolver::class, [
'client' => $this->copperClient,
]);
$strategy = $strategyResolver->resolve(
$this->config,
OpportunitySyncStrategyResolver::SINGLE_SYNC_OPPORTUNITY_STRATEGY
);
$parameters['crm_id'] = $crmId;
$cpOpportunity = $strategy->fetchOpportunities($parameters);
return $this->importOpportunity($cpOpportunity);
}
private function importOpportunity($crmData): ?Opportunity
{
$companyId = (string) $crmData['company_id'];
if (empty($companyId)) {
$this->logger->error(self::OPPORTUNITY_IMPORT_FAILED_ERROR_MESSAGE, [
'error' => 'No company id',
]);
return null;
}
$account = $this->config->accounts()->where('crm_provider_id', $companyId)->first();
if ($account === null) {
$account = $this->syncAccount($companyId);
}
$profile = $this->config->profiles()->where('crm_provider_id', (string) $crmData['assignee_id'])->first();
$businessProcess = $this->config
->businessProcesses()
->where('crm_provider_id', (string) $crmData['pipeline_id'])
->first();
if ($businessProcess === null) {
// It is possible for some deals to not be in a pipeline.
$this->logger->warning(self::OPPORTUNITY_IMPORT_FAILED_ERROR_MESSAGE, [
'error' => 'Missing pipeline',
]);
return null;
}
$stage = $businessProcess
->stages()
->where('crm_provider_id', (string) $crmData['pipeline_stage_id'])
->where('type', Stage::TYPE_OPPORTUNITY)
->first();
if ($stage === null) {
$this->logger->info('Stage not found, importing', [
'pipeline_id' => $crmData['pipeline_id'],
'stage_id' => $crmData['pipeline_stage_id'],
]);
$stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $crmData['pipeline_stage_id']);
}
$recordType = $businessProcess->recordTypes()->first();
$closeDate = null;
$isClosed = $crmData['status'] !== self::OPPORTUNITY_STATUS_OPEN;
if ($crmData['close_date']) {
$closeDate = Carbon::parse($crmData['close_date'])->format('Y-m-d');
}
$data = [
'team_id' => $this->team->getId(),
'account_id' => $account->id,
'user_id' => $profile->user_id ?? null,
'owner_id' => $crmData['assignee_id'],
'name' => mb_strimwidth($crmData['name'], 0, 128),
'value' => $crmData['monetary_value'],
'currency_code' => CurrencyFormatter::formatCode($crmData['monetary_unit']),
'close_date' => $closeDate,
'is_closed' => $isClosed,
'is_won' => $crmData['status'] === 'Won',
'stage_id' => $stage->id,
'record_type_id' => $recordType->id ?? null,
'remotely_created_at' => $crmData['date_created'],
];
/** @var Opportunity $opportunity */
$opportunity = $this->config
->opportunities()
->updateOrCreate(['crm_provider_id' => (string) $crmData['id']], $data)
;
// import external fields into crm_field_data if present
$dealInsightsFields = $this->getOpportunitySyncableFields();
$this->importOpportunityCrmFieldData($crmData, $dealInsightsFields, $opportunity->id);
return $opportunity;
}
/**
* @param array<string, mixed> $opportunityData
* @param string[] $dealInsightsFields
*/
public function importOpportunityCrmFieldData(
array $opportunityData,
array $dealInsightsFields,
int $opportunityId
): void {
foreach ($dealInsightsFields as $fieldName) {
$field = $this->config->fields()->with('entities')
->where('crm_provider_id', $fieldName)
->where('object_type', Field::OBJECT_OPPORTUNITY)
->first();
if (! $field instanceof Field) {
$this->logger->warning('Field not found', [
'field' => $fieldName,
'opportunity_id' => $opportunityId,
]);
continue;
}
$entity = $field->getEntities()->first();
if (! $entity instanceof LayoutEntity) {
continue;
}
$fieldValue = $this->extractFieldValue($opportunityData, $fieldName);
$normalizedValue = $this->normalizeValue($field->getType(), (string) $fieldValue, true);
$field->data()
->updateOrCreate([
'crm_layout_entity_id' => $entity->id,
'crm_field_id' => $field->id,
'object_type' => 'opportunity',
'object_id' => $opportunityId,
], ['value' => $normalizedValue])
;
}
}
/**
* @inheritdoc
*/
public function syncContacts(Carbon $since, ?Carbon $to = null): int
{
$syncCount = 0;
try {
$cpContacts = $this->copperClient->contacts()->paginate([
'minimum_interaction_date' => $since->timestamp,
'sort_by' => 'date_modified',
]);
foreach ($cpContacts as $cpContact) {
// Only sync if previously imported.
if ($this->hasContact($cpContact['id'])) {
$this->importContact($cpContact);
$syncCount++;
}
}
} catch (\Exception $exception) {
// Do nothing for now.
throw $exception;
}
return $syncCount;
}
/**
* @inheritdoc
*/
public function syncContact(string $crmId): ?Contact
{
$cpContact = $this->copperClient->contacts()->find($crmId);
if (empty($cpContact)) {
return null;
}
return $this->importContact($cpContact);
}
/**
* @inheritdoc
*/
private function importContact($crmData): Contact
{
$account = null;
if ($crmData['company_id']) {
$account = $this->team
->crm
->accounts()
->where('crm_provider_id', (string) $crmData['company_id'])
->first();
if ($account === null) {
$account = $this->syncAccount($crmData['company_id']);
}
}
$countryCode = null;
if (empty($crmData['address']) === false && $crmData['address']['country']) {
$countryCode = $this->convertCountryNameToCode($crmData['address']['country']);
}
$mobilePhone = $parsedNumber = null;
foreach ($crmData['phone_numbers'] as $phoneNumber) {
if ($phoneNumber['category'] === 'mobile') {
$mobilePhone = $this->buildContactMobilePhone($countryCode, $phoneNumber['number']);
} else {
$parsedNumber = $this->buildContactPhone($countryCode, $phoneNumber['number']);
}
}
$email = null;
if (empty($crmData['emails']) === false) {
$email = mb_strimwidth($crmData['emails'][0]['email'], 0, 80);
}
$profile = $this->config->profiles()->where('crm_provider_id', (string) $crmData['assignee_id'])->first();
$data = [
'team_id' => $this->team->id,
'account_id' => $account->id ?? null,
'user_id' => $profile ? $profile->user_id : null,
'owner_id' => $crmData['assignee_id'],
'name' => $crmData['name'],
'email' => $email,
'phone' => $parsedNumber['phone'] ?? null,
'mobile_phone' => $mobilePhone ?? null,
'ext' => $parsedNumber['ext'] ?? null,
'title' => $crmData['title'],
'country_code' => $countryCode,
'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(
crmConfiguration: $this->config,
crmProviderId: $crmData['id'],
modelType: Contact::class,
fileName: $crmData['id'],
avatarText: $crmData['name']
),
'remotely_created_at' => $crmData['date_created'],
];
return $this->config->contacts()->updateOrCreate(['crm_provider_id' => (string) $crmData['id']], $data);
}
private function buildContactPhone(?string $countryCode, ?string $number): ?array
{
if ($number) {
// Trim to our width and attempt to parse it.
$number = mb_strimwidth($number, 0, 25);
$parsedNumber = parsePhoneNumber($countryCode, $number);
} else {
$parsedNumber = [];
}
return $parsedNumber;
}
private function buildContactMobilePhone(?string $countryCode, ?string $number): ?string
{
return $number ? mb_strimwidth(phone_e164($countryCode, $number), 0, 25) : null;
}
/**
* @inheritdoc
*/
public function syncOrganization(): void
{
$organization = $this->copperClient->organisation()->all();
if ($this->config->crm_provider_id !== $organization['id']) {
$this->config->update(['crm_provider_id' => $organization['id']]);
}
}
private function syncCustomFields(): void
{
foreach ($this->getFieldTypes() as $fieldType) {
$currentFields = $this->getFields($fieldType);
foreach ($currentFields as $field) {
/** @var Field $field */
$field = $this->config->fields()->updateOrCreate([
'crm_provider_id' => $field['name'],
'object_type' => $fieldType,
], [
'label' => mb_strimwidth($field['label'], 0, Field::LABEL_MAX_LENGTH),
'type' => $field['type'],
]);
if ($field->getType() === Field::TYPE_PICKLIST) {
$this->importPicklistValues($field);
}
}
}
$this->config->save();
}
protected function getFieldTypes(): array
{
return [
parent::OBJECT_TASK,
parent::OBJECT_LEAD,
parent::OBJECT_ACCOUNT,
parent::OBJECT_CONTACT,
parent::OBJECT_OPPORTUNITY,
];
}
/**
* @inheritdoc
*/
public function syncProfiles(?User $userToSearch = null): ?Profile
{
$profiles = $this->copperClient->users()->all();
foreach ($profiles as $profile) {
$user = $this->userRepository->findByTeamAndEmailAddress($this->team, $profile['email'], onlyActive: true);
// Only import profiles for users we have locally.
if ($user) {
$profile = $user->crmProfile()->updateOrCreate([
'crm_configuration_id' => $this->config->id,
'crm_provider_id' => $profile['id'],
]);
if ($userToSearch && $userToSearch->id === $user->id) {
return $profile;
}
}
}
return null;
}
/**
* @inheritdoc
*/
public function syncProfileFields(): void
{
$fieldTypes = [
'task',
'lead',
'account',
'contact',
'opportunity',
];
foreach ($fieldTypes as $fieldType) {
try {
$currentFields = $this->getFields(ucfirst($fieldType));
$newFields = array_column($currentFields, 'name');
$changedFields = array_merge(
array_diff($this->profile->getFieldsAsArray($fieldType), $newFields),
array_diff($newFields, $this->profile->getFieldsAsArray($fieldType))
);
if (\count($changedFields) > 0) {
$fields = implode(',', $newFields);
$this->logger->debug(
ucfirst($fieldType)
. ' fields changed from '
. $this->profile->{$fieldType . '_fields'}
. ' to: '
. $fields
);
$this->profile->{$fieldType . '_fields'} = $fields;
}
} catch (\Exception $exception) {
$this->logger->error('No access to ' . $fieldType . ' object, skipping...');
continue;
}
}
$this->profile->save();
}
/**
* @inheritdoc
*/
public function find(string $name, array $scopes): array
{
$count = $this->limit ?? 20;
$offset = $this->offset ?? 0;
$scopeValues = array_map(fn ($scope) => is_string($scope) ? $scope : $scope->value, $scopes);
$cacheKey = sprintf('copper-search-%d-%s-%s-%d-%d', $this->team->id, $name, implode('-', $scopeValues), $count, $offset);
return Cache::remember($cacheKey, 300, function () use ($name, $scopes, $count, $offset) {
// Try local search first
$localResults = $this->searchLocally($name, $scopes, $count, $offset);
// If no local results, fall back to remote API search
if (empty($localResults)) {
return $this->searchRemotely($name, $scopes, $count, $offset);
}
return $localResults;
});
}
/**
* @inheritdoc
*/
public function findOpportunities(?string $crmAccountId, ?string $crmContactId, ?int $userId = null): array
{
$data = [];
$ownerData = [];
$ownerId = null;
$contact = null;
$account = null;
if ($crmAccountId) {
$account = $this->config->accounts()->where('crm_provider_id', $crmAccountId)->first();
if ($account === null) {
$account = $this->syncAccount($crmAccountId);
}
}
if ($crmContactId) {
$contact = $this->config->contacts()->where('crm_provider_id', $crmContactId)->first();
if ($contact === null) {
$contact = $this->syncContact($crmContactId);
}
}
if ($contact || $account) {
if ($contact && $account === null) {
$account = $contact->account;
}
if ($account === null) {
return [];
}
$params = [
'company_ids' => [$account->crm_provider_id],
'status_ids' => [0], // The integer values are 0, 1, 2, 3, for "Open", "Won", "Lost", and "Abandoned".
'sort_by' => 'date_modified',
'sort_direction' => 'desc',
];
switch ($this->config->opportunity_assignment_rule) {
case Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_UPDATED:
$params['sort_by'] = 'date_modified';
$params['sort_direction'] = 'desc';
break;
case Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_CREATED:
$params['sort_by'] = 'date_created';
$params['sort_direction'] = 'desc';
break;
case Configuration::OPP_ASSIGNMENT_ALL_OPEN_OLDEST_CREATED:
$params['sort_by'] = 'date_created';
$params['sort_direction'] = 'asc';
break;
case Configuration::OPP_ASSIGNMENT_ALL_RECENTLY_UPDATED:
$params['sort_by'] = 'date_modified';
$params['sort_direction'] = 'desc';
$params['status_ids'] = [0, 1, 2, 3];
}
$cpOpportunities = $this->copperClient->opportunities()->search($params);
if ($userId) {
$profileRepository = app(ProfileRepository::class);
$profile = $profileRepository->findProfileByUserId($this->config, $userId);
$ownerId = $profile instanceof Profile ? $profile->getCrmProviderId() : null;
}
foreach ($cpOpportunities as $cpOpportunity) {
$businessProcess = $this->config
->businessProcesses()
->where('crm_provider_id', (string) $cpOpportunity['pipeline_id'])
->first();
if ($businessProcess === null) {
// Import it.
$businessProcess = $this->importStages(
[Stage::TYPE_OPPORTUNITY],
$cpOpportunity['pipeline_stage_id']
);
}
$stage = $businessProcess
->stages()
->where('crm_provider_id', (string) $cpOpportunity['pipeline_stage_id'])
->where('type', Stage::TYPE_OPPORTUNITY)
->first();
if ($stage === null) {
// Import it.
$stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $cpOpportunity['pipeline_stage_id']);
}
$recordType = $businessProcess->recordTypes()->first();
$record = [
'crmId' => $cpOpportunity['id'],
'name' => $cpOpportunity['name'],
'value' => currency_format($cpOpportunity['monetary_value'], $cpOpportunity['monetary_unit']),
'won' => $cpOpportunity['status'] === 'Won',
'stage' => [
'id' => $stage->id_string,
'name' => $stage->name,
],
'recordType' => [
'id' => $recordType->id_string,
'name' => $recordType->name,
],
];
if ($ownerId && isset($cpOpportunity['assignee_id']) && $cpOpportunity['assignee_id'] === $ownerId) {
$ownerData[] = $record;
}
$data[] = $record;
}
}
if (! empty($ownerData)) {
return $ownerData;
}
return $data;
}
/**
* @inheritdoc
*/
public function getTasks(string $objectType, string $objectId, ?string $opportunityId): array
{
$data = $cpTasks = [];
switch ($objectType) {
case 'lead':
$cpTasks = $this->requestWithExceptionHandling(
fn () => $this->copperClient->leads()->relatedTasks($objectId)
);
break;
case 'account':
$cpTasks = $this->copperClient->accounts()->relatedTasks($objectId);
break;
case 'contact':
if ($opportunityId) {
$cpTasks = $this->copperClient->opportunities()->relatedTasks($opportunityId);
} elseif ($objectType === 'contact') {
$cpTasks = $this->copperClient->contacts()->relatedTasks($objectId);
}
break;
}
foreach ($cpTasks as $cpTask) {
// We only get the ID itself returned, so pull out the full object.
try {
$cpTask = $this->copperClient->tasks()->find($cpTask['id']);
} catch (Exception $exception) {
$this->logger->error('[Copper] Error fetching task', [
'resource_id' => $cpTask['id'],
'error' => $exception->getMessage(),
]);
continue;
}
// Only allow open tasks.
if ($cpTask['status'] === 'Open') {
$data[] = [
'crmId' => $cpTask['id'],
'subject' => $cpTask['name'],
'due' => $cpTask['due_date']
? Carbon::createFromTimestamp($cpTask['due_date'])->format('Y-m-d\TH:i:sO')
: null,
'type' => null,
];
}
}
return $data;
}
/**
* Try to find email address in CRM service
*
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
*}
*/
public function matchExactlyByEmail(string $email, ?int $userId = null): ?array
{
$cpObjectType = 'contact';
$cpObjects = $this->copperClient->contacts()->search([
'emails' => $email,
'sort_by' => 'date_modified',
'sort_direction' => 'desc',
]);
if (empty($cpObjects)) {
$cpObjectType = 'lead';
$cpObjects = $this->requestWithExceptionHandling(
fn () => $this->copperClient->leads()->search([
'emails' => ...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
45376
|
1626
|
17
|
2026-05-14T14:33:24.529049+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-14/1778 /Users/lukas/.screenpipe/data/data/2026-05-14/1778769204529_m2.jpg...
|
PhpStorm
|
faVsco.js – Copper/Service.php
|
1
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
#12066 on JY-20725-handle Project: faVsco.js, menu
#12066 on JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
HandleHubspotRateLimitTest
Run 'HandleHubspotRateLimitTest'
Debug 'HandleHubspotRateLimitTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
11
72
1
4
Previous Highlighted Error
Next Highlighted Error
<?php
namespace Jiminny\Services\Crm\Copper;
use Carbon\Carbon;
use Exception;
use GuzzleHttp\Exception\ClientException;
use Illuminate\Support\Facades\Cache;
use Jiminny\Contracts\Services\Crm\LayoutManagementInterface;
use Jiminny\Contracts\Services\Crm\MatchCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\Provider\CopperInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityLookupInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityManipulationInterface;
use Jiminny\Contracts\Services\Crm\SettingsInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmMetadataInterface;
use Jiminny\Contracts\Services\Crm\VerifyTaskExistsInterface;
use Jiminny\Exceptions\CrmException;
use Jiminny\Exceptions\HttpNotFoundException;
use Jiminny\Models\Account;
use Jiminny\Models\Activity;
use Jiminny\Models\Contact;
use Jiminny\Models\Crm\BusinessProcess;
use Jiminny\Models\Crm\Configuration;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\FieldData;
use Jiminny\Models\Crm\FieldValue;
use Jiminny\Models\Crm\LayoutEntity;
use Jiminny\Models\Crm\Profile;
use Jiminny\Models\Lead;
use Jiminny\Models\Opportunity;
use Jiminny\Models\Playbook;
use Jiminny\Models\PlaybookCategory;
use Jiminny\Models\SocialAccount;
use Jiminny\Models\Stage;
use Jiminny\Models\User;
use Jiminny\Repositories\Crm\CrmEntityRepository;
use Jiminny\Repositories\Crm\FieldRepository;
use Jiminny\Repositories\Crm\ProfileRepository;
use Jiminny\Repositories\UserRepository;
use Jiminny\Services\Avatar\ProspectPhotoPathService;
use Jiminny\Services\Crm\BaseService;
use Jiminny\Services\Crm\Copper\Api\Client;
use Jiminny\Services\Crm\Copper\Api\ClientFactory;
use Jiminny\Services\Crm\Copper\Exception\RequestFailedException;
use Jiminny\Services\Crm\Helpers\FilterJoinedParticipants;
use Jiminny\Services\Crm\OpportunitySyncStrategyResolver;
use Jiminny\Services\Crm\Copper\Traits\SearchesCrmEntities;
use Jiminny\Utils\CurrencyFormatter;
use Jiminny\Utils\StringUtil;
class Service extends BaseService implements
CopperInterface,
SyncCrmEntitiesInterface,
SyncCrmMetadataInterface,
RemoteEntityManipulationInterface,
LayoutManagementInterface,
SettingsInterface,
MatchCrmEntitiesInterface,
RemoteEntityLookupInterface,
VerifyTaskExistsInterface
{
use SearchesCrmEntities;
private const int NOTE_BODY_MAX_LENGTH = 3000000;
private const int TASK_VERIFICATION_CACHE_TTL = 86400; // 1 day
private const array STANDARD_OPPORTUNITY_FIELDS = [
'monetary_value' => [
'label' => 'Value',
'available_on' => Field::OBJECT_OPPORTUNITY,
'type' => Field::TYPE_CURRENCY,
],
'pipeline_stage_id' => [
'label' => 'Stage',
'type' => Field::TYPE_PICKLIST,
'available_on' => Field::OBJECT_OPPORTUNITY,
],
'close_date' => [
'label' => 'Close date',
'type' => Field::TYPE_DATE,
'available_on' => Field::OBJECT_OPPORTUNITY,
],
'win_probability' => [
'label' => 'Win probability',
'type' => Field::TYPE_NUMBER,
'available_on' => Field::OBJECT_OPPORTUNITY,
],
];
private const string UNSUPPORTED_OBJECT_TYPE = 'unsupported';
private const string OPPORTUNITY_STATUS_OPEN = 'Open';
private const string OPPORTUNITY_IMPORT_FAILED_ERROR_MESSAGE = 'Opportunity import failed';
private const string RESOURCE_UPDATE_FAILED_ERROR_MESSAGE = 'Failed to update resource';
private FieldValueConverter $fieldValueConverter;
private ClientFactory $clientFactory;
private ?Client $copperClient = null;
public function __construct(
ClientFactory $clientFactory,
FieldValueConverter $fieldValueConverter,
private readonly UserRepository $userRepository,
private readonly ProspectPhotoPathService $prospectPhotoPathService,
private readonly CrmEntityRepository $crmEntityRepository
) {
parent::__construct();
$this->clientFactory = $clientFactory;
$this->fieldValueConverter = $fieldValueConverter;
}
public function getDisplayName(): string
{
return 'Copper';
}
/**
* Get the CRM entity repository (used by SearchesCrmEntities trait)
*/
protected function getCrmEntityRepository(): CrmEntityRepository
{
return $this->crmEntityRepository;
}
public function getMinimumApiVersion(): string
{
return Client::MIN_API_VERSION;
}
protected function getOAuthAccount(User $user): ?SocialAccount
{
// In this case, the Account Owner is always the connection for any API operations.
$owner = $user->team->owner;
return $owner->getSocialAccount(SocialAccount::PROVIDER_COPPER);
}
public function setUser(User $user): void
{
$this->configureSentryScope($user);
$this->team = $user->getTeam();
$this->teamService->setTeam($user->getTeam());
$this->setConfiguration($user->getTeam()->getCrmConfiguration());
// Reset state.
$this->profile = $user->getProfile();
if (! $this->profile instanceof Profile) {
// Try to fetch their profile, maybe it was not yet imported?
$this->profile = $this->getProfile($user);
}
}
public function setConfiguration(Configuration $configuration): void
{
$this->config = $configuration;
$this->copperClient = $this->clientFactory->create($configuration);
if ($this->team === null) {
$this->team = $configuration->getTeam();
}
}
public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string
{
return $this->fieldValueConverter->convertFromCrm($fieldType, $fieldValue);
}
private function mapObjectType(string $objectType): string
{
$objectType = strtolower($objectType);
switch ($objectType) {
case Field::OBJECT_TASK:
case Field::OBJECT_OPPORTUNITY:
case Field::OBJECT_LEAD:
$mappedType = $objectType;
break;
case Field::OBJECT_ACCOUNT:
$mappedType = 'company';
break;
case Field::OBJECT_CONTACT:
$mappedType = 'person';
break;
default:
$mappedType = self::UNSUPPORTED_OBJECT_TYPE;
break;
}
return $mappedType;
}
/**
* @param string $crmObject The name of the CRM object. i.e. Opportunity or Contact
*/
protected function getFields(string $crmObject): array
{
$mappedObjectType = $this->mapObjectType($crmObject);
if (! $this->isSupportedObjectType($mappedObjectType)) {
return [];
}
$customFields = $this->copperClient->fields()->all();
$filteredFields = array_filter(
$customFields,
static fn ($field) => in_array($mappedObjectType, $field['available_on'], true)
);
return array_map(
fn (array $property) => [
'name' => $property['id'],
'label' => $property['name'],
'type' => $this->convertFieldType($property['data_type']),
],
$filteredFields
);
}
/**
* @inheritdoc
*/
public function getDefaultFields(string $activityType): array
{
$fields = [];
if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {
$defaultFields = FieldDefinitions::defaultTaskFields();
// This lazy creates these fields if not already setup.
foreach ($defaultFields as $defaultField) {
$fields[] = $this->config->fields()->firstOrCreate($defaultField);
}
}
return $fields;
}
/**
* @inheritdoc
*/
public function getDefaultActivityField(string $activityType): Field
{
// Setup the activity field as the default Type.
$activityField = $this->config->fields()->where([
'crm_provider_id' => 'type',
'object_type' => $activityType,
])->first();
return $activityField;
}
/**
* @inheritdoc
*/
public function getSupportedPlaybookTypes(): array
{
return [Playbook::ACTIVITY_TYPE_TASK];
}
public function getDealInsightsFields(): array
{
return FieldDefinitions::dealInsightsFields();
}
protected function getDefaultFollowupLayoutFields(string $activityType): array
{
$fields = [];
$fieldRepo = app(FieldRepository::class);
$fieldFilters = FieldDefinitions::taskFollowupFieldsFilter();
foreach ($fieldFilters as $data) {
$field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $data);
// Only add the field if it is created, which it should be.
if ($field) {
$fields[] = $field;
}
}
return $fields;
}
public function syncFields(): void
{
$this->syncStandardFields();
$this->syncCustomFields();
}
public function syncField(Field $field): void
{
if ($field->getCrmProviderId() === 'type') {
/**
* This field is a special case, it is not a custom field and is not returned by the Copper API.
*
* The field is created manually during layout setup.
*
* @see getDefaultFields()
*
* There is a separate endpoint to get the activity types.
* @see importPicklistValues()
*/
return;
}
if ($field->getCrmProviderId() === 'pipeline_stage_id') {
// Not a custom type
return;
}
$crmField = $this->copperClient->fields()->find($field->getCrmProviderId());
$field->label = $crmField['name'];
$field->type = $this->convertFieldType($crmField['data_type']);
$field->save();
}
/**
* @param string $fieldType How the data should be input
*/
private function convertFieldType(string $fieldType): string
{
$fieldType = strtolower($fieldType);
switch ($fieldType) {
case 'dropdown':
$convertedType = Field::TYPE_PICKLIST;
break;
case 'string':
$convertedType = Field::TYPE_TEXT;
break;
case 'float':
case 'percentage':
$convertedType = Field::TYPE_NUMBER;
break;
case 'text':
$convertedType = Field::TYPE_TEXTAREA;
break;
case 'checkbox':
$convertedType = Field::TYPE_BOOLEAN;
break;
case Field::TYPE_DATE:
case Field::TYPE_CURRENCY:
case Field::TYPE_URL:
$convertedType = $fieldType;
break;
default:
$convertedType = Field::TYPE_UNSUPPORTED;
break;
}
return $convertedType;
}
/**
* @inheritdoc
*/
public function importPicklistValues(Field $field): array
{
$fieldValues = [];
if ($field->object_type === 'opportunity' && $field->crm_provider_id === 'pipeline_stage_id') {
return $this->importFieldValuesForDealInsights($field);
}
$values = [];
if ($field->object_type === Field::OBJECT_TASK && $field->crm_provider_id === 'type') {
$activityTypes = $this->copperClient->activityTypes()->all();
/**
* We ignore "system" activity types, disabled activity types and the "Note" activity type.
*/
$activityTypes = array_filter(
$activityTypes['user'],
fn (array $activityType) => ! $activityType['is_disabled'] && $activityType['id'] !== 0
);
// Import all activity values.
$i = 0;
foreach ($activityTypes as $activityType) {
$values[] = [
'value' => $activityType['id'],
'label' => mb_strimwidth($activityType['name'], 0, Field::LABEL_MAX_LENGTH),
'sequence' => $i++,
];
}
} else {
// We just check for the field in the custom definitions.
$crmField = $this->copperClient->fields()->find($field->crm_provider_id);
// Import all values.
foreach ($crmField['options'] as $option) {
$values[] = [
'value' => $option['id'],
'label' => mb_strimwidth($option['name'], 0, 191),
'sequence' => $option['rank'],
];
}
}
// Take the existing set of field values.
$fieldsToPurge = $field->values()->get()->pluck('value')->toArray();
foreach ($values as $value) {
// For all current values, upsert the field.
$value['value'] = substr($value['value'] ?? '', 0, 255);
$fieldValues[] = $field->values()->updateOrCreate([
'value' => $value['value'],
], $value);
// Remove this value from the ones we are going to purge.
$key = array_search($value['value'], $fieldsToPurge, false);
if ($key !== false) {
unset($fieldsToPurge[$key]);
}
}
// Delete the old values that are no longer used.
$field->values()->whereIn('value', $fieldsToPurge)->delete();
return $fieldValues;
}
/**
*
* @return FieldValue[]
*/
private function importFieldValuesForDealInsights(Field $field): array
{
$fieldValues = [];
$pipelines = $this->copperClient->pipelines()->all();
foreach ($pipelines as $pipeline) {
foreach ($pipeline['stages'] as $i => $dealStage) {
$fieldValues[] = $field->values()->updateOrCreate(
['value' => $dealStage['id']],
[
'label' => $dealStage['name'],
'sequence' => $i,
]
);
}
}
return $fieldValues;
}
/**
* @inheritdoc
*/
public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage
{
if ($types === null) {
$types = [Stage::TYPE_LEAD, Stage::TYPE_OPPORTUNITY];
}
$missingStage = null;
foreach ($types as $type) {
if ($type === Stage::TYPE_LEAD) {
$statuses = $this->requestWithExceptionHandling(
fn () => $this->copperClient->leadStatuses()->all()
);
if (count($statuses) > 0) {
$businessProcess = $this->config->businessProcesses()->updateOrCreate([
'crm_provider_id' => BusinessProcess::TYPE_LEAD,
], [
'team_id' => $this->team->id,
'name' => BusinessProcess::TYPE_LEAD,
'type' => BusinessProcess::TYPE_LEAD,
'is_selectable' => true,
]);
// A record type is really a clone of the business process, used to store which record uses which pipeline.
$this->config->recordTypes()->updateOrCreate([
'crm_provider_id' => BusinessProcess::TYPE_LEAD,
], [
'team_id' => $this->team->id,
'name' => BusinessProcess::TYPE_LEAD,
'is_selectable' => true,
'business_process_id' => $businessProcess->id ?? null,
]);
$leadStatuses = [];
foreach ($statuses as $status) {
// Upsert all statuses for the team.
$status = $this->config->stages()->updateOrCreate([
'crm_provider_id' => $status['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($status['name'], 0, 50),
'label' => mb_strimwidth($status['name'], 0, 191),
'type' => Stage::TYPE_LEAD,
'sequence' => $status['order'],
'is_selectable' => true,
]);
if ($missingStageName && $missingStageName === $status['id']) {
$missingStage = $status;
}
$leadStatuses[] = $status['id'];
}
$businessProcess->stages()->sync($leadStatuses);
}
} else {
// We create a business process to contain the pipeline, and later store all stages against it.
foreach ($this->copperClient->pipelines()->all() as $pipeline) {
$businessProcess = $this->config->businessProcesses()->updateOrCreate([
'crm_provider_id' => $pipeline['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($pipeline['name'], 0, 150),
'type' => BusinessProcess::TYPE_OPPORTUNITY,
'is_selectable' => true,
]);
// A record type is really a clone of the business process, used to store which record uses which pipeline.
$this->config->recordTypes()->updateOrCreate([
'crm_provider_id' => $pipeline['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($pipeline['name'], 0, 150),
'is_selectable' => true,
'business_process_id' => $businessProcess->id ?? null,
]);
$stages = [];
foreach ($pipeline['stages'] as $i => $dealStage) {
// Upsert all stages for the team.
$stage = $this->config->stages()->updateOrCreate([
'crm_provider_id' => $dealStage['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($dealStage['name'], 0, 50),
'label' => mb_strimwidth($dealStage['name'], 0, 191),
'type' => Stage::TYPE_OPPORTUNITY,
'sequence' => $i,
'is_selectable' => true,
'probability' => $dealStage['win_probability'],
]);
if ($missingStageName && $missingStageName === $dealStage['id']) {
$missingStage = $stage;
}
$stages[] = $stage['id'];
}
$businessProcess->stages()->sync($stages);
}
}
}
return $missingStage;
}
/**
* @inheritdoc
*/
public function syncLeads(Carbon $since, ?Carbon $to = null, ?string $crmProfileId = null): int
{
$syncCount = 0;
$cpLeads = $this->requestWithExceptionHandling(
fn () => $this->copperClient->leads()->paginate([
'minimum_modified_date' => $since->timestamp,
'sort_by' => 'date_modified',
])
);
foreach ($cpLeads as $cpLead) {
// Only sync if previously imported.
if ($this->hasLead($cpLead['id']) === false) {
$this->importLead($cpLead);
$syncCount++;
}
}
return $syncCount;
}
/**
* @inheritdoc
*/
public function syncLead(string $crmId): ?Lead
{
$cpLead = $this->requestWithExceptionHandling(
fn () => $this->copperClient->leads()->find($crmId)
);
if (\count($cpLead) === 0) {
return null;
}
return $this->importLead($cpLead);
}
/**
* @inheritdoc
*/
private function importLead($crmData): ?Lead
{
$countryCode = null;
if (! empty($crmData['address']) && isset($crmData['address']['country'])) {
$countryCode = $this->convertCountryNameToCode($crmData['address']['country']);
}
$mobilePhone = $parsedNumber = null;
if (! empty($crmData['phone_numbers'])) {
foreach ($crmData['phone_numbers'] as $phoneNumber) {
if ($phoneNumber['category'] === 'mobile') {
$mobilePhone = $this->buildContactMobilePhone($countryCode, $phoneNumber['number']);
} else {
$parsedNumber = $this->buildContactPhone($countryCode, $phoneNumber['number']);
}
}
}
$name = $crmData['name'];
if (empty($crmData['company_name'])) {
$company = 'Unknown';
} else {
$company = mb_strimwidth($crmData['company_name'], 0, 191);
}
$domain = null;
if (empty($crmData['websites']) === false) {
$domain = mb_strimwidth($crmData['websites'][0]['url'], 0, 191);
$domain = StringUtil::resolveDomain($domain);
}
$email = null;
if (empty($crmData['email']) === false) {
$email = mb_strimwidth($crmData['email']['email'], 0, 80);
}
$stage = $this->config
->stages()
->where('crm_provider_id', (string) $crmData['status_id'])
->where('type', Stage::TYPE_LEAD)
->first();
if ($stage === null) {
// Import it.
$stage = $this->importStages([Stage::TYPE_LEAD], $crmData['status_id']);
}
if (! $stage instanceof Stage) {
return null;
}
$profile = $this->config->profiles()->where('crm_provider_id', (string) $crmData['assignee_id'])->first();
$data = [
'team_id' => $this->team->id,
'user_id' => $profile ? $profile->user_id : null,
'owner_id' => $crmData['assignee_id'],
'name' => mb_strimwidth($name, 0, 191),
'company' => $company,
'domain' => $domain,
'title' => $crmData['title'] ? mb_strimwidth($crmData['title'], 0, 128) : null,
'email' => $email,
'phone' => $parsedNumber['phone'] ?? null,
'ext' => $parsedNumber['ext'] ?? null,
'mobile_phone' => $mobilePhone,
'stage_id' => $stage->id,
'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(
crmConfiguration: $this->config,
crmProviderId: $crmData['id'],
modelType: Lead::class,
fileName: $crmData['id'],
avatarText: $crmData['name']
),
'country_code' => $countryCode,
'remotely_created_at' => $crmData['date_created'],
];
return $this->config->leads()->updateOrCreate(['crm_provider_id' => (string) $crmData['id']], $data);
}
/**
* @inheritdoc
*/
public function syncAccounts(Carbon $since, ?Carbon $to = null): int
{
$syncCount = 0;
try {
$cpAccounts = $this->copperClient->accounts()->paginate([
'minimum_modified_date' => $since->timestamp,
'sort_by' => 'date_modified',
]);
foreach ($cpAccounts as $cpAccount) {
// Only sync if previously imported.
if ($this->hasAccount($cpAccount['id'])) {
$this->importAccount($cpAccount);
$syncCount++;
}
}
} catch (\Exception $exception) {
// Do nothing for now.
}
return $syncCount;
}
/**
* @inheritdoc
*/
public function syncAccount(string $crmId): ?Account
{
$cpAccount = $this->copperClient->accounts()->find($crmId);
if (empty($cpAccount)) {
return null;
}
return $this->importAccount($cpAccount);
}
/**
* @inheritdoc
*/
private function importAccount($crmData): Account
{
$countryCode = null;
if (empty($crmData['address']) === false && $crmData['address']['country']) {
$countryCode = $this->convertCountryNameToCode($crmData['address']['country']);
}
$name = $crmData['name'];
$parsedNumber = null;
foreach ($crmData['phone_numbers'] as $phoneNumber) {
$parsedNumber = parsePhoneNumber($countryCode, $phoneNumber['number']);
}
$domain = null;
if (empty($crmData['websites']) === false) {
$domain = mb_strimwidth($crmData['websites'][0]['url'], 0, 191);
$domain = StringUtil::resolveDomain($domain);
}
$profile = $this->config->profiles()->where('crm_provider_id', (string) $crmData['assignee_id'])->first();
$data = [
'team_id' => $this->team->id,
'user_id' => $profile ? $profile->user_id : null,
'owner_id' => $crmData['assignee_id'],
'name' => mb_strimwidth($name, 0, 191),
'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(
crmConfiguration: $this->config,
crmProviderId: $crmData['id'],
modelType: Account::class,
fileName: $crmData['id'],
avatarText: $crmData['name']
),
'country_code' => $countryCode,
'domain' => $domain,
'phone' => $parsedNumber['phone'] ?? null,
'ext' => $parsedNumber['ext'] ?? null,
'remotely_created_at' => $crmData['date_created'],
];
return $this->config->accounts()->updateOrCreate(['crm_provider_id' => (string) $crmData['id']], $data);
}
public function syncOpportunities(array $parameters, ?string $strategy = null): int
{
$strategyResolver = app(OpportunitySyncStrategyResolver::class, [
'client' => $this->copperClient,
]);
$strategies = $strategyResolver->getStrategies($this->config, $strategy);
$syncCount = 0;
try {
foreach ($strategies as $syncStrategy) {
foreach ($syncStrategy->fetchOpportunities($parameters) as $opportunityData) {
$this->importOpportunity($opportunityData);
$syncCount++;
}
}
} catch (\Exception $exception) {
// Do nothing for now.
$this->logger->error(self::OPPORTUNITY_IMPORT_FAILED_ERROR_MESSAGE, [
'error' => $exception->getMessage(),
]);
}
return $syncCount;
}
public function syncOpportunity(string $crmId): ?Opportunity
{
$strategyResolver = app(OpportunitySyncStrategyResolver::class, [
'client' => $this->copperClient,
]);
$strategy = $strategyResolver->resolve(
$this->config,
OpportunitySyncStrategyResolver::SINGLE_SYNC_OPPORTUNITY_STRATEGY
);
$parameters['crm_id'] = $crmId;
$cpOpportunity = $strategy->fetchOpportunities($parameters);
return $this->importOpportunity($cpOpportunity);
}
private function importOpportunity($crmData): ?Opportunity
{
$companyId = (string) $crmData['company_id'];
if (empty($companyId)) {
$this->logger->error(self::OPPORTUNITY_IMPORT_FAILED_ERROR_MESSAGE, [
'error' => 'No company id',
]);
return null;
}
$account = $this->config->accounts()->where('crm_provider_id', $companyId)->first();
if ($account === null) {
$account = $this->syncAccount($companyId);
}
$profile = $this->config->profiles()->where('crm_provider_id', (string) $crmData['assignee_id'])->first();
$businessProcess = $this->config
->businessProcesses()
->where('crm_provider_id', (string) $crmData['pipeline_id'])
->first();
if ($businessProcess === null) {
// It is possible for some deals to not be in a pipeline.
$this->logger->warning(self::OPPORTUNITY_IMPORT_FAILED_ERROR_MESSAGE, [
'error' => 'Missing pipeline',
]);
return null;
}
$stage = $businessProcess
->stages()
->where('crm_provider_id', (string) $crmData['pipeline_stage_id'])
->where('type', Stage::TYPE_OPPORTUNITY)
->first();
if ($stage === null) {
$this->logger->info('Stage not found, importing', [
'pipeline_id' => $crmData['pipeline_id'],
'stage_id' => $crmData['pipeline_stage_id'],
]);
$stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $crmData['pipeline_stage_id']);
}
$recordType = $businessProcess->recordTypes()->first();
$closeDate = null;
$isClosed = $crmData['status'] !== self::OPPORTUNITY_STATUS_OPEN;
if ($crmData['close_date']) {
$closeDate = Carbon::parse($crmData['close_date'])->format('Y-m-d');
}
$data = [
'team_id' => $this->team->getId(),
'account_id' => $account->id,
'user_id' => $profile->user_id ?? null,
'owner_id' => $crmData['assignee_id'],
'name' => mb_strimwidth($crmData['name'], 0, 128),
'value' => $crmData['monetary_value'],
'currency_code' => CurrencyFormatter::formatCode($crmData['monetary_unit']),
'close_date' => $closeDate,
'is_closed' => $isClosed,
'is_won' => $crmData['status'] === 'Won',
'stage_id' => $stage->id,
'record_type_id' => $recordType->id ?? null,
'remotely_created_at' => $crmData['date_created'],
];
/** @var Opportunity $opportunity */
$opportunity = $this->config
->opportunities()
->updateOrCreate(['crm_provider_id' => (string) $crmData['id']], $data)
;
// import external fields into crm_field_data if present
$dealInsightsFields = $this->getOpportunitySyncableFields();
$this->importOpportunityCrmFieldData($crmData, $dealInsightsFields, $opportunity->id);
return $opportunity;
}
/**
* @param array<string, mixed> $opportunityData
* @param string[] $dealInsightsFields
*/
public function importOpportunityCrmFieldData(
array $opportunityData,
array $dealInsightsFields,
int $opportunityId
): void {
foreach ($dealInsightsFields as $fieldName) {
$field = $this->config->fields()->with('entities')
->where('crm_provider_id', $fieldName)
->where('object_type', Field::OBJECT_OPPORTUNITY)
->first();
if (! $field instanceof Field) {
$this->logger->warning('Field not found', [
'field' => $fieldName,
'opportunity_id' => $opportunityId,
]);
continue;
}
$entity = $field->getEntities()->first();
if (! $entity instanceof LayoutEntity) {
continue;
}
$fieldValue = $this->extractFieldValue($opportunityData, $fieldName);
$normalizedValue = $this->normalizeValue($field->getType(), (string) $fieldValue, true);
$field->data()
->updateOrCreate([
'crm_layout_entity_id' => $entity->id,
'crm_field_id' => $field->id,
'object_type' => 'opportunity',
'object_id' => $opportunityId,
], ['value' => $normalizedValue])
;
}
}
/**
* @inheritdoc
*/
public function syncContacts(Carbon $since, ?Carbon $to = null): int
{
$syncCount = 0;
try {
$cpContacts = $this->copperClient->contacts()->paginate([
'minimum_interaction_date' => $since->timestamp,
'sort_by' => 'date_modified',
]);
foreach ($cpContacts as $cpContact) {
// Only sync if previously imported.
if ($this->hasContact($cpContact['id'])) {
$this->importContact($cpContact);
$syncCount++;
}
}
} catch (\Exception $exception) {
// Do nothing for now.
throw $exception;
}
return $syncCount;
}
/**
* @inheritdoc
*/
public function syncContact(string $crmId): ?Contact
{
$cpContact = $this->copperClient->contacts()->find($crmId);
if (empty($cpContact)) {
return null;
}
return $this->importContact($cpContact);
}
/**
* @inheritdoc
*/
private function importContact($crmData): Contact
{
$account = null;
if ($crmData['company_id']) {
$account = $this->team
->crm
->accounts()
->where('crm_provider_id', (string) $crmData['company_id'])
->first();
if ($account === null) {
$account = $this->syncAccount($crmData['company_id']);
}
}
$countryCode = null;
if (empty($crmData['address']) === false && $crmData['address']['country']) {
$countryCode = $this->convertCountryNameToCode($crmData['address']['country']);
}
$mobilePhone = $parsedNumber = null;
foreach ($crmData['phone_numbers'] as $phoneNumber) {
if ($phoneNumber['category'] === 'mobile') {
$mobilePhone = $this->buildContactMobilePhone($countryCode, $phoneNumber['number']);
} else {
$parsedNumber = $this->buildContactPhone($countryCode, $phoneNumber['number']);
}
}
$email = null;
if (empty($crmData['emails']) === false) {
$email = mb_strimwidth($crmData['emails'][0]['email'], 0, 80);
}
$profile = $this->config->profiles()->where('crm_provider_id', (string) $crmData['assignee_id'])->first();
$data = [
'team_id' => $this->team->id,
'account_id' => $account->id ?? null,
'user_id' => $profile ? $profile->user_id : null,
'owner_id' => $crmData['assignee_id'],
'name' => $crmData['name'],
'email' => $email,
'phone' => $parsedNumber['phone'] ?? null,
'mobile_phone' => $mobilePhone ?? null,
'ext' => $parsedNumber['ext'] ?? null,
'title' => $crmData['title'],
'country_code' => $countryCode,
'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(
crmConfiguration: $this->config,
crmProviderId: $crmData['id'],
modelType: Contact::class,
fileName: $crmData['id'],
avatarText: $crmData['name']
),
'remotely_created_at' => $crmData['date_created'],
];
return $this->config->contacts()->updateOrCreate(['crm_provider_id' => (string) $crmData['id']], $data);
}
private function buildContactPhone(?string $countryCode, ?string $number): ?array
{
if ($number) {
// Trim to our width and attempt to parse it.
$number = mb_strimwidth($number, 0, 25);
$parsedNumber = parsePhoneNumber($countryCode, $number);
} else {
$parsedNumber = [];
}
return $parsedNumber;
}
private function buildContactMobilePhone(?string $countryCode, ?string $number): ?string
{
return $number ? mb_strimwidth(phone_e164($countryCode, $number), 0, 25) : null;
}
/**
* @inheritdoc
*/
public function syncOrganization(): void
{
$organization = $this->copperClient->organisation()->all();
if ($this->config->crm_provider_id !== $organization['id']) {
$this->config->update(['crm_provider_id' => $organization['id']]);
}
}
private function syncCustomFields(): void
{
foreach ($this->getFieldTypes() as $fieldType) {
$currentFields = $this->getFields($fieldType);
foreach ($currentFields as $field) {
/** @var Field $field */
$field = $this->config->fields()->updateOrCreate([
'crm_provider_id' => $field['name'],
'object_type' => $fieldType,
], [
'label' => mb_strimwidth($field['label'], 0, Field::LABEL_MAX_LENGTH),
'type' => $field['type'],
]);
if ($field->getType() === Field::TYPE_PICKLIST) {
$this->importPicklistValues($field);
}
}
}
$this->config->save();
}
protected function getFieldTypes(): array
{
return [
parent::OBJECT_TASK,
parent::OBJECT_LEAD,
parent::OBJECT_ACCOUNT,
parent::OBJECT_CONTACT,
parent::OBJECT_OPPORTUNITY,
];
}
/**
* @inheritdoc
*/
public function syncProfiles(?User $userToSearch = null): ?Profile
{
$profiles = $this->copperClient->users()->all();
foreach ($profiles as $profile) {
$user = $this->userRepository->findByTeamAndEmailAddress($this->team, $profile['email'], onlyActive: true);
// Only import profiles for users we have locally.
if ($user) {
$profile = $user->crmProfile()->updateOrCreate([
'crm_configuration_id' => $this->config->id,
'crm_provider_id' => $profile['id'],
]);
if ($userToSearch && $userToSearch->id === $user->id) {
return $profile;
}
}
}
return null;
}
/**
* @inheritdoc
*/
public function syncProfileFields(): void
{
$fieldTypes = [
'task',
'lead',
'account',
'contact',
'opportunity',
];
foreach ($fieldTypes as $fieldType) {
try {
$currentFields = $this->getFields(ucfirst($fieldType));
$newFields = array_column($currentFields, 'name');
$changedFields = array_merge(
array_diff($this->profile->getFieldsAsArray($fieldType), $newFields),
array_diff($newFields, $this->profile->getFieldsAsArray($fieldType))
);
if (\count($changedFields) > 0) {
$fields = implode(',', $newFields);
$this->logger->debug(
ucfirst($fieldType)
. ' fields changed from '
. $this->profile->{$fieldType . '_fields'}
. ' to: '
. $fields
);
$this->profile->{$fieldType . '_fields'} = $fields;
}
} catch (\Exception $exception) {
$this->logger->error('No access to ' . $fieldType . ' object, skipping...');
continue;
}
}
$this->profile->save();
}
/**
* @inheritdoc
*/
public function find(string $name, array $scopes): array
{
$count = $this->limit ?? 20;
$offset = $this->offset ?? 0;
$scopeValues = array_map(fn ($scope) => is_string($scope) ? $scope : $scope->value, $scopes);
$cacheKey = sprintf('copper-search-%d-%s-%s-%d-%d', $this->team->id, $name, implode('-', $scopeValues), $count, $offset);
return Cache::remember($cacheKey, 300, function () use ($name, $scopes, $count, $offset) {
// Try local search first
$localResults = $this->searchLocally($name, $scopes, $count, $offset);
// If no local results, fall back to remote API search
if (empty($localResults)) {
return $this->searchRemotely($name, $scopes, $count, $offset);
}
return $localResults;
});
}
/**
* @inheritdoc
*/
public function findOpportunities(?string $crmAccountId, ?string $crmContactId, ?int $userId = null): array
{
$data = [];
$ownerData = [];
$ownerId = null;
$contact = null;
$account = null;
if ($crmAccountId) {
$account = $this->config->accounts()->where('crm_provider_id', $crmAccountId)->first();
if ($account === null) {
$account = $this->syncAccount($crmAccountId);
}
}
if ($crmContactId) {
$contact = $this->config->contacts()->where('crm_provider_id', $crmContactId)->first();
if ($contact === null) {
$contact = $this->syncContact($crmContactId);
}
}
if ($contact || $account) {
if ($contact && $account === null) {
$account = $contact->account;
}
if ($account === null) {
return [];
}
$params = [
'company_ids' => [$account->crm_provider_id],
'status_ids' => [0], // The integer values are 0, 1, 2, 3, for "Open", "Won", "Lost", and "Abandoned".
'sort_by' => 'date_modified',
'sort_direction' => 'desc',
];
switch ($this->config->opportunity_assignment_rule) {
case Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_UPDATED:
$params['sort_by'] = 'date_modified';
$params['sort_direction'] = 'desc';
break;
case Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_CREATED:
$params['sort_by'] = 'date_created';
$params['sort_direction'] = 'desc';
break;
case Configuration::OPP_ASSIGNMENT_ALL_OPEN_OLDEST_CREATED:
$params['sort_by'] = 'date_created';
$params['sort_direction'] = 'asc';
break;
case Configuration::OPP_ASSIGNMENT_ALL_RECENTLY_UPDATED:
$params['sort_by'] = 'date_modified';
$params['sort_direction'] = 'desc';
$params['status_ids'] = [0, 1, 2, 3];
}
$cpOpportunities = $this->copperClient->opportunities()->search($params);
if ($userId) {
$profileRepository = app(ProfileRepository::class);
$profile = $profileRepository->findProfileByUserId($this->config, $userId);
$ownerId = $profile instanceof Profile ? $profile->getCrmProviderId() : null;
}
foreach ($cpOpportunities as $cpOpportunity) {
$businessProcess = $this->config
->businessProcesses()
->where('crm_provider_id', (string) $cpOpportunity['pipeline_id'])
->first();
if ($businessProcess === null) {
// Import it.
$businessProcess = $this->importStages(
[Stage::TYPE_OPPORTUNITY],
$cpOpportunity['pipeline_stage_id']
);
}
$stage = $businessProcess
->stages()
->where('crm_provider_id', (string) $cpOpportunity['pipeline_stage_id'])
->where('type', Stage::TYPE_OPPORTUNITY)
->first();
if ($stage === null) {
// Import it.
$stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $cpOpportunity['pipeline_stage_id']);
}
$recordType = $businessProcess->recordTypes()->first();
$record = [
'crmId' => $cpOpportunity['id'],
'name' => $cpOpportunity['name'],
'value' => currency_format($cpOpportunity['monetary_value'], $cpOpportunity['monetary_unit']),
'won' => $cpOpportunity['status'] === 'Won',
'stage' => [
'id' => $stage->id_string,
'name' => $stage->name,
],
'recordType' => [
'id' => $recordType->id_string,
'name' => $recordType->name,
],
];
if ($ownerId && isset($cpOpportunity['assignee_id']) && $cpOpportunity['assignee_id'] === $ownerId) {
$ownerData[] = $record;
}
$data[] = $record;
}
}
if (! empty($ownerData)) {
return $ownerData;
}
return $data;
}
/**
* @inheritdoc
*/
public function getTasks(string $objectType, string $objectId, ?string $opportunityId): array
{
$data = $cpTasks = [];
switch ($objectType) {
case 'lead':
$cpTasks = $this->requestWithExceptionHandling(
fn () => $this->copperClient->leads()->relatedTasks($objectId)
);
break;
case 'account':
$cpTasks = $this->copperClient->accounts()->relatedTasks($objectId);
break;
case 'contact':
if ($opportunityId) {
$cpTasks = $this->copperClient->opportunities()->relatedTasks($opportunityId);
} elseif ($objectType === 'contact') {
$cpTasks = $this->copperClient->contacts()->relatedTasks($objectId);
}
break;
}
foreach ($cpTasks as $cpTask) {
// We only get the ID itself returned, so pull out the full object.
try {
$cpTask = $this->copperClient->tasks()->find($cpTask['id']);
} catch (Exception $exception) {
$this->logger->error('[Copper] Error fetching task', [
'resource_id' => $cpTask['id'],
'error' => $exception->getMessage(),
]);
continue;
}
// Only allow open tasks.
if ($cpTask['status'] === 'Open') {
$data[] = [
'crmId' => $cpTask['id'],
'subject' => $cpTask['name'],
'due' => $cpTask['due_date']
? Carbon::createFromTimestamp($cpTask['due_date'])->format('Y-m-d\TH:i:sO')
: null,
'type' => null,
];
}
}
return $data;
}
/**
* Try to find email address in CRM service
*
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
*}
*/
public function matchExactlyByEmail(string $email, ?int $userId = null): ?array
{
$cpObjectType = 'contact';
$cpObjects = $this->copperClient->contacts()->search([
'emails' => $email,
'sort_by' => 'date_modified',
'sort_direction' => 'desc',
]);
if (empty($cpObjects)) {
$cpObjectType = 'lead';
$cpObjects = $this->requestWithExceptionHandling(
fn () => $this->copperClient->leads()->search([
'emails' => ...
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"bounds":{"left":0.2962101,"top":1.0,"width":0.03856383,"height":-0.019952059},"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"#12066 on JY-20725-handle-HS-search-rate-limit, menu","depth":5,"bounds":{"left":0.33477393,"top":1.0,"width":0.122340426,"height":-0.019952059},"on_screen":true,"help_text":"Pull request #12066 exists for current branch JY-20725-handle-HS-search-rate-limit, but local branch is out of sync with remote","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"bounds":{"left":0.5731383,"top":1.0,"width":0.011303191,"height":-0.019952059},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"HandleHubspotRateLimitTest","depth":6,"bounds":{"left":0.5884308,"top":1.0,"width":0.076130316,"height":-0.019952059},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'HandleHubspotRateLimitTest'","depth":6,"bounds":{"left":0.66456115,"top":1.0,"width":0.011303191,"height":-0.019952059},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'HandleHubspotRateLimitTest'","depth":6,"bounds":{"left":0.67586434,"top":1.0,"width":0.011303191,"height":-0.019952059},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"bounds":{"left":0.6871675,"top":1.0,"width":0.011303191,"height":-0.019952059},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"bounds":{"left":0.7150931,"top":1.0,"width":0.011303191,"height":-0.019952059},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"bounds":{"left":0.72639626,"top":1.0,"width":0.011303191,"height":-0.019952059},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"IDE and Project Settings","depth":5,"bounds":{"left":0.73769945,"top":1.0,"width":0.011303191,"height":-0.019952059},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.042220745,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"11","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"72","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"1","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"4","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\nnamespace Jiminny\\Services\\Crm\\Copper;\n\nuse Carbon\\Carbon;\nuse Exception;\nuse GuzzleHttp\\Exception\\ClientException;\nuse Illuminate\\Support\\Facades\\Cache;\nuse Jiminny\\Contracts\\Services\\Crm\\LayoutManagementInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\MatchCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\Provider\\CopperInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityLookupInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityManipulationInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SettingsInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmMetadataInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\VerifyTaskExistsInterface;\nuse Jiminny\\Exceptions\\CrmException;\nuse Jiminny\\Exceptions\\HttpNotFoundException;\nuse Jiminny\\Models\\Account;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Contact;\nuse Jiminny\\Models\\Crm\\BusinessProcess;\nuse Jiminny\\Models\\Crm\\Configuration;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Models\\Crm\\FieldData;\nuse Jiminny\\Models\\Crm\\FieldValue;\nuse Jiminny\\Models\\Crm\\LayoutEntity;\nuse Jiminny\\Models\\Crm\\Profile;\nuse Jiminny\\Models\\Lead;\nuse Jiminny\\Models\\Opportunity;\nuse Jiminny\\Models\\Playbook;\nuse Jiminny\\Models\\PlaybookCategory;\nuse Jiminny\\Models\\SocialAccount;\nuse Jiminny\\Models\\Stage;\nuse Jiminny\\Models\\User;\nuse Jiminny\\Repositories\\Crm\\CrmEntityRepository;\nuse Jiminny\\Repositories\\Crm\\FieldRepository;\nuse Jiminny\\Repositories\\Crm\\ProfileRepository;\nuse Jiminny\\Repositories\\UserRepository;\nuse Jiminny\\Services\\Avatar\\ProspectPhotoPathService;\nuse Jiminny\\Services\\Crm\\BaseService;\nuse Jiminny\\Services\\Crm\\Copper\\Api\\Client;\nuse Jiminny\\Services\\Crm\\Copper\\Api\\ClientFactory;\nuse Jiminny\\Services\\Crm\\Copper\\Exception\\RequestFailedException;\nuse Jiminny\\Services\\Crm\\Helpers\\FilterJoinedParticipants;\nuse Jiminny\\Services\\Crm\\OpportunitySyncStrategyResolver;\nuse Jiminny\\Services\\Crm\\Copper\\Traits\\SearchesCrmEntities;\nuse Jiminny\\Utils\\CurrencyFormatter;\nuse Jiminny\\Utils\\StringUtil;\n\nclass Service extends BaseService implements\n CopperInterface,\n SyncCrmEntitiesInterface,\n SyncCrmMetadataInterface,\n RemoteEntityManipulationInterface,\n LayoutManagementInterface,\n SettingsInterface,\n MatchCrmEntitiesInterface,\n RemoteEntityLookupInterface,\n VerifyTaskExistsInterface\n{\n use SearchesCrmEntities;\n private const int NOTE_BODY_MAX_LENGTH = 3000000;\n\n private const int TASK_VERIFICATION_CACHE_TTL = 86400; // 1 day\n\n private const array STANDARD_OPPORTUNITY_FIELDS = [\n 'monetary_value' => [\n 'label' => 'Value',\n 'available_on' => Field::OBJECT_OPPORTUNITY,\n 'type' => Field::TYPE_CURRENCY,\n ],\n 'pipeline_stage_id' => [\n 'label' => 'Stage',\n 'type' => Field::TYPE_PICKLIST,\n 'available_on' => Field::OBJECT_OPPORTUNITY,\n ],\n 'close_date' => [\n 'label' => 'Close date',\n 'type' => Field::TYPE_DATE,\n 'available_on' => Field::OBJECT_OPPORTUNITY,\n ],\n 'win_probability' => [\n 'label' => 'Win probability',\n 'type' => Field::TYPE_NUMBER,\n 'available_on' => Field::OBJECT_OPPORTUNITY,\n ],\n ];\n private const string UNSUPPORTED_OBJECT_TYPE = 'unsupported';\n private const string OPPORTUNITY_STATUS_OPEN = 'Open';\n private const string OPPORTUNITY_IMPORT_FAILED_ERROR_MESSAGE = 'Opportunity import failed';\n private const string RESOURCE_UPDATE_FAILED_ERROR_MESSAGE = 'Failed to update resource';\n\n private FieldValueConverter $fieldValueConverter;\n private ClientFactory $clientFactory;\n private ?Client $copperClient = null;\n\n public function __construct(\n ClientFactory $clientFactory,\n FieldValueConverter $fieldValueConverter,\n private readonly UserRepository $userRepository,\n private readonly ProspectPhotoPathService $prospectPhotoPathService,\n private readonly CrmEntityRepository $crmEntityRepository\n ) {\n parent::__construct();\n\n $this->clientFactory = $clientFactory;\n $this->fieldValueConverter = $fieldValueConverter;\n }\n\n public function getDisplayName(): string\n {\n return 'Copper';\n }\n\n /**\n * Get the CRM entity repository (used by SearchesCrmEntities trait)\n */\n protected function getCrmEntityRepository(): CrmEntityRepository\n {\n return $this->crmEntityRepository;\n }\n\n public function getMinimumApiVersion(): string\n {\n return Client::MIN_API_VERSION;\n }\n\n protected function getOAuthAccount(User $user): ?SocialAccount\n {\n // In this case, the Account Owner is always the connection for any API operations.\n $owner = $user->team->owner;\n\n return $owner->getSocialAccount(SocialAccount::PROVIDER_COPPER);\n }\n\n public function setUser(User $user): void\n {\n $this->configureSentryScope($user);\n\n $this->team = $user->getTeam();\n $this->teamService->setTeam($user->getTeam());\n $this->setConfiguration($user->getTeam()->getCrmConfiguration());\n\n // Reset state.\n $this->profile = $user->getProfile();\n\n if (! $this->profile instanceof Profile) {\n // Try to fetch their profile, maybe it was not yet imported?\n $this->profile = $this->getProfile($user);\n }\n }\n\n public function setConfiguration(Configuration $configuration): void\n {\n $this->config = $configuration;\n $this->copperClient = $this->clientFactory->create($configuration);\n\n if ($this->team === null) {\n $this->team = $configuration->getTeam();\n }\n }\n\n public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string\n {\n return $this->fieldValueConverter->convertFromCrm($fieldType, $fieldValue);\n }\n\n private function mapObjectType(string $objectType): string\n {\n $objectType = strtolower($objectType);\n\n switch ($objectType) {\n case Field::OBJECT_TASK:\n case Field::OBJECT_OPPORTUNITY:\n case Field::OBJECT_LEAD:\n $mappedType = $objectType;\n\n break;\n case Field::OBJECT_ACCOUNT:\n $mappedType = 'company';\n\n break;\n case Field::OBJECT_CONTACT:\n $mappedType = 'person';\n\n break;\n default:\n $mappedType = self::UNSUPPORTED_OBJECT_TYPE;\n\n break;\n }\n\n return $mappedType;\n }\n\n /**\n * @param string $crmObject The name of the CRM object. i.e. Opportunity or Contact\n */\n protected function getFields(string $crmObject): array\n {\n $mappedObjectType = $this->mapObjectType($crmObject);\n\n if (! $this->isSupportedObjectType($mappedObjectType)) {\n return [];\n }\n\n $customFields = $this->copperClient->fields()->all();\n\n $filteredFields = array_filter(\n $customFields,\n static fn ($field) => in_array($mappedObjectType, $field['available_on'], true)\n );\n\n return array_map(\n fn (array $property) => [\n 'name' => $property['id'],\n 'label' => $property['name'],\n 'type' => $this->convertFieldType($property['data_type']),\n ],\n $filteredFields\n );\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultFields(string $activityType): array\n {\n $fields = [];\n\n if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {\n $defaultFields = FieldDefinitions::defaultTaskFields();\n\n // This lazy creates these fields if not already setup.\n foreach ($defaultFields as $defaultField) {\n $fields[] = $this->config->fields()->firstOrCreate($defaultField);\n }\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultActivityField(string $activityType): Field\n {\n // Setup the activity field as the default Type.\n $activityField = $this->config->fields()->where([\n 'crm_provider_id' => 'type',\n 'object_type' => $activityType,\n ])->first();\n\n return $activityField;\n }\n\n /**\n * @inheritdoc\n */\n public function getSupportedPlaybookTypes(): array\n {\n return [Playbook::ACTIVITY_TYPE_TASK];\n }\n\n public function getDealInsightsFields(): array\n {\n return FieldDefinitions::dealInsightsFields();\n }\n\n protected function getDefaultFollowupLayoutFields(string $activityType): array\n {\n $fields = [];\n $fieldRepo = app(FieldRepository::class);\n $fieldFilters = FieldDefinitions::taskFollowupFieldsFilter();\n\n foreach ($fieldFilters as $data) {\n $field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $data);\n\n // Only add the field if it is created, which it should be.\n if ($field) {\n $fields[] = $field;\n }\n }\n\n return $fields;\n }\n\n public function syncFields(): void\n {\n $this->syncStandardFields();\n $this->syncCustomFields();\n }\n\n public function syncField(Field $field): void\n {\n if ($field->getCrmProviderId() === 'type') {\n /**\n * This field is a special case, it is not a custom field and is not returned by the Copper API.\n *\n * The field is created manually during layout setup.\n *\n * @see getDefaultFields()\n *\n * There is a separate endpoint to get the activity types.\n * @see importPicklistValues()\n */\n return;\n }\n\n if ($field->getCrmProviderId() === 'pipeline_stage_id') {\n // Not a custom type\n return;\n }\n\n $crmField = $this->copperClient->fields()->find($field->getCrmProviderId());\n $field->label = $crmField['name'];\n $field->type = $this->convertFieldType($crmField['data_type']);\n\n $field->save();\n }\n\n /**\n * @param string $fieldType How the data should be input\n */\n private function convertFieldType(string $fieldType): string\n {\n $fieldType = strtolower($fieldType);\n\n switch ($fieldType) {\n case 'dropdown':\n $convertedType = Field::TYPE_PICKLIST;\n\n break;\n case 'string':\n $convertedType = Field::TYPE_TEXT;\n\n break;\n case 'float':\n case 'percentage':\n $convertedType = Field::TYPE_NUMBER;\n\n break;\n case 'text':\n $convertedType = Field::TYPE_TEXTAREA;\n\n break;\n case 'checkbox':\n $convertedType = Field::TYPE_BOOLEAN;\n\n break;\n case Field::TYPE_DATE:\n case Field::TYPE_CURRENCY:\n case Field::TYPE_URL:\n $convertedType = $fieldType;\n\n break;\n default:\n $convertedType = Field::TYPE_UNSUPPORTED;\n\n break;\n }\n\n return $convertedType;\n }\n\n /**\n * @inheritdoc\n */\n public function importPicklistValues(Field $field): array\n {\n $fieldValues = [];\n\n if ($field->object_type === 'opportunity' && $field->crm_provider_id === 'pipeline_stage_id') {\n return $this->importFieldValuesForDealInsights($field);\n }\n\n $values = [];\n\n if ($field->object_type === Field::OBJECT_TASK && $field->crm_provider_id === 'type') {\n $activityTypes = $this->copperClient->activityTypes()->all();\n\n /**\n * We ignore \"system\" activity types, disabled activity types and the \"Note\" activity type.\n */\n $activityTypes = array_filter(\n $activityTypes['user'],\n fn (array $activityType) => ! $activityType['is_disabled'] && $activityType['id'] !== 0\n );\n\n // Import all activity values.\n $i = 0;\n foreach ($activityTypes as $activityType) {\n $values[] = [\n 'value' => $activityType['id'],\n 'label' => mb_strimwidth($activityType['name'], 0, Field::LABEL_MAX_LENGTH),\n 'sequence' => $i++,\n ];\n }\n } else {\n // We just check for the field in the custom definitions.\n $crmField = $this->copperClient->fields()->find($field->crm_provider_id);\n\n // Import all values.\n foreach ($crmField['options'] as $option) {\n $values[] = [\n 'value' => $option['id'],\n 'label' => mb_strimwidth($option['name'], 0, 191),\n 'sequence' => $option['rank'],\n ];\n }\n }\n\n // Take the existing set of field values.\n $fieldsToPurge = $field->values()->get()->pluck('value')->toArray();\n\n foreach ($values as $value) {\n // For all current values, upsert the field.\n $value['value'] = substr($value['value'] ?? '', 0, 255);\n $fieldValues[] = $field->values()->updateOrCreate([\n 'value' => $value['value'],\n ], $value);\n\n // Remove this value from the ones we are going to purge.\n $key = array_search($value['value'], $fieldsToPurge, false);\n if ($key !== false) {\n unset($fieldsToPurge[$key]);\n }\n }\n\n // Delete the old values that are no longer used.\n $field->values()->whereIn('value', $fieldsToPurge)->delete();\n\n return $fieldValues;\n }\n\n /**\n *\n * @return FieldValue[]\n */\n private function importFieldValuesForDealInsights(Field $field): array\n {\n $fieldValues = [];\n $pipelines = $this->copperClient->pipelines()->all();\n\n foreach ($pipelines as $pipeline) {\n foreach ($pipeline['stages'] as $i => $dealStage) {\n $fieldValues[] = $field->values()->updateOrCreate(\n ['value' => $dealStage['id']],\n [\n 'label' => $dealStage['name'],\n 'sequence' => $i,\n ]\n );\n }\n }\n\n return $fieldValues;\n }\n\n /**\n * @inheritdoc\n */\n public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage\n {\n if ($types === null) {\n $types = [Stage::TYPE_LEAD, Stage::TYPE_OPPORTUNITY];\n }\n $missingStage = null;\n\n foreach ($types as $type) {\n if ($type === Stage::TYPE_LEAD) {\n $statuses = $this->requestWithExceptionHandling(\n fn () => $this->copperClient->leadStatuses()->all()\n );\n\n if (count($statuses) > 0) {\n $businessProcess = $this->config->businessProcesses()->updateOrCreate([\n 'crm_provider_id' => BusinessProcess::TYPE_LEAD,\n ], [\n 'team_id' => $this->team->id,\n 'name' => BusinessProcess::TYPE_LEAD,\n 'type' => BusinessProcess::TYPE_LEAD,\n 'is_selectable' => true,\n ]);\n\n // A record type is really a clone of the business process, used to store which record uses which pipeline.\n $this->config->recordTypes()->updateOrCreate([\n 'crm_provider_id' => BusinessProcess::TYPE_LEAD,\n ], [\n 'team_id' => $this->team->id,\n 'name' => BusinessProcess::TYPE_LEAD,\n 'is_selectable' => true,\n 'business_process_id' => $businessProcess->id ?? null,\n ]);\n\n $leadStatuses = [];\n foreach ($statuses as $status) {\n // Upsert all statuses for the team.\n $status = $this->config->stages()->updateOrCreate([\n 'crm_provider_id' => $status['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($status['name'], 0, 50),\n 'label' => mb_strimwidth($status['name'], 0, 191),\n 'type' => Stage::TYPE_LEAD,\n 'sequence' => $status['order'],\n 'is_selectable' => true,\n ]);\n\n if ($missingStageName && $missingStageName === $status['id']) {\n $missingStage = $status;\n }\n\n $leadStatuses[] = $status['id'];\n }\n\n $businessProcess->stages()->sync($leadStatuses);\n }\n } else {\n // We create a business process to contain the pipeline, and later store all stages against it.\n foreach ($this->copperClient->pipelines()->all() as $pipeline) {\n $businessProcess = $this->config->businessProcesses()->updateOrCreate([\n 'crm_provider_id' => $pipeline['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($pipeline['name'], 0, 150),\n 'type' => BusinessProcess::TYPE_OPPORTUNITY,\n 'is_selectable' => true,\n ]);\n\n // A record type is really a clone of the business process, used to store which record uses which pipeline.\n $this->config->recordTypes()->updateOrCreate([\n 'crm_provider_id' => $pipeline['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($pipeline['name'], 0, 150),\n 'is_selectable' => true,\n 'business_process_id' => $businessProcess->id ?? null,\n ]);\n\n $stages = [];\n\n foreach ($pipeline['stages'] as $i => $dealStage) {\n // Upsert all stages for the team.\n $stage = $this->config->stages()->updateOrCreate([\n 'crm_provider_id' => $dealStage['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($dealStage['name'], 0, 50),\n 'label' => mb_strimwidth($dealStage['name'], 0, 191),\n 'type' => Stage::TYPE_OPPORTUNITY,\n 'sequence' => $i,\n 'is_selectable' => true,\n 'probability' => $dealStage['win_probability'],\n ]);\n\n if ($missingStageName && $missingStageName === $dealStage['id']) {\n $missingStage = $stage;\n }\n\n $stages[] = $stage['id'];\n }\n\n $businessProcess->stages()->sync($stages);\n }\n }\n }\n\n return $missingStage;\n }\n\n /**\n * @inheritdoc\n */\n public function syncLeads(Carbon $since, ?Carbon $to = null, ?string $crmProfileId = null): int\n {\n $syncCount = 0;\n\n $cpLeads = $this->requestWithExceptionHandling(\n fn () => $this->copperClient->leads()->paginate([\n 'minimum_modified_date' => $since->timestamp,\n 'sort_by' => 'date_modified',\n ])\n );\n\n foreach ($cpLeads as $cpLead) {\n // Only sync if previously imported.\n if ($this->hasLead($cpLead['id']) === false) {\n $this->importLead($cpLead);\n $syncCount++;\n }\n }\n\n return $syncCount;\n }\n\n /**\n * @inheritdoc\n */\n public function syncLead(string $crmId): ?Lead\n {\n $cpLead = $this->requestWithExceptionHandling(\n fn () => $this->copperClient->leads()->find($crmId)\n );\n\n if (\\count($cpLead) === 0) {\n return null;\n }\n\n return $this->importLead($cpLead);\n }\n\n /**\n * @inheritdoc\n */\n private function importLead($crmData): ?Lead\n {\n $countryCode = null;\n if (! empty($crmData['address']) && isset($crmData['address']['country'])) {\n $countryCode = $this->convertCountryNameToCode($crmData['address']['country']);\n }\n\n $mobilePhone = $parsedNumber = null;\n if (! empty($crmData['phone_numbers'])) {\n foreach ($crmData['phone_numbers'] as $phoneNumber) {\n if ($phoneNumber['category'] === 'mobile') {\n $mobilePhone = $this->buildContactMobilePhone($countryCode, $phoneNumber['number']);\n } else {\n $parsedNumber = $this->buildContactPhone($countryCode, $phoneNumber['number']);\n }\n }\n }\n\n $name = $crmData['name'];\n\n if (empty($crmData['company_name'])) {\n $company = 'Unknown';\n } else {\n $company = mb_strimwidth($crmData['company_name'], 0, 191);\n }\n\n $domain = null;\n if (empty($crmData['websites']) === false) {\n $domain = mb_strimwidth($crmData['websites'][0]['url'], 0, 191);\n $domain = StringUtil::resolveDomain($domain);\n }\n\n $email = null;\n if (empty($crmData['email']) === false) {\n $email = mb_strimwidth($crmData['email']['email'], 0, 80);\n }\n\n $stage = $this->config\n ->stages()\n ->where('crm_provider_id', (string) $crmData['status_id'])\n ->where('type', Stage::TYPE_LEAD)\n ->first();\n\n if ($stage === null) {\n // Import it.\n $stage = $this->importStages([Stage::TYPE_LEAD], $crmData['status_id']);\n }\n\n if (! $stage instanceof Stage) {\n return null;\n }\n\n $profile = $this->config->profiles()->where('crm_provider_id', (string) $crmData['assignee_id'])->first();\n\n $data = [\n 'team_id' => $this->team->id,\n 'user_id' => $profile ? $profile->user_id : null,\n 'owner_id' => $crmData['assignee_id'],\n 'name' => mb_strimwidth($name, 0, 191),\n 'company' => $company,\n 'domain' => $domain,\n 'title' => $crmData['title'] ? mb_strimwidth($crmData['title'], 0, 128) : null,\n 'email' => $email,\n 'phone' => $parsedNumber['phone'] ?? null,\n 'ext' => $parsedNumber['ext'] ?? null,\n 'mobile_phone' => $mobilePhone,\n 'stage_id' => $stage->id,\n 'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(\n crmConfiguration: $this->config,\n crmProviderId: $crmData['id'],\n modelType: Lead::class,\n fileName: $crmData['id'],\n avatarText: $crmData['name']\n ),\n 'country_code' => $countryCode,\n 'remotely_created_at' => $crmData['date_created'],\n ];\n\n return $this->config->leads()->updateOrCreate(['crm_provider_id' => (string) $crmData['id']], $data);\n }\n\n /**\n * @inheritdoc\n */\n public function syncAccounts(Carbon $since, ?Carbon $to = null): int\n {\n $syncCount = 0;\n\n try {\n $cpAccounts = $this->copperClient->accounts()->paginate([\n 'minimum_modified_date' => $since->timestamp,\n 'sort_by' => 'date_modified',\n ]);\n\n foreach ($cpAccounts as $cpAccount) {\n // Only sync if previously imported.\n if ($this->hasAccount($cpAccount['id'])) {\n $this->importAccount($cpAccount);\n $syncCount++;\n }\n }\n } catch (\\Exception $exception) {\n // Do nothing for now.\n }\n\n return $syncCount;\n }\n\n /**\n * @inheritdoc\n */\n public function syncAccount(string $crmId): ?Account\n {\n $cpAccount = $this->copperClient->accounts()->find($crmId);\n\n if (empty($cpAccount)) {\n return null;\n }\n\n return $this->importAccount($cpAccount);\n }\n\n /**\n * @inheritdoc\n */\n private function importAccount($crmData): Account\n {\n $countryCode = null;\n if (empty($crmData['address']) === false && $crmData['address']['country']) {\n $countryCode = $this->convertCountryNameToCode($crmData['address']['country']);\n }\n\n $name = $crmData['name'];\n\n $parsedNumber = null;\n foreach ($crmData['phone_numbers'] as $phoneNumber) {\n $parsedNumber = parsePhoneNumber($countryCode, $phoneNumber['number']);\n }\n\n $domain = null;\n if (empty($crmData['websites']) === false) {\n $domain = mb_strimwidth($crmData['websites'][0]['url'], 0, 191);\n $domain = StringUtil::resolveDomain($domain);\n }\n\n $profile = $this->config->profiles()->where('crm_provider_id', (string) $crmData['assignee_id'])->first();\n\n $data = [\n 'team_id' => $this->team->id,\n 'user_id' => $profile ? $profile->user_id : null,\n 'owner_id' => $crmData['assignee_id'],\n 'name' => mb_strimwidth($name, 0, 191),\n 'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(\n crmConfiguration: $this->config,\n crmProviderId: $crmData['id'],\n modelType: Account::class,\n fileName: $crmData['id'],\n avatarText: $crmData['name']\n ),\n 'country_code' => $countryCode,\n 'domain' => $domain,\n 'phone' => $parsedNumber['phone'] ?? null,\n 'ext' => $parsedNumber['ext'] ?? null,\n 'remotely_created_at' => $crmData['date_created'],\n ];\n\n return $this->config->accounts()->updateOrCreate(['crm_provider_id' => (string) $crmData['id']], $data);\n }\n\n public function syncOpportunities(array $parameters, ?string $strategy = null): int\n {\n $strategyResolver = app(OpportunitySyncStrategyResolver::class, [\n 'client' => $this->copperClient,\n ]);\n $strategies = $strategyResolver->getStrategies($this->config, $strategy);\n\n $syncCount = 0;\n\n try {\n foreach ($strategies as $syncStrategy) {\n foreach ($syncStrategy->fetchOpportunities($parameters) as $opportunityData) {\n $this->importOpportunity($opportunityData);\n $syncCount++;\n }\n }\n } catch (\\Exception $exception) {\n // Do nothing for now.\n $this->logger->error(self::OPPORTUNITY_IMPORT_FAILED_ERROR_MESSAGE, [\n 'error' => $exception->getMessage(),\n ]);\n }\n\n return $syncCount;\n }\n\n public function syncOpportunity(string $crmId): ?Opportunity\n {\n $strategyResolver = app(OpportunitySyncStrategyResolver::class, [\n 'client' => $this->copperClient,\n ]);\n $strategy = $strategyResolver->resolve(\n $this->config,\n OpportunitySyncStrategyResolver::SINGLE_SYNC_OPPORTUNITY_STRATEGY\n );\n\n $parameters['crm_id'] = $crmId;\n\n $cpOpportunity = $strategy->fetchOpportunities($parameters);\n\n return $this->importOpportunity($cpOpportunity);\n }\n\n private function importOpportunity($crmData): ?Opportunity\n {\n $companyId = (string) $crmData['company_id'];\n\n if (empty($companyId)) {\n $this->logger->error(self::OPPORTUNITY_IMPORT_FAILED_ERROR_MESSAGE, [\n 'error' => 'No company id',\n ]);\n\n return null;\n }\n\n $account = $this->config->accounts()->where('crm_provider_id', $companyId)->first();\n\n if ($account === null) {\n $account = $this->syncAccount($companyId);\n }\n\n $profile = $this->config->profiles()->where('crm_provider_id', (string) $crmData['assignee_id'])->first();\n\n $businessProcess = $this->config\n ->businessProcesses()\n ->where('crm_provider_id', (string) $crmData['pipeline_id'])\n ->first();\n\n if ($businessProcess === null) {\n // It is possible for some deals to not be in a pipeline.\n $this->logger->warning(self::OPPORTUNITY_IMPORT_FAILED_ERROR_MESSAGE, [\n 'error' => 'Missing pipeline',\n ]);\n\n return null;\n }\n\n $stage = $businessProcess\n ->stages()\n ->where('crm_provider_id', (string) $crmData['pipeline_stage_id'])\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->first();\n\n if ($stage === null) {\n $this->logger->info('Stage not found, importing', [\n 'pipeline_id' => $crmData['pipeline_id'],\n 'stage_id' => $crmData['pipeline_stage_id'],\n ]);\n\n $stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $crmData['pipeline_stage_id']);\n }\n\n $recordType = $businessProcess->recordTypes()->first();\n $closeDate = null;\n $isClosed = $crmData['status'] !== self::OPPORTUNITY_STATUS_OPEN;\n\n if ($crmData['close_date']) {\n $closeDate = Carbon::parse($crmData['close_date'])->format('Y-m-d');\n }\n\n $data = [\n 'team_id' => $this->team->getId(),\n 'account_id' => $account->id,\n 'user_id' => $profile->user_id ?? null,\n 'owner_id' => $crmData['assignee_id'],\n 'name' => mb_strimwidth($crmData['name'], 0, 128),\n 'value' => $crmData['monetary_value'],\n 'currency_code' => CurrencyFormatter::formatCode($crmData['monetary_unit']),\n 'close_date' => $closeDate,\n 'is_closed' => $isClosed,\n 'is_won' => $crmData['status'] === 'Won',\n 'stage_id' => $stage->id,\n 'record_type_id' => $recordType->id ?? null,\n 'remotely_created_at' => $crmData['date_created'],\n ];\n\n /** @var Opportunity $opportunity */\n $opportunity = $this->config\n ->opportunities()\n ->updateOrCreate(['crm_provider_id' => (string) $crmData['id']], $data)\n ;\n\n // import external fields into crm_field_data if present\n $dealInsightsFields = $this->getOpportunitySyncableFields();\n $this->importOpportunityCrmFieldData($crmData, $dealInsightsFields, $opportunity->id);\n\n return $opportunity;\n }\n\n /**\n * @param array<string, mixed> $opportunityData\n * @param string[] $dealInsightsFields\n */\n public function importOpportunityCrmFieldData(\n array $opportunityData,\n array $dealInsightsFields,\n int $opportunityId\n ): void {\n foreach ($dealInsightsFields as $fieldName) {\n $field = $this->config->fields()->with('entities')\n ->where('crm_provider_id', $fieldName)\n ->where('object_type', Field::OBJECT_OPPORTUNITY)\n ->first();\n\n if (! $field instanceof Field) {\n $this->logger->warning('Field not found', [\n 'field' => $fieldName,\n 'opportunity_id' => $opportunityId,\n ]);\n\n continue;\n }\n\n $entity = $field->getEntities()->first();\n\n if (! $entity instanceof LayoutEntity) {\n continue;\n }\n\n $fieldValue = $this->extractFieldValue($opportunityData, $fieldName);\n $normalizedValue = $this->normalizeValue($field->getType(), (string) $fieldValue, true);\n\n $field->data()\n ->updateOrCreate([\n 'crm_layout_entity_id' => $entity->id,\n 'crm_field_id' => $field->id,\n 'object_type' => 'opportunity',\n 'object_id' => $opportunityId,\n ], ['value' => $normalizedValue])\n ;\n }\n }\n\n /**\n * @inheritdoc\n */\n public function syncContacts(Carbon $since, ?Carbon $to = null): int\n {\n $syncCount = 0;\n\n try {\n $cpContacts = $this->copperClient->contacts()->paginate([\n 'minimum_interaction_date' => $since->timestamp,\n 'sort_by' => 'date_modified',\n ]);\n\n foreach ($cpContacts as $cpContact) {\n // Only sync if previously imported.\n if ($this->hasContact($cpContact['id'])) {\n $this->importContact($cpContact);\n $syncCount++;\n }\n }\n } catch (\\Exception $exception) {\n // Do nothing for now.\n throw $exception;\n }\n\n return $syncCount;\n }\n\n /**\n * @inheritdoc\n */\n public function syncContact(string $crmId): ?Contact\n {\n $cpContact = $this->copperClient->contacts()->find($crmId);\n\n if (empty($cpContact)) {\n return null;\n }\n\n return $this->importContact($cpContact);\n }\n\n /**\n * @inheritdoc\n */\n private function importContact($crmData): Contact\n {\n $account = null;\n if ($crmData['company_id']) {\n $account = $this->team\n ->crm\n ->accounts()\n ->where('crm_provider_id', (string) $crmData['company_id'])\n ->first();\n\n if ($account === null) {\n $account = $this->syncAccount($crmData['company_id']);\n }\n }\n\n $countryCode = null;\n if (empty($crmData['address']) === false && $crmData['address']['country']) {\n $countryCode = $this->convertCountryNameToCode($crmData['address']['country']);\n }\n\n $mobilePhone = $parsedNumber = null;\n foreach ($crmData['phone_numbers'] as $phoneNumber) {\n if ($phoneNumber['category'] === 'mobile') {\n $mobilePhone = $this->buildContactMobilePhone($countryCode, $phoneNumber['number']);\n } else {\n $parsedNumber = $this->buildContactPhone($countryCode, $phoneNumber['number']);\n }\n }\n\n $email = null;\n if (empty($crmData['emails']) === false) {\n $email = mb_strimwidth($crmData['emails'][0]['email'], 0, 80);\n }\n\n $profile = $this->config->profiles()->where('crm_provider_id', (string) $crmData['assignee_id'])->first();\n\n $data = [\n 'team_id' => $this->team->id,\n 'account_id' => $account->id ?? null,\n 'user_id' => $profile ? $profile->user_id : null,\n 'owner_id' => $crmData['assignee_id'],\n 'name' => $crmData['name'],\n 'email' => $email,\n 'phone' => $parsedNumber['phone'] ?? null,\n 'mobile_phone' => $mobilePhone ?? null,\n 'ext' => $parsedNumber['ext'] ?? null,\n 'title' => $crmData['title'],\n 'country_code' => $countryCode,\n 'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(\n crmConfiguration: $this->config,\n crmProviderId: $crmData['id'],\n modelType: Contact::class,\n fileName: $crmData['id'],\n avatarText: $crmData['name']\n ),\n 'remotely_created_at' => $crmData['date_created'],\n ];\n\n return $this->config->contacts()->updateOrCreate(['crm_provider_id' => (string) $crmData['id']], $data);\n }\n\n private function buildContactPhone(?string $countryCode, ?string $number): ?array\n {\n if ($number) {\n // Trim to our width and attempt to parse it.\n $number = mb_strimwidth($number, 0, 25);\n $parsedNumber = parsePhoneNumber($countryCode, $number);\n } else {\n $parsedNumber = [];\n }\n\n return $parsedNumber;\n }\n\n private function buildContactMobilePhone(?string $countryCode, ?string $number): ?string\n {\n return $number ? mb_strimwidth(phone_e164($countryCode, $number), 0, 25) : null;\n }\n\n /**\n * @inheritdoc\n */\n public function syncOrganization(): void\n {\n $organization = $this->copperClient->organisation()->all();\n\n if ($this->config->crm_provider_id !== $organization['id']) {\n $this->config->update(['crm_provider_id' => $organization['id']]);\n }\n }\n\n private function syncCustomFields(): void\n {\n foreach ($this->getFieldTypes() as $fieldType) {\n $currentFields = $this->getFields($fieldType);\n\n foreach ($currentFields as $field) {\n /** @var Field $field */\n $field = $this->config->fields()->updateOrCreate([\n 'crm_provider_id' => $field['name'],\n 'object_type' => $fieldType,\n ], [\n 'label' => mb_strimwidth($field['label'], 0, Field::LABEL_MAX_LENGTH),\n 'type' => $field['type'],\n ]);\n\n if ($field->getType() === Field::TYPE_PICKLIST) {\n $this->importPicklistValues($field);\n }\n }\n }\n\n $this->config->save();\n }\n\n protected function getFieldTypes(): array\n {\n return [\n parent::OBJECT_TASK,\n parent::OBJECT_LEAD,\n parent::OBJECT_ACCOUNT,\n parent::OBJECT_CONTACT,\n parent::OBJECT_OPPORTUNITY,\n ];\n }\n\n /**\n * @inheritdoc\n */\n public function syncProfiles(?User $userToSearch = null): ?Profile\n {\n $profiles = $this->copperClient->users()->all();\n\n foreach ($profiles as $profile) {\n $user = $this->userRepository->findByTeamAndEmailAddress($this->team, $profile['email'], onlyActive: true);\n\n // Only import profiles for users we have locally.\n if ($user) {\n $profile = $user->crmProfile()->updateOrCreate([\n 'crm_configuration_id' => $this->config->id,\n 'crm_provider_id' => $profile['id'],\n ]);\n\n if ($userToSearch && $userToSearch->id === $user->id) {\n return $profile;\n }\n }\n }\n\n return null;\n }\n\n /**\n * @inheritdoc\n */\n public function syncProfileFields(): void\n {\n $fieldTypes = [\n 'task',\n 'lead',\n 'account',\n 'contact',\n 'opportunity',\n ];\n\n foreach ($fieldTypes as $fieldType) {\n try {\n $currentFields = $this->getFields(ucfirst($fieldType));\n\n $newFields = array_column($currentFields, 'name');\n $changedFields = array_merge(\n array_diff($this->profile->getFieldsAsArray($fieldType), $newFields),\n array_diff($newFields, $this->profile->getFieldsAsArray($fieldType))\n );\n\n if (\\count($changedFields) > 0) {\n $fields = implode(',', $newFields);\n $this->logger->debug(\n ucfirst($fieldType)\n . ' fields changed from '\n . $this->profile->{$fieldType . '_fields'}\n . ' to: '\n . $fields\n );\n\n $this->profile->{$fieldType . '_fields'} = $fields;\n }\n } catch (\\Exception $exception) {\n $this->logger->error('No access to ' . $fieldType . ' object, skipping...');\n\n continue;\n }\n }\n\n $this->profile->save();\n }\n\n /**\n * @inheritdoc\n */\n public function find(string $name, array $scopes): array\n {\n $count = $this->limit ?? 20;\n $offset = $this->offset ?? 0;\n\n $scopeValues = array_map(fn ($scope) => is_string($scope) ? $scope : $scope->value, $scopes);\n $cacheKey = sprintf('copper-search-%d-%s-%s-%d-%d', $this->team->id, $name, implode('-', $scopeValues), $count, $offset);\n\n return Cache::remember($cacheKey, 300, function () use ($name, $scopes, $count, $offset) {\n // Try local search first\n $localResults = $this->searchLocally($name, $scopes, $count, $offset);\n\n // If no local results, fall back to remote API search\n if (empty($localResults)) {\n return $this->searchRemotely($name, $scopes, $count, $offset);\n }\n\n return $localResults;\n });\n }\n\n /**\n * @inheritdoc\n */\n public function findOpportunities(?string $crmAccountId, ?string $crmContactId, ?int $userId = null): array\n {\n $data = [];\n $ownerData = [];\n $ownerId = null;\n $contact = null;\n $account = null;\n\n if ($crmAccountId) {\n $account = $this->config->accounts()->where('crm_provider_id', $crmAccountId)->first();\n\n if ($account === null) {\n $account = $this->syncAccount($crmAccountId);\n }\n }\n\n if ($crmContactId) {\n $contact = $this->config->contacts()->where('crm_provider_id', $crmContactId)->first();\n\n if ($contact === null) {\n $contact = $this->syncContact($crmContactId);\n }\n }\n\n if ($contact || $account) {\n if ($contact && $account === null) {\n $account = $contact->account;\n }\n\n if ($account === null) {\n return [];\n }\n\n $params = [\n 'company_ids' => [$account->crm_provider_id],\n 'status_ids' => [0], // The integer values are 0, 1, 2, 3, for \"Open\", \"Won\", \"Lost\", and \"Abandoned\".\n 'sort_by' => 'date_modified',\n 'sort_direction' => 'desc',\n ];\n\n switch ($this->config->opportunity_assignment_rule) {\n case Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_UPDATED:\n $params['sort_by'] = 'date_modified';\n $params['sort_direction'] = 'desc';\n\n break;\n\n case Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_CREATED:\n $params['sort_by'] = 'date_created';\n $params['sort_direction'] = 'desc';\n\n break;\n\n case Configuration::OPP_ASSIGNMENT_ALL_OPEN_OLDEST_CREATED:\n $params['sort_by'] = 'date_created';\n $params['sort_direction'] = 'asc';\n\n break;\n\n case Configuration::OPP_ASSIGNMENT_ALL_RECENTLY_UPDATED:\n $params['sort_by'] = 'date_modified';\n $params['sort_direction'] = 'desc';\n $params['status_ids'] = [0, 1, 2, 3];\n }\n\n $cpOpportunities = $this->copperClient->opportunities()->search($params);\n\n if ($userId) {\n $profileRepository = app(ProfileRepository::class);\n $profile = $profileRepository->findProfileByUserId($this->config, $userId);\n\n $ownerId = $profile instanceof Profile ? $profile->getCrmProviderId() : null;\n }\n\n foreach ($cpOpportunities as $cpOpportunity) {\n $businessProcess = $this->config\n ->businessProcesses()\n ->where('crm_provider_id', (string) $cpOpportunity['pipeline_id'])\n ->first();\n\n if ($businessProcess === null) {\n // Import it.\n $businessProcess = $this->importStages(\n [Stage::TYPE_OPPORTUNITY],\n $cpOpportunity['pipeline_stage_id']\n );\n }\n\n $stage = $businessProcess\n ->stages()\n ->where('crm_provider_id', (string) $cpOpportunity['pipeline_stage_id'])\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->first();\n\n if ($stage === null) {\n // Import it.\n $stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $cpOpportunity['pipeline_stage_id']);\n }\n\n $recordType = $businessProcess->recordTypes()->first();\n\n $record = [\n 'crmId' => $cpOpportunity['id'],\n 'name' => $cpOpportunity['name'],\n 'value' => currency_format($cpOpportunity['monetary_value'], $cpOpportunity['monetary_unit']),\n 'won' => $cpOpportunity['status'] === 'Won',\n 'stage' => [\n 'id' => $stage->id_string,\n 'name' => $stage->name,\n ],\n 'recordType' => [\n 'id' => $recordType->id_string,\n 'name' => $recordType->name,\n ],\n ];\n\n if ($ownerId && isset($cpOpportunity['assignee_id']) && $cpOpportunity['assignee_id'] === $ownerId) {\n $ownerData[] = $record;\n }\n\n $data[] = $record;\n }\n }\n\n if (! empty($ownerData)) {\n return $ownerData;\n }\n\n return $data;\n }\n\n /**\n * @inheritdoc\n */\n public function getTasks(string $objectType, string $objectId, ?string $opportunityId): array\n {\n $data = $cpTasks = [];\n\n switch ($objectType) {\n case 'lead':\n $cpTasks = $this->requestWithExceptionHandling(\n fn () => $this->copperClient->leads()->relatedTasks($objectId)\n );\n\n break;\n\n case 'account':\n $cpTasks = $this->copperClient->accounts()->relatedTasks($objectId);\n\n break;\n case 'contact':\n if ($opportunityId) {\n $cpTasks = $this->copperClient->opportunities()->relatedTasks($opportunityId);\n } elseif ($objectType === 'contact') {\n $cpTasks = $this->copperClient->contacts()->relatedTasks($objectId);\n }\n\n break;\n }\n\n foreach ($cpTasks as $cpTask) {\n // We only get the ID itself returned, so pull out the full object.\n try {\n $cpTask = $this->copperClient->tasks()->find($cpTask['id']);\n } catch (Exception $exception) {\n $this->logger->error('[Copper] Error fetching task', [\n 'resource_id' => $cpTask['id'],\n 'error' => $exception->getMessage(),\n ]);\n\n continue;\n }\n\n // Only allow open tasks.\n if ($cpTask['status'] === 'Open') {\n $data[] = [\n 'crmId' => $cpTask['id'],\n 'subject' => $cpTask['name'],\n 'due' => $cpTask['due_date']\n ? Carbon::createFromTimestamp($cpTask['due_date'])->format('Y-m-d\\TH:i:sO')\n : null,\n 'type' => null,\n ];\n }\n }\n\n return $data;\n }\n\n /**\n * Try to find email address in CRM service\n *\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n *}\n */\n public function matchExactlyByEmail(string $email, ?int $userId = null): ?array\n {\n $cpObjectType = 'contact';\n\n $cpObjects = $this->copperClient->contacts()->search([\n 'emails' => $email,\n 'sort_by' => 'date_modified',\n 'sort_direction' => 'desc',\n ]);\n\n if (empty($cpObjects)) {\n $cpObjectType = 'lead';\n $cpObjects = $this->requestWithExceptionHandling(\n fn () => $this->copperClient->leads()->search([\n 'emails' => $email,\n 'sort_by' => 'date_modified',\n 'sort_direction' => 'desc',\n ])\n );\n }\n\n if (empty($cpObjects)) {\n return null;\n }\n\n // Search returns an array of results, but we only want the first\n $cpObject = reset($cpObjects);\n $data = $this->convertCrmData($cpObject, $cpObjectType, $userId);\n\n return ! empty(array_filter($data)) ? $data : null;\n }\n\n /**\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n public function matchByPhone(string $phone, ?string $rawPhoneNumber = null, ?int $userId = null): ?array\n {\n // Check if the user is internal.\n $teamMember = $this->team->users()->where('phone', $phone)->exists();\n\n // Skip the attendee if internal.\n if ($teamMember === false) {\n $cpObjectType = 'contact';\n $cpObject = $this->copperClient->contacts()->search([\n 'phone_number' => $phone,\n 'sort_by' => 'date_modified',\n 'sort_direction' => 'desc',\n ]);\n\n if (\\count($cpObject) === 0) {\n $cpObjectType = 'lead';\n $cpObject = $this->requestWithExceptionHandling(\n fn () => $this->copperClient->leads()->search([\n 'phone_number' => $phone,\n 'sort_by' => 'date_modified',\n 'sort_direction' => 'desc',\n ])\n );\n }\n\n if (empty($cpObject)) {\n return null;\n }\n\n $cpObject = reset($cpObject);\n $data = $this->convertCrmData($cpObject, $cpObjectType, $userId);\n\n return ! empty(array_filter($data)) ? $data : null;\n }\n\n return null;\n }\n\n /**\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n public function matchByName(string $name, ?int $userId = null): ?array\n {\n // Don't waste time searching for single character strings.\n if (\\strlen($name) <= 1) {\n return null;\n }\n\n $result = \\Cache::remember($this->team->id . $name, 60, function () use ($name, $userId) {\n $cpObjectType = 'contact';\n $cpObject = $this->copperClient->contacts()->search([\n 'name' => $name,\n 'sort_by' => 'date_modified',\n 'sort_direction' => 'desc',\n ]);\n\n if (\\count($cpObject) === 0) {\n $cpObjectType = 'lead';\n $cpObject = $this->requestWithExceptionHandling(\n fn () => $this->copperClient->leads()->search([\n 'name' => $name,\n 'sort_by' => 'date_modified',\n 'sort_direction' => 'desc',\n ])\n );\n }\n\n if (! empty($cpObject)) {\n $cpObject = reset($cpObject);\n }\n\n $data = $this->convertCrmData($cpObject, $cpObjectType, $userId);\n\n return ! empty(array_filter($data)) ? $data : false;\n });\n\n return is_array($result) ? $result : null;\n }\n\n private function convertCrmData(array $object, string $objectType, ?int $userId = null): array\n {\n $lead = null;\n $contact = null;\n $opportunity = null;\n $account = null;\n $stage = null;\n $countryCode = null;\n\n if ($objectType === 'lead') {\n $lead = $this->importLead($object);\n\n // Lead might not be imported if the Stage is null for example.\n if ($lead) {\n $countryCode = $lead->country_code;\n $stage = $lead->stage;\n }\n } else {\n $contact = $this->importContact($object);\n $account = $contact->account;\n\n if ($contact->country_code) {\n $countryCode = $contact->country_code;\n } elseif ($account) {\n $countryCode = $account->country_code;\n }\n\n try {\n $cpOpportunities = $this->findOpportunities(\n $account?->crm_provider_id,\n $contact->crm_provider_id,\n $userId,\n );\n\n if (! empty($cpOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($cpOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n } catch (\\Exception $e) {\n // Nothing to see here.\n }\n }\n\n return [\n $lead,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n }\n\n public function saveActivity(Activity $activity): Activity\n {\n switch ($activity->type) {\n case Activity::TYPE_CONFERENCE:\n case Activity::TYPE_SOFTPHONE:\n case Activity::TYPE_SOFTPHONE_INBOUND:\n $activity = $this->buildCallPayload($activity);\n\n break;\n case Activity::TYPE_SMS_INBOUND:\n case Activity::TYPE_SMS_OUTBOUND:\n $activity = $this->buildTextMessagePayload($activity);\n\n break;\n }\n\n return $activity;\n }\n\n private function convertActivityAssociations(Activity $activity): array\n {\n if ($activity->lead) {\n $type = 'lead';\n $id = $activity->lead->crm_provider_id;\n } elseif ($activity->contact) {\n $type = 'person';\n $id = $activity->contact->crm_provider_id;\n } elseif ($activity->opportunity) {\n $type = 'opportunity';\n $id = $activity->opportunity->crm_provider_id;\n } else {\n $type = 'company';\n $id = $activity->account->crm_provider_id;\n }\n\n return [\n 'type' => $type,\n 'id' => $id,\n ];\n }\n\n private function convertActivityType(PlaybookCategory $activityType): array\n {\n return [\n 'category' => 'user',\n 'id' => $this->getCrmType($activityType)->value,\n ];\n }\n\n /**\n * @throws CrmException\n */\n private function buildCallPayload(Activity $activity): Activity\n {\n try {\n if ($activity->crm_provider_id) {\n // The activity should be logged under the existing Task (not Activity).\n $newActivityType = $this->getCrmType($activity->category);\n if ($newActivityType === null) {\n $message = sprintf('[Copper] invalid CRM Custom Activity Type: %s', $activity->category);\n\n throw new CrmException($message, 400);\n }\n $this->copperClient->tasks()->update($activity->crm_provider_id, [\n 'status' => 'Completed',\n 'custom_activity_type_id' => $newActivityType->value,\n 'details' => $this->generateActivityDescription($activity),\n ]);\n } else {\n if ($this->getCrmType($activity->category) === null) {\n $message = sprintf('[Copper] Invalid CRM Activity Type: %s', $activity->category->getName());\n\n throw new CrmException($message, 400);\n }\n\n if ($this->getCrmType($activity->category)->value === null) {\n $message = sprintf('[Copper] Invalid CRM Activity Type: %s value', $activity->category->getName());\n\n throw new CrmException($message, 400);\n }\n // Just create an activity.\n $cpActivity = $this->copperClient->activities()->create([\n 'user_id' => $this->profile->crm_provider_id,\n 'activity_date' => $activity->scheduled_start_time->timestamp,\n 'parent' => $this->convertActivityAssociations($activity),\n 'type' => $this->convertActivityType($activity->category),\n 'details' => $this->generateActivityDescription($activity),\n ]);\n\n $activity->crm_provider_id = $cpActivity['id'];\n $activity->save();\n }\n } catch (ClientException $exception) {\n //Log all errors to Sentry\n \\Sentry::captureException($exception);\n\n $response = $exception->getResponse();\n\n $responseBody = $response->getBody();\n $message = $responseBody;\n $errorCode = $response->getStatusCode();\n\n $jsonResponse = json_decode($responseBody, true);\n if (isset($jsonResponse[0]['message'])) {\n $message = $jsonResponse[0]['message'];\n }\n\n throw new CrmException($message, $errorCode);\n }\n\n return $activity;\n }\n\n private function buildTextMessagePayload(Activity $activity): Activity\n {\n if ($this->getCrmType($activity->category) === null) {\n $message = sprintf('[Copper] Invalid CRM Activity Type: %s', $activity->category->getName());\n\n throw new CrmException($message, 400);\n }\n\n if ($this->getCrmType($activity->category)->value === null) {\n $message = sprintf('[Copper] Invalid CRM Activity Type: %s value', $activity->category->getName());\n\n throw new CrmException($message, 400);\n }\n\n $cpActivity = $this->copperClient->activities()->create([\n 'user_id' => $this->profile->crm_provider_id,\n 'activity_date' => $activity->created_at->timestamp,\n 'parent' => $this->convertActivityAssociations($activity),\n 'type' => $this->convertActivityType($activity->category),\n 'details' => $this->generateActivityDescription($activity),\n ]);\n\n $activity->crm_provider_id = $cpActivity['id'];\n $activity->save();\n\n return $activity;\n }\n\n private function generateActivityDescription(Activity $activity): string\n {\n $description = '';\n\n switch ($activity->type) {\n case Activity::TYPE_SOFTPHONE:\n case Activity::TYPE_SOFTPHONE_INBOUND:\n case Activity::TYPE_CONFERENCE:\n\n $description = 'Jiminny Call Summary' . PHP_EOL . PHP_EOL;\n $description .= 'Call Duration: ' . $activity->duration_for_humans . PHP_EOL . PHP_EOL;\n\n // Tell them which number was dialed.\n if ($activity->to) {\n $description .= 'Dialed: ' . $activity->to->phone_number . PHP_EOL . PHP_EOL;\n }\n\n if ($activity->hasReasonCodeBotKicked()) {\n $description .= 'Notetaker removed from this meeting' . PHP_EOL . PHP_EOL;\n // When we fix the state to be Activity::RECORDING_RECORDED as it should be this can change.\n } elseif ($activity->hasReasonCodeNotCompliant()) {\n $description .= 'Notetaker did not join due to recording consent not being provided by attendees' . PHP_EOL . PHP_EOL;\n } elseif ($activity->canReviewActivity()) {\n $playbackUrl = $activity->user->team->partner->getPlaybackUrl($activity);\n $description .= 'Review Activity:' . PHP_EOL . $playbackUrl . PHP_EOL . PHP_EOL;\n }\n\n if ($activity->getCrmType() === Activity::TYPE_CONFERENCE) {\n $description .= 'Attendees:'\n . PHP_EOL\n . (new FilterJoinedParticipants())->toString($activity);\n }\n\n if (\\count($activity->notes) > 0) {\n $description .= PHP_EOL . PHP_EOL . 'Notes: ' . PHP_EOL;\n\n foreach ($activity->notes as $note) {\n $nTime = (int) ceil($note->time);\n $time = ($nTime > 3600) ? gmdate('H:i:s', $nTime) : gmdate('i:s', $nTime);\n $description .= $time . ' ' . $note->note . PHP_EOL;\n }\n }\n\n // Get all private messages.\n $messages = $activity->messages()\n ->where('is_private', 1)\n ->orderBy('created_at', 'asc');\n\n if ($messages->count() > 0) {\n $description .= PHP_EOL . PHP_EOL . 'Coaching Chat: ' . PHP_EOL;\n\n foreach ($messages->get() as $message) {\n $description .= $message->participant->name . ': ' . $message->message . PHP_EOL;\n }\n }\n\n // Get all public messages.\n $messages = $activity->messages()\n ->where('is_private', 0)\n ->orderBy('created_at', 'asc');\n\n if ($messages->count() > 0) {\n $description .= PHP_EOL . PHP_EOL . 'Customer Chat: ' . PHP_EOL;\n\n foreach ($messages->get() as $message) {\n $description .= $message->participant->name . ': ' . $message->message . PHP_EOL;\n }\n }\n\n if ($activity->summary) {\n $description .= PHP_EOL . PHP_EOL . 'Summary: ' . PHP_EOL . $activity->summary;\n }\n\n break;\n\n case Activity::TYPE_SMS_INBOUND:\n case Activity::TYPE_SMS_OUTBOUND:\n $description = $activity->description;\n\n break;\n }\n\n return $description;\n }\n\n public function saveFollowupActivity(Activity $activity, array $fields): ?string\n {\n // This is the user provided activity subject field.\n if (empty($fields['type'])) {\n return null;\n }\n\n $reminder = null;\n if (empty($fields['reminder_date']) === false) {\n $formatReminder = Carbon::createFromFormat('Y-m-d\\TH:i:s\\Z', $fields['reminder_date']);\n $reminder = $formatReminder->getTimestamp();\n }\n\n $due = null;\n if (empty($fields['due_date']) === false) {\n $formatDue = Carbon::createFromFormat('Y-m-d\\TH:i:s\\Z', $fields['due_date']);\n $due = $formatDue->getTimestamp();\n }\n\n $data = [\n 'custom_activity_type_id' => $fields['type'],\n 'status' => 'Open',\n 'assignee_id' => $this->profile->crm_provider_id,\n 'due_date' => $due,\n 'details' => $fields['details'] ?? '',\n 'reminder_date' => $reminder,\n 'name' => $fields['name'] ?? 'Follow-up',\n 'related_resource' => $this->convertActivityAssociations($activity),\n 'custom_fields' => $this->fetchCustomFieldData($activity, Field::OBJECT_TASK),\n ];\n\n // We don't actually create a corresponding activity object on our side yet.\n return $this->copperClient->tasks()->create($data)['id'];\n }\n\n /**\n * Store transcripts as note.\n *\n * @throws \\Exception\n */\n public function createTranscriptNotes(Activity $activity): void\n {\n // Generate activity transcription.\n $transcriptionData = $this->generateTranscription($activity);\n\n // Truncate Notes with max notes length because transcription text could be very long.\n $transcripts = mb_strimwidth($transcriptionData, 0, static::NOTE_BODY_MAX_LENGTH);\n\n $data = [\n 'user_id' => $this->profile->crm_provider_id,\n 'type' => [\n 'category' => 'user',\n 'id' => 0, // This creates a note.\n ],\n 'activity_date' => time(),\n 'details' => $transcripts,\n 'parent' => $this->convertActivityAssociations($activity),\n ];\n\n $cpActivity = $this->copperClient->activities()->create($data);\n\n // Store CRM Activity ID in transcription.\n $transcription = $activity->getTranscription();\n $transcription->crm_activity_id = $cpActivity['id'];\n $transcription->save();\n }\n\n /**\n * @inheritdoc\n */\n public function updateStage($crmObject, Stage $stage): void\n {\n if ($crmObject instanceof Lead) {\n $this->requestWithExceptionHandling(\n fn () => $this->copperClient->leads()->update($crmObject->crm_provider_id, [\n 'status_id' => $stage->crm_provider_id,\n ])\n );\n } else {\n $this->copperClient->opportunities()->update($crmObject->crm_provider_id, [\n 'pipeline_stage_id' => $stage->crm_provider_id,\n ]);\n }\n }\n\n public function prepareValueForUpdate(array $params): array\n {\n return [$params['fieldName'] => $params['fieldValue']];\n }\n\n /*\n * @inheritdoc\n */\n public function updateRecord(string $objectType, string $objectId, array $data, array $headers = []): void\n {\n $preparedData = $data;\n\n if ($objectType === Field::OBJECT_OPPORTUNITY) {\n $preparedData = $this->prepareOpportunityFieldsForUpdate($data);\n }\n\n try {\n $this->copperClient\n ->createResourceFromObjectType($objectType)\n ->update($objectId, $preparedData)\n ;\n } catch (Exception $e) {\n $this->logger->error(self::RESOURCE_UPDATE_FAILED_ERROR_MESSAGE, [\n 'resource_id' => $objectId,\n 'data' => $preparedData,\n 'error' => $e->getMessage(),\n ]);\n\n throw new CrmException(self::RESOURCE_UPDATE_FAILED_ERROR_MESSAGE, $e->getCode(), $e);\n }\n }\n\n /**\n * @param array<string,string> $fieldData\n *\n * @throws CrmException\n *\n * @return array<string,array<int,array<string,bool|float|int|string|null>>|bool|float|int|string|null>\n */\n private function prepareOpportunityFieldsForUpdate(array $fieldData): array\n {\n $preparedData = [];\n $customFields = [];\n\n foreach ($fieldData as $fieldName => $fieldValue) {\n $field = $this->config\n ->fields()\n ->where('crm_provider_id', $fieldName)\n ->first();\n\n if (! $field instanceof Field) {\n throw new CrmException('Field not found');\n }\n\n $convertedValue = $this->fieldValueConverter->convertToCrm($field->getType(), $fieldValue);\n\n if ($this->isStandardField($fieldName)) {\n $preparedData[$fieldName] = $convertedValue;\n\n continue;\n }\n\n $customFields[] = [\n 'custom_field_definition_id' => $fieldName,\n 'value' => $convertedValue,\n ];\n }\n\n if (! empty($customFields)) {\n $preparedData['custom_fields'] = $customFields;\n }\n\n return $preparedData;\n }\n\n /*\n * @inheritdoc\n */\n public function getRecord(string $objectType, string $objectId, array $fields = []): array\n {\n return $this->copperClient->createResourceFromObjectType($objectType)->find($objectId);\n }\n\n private function fetchCustomFieldData(Activity $activity, string $objectType): array\n {\n $payload = [];\n\n $fieldData = FieldData::where([\n 'object_id' => $activity->id,\n ])->whereHas('field', function ($query) use ($objectType) {\n $query->where('object_type', $objectType);\n })->get();\n\n foreach ($fieldData as $data) {\n // Add the field and value to the payload.\n $payload += [\n 'custom_field_definition_id' => $data->field->crm_provider_id,\n 'value' => $data->value,\n ];\n }\n\n return $payload;\n }\n\n public function generateProviderUrl(string $providerId, string $objectType): ?string\n {\n $baseUrl = 'https://app.prosperworks.com/companies/' . $this->config->crm_provider_id . '/app#/';\n switch ($objectType) {\n case 'lead':\n $url = $baseUrl . 'leads/' . $providerId;\n\n break;\n\n case 'account':\n $url = $baseUrl . 'organization/' . $providerId;\n\n break;\n\n case 'contact':\n $url = $baseUrl . 'contact/' . $providerId;\n\n break;\n\n case 'opportunity':\n $url = $baseUrl . 'deal/' . $providerId;\n\n break;\n\n default:\n case 'task':\n case 'activity':\n // Sadly we can't deeplink to these in Copper UI.\n $url = null;\n }\n\n return $url;\n }\n\n /**\n * Generate transcription for the activity.\n */\n private function generateTranscription(Activity $activity): string\n {\n if (! ($this->config->store_transcript)) {\n // If sending transcription to activity toggle is disabled\n return '';\n }\n\n return $this->transcriptionService\n ->findTranscriptionByActivity($activity)\n ->map(static function (array $transcriptionSegment): string {\n return $transcriptionSegment['formattedStartsAt'] . ' | ' . $transcriptionSegment['transcript'];\n })\n ->implode(PHP_EOL);\n }\n\n /**\n * @param array<string, mixed> $opportunityData\n *\n * @return int|string|bool|float|null\n */\n private function extractFieldValue(array $opportunityData, string $fieldName)\n {\n $fieldValue = $this->isStandardField($fieldName)\n ? $opportunityData[$fieldName]\n : $this->resolveCustomFieldFromOpportunityData($opportunityData, $fieldName);\n\n if (is_array($fieldValue)) {\n $fieldValue = implode(', ', $fieldValue);\n }\n\n return $fieldValue;\n }\n\n private function isStandardField(string $fieldName): bool\n {\n return array_key_exists($fieldName, self::STANDARD_OPPORTUNITY_FIELDS);\n }\n\n /**\n * @param array<string, mixed> $opportunityData\n *\n * @return string|int|float|bool|null|array<string, mixed>\n */\n private function resolveCustomFieldFromOpportunityData(array $opportunityData, string $fieldName)\n {\n $fieldData = array_filter(\n $opportunityData['custom_fields'],\n static fn ($field) => (string) $field['custom_field_definition_id'] === $fieldName\n );\n\n return array_pop($fieldData)['value'] ?? '';\n }\n\n private function isSupportedObjectType(string $objectType): bool\n {\n return $objectType !== self::UNSUPPORTED_OBJECT_TYPE;\n }\n\n private function syncStandardFields(): void\n {\n foreach (self::STANDARD_OPPORTUNITY_FIELDS as $fieldId => $fieldData) {\n $this->config->fields()->updateOrCreate([\n 'crm_provider_id' => mb_strimwidth($fieldId, 0, 128),\n 'object_type' => Field::OBJECT_OPPORTUNITY,\n ], [\n 'label' => mb_strimwidth($fieldData['label'], 0, Field::LABEL_MAX_LENGTH),\n 'type' => $fieldData['type'],\n ]);\n }\n\n $this->importStages([Stage::TYPE_OPPORTUNITY]);\n }\n\n private function requestWithExceptionHandling(\\Closure $apiRequest): iterable\n {\n try {\n return $apiRequest();\n } catch (\\Exception $exception) {\n if ($exception->getCode() === 403) {\n // Object might not be supported\n return [];\n }\n\n \\Sentry::captureException($exception);\n\n return [];\n }\n }\n\n public function verifyTaskExists(Activity $activity): bool\n {\n $crmProviderId = $activity->getCrmProviderId();\n $cacheKey = \"crm_task_exists:{$this->config->getId()}:$crmProviderId\";\n\n return Cache::remember($cacheKey, self::TASK_VERIFICATION_CACHE_TTL, function () use ($crmProviderId) {\n try {\n if ($this->copperClient === null) {\n return false;\n }\n\n $task = $this->copperClient->tasks()->find($crmProviderId);\n\n return ! empty($task);\n } catch (HttpNotFoundException) {\n // Task not found in CRM - this is expected and permanent\n $this->logger->info('[Copper] Task not found during verification', [\n 'task_id' => $crmProviderId,\n 'config_id' => $this->config->getId(),\n ]);\n\n return false;\n } catch (RequestFailedException $e) {\n // Handle 404 responses from Copper API\n if ($e->getCode() === 404) {\n $this->logger->info('[Copper] Task not found during verification (404)', [\n 'task_id' => $crmProviderId,\n 'config_id' => $this->config->getId(),\n ]);\n\n return false;\n }\n\n // Re-throw other Copper exceptions for retry\n throw $e;\n }\n // Let other exceptions (network errors, rate limits, etc.) bubble up for retry\n });\n }\n}","depth":4,"on_screen":true,"value":"<?php\n\nnamespace Jiminny\\Services\\Crm\\Copper;\n\nuse Carbon\\Carbon;\nuse Exception;\nuse GuzzleHttp\\Exception\\ClientException;\nuse Illuminate\\Support\\Facades\\Cache;\nuse Jiminny\\Contracts\\Services\\Crm\\LayoutManagementInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\MatchCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\Provider\\CopperInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityLookupInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityManipulationInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SettingsInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmMetadataInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\VerifyTaskExistsInterface;\nuse Jiminny\\Exceptions\\CrmException;\nuse Jiminny\\Exceptions\\HttpNotFoundException;\nuse Jiminny\\Models\\Account;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Contact;\nuse Jiminny\\Models\\Crm\\BusinessProcess;\nuse Jiminny\\Models\\Crm\\Configuration;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Models\\Crm\\FieldData;\nuse Jiminny\\Models\\Crm\\FieldValue;\nuse Jiminny\\Models\\Crm\\LayoutEntity;\nuse Jiminny\\Models\\Crm\\Profile;\nuse Jiminny\\Models\\Lead;\nuse Jiminny\\Models\\Opportunity;\nuse Jiminny\\Models\\Playbook;\nuse Jiminny\\Models\\PlaybookCategory;\nuse Jiminny\\Models\\SocialAccount;\nuse Jiminny\\Models\\Stage;\nuse Jiminny\\Models\\User;\nuse Jiminny\\Repositories\\Crm\\CrmEntityRepository;\nuse Jiminny\\Repositories\\Crm\\FieldRepository;\nuse Jiminny\\Repositories\\Crm\\ProfileRepository;\nuse Jiminny\\Repositories\\UserRepository;\nuse Jiminny\\Services\\Avatar\\ProspectPhotoPathService;\nuse Jiminny\\Services\\Crm\\BaseService;\nuse Jiminny\\Services\\Crm\\Copper\\Api\\Client;\nuse Jiminny\\Services\\Crm\\Copper\\Api\\ClientFactory;\nuse Jiminny\\Services\\Crm\\Copper\\Exception\\RequestFailedException;\nuse Jiminny\\Services\\Crm\\Helpers\\FilterJoinedParticipants;\nuse Jiminny\\Services\\Crm\\OpportunitySyncStrategyResolver;\nuse Jiminny\\Services\\Crm\\Copper\\Traits\\SearchesCrmEntities;\nuse Jiminny\\Utils\\CurrencyFormatter;\nuse Jiminny\\Utils\\StringUtil;\n\nclass Service extends BaseService implements\n CopperInterface,\n SyncCrmEntitiesInterface,\n SyncCrmMetadataInterface,\n RemoteEntityManipulationInterface,\n LayoutManagementInterface,\n SettingsInterface,\n MatchCrmEntitiesInterface,\n RemoteEntityLookupInterface,\n VerifyTaskExistsInterface\n{\n use SearchesCrmEntities;\n private const int NOTE_BODY_MAX_LENGTH = 3000000;\n\n private const int TASK_VERIFICATION_CACHE_TTL = 86400; // 1 day\n\n private const array STANDARD_OPPORTUNITY_FIELDS = [\n 'monetary_value' => [\n 'label' => 'Value',\n 'available_on' => Field::OBJECT_OPPORTUNITY,\n 'type' => Field::TYPE_CURRENCY,\n ],\n 'pipeline_stage_id' => [\n 'label' => 'Stage',\n 'type' => Field::TYPE_PICKLIST,\n 'available_on' => Field::OBJECT_OPPORTUNITY,\n ],\n 'close_date' => [\n 'label' => 'Close date',\n 'type' => Field::TYPE_DATE,\n 'available_on' => Field::OBJECT_OPPORTUNITY,\n ],\n 'win_probability' => [\n 'label' => 'Win probability',\n 'type' => Field::TYPE_NUMBER,\n 'available_on' => Field::OBJECT_OPPORTUNITY,\n ],\n ];\n private const string UNSUPPORTED_OBJECT_TYPE = 'unsupported';\n private const string OPPORTUNITY_STATUS_OPEN = 'Open';\n private const string OPPORTUNITY_IMPORT_FAILED_ERROR_MESSAGE = 'Opportunity import failed';\n private const string RESOURCE_UPDATE_FAILED_ERROR_MESSAGE = 'Failed to update resource';\n\n private FieldValueConverter $fieldValueConverter;\n private ClientFactory $clientFactory;\n private ?Client $copperClient = null;\n\n public function __construct(\n ClientFactory $clientFactory,\n FieldValueConverter $fieldValueConverter,\n private readonly UserRepository $userRepository,\n private readonly ProspectPhotoPathService $prospectPhotoPathService,\n private readonly CrmEntityRepository $crmEntityRepository\n ) {\n parent::__construct();\n\n $this->clientFactory = $clientFactory;\n $this->fieldValueConverter = $fieldValueConverter;\n }\n\n public function getDisplayName(): string\n {\n return 'Copper';\n }\n\n /**\n * Get the CRM entity repository (used by SearchesCrmEntities trait)\n */\n protected function getCrmEntityRepository(): CrmEntityRepository\n {\n return $this->crmEntityRepository;\n }\n\n public function getMinimumApiVersion(): string\n {\n return Client::MIN_API_VERSION;\n }\n\n protected function getOAuthAccount(User $user): ?SocialAccount\n {\n // In this case, the Account Owner is always the connection for any API operations.\n $owner = $user->team->owner;\n\n return $owner->getSocialAccount(SocialAccount::PROVIDER_COPPER);\n }\n\n public function setUser(User $user): void\n {\n $this->configureSentryScope($user);\n\n $this->team = $user->getTeam();\n $this->teamService->setTeam($user->getTeam());\n $this->setConfiguration($user->getTeam()->getCrmConfiguration());\n\n // Reset state.\n $this->profile = $user->getProfile();\n\n if (! $this->profile instanceof Profile) {\n // Try to fetch their profile, maybe it was not yet imported?\n $this->profile = $this->getProfile($user);\n }\n }\n\n public function setConfiguration(Configuration $configuration): void\n {\n $this->config = $configuration;\n $this->copperClient = $this->clientFactory->create($configuration);\n\n if ($this->team === null) {\n $this->team = $configuration->getTeam();\n }\n }\n\n public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string\n {\n return $this->fieldValueConverter->convertFromCrm($fieldType, $fieldValue);\n }\n\n private function mapObjectType(string $objectType): string\n {\n $objectType = strtolower($objectType);\n\n switch ($objectType) {\n case Field::OBJECT_TASK:\n case Field::OBJECT_OPPORTUNITY:\n case Field::OBJECT_LEAD:\n $mappedType = $objectType;\n\n break;\n case Field::OBJECT_ACCOUNT:\n $mappedType = 'company';\n\n break;\n case Field::OBJECT_CONTACT:\n $mappedType = 'person';\n\n break;\n default:\n $mappedType = self::UNSUPPORTED_OBJECT_TYPE;\n\n break;\n }\n\n return $mappedType;\n }\n\n /**\n * @param string $crmObject The name of the CRM object. i.e. Opportunity or Contact\n */\n protected function getFields(string $crmObject): array\n {\n $mappedObjectType = $this->mapObjectType($crmObject);\n\n if (! $this->isSupportedObjectType($mappedObjectType)) {\n return [];\n }\n\n $customFields = $this->copperClient->fields()->all();\n\n $filteredFields = array_filter(\n $customFields,\n static fn ($field) => in_array($mappedObjectType, $field['available_on'], true)\n );\n\n return array_map(\n fn (array $property) => [\n 'name' => $property['id'],\n 'label' => $property['name'],\n 'type' => $this->convertFieldType($property['data_type']),\n ],\n $filteredFields\n );\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultFields(string $activityType): array\n {\n $fields = [];\n\n if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {\n $defaultFields = FieldDefinitions::defaultTaskFields();\n\n // This lazy creates these fields if not already setup.\n foreach ($defaultFields as $defaultField) {\n $fields[] = $this->config->fields()->firstOrCreate($defaultField);\n }\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultActivityField(string $activityType): Field\n {\n // Setup the activity field as the default Type.\n $activityField = $this->config->fields()->where([\n 'crm_provider_id' => 'type',\n 'object_type' => $activityType,\n ])->first();\n\n return $activityField;\n }\n\n /**\n * @inheritdoc\n */\n public function getSupportedPlaybookTypes(): array\n {\n return [Playbook::ACTIVITY_TYPE_TASK];\n }\n\n public function getDealInsightsFields(): array\n {\n return FieldDefinitions::dealInsightsFields();\n }\n\n protected function getDefaultFollowupLayoutFields(string $activityType): array\n {\n $fields = [];\n $fieldRepo = app(FieldRepository::class);\n $fieldFilters = FieldDefinitions::taskFollowupFieldsFilter();\n\n foreach ($fieldFilters as $data) {\n $field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $data);\n\n // Only add the field if it is created, which it should be.\n if ($field) {\n $fields[] = $field;\n }\n }\n\n return $fields;\n }\n\n public function syncFields(): void\n {\n $this->syncStandardFields();\n $this->syncCustomFields();\n }\n\n public function syncField(Field $field): void\n {\n if ($field->getCrmProviderId() === 'type') {\n /**\n * This field is a special case, it is not a custom field and is not returned by the Copper API.\n *\n * The field is created manually during layout setup.\n *\n * @see getDefaultFields()\n *\n * There is a separate endpoint to get the activity types.\n * @see importPicklistValues()\n */\n return;\n }\n\n if ($field->getCrmProviderId() === 'pipeline_stage_id') {\n // Not a custom type\n return;\n }\n\n $crmField = $this->copperClient->fields()->find($field->getCrmProviderId());\n $field->label = $crmField['name'];\n $field->type = $this->convertFieldType($crmField['data_type']);\n\n $field->save();\n }\n\n /**\n * @param string $fieldType How the data should be input\n */\n private function convertFieldType(string $fieldType): string\n {\n $fieldType = strtolower($fieldType);\n\n switch ($fieldType) {\n case 'dropdown':\n $convertedType = Field::TYPE_PICKLIST;\n\n break;\n case 'string':\n $convertedType = Field::TYPE_TEXT;\n\n break;\n case 'float':\n case 'percentage':\n $convertedType = Field::TYPE_NUMBER;\n\n break;\n case 'text':\n $convertedType = Field::TYPE_TEXTAREA;\n\n break;\n case 'checkbox':\n $convertedType = Field::TYPE_BOOLEAN;\n\n break;\n case Field::TYPE_DATE:\n case Field::TYPE_CURRENCY:\n case Field::TYPE_URL:\n $convertedType = $fieldType;\n\n break;\n default:\n $convertedType = Field::TYPE_UNSUPPORTED;\n\n break;\n }\n\n return $convertedType;\n }\n\n /**\n * @inheritdoc\n */\n public function importPicklistValues(Field $field): array\n {\n $fieldValues = [];\n\n if ($field->object_type === 'opportunity' && $field->crm_provider_id === 'pipeline_stage_id') {\n return $this->importFieldValuesForDealInsights($field);\n }\n\n $values = [];\n\n if ($field->object_type === Field::OBJECT_TASK && $field->crm_provider_id === 'type') {\n $activityTypes = $this->copperClient->activityTypes()->all();\n\n /**\n * We ignore \"system\" activity types, disabled activity types and the \"Note\" activity type.\n */\n $activityTypes = array_filter(\n $activityTypes['user'],\n fn (array $activityType) => ! $activityType['is_disabled'] && $activityType['id'] !== 0\n );\n\n // Import all activity values.\n $i = 0;\n foreach ($activityTypes as $activityType) {\n $values[] = [\n 'value' => $activityType['id'],\n 'label' => mb_strimwidth($activityType['name'], 0, Field::LABEL_MAX_LENGTH),\n 'sequence' => $i++,\n ];\n }\n } else {\n // We just check for the field in the custom definitions.\n $crmField = $this->copperClient->fields()->find($field->crm_provider_id);\n\n // Import all values.\n foreach ($crmField['options'] as $option) {\n $values[] = [\n 'value' => $option['id'],\n 'label' => mb_strimwidth($option['name'], 0, 191),\n 'sequence' => $option['rank'],\n ];\n }\n }\n\n // Take the existing set of field values.\n $fieldsToPurge = $field->values()->get()->pluck('value')->toArray();\n\n foreach ($values as $value) {\n // For all current values, upsert the field.\n $value['value'] = substr($value['value'] ?? '', 0, 255);\n $fieldValues[] = $field->values()->updateOrCreate([\n 'value' => $value['value'],\n ], $value);\n\n // Remove this value from the ones we are going to purge.\n $key = array_search($value['value'], $fieldsToPurge, false);\n if ($key !== false) {\n unset($fieldsToPurge[$key]);\n }\n }\n\n // Delete the old values that are no longer used.\n $field->values()->whereIn('value', $fieldsToPurge)->delete();\n\n return $fieldValues;\n }\n\n /**\n *\n * @return FieldValue[]\n */\n private function importFieldValuesForDealInsights(Field $field): array\n {\n $fieldValues = [];\n $pipelines = $this->copperClient->pipelines()->all();\n\n foreach ($pipelines as $pipeline) {\n foreach ($pipeline['stages'] as $i => $dealStage) {\n $fieldValues[] = $field->values()->updateOrCreate(\n ['value' => $dealStage['id']],\n [\n 'label' => $dealStage['name'],\n 'sequence' => $i,\n ]\n );\n }\n }\n\n return $fieldValues;\n }\n\n /**\n * @inheritdoc\n */\n public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage\n {\n if ($types === null) {\n $types = [Stage::TYPE_LEAD, Stage::TYPE_OPPORTUNITY];\n }\n $missingStage = null;\n\n foreach ($types as $type) {\n if ($type === Stage::TYPE_LEAD) {\n $statuses = $this->requestWithExceptionHandling(\n fn () => $this->copperClient->leadStatuses()->all()\n );\n\n if (count($statuses) > 0) {\n $businessProcess = $this->config->businessProcesses()->updateOrCreate([\n 'crm_provider_id' => BusinessProcess::TYPE_LEAD,\n ], [\n 'team_id' => $this->team->id,\n 'name' => BusinessProcess::TYPE_LEAD,\n 'type' => BusinessProcess::TYPE_LEAD,\n 'is_selectable' => true,\n ]);\n\n // A record type is really a clone of the business process, used to store which record uses which pipeline.\n $this->config->recordTypes()->updateOrCreate([\n 'crm_provider_id' => BusinessProcess::TYPE_LEAD,\n ], [\n 'team_id' => $this->team->id,\n 'name' => BusinessProcess::TYPE_LEAD,\n 'is_selectable' => true,\n 'business_process_id' => $businessProcess->id ?? null,\n ]);\n\n $leadStatuses = [];\n foreach ($statuses as $status) {\n // Upsert all statuses for the team.\n $status = $this->config->stages()->updateOrCreate([\n 'crm_provider_id' => $status['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($status['name'], 0, 50),\n 'label' => mb_strimwidth($status['name'], 0, 191),\n 'type' => Stage::TYPE_LEAD,\n 'sequence' => $status['order'],\n 'is_selectable' => true,\n ]);\n\n if ($missingStageName && $missingStageName === $status['id']) {\n $missingStage = $status;\n }\n\n $leadStatuses[] = $status['id'];\n }\n\n $businessProcess->stages()->sync($leadStatuses);\n }\n } else {\n // We create a business process to contain the pipeline, and later store all stages against it.\n foreach ($this->copperClient->pipelines()->all() as $pipeline) {\n $businessProcess = $this->config->businessProcesses()->updateOrCreate([\n 'crm_provider_id' => $pipeline['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($pipeline['name'], 0, 150),\n 'type' => BusinessProcess::TYPE_OPPORTUNITY,\n 'is_selectable' => true,\n ]);\n\n // A record type is really a clone of the business process, used to store which record uses which pipeline.\n $this->config->recordTypes()->updateOrCreate([\n 'crm_provider_id' => $pipeline['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($pipeline['name'], 0, 150),\n 'is_selectable' => true,\n 'business_process_id' => $businessProcess->id ?? null,\n ]);\n\n $stages = [];\n\n foreach ($pipeline['stages'] as $i => $dealStage) {\n // Upsert all stages for the team.\n $stage = $this->config->stages()->updateOrCreate([\n 'crm_provider_id' => $dealStage['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($dealStage['name'], 0, 50),\n 'label' => mb_strimwidth($dealStage['name'], 0, 191),\n 'type' => Stage::TYPE_OPPORTUNITY,\n 'sequence' => $i,\n 'is_selectable' => true,\n 'probability' => $dealStage['win_probability'],\n ]);\n\n if ($missingStageName && $missingStageName === $dealStage['id']) {\n $missingStage = $stage;\n }\n\n $stages[] = $stage['id'];\n }\n\n $businessProcess->stages()->sync($stages);\n }\n }\n }\n\n return $missingStage;\n }\n\n /**\n * @inheritdoc\n */\n public function syncLeads(Carbon $since, ?Carbon $to = null, ?string $crmProfileId = null): int\n {\n $syncCount = 0;\n\n $cpLeads = $this->requestWithExceptionHandling(\n fn () => $this->copperClient->leads()->paginate([\n 'minimum_modified_date' => $since->timestamp,\n 'sort_by' => 'date_modified',\n ])\n );\n\n foreach ($cpLeads as $cpLead) {\n // Only sync if previously imported.\n if ($this->hasLead($cpLead['id']) === false) {\n $this->importLead($cpLead);\n $syncCount++;\n }\n }\n\n return $syncCount;\n }\n\n /**\n * @inheritdoc\n */\n public function syncLead(string $crmId): ?Lead\n {\n $cpLead = $this->requestWithExceptionHandling(\n fn () => $this->copperClient->leads()->find($crmId)\n );\n\n if (\\count($cpLead) === 0) {\n return null;\n }\n\n return $this->importLead($cpLead);\n }\n\n /**\n * @inheritdoc\n */\n private function importLead($crmData): ?Lead\n {\n $countryCode = null;\n if (! empty($crmData['address']) && isset($crmData['address']['country'])) {\n $countryCode = $this->convertCountryNameToCode($crmData['address']['country']);\n }\n\n $mobilePhone = $parsedNumber = null;\n if (! empty($crmData['phone_numbers'])) {\n foreach ($crmData['phone_numbers'] as $phoneNumber) {\n if ($phoneNumber['category'] === 'mobile') {\n $mobilePhone = $this->buildContactMobilePhone($countryCode, $phoneNumber['number']);\n } else {\n $parsedNumber = $this->buildContactPhone($countryCode, $phoneNumber['number']);\n }\n }\n }\n\n $name = $crmData['name'];\n\n if (empty($crmData['company_name'])) {\n $company = 'Unknown';\n } else {\n $company = mb_strimwidth($crmData['company_name'], 0, 191);\n }\n\n $domain = null;\n if (empty($crmData['websites']) === false) {\n $domain = mb_strimwidth($crmData['websites'][0]['url'], 0, 191);\n $domain = StringUtil::resolveDomain($domain);\n }\n\n $email = null;\n if (empty($crmData['email']) === false) {\n $email = mb_strimwidth($crmData['email']['email'], 0, 80);\n }\n\n $stage = $this->config\n ->stages()\n ->where('crm_provider_id', (string) $crmData['status_id'])\n ->where('type', Stage::TYPE_LEAD)\n ->first();\n\n if ($stage === null) {\n // Import it.\n $stage = $this->importStages([Stage::TYPE_LEAD], $crmData['status_id']);\n }\n\n if (! $stage instanceof Stage) {\n return null;\n }\n\n $profile = $this->config->profiles()->where('crm_provider_id', (string) $crmData['assignee_id'])->first();\n\n $data = [\n 'team_id' => $this->team->id,\n 'user_id' => $profile ? $profile->user_id : null,\n 'owner_id' => $crmData['assignee_id'],\n 'name' => mb_strimwidth($name, 0, 191),\n 'company' => $company,\n 'domain' => $domain,\n 'title' => $crmData['title'] ? mb_strimwidth($crmData['title'], 0, 128) : null,\n 'email' => $email,\n 'phone' => $parsedNumber['phone'] ?? null,\n 'ext' => $parsedNumber['ext'] ?? null,\n 'mobile_phone' => $mobilePhone,\n 'stage_id' => $stage->id,\n 'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(\n crmConfiguration: $this->config,\n crmProviderId: $crmData['id'],\n modelType: Lead::class,\n fileName: $crmData['id'],\n avatarText: $crmData['name']\n ),\n 'country_code' => $countryCode,\n 'remotely_created_at' => $crmData['date_created'],\n ];\n\n return $this->config->leads()->updateOrCreate(['crm_provider_id' => (string) $crmData['id']], $data);\n }\n\n /**\n * @inheritdoc\n */\n public function syncAccounts(Carbon $since, ?Carbon $to = null): int\n {\n $syncCount = 0;\n\n try {\n $cpAccounts = $this->copperClient->accounts()->paginate([\n 'minimum_modified_date' => $since->timestamp,\n 'sort_by' => 'date_modified',\n ]);\n\n foreach ($cpAccounts as $cpAccount) {\n // Only sync if previously imported.\n if ($this->hasAccount($cpAccount['id'])) {\n $this->importAccount($cpAccount);\n $syncCount++;\n }\n }\n } catch (\\Exception $exception) {\n // Do nothing for now.\n }\n\n return $syncCount;\n }\n\n /**\n * @inheritdoc\n */\n public function syncAccount(string $crmId): ?Account\n {\n $cpAccount = $this->copperClient->accounts()->find($crmId);\n\n if (empty($cpAccount)) {\n return null;\n }\n\n return $this->importAccount($cpAccount);\n }\n\n /**\n * @inheritdoc\n */\n private function importAccount($crmData): Account\n {\n $countryCode = null;\n if (empty($crmData['address']) === false && $crmData['address']['country']) {\n $countryCode = $this->convertCountryNameToCode($crmData['address']['country']);\n }\n\n $name = $crmData['name'];\n\n $parsedNumber = null;\n foreach ($crmData['phone_numbers'] as $phoneNumber) {\n $parsedNumber = parsePhoneNumber($countryCode, $phoneNumber['number']);\n }\n\n $domain = null;\n if (empty($crmData['websites']) === false) {\n $domain = mb_strimwidth($crmData['websites'][0]['url'], 0, 191);\n $domain = StringUtil::resolveDomain($domain);\n }\n\n $profile = $this->config->profiles()->where('crm_provider_id', (string) $crmData['assignee_id'])->first();\n\n $data = [\n 'team_id' => $this->team->id,\n 'user_id' => $profile ? $profile->user_id : null,\n 'owner_id' => $crmData['assignee_id'],\n 'name' => mb_strimwidth($name, 0, 191),\n 'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(\n crmConfiguration: $this->config,\n crmProviderId: $crmData['id'],\n modelType: Account::class,\n fileName: $crmData['id'],\n avatarText: $crmData['name']\n ),\n 'country_code' => $countryCode,\n 'domain' => $domain,\n 'phone' => $parsedNumber['phone'] ?? null,\n 'ext' => $parsedNumber['ext'] ?? null,\n 'remotely_created_at' => $crmData['date_created'],\n ];\n\n return $this->config->accounts()->updateOrCreate(['crm_provider_id' => (string) $crmData['id']], $data);\n }\n\n public function syncOpportunities(array $parameters, ?string $strategy = null): int\n {\n $strategyResolver = app(OpportunitySyncStrategyResolver::class, [\n 'client' => $this->copperClient,\n ]);\n $strategies = $strategyResolver->getStrategies($this->config, $strategy);\n\n $syncCount = 0;\n\n try {\n foreach ($strategies as $syncStrategy) {\n foreach ($syncStrategy->fetchOpportunities($parameters) as $opportunityData) {\n $this->importOpportunity($opportunityData);\n $syncCount++;\n }\n }\n } catch (\\Exception $exception) {\n // Do nothing for now.\n $this->logger->error(self::OPPORTUNITY_IMPORT_FAILED_ERROR_MESSAGE, [\n 'error' => $exception->getMessage(),\n ]);\n }\n\n return $syncCount;\n }\n\n public function syncOpportunity(string $crmId): ?Opportunity\n {\n $strategyResolver = app(OpportunitySyncStrategyResolver::class, [\n 'client' => $this->copperClient,\n ]);\n $strategy = $strategyResolver->resolve(\n $this->config,\n OpportunitySyncStrategyResolver::SINGLE_SYNC_OPPORTUNITY_STRATEGY\n );\n\n $parameters['crm_id'] = $crmId;\n\n $cpOpportunity = $strategy->fetchOpportunities($parameters);\n\n return $this->importOpportunity($cpOpportunity);\n }\n\n private function importOpportunity($crmData): ?Opportunity\n {\n $companyId = (string) $crmData['company_id'];\n\n if (empty($companyId)) {\n $this->logger->error(self::OPPORTUNITY_IMPORT_FAILED_ERROR_MESSAGE, [\n 'error' => 'No company id',\n ]);\n\n return null;\n }\n\n $account = $this->config->accounts()->where('crm_provider_id', $companyId)->first();\n\n if ($account === null) {\n $account = $this->syncAccount($companyId);\n }\n\n $profile = $this->config->profiles()->where('crm_provider_id', (string) $crmData['assignee_id'])->first();\n\n $businessProcess = $this->config\n ->businessProcesses()\n ->where('crm_provider_id', (string) $crmData['pipeline_id'])\n ->first();\n\n if ($businessProcess === null) {\n // It is possible for some deals to not be in a pipeline.\n $this->logger->warning(self::OPPORTUNITY_IMPORT_FAILED_ERROR_MESSAGE, [\n 'error' => 'Missing pipeline',\n ]);\n\n return null;\n }\n\n $stage = $businessProcess\n ->stages()\n ->where('crm_provider_id', (string) $crmData['pipeline_stage_id'])\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->first();\n\n if ($stage === null) {\n $this->logger->info('Stage not found, importing', [\n 'pipeline_id' => $crmData['pipeline_id'],\n 'stage_id' => $crmData['pipeline_stage_id'],\n ]);\n\n $stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $crmData['pipeline_stage_id']);\n }\n\n $recordType = $businessProcess->recordTypes()->first();\n $closeDate = null;\n $isClosed = $crmData['status'] !== self::OPPORTUNITY_STATUS_OPEN;\n\n if ($crmData['close_date']) {\n $closeDate = Carbon::parse($crmData['close_date'])->format('Y-m-d');\n }\n\n $data = [\n 'team_id' => $this->team->getId(),\n 'account_id' => $account->id,\n 'user_id' => $profile->user_id ?? null,\n 'owner_id' => $crmData['assignee_id'],\n 'name' => mb_strimwidth($crmData['name'], 0, 128),\n 'value' => $crmData['monetary_value'],\n 'currency_code' => CurrencyFormatter::formatCode($crmData['monetary_unit']),\n 'close_date' => $closeDate,\n 'is_closed' => $isClosed,\n 'is_won' => $crmData['status'] === 'Won',\n 'stage_id' => $stage->id,\n 'record_type_id' => $recordType->id ?? null,\n 'remotely_created_at' => $crmData['date_created'],\n ];\n\n /** @var Opportunity $opportunity */\n $opportunity = $this->config\n ->opportunities()\n ->updateOrCreate(['crm_provider_id' => (string) $crmData['id']], $data)\n ;\n\n // import external fields into crm_field_data if present\n $dealInsightsFields = $this->getOpportunitySyncableFields();\n $this->importOpportunityCrmFieldData($crmData, $dealInsightsFields, $opportunity->id);\n\n return $opportunity;\n }\n\n /**\n * @param array<string, mixed> $opportunityData\n * @param string[] $dealInsightsFields\n */\n public function importOpportunityCrmFieldData(\n array $opportunityData,\n array $dealInsightsFields,\n int $opportunityId\n ): void {\n foreach ($dealInsightsFields as $fieldName) {\n $field = $this->config->fields()->with('entities')\n ->where('crm_provider_id', $fieldName)\n ->where('object_type', Field::OBJECT_OPPORTUNITY)\n ->first();\n\n if (! $field instanceof Field) {\n $this->logger->warning('Field not found', [\n 'field' => $fieldName,\n 'opportunity_id' => $opportunityId,\n ]);\n\n continue;\n }\n\n $entity = $field->getEntities()->first();\n\n if (! $entity instanceof LayoutEntity) {\n continue;\n }\n\n $fieldValue = $this->extractFieldValue($opportunityData, $fieldName);\n $normalizedValue = $this->normalizeValue($field->getType(), (string) $fieldValue, true);\n\n $field->data()\n ->updateOrCreate([\n 'crm_layout_entity_id' => $entity->id,\n 'crm_field_id' => $field->id,\n 'object_type' => 'opportunity',\n 'object_id' => $opportunityId,\n ], ['value' => $normalizedValue])\n ;\n }\n }\n\n /**\n * @inheritdoc\n */\n public function syncContacts(Carbon $since, ?Carbon $to = null): int\n {\n $syncCount = 0;\n\n try {\n $cpContacts = $this->copperClient->contacts()->paginate([\n 'minimum_interaction_date' => $since->timestamp,\n 'sort_by' => 'date_modified',\n ]);\n\n foreach ($cpContacts as $cpContact) {\n // Only sync if previously imported.\n if ($this->hasContact($cpContact['id'])) {\n $this->importContact($cpContact);\n $syncCount++;\n }\n }\n } catch (\\Exception $exception) {\n // Do nothing for now.\n throw $exception;\n }\n\n return $syncCount;\n }\n\n /**\n * @inheritdoc\n */\n public function syncContact(string $crmId): ?Contact\n {\n $cpContact = $this->copperClient->contacts()->find($crmId);\n\n if (empty($cpContact)) {\n return null;\n }\n\n return $this->importContact($cpContact);\n }\n\n /**\n * @inheritdoc\n */\n private function importContact($crmData): Contact\n {\n $account = null;\n if ($crmData['company_id']) {\n $account = $this->team\n ->crm\n ->accounts()\n ->where('crm_provider_id', (string) $crmData['company_id'])\n ->first();\n\n if ($account === null) {\n $account = $this->syncAccount($crmData['company_id']);\n }\n }\n\n $countryCode = null;\n if (empty($crmData['address']) === false && $crmData['address']['country']) {\n $countryCode = $this->convertCountryNameToCode($crmData['address']['country']);\n }\n\n $mobilePhone = $parsedNumber = null;\n foreach ($crmData['phone_numbers'] as $phoneNumber) {\n if ($phoneNumber['category'] === 'mobile') {\n $mobilePhone = $this->buildContactMobilePhone($countryCode, $phoneNumber['number']);\n } else {\n $parsedNumber = $this->buildContactPhone($countryCode, $phoneNumber['number']);\n }\n }\n\n $email = null;\n if (empty($crmData['emails']) === false) {\n $email = mb_strimwidth($crmData['emails'][0]['email'], 0, 80);\n }\n\n $profile = $this->config->profiles()->where('crm_provider_id', (string) $crmData['assignee_id'])->first();\n\n $data = [\n 'team_id' => $this->team->id,\n 'account_id' => $account->id ?? null,\n 'user_id' => $profile ? $profile->user_id : null,\n 'owner_id' => $crmData['assignee_id'],\n 'name' => $crmData['name'],\n 'email' => $email,\n 'phone' => $parsedNumber['phone'] ?? null,\n 'mobile_phone' => $mobilePhone ?? null,\n 'ext' => $parsedNumber['ext'] ?? null,\n 'title' => $crmData['title'],\n 'country_code' => $countryCode,\n 'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(\n crmConfiguration: $this->config,\n crmProviderId: $crmData['id'],\n modelType: Contact::class,\n fileName: $crmData['id'],\n avatarText: $crmData['name']\n ),\n 'remotely_created_at' => $crmData['date_created'],\n ];\n\n return $this->config->contacts()->updateOrCreate(['crm_provider_id' => (string) $crmData['id']], $data);\n }\n\n private function buildContactPhone(?string $countryCode, ?string $number): ?array\n {\n if ($number) {\n // Trim to our width and attempt to parse it.\n $number = mb_strimwidth($number, 0, 25);\n $parsedNumber = parsePhoneNumber($countryCode, $number);\n } else {\n $parsedNumber = [];\n }\n\n return $parsedNumber;\n }\n\n private function buildContactMobilePhone(?string $countryCode, ?string $number): ?string\n {\n return $number ? mb_strimwidth(phone_e164($countryCode, $number), 0, 25) : null;\n }\n\n /**\n * @inheritdoc\n */\n public function syncOrganization(): void\n {\n $organization = $this->copperClient->organisation()->all();\n\n if ($this->config->crm_provider_id !== $organization['id']) {\n $this->config->update(['crm_provider_id' => $organization['id']]);\n }\n }\n\n private function syncCustomFields(): void\n {\n foreach ($this->getFieldTypes() as $fieldType) {\n $currentFields = $this->getFields($fieldType);\n\n foreach ($currentFields as $field) {\n /** @var Field $field */\n $field = $this->config->fields()->updateOrCreate([\n 'crm_provider_id' => $field['name'],\n 'object_type' => $fieldType,\n ], [\n 'label' => mb_strimwidth($field['label'], 0, Field::LABEL_MAX_LENGTH),\n 'type' => $field['type'],\n ]);\n\n if ($field->getType() === Field::TYPE_PICKLIST) {\n $this->importPicklistValues($field);\n }\n }\n }\n\n $this->config->save();\n }\n\n protected function getFieldTypes(): array\n {\n return [\n parent::OBJECT_TASK,\n parent::OBJECT_LEAD,\n parent::OBJECT_ACCOUNT,\n parent::OBJECT_CONTACT,\n parent::OBJECT_OPPORTUNITY,\n ];\n }\n\n /**\n * @inheritdoc\n */\n public function syncProfiles(?User $userToSearch = null): ?Profile\n {\n $profiles = $this->copperClient->users()->all();\n\n foreach ($profiles as $profile) {\n $user = $this->userRepository->findByTeamAndEmailAddress($this->team, $profile['email'], onlyActive: true);\n\n // Only import profiles for users we have locally.\n if ($user) {\n $profile = $user->crmProfile()->updateOrCreate([\n 'crm_configuration_id' => $this->config->id,\n 'crm_provider_id' => $profile['id'],\n ]);\n\n if ($userToSearch && $userToSearch->id === $user->id) {\n return $profile;\n }\n }\n }\n\n return null;\n }\n\n /**\n * @inheritdoc\n */\n public function syncProfileFields(): void\n {\n $fieldTypes = [\n 'task',\n 'lead',\n 'account',\n 'contact',\n 'opportunity',\n ];\n\n foreach ($fieldTypes as $fieldType) {\n try {\n $currentFields = $this->getFields(ucfirst($fieldType));\n\n $newFields = array_column($currentFields, 'name');\n $changedFields = array_merge(\n array_diff($this->profile->getFieldsAsArray($fieldType), $newFields),\n array_diff($newFields, $this->profile->getFieldsAsArray($fieldType))\n );\n\n if (\\count($changedFields) > 0) {\n $fields = implode(',', $newFields);\n $this->logger->debug(\n ucfirst($fieldType)\n . ' fields changed from '\n . $this->profile->{$fieldType . '_fields'}\n . ' to: '\n . $fields\n );\n\n $this->profile->{$fieldType . '_fields'} = $fields;\n }\n } catch (\\Exception $exception) {\n $this->logger->error('No access to ' . $fieldType . ' object, skipping...');\n\n continue;\n }\n }\n\n $this->profile->save();\n }\n\n /**\n * @inheritdoc\n */\n public function find(string $name, array $scopes): array\n {\n $count = $this->limit ?? 20;\n $offset = $this->offset ?? 0;\n\n $scopeValues = array_map(fn ($scope) => is_string($scope) ? $scope : $scope->value, $scopes);\n $cacheKey = sprintf('copper-search-%d-%s-%s-%d-%d', $this->team->id, $name, implode('-', $scopeValues), $count, $offset);\n\n return Cache::remember($cacheKey, 300, function () use ($name, $scopes, $count, $offset) {\n // Try local search first\n $localResults = $this->searchLocally($name, $scopes, $count, $offset);\n\n // If no local results, fall back to remote API search\n if (empty($localResults)) {\n return $this->searchRemotely($name, $scopes, $count, $offset);\n }\n\n return $localResults;\n });\n }\n\n /**\n * @inheritdoc\n */\n public function findOpportunities(?string $crmAccountId, ?string $crmContactId, ?int $userId = null): array\n {\n $data = [];\n $ownerData = [];\n $ownerId = null;\n $contact = null;\n $account = null;\n\n if ($crmAccountId) {\n $account = $this->config->accounts()->where('crm_provider_id', $crmAccountId)->first();\n\n if ($account === null) {\n $account = $this->syncAccount($crmAccountId);\n }\n }\n\n if ($crmContactId) {\n $contact = $this->config->contacts()->where('crm_provider_id', $crmContactId)->first();\n\n if ($contact === null) {\n $contact = $this->syncContact($crmContactId);\n }\n }\n\n if ($contact || $account) {\n if ($contact && $account === null) {\n $account = $contact->account;\n }\n\n if ($account === null) {\n return [];\n }\n\n $params = [\n 'company_ids' => [$account->crm_provider_id],\n 'status_ids' => [0], // The integer values are 0, 1, 2, 3, for \"Open\", \"Won\", \"Lost\", and \"Abandoned\".\n 'sort_by' => 'date_modified',\n 'sort_direction' => 'desc',\n ];\n\n switch ($this->config->opportunity_assignment_rule) {\n case Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_UPDATED:\n $params['sort_by'] = 'date_modified';\n $params['sort_direction'] = 'desc';\n\n break;\n\n case Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_CREATED:\n $params['sort_by'] = 'date_created';\n $params['sort_direction'] = 'desc';\n\n break;\n\n case Configuration::OPP_ASSIGNMENT_ALL_OPEN_OLDEST_CREATED:\n $params['sort_by'] = 'date_created';\n $params['sort_direction'] = 'asc';\n\n break;\n\n case Configuration::OPP_ASSIGNMENT_ALL_RECENTLY_UPDATED:\n $params['sort_by'] = 'date_modified';\n $params['sort_direction'] = 'desc';\n $params['status_ids'] = [0, 1, 2, 3];\n }\n\n $cpOpportunities = $this->copperClient->opportunities()->search($params);\n\n if ($userId) {\n $profileRepository = app(ProfileRepository::class);\n $profile = $profileRepository->findProfileByUserId($this->config, $userId);\n\n $ownerId = $profile instanceof Profile ? $profile->getCrmProviderId() : null;\n }\n\n foreach ($cpOpportunities as $cpOpportunity) {\n $businessProcess = $this->config\n ->businessProcesses()\n ->where('crm_provider_id', (string) $cpOpportunity['pipeline_id'])\n ->first();\n\n if ($businessProcess === null) {\n // Import it.\n $businessProcess = $this->importStages(\n [Stage::TYPE_OPPORTUNITY],\n $cpOpportunity['pipeline_stage_id']\n );\n }\n\n $stage = $businessProcess\n ->stages()\n ->where('crm_provider_id', (string) $cpOpportunity['pipeline_stage_id'])\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->first();\n\n if ($stage === null) {\n // Import it.\n $stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $cpOpportunity['pipeline_stage_id']);\n }\n\n $recordType = $businessProcess->recordTypes()->first();\n\n $record = [\n 'crmId' => $cpOpportunity['id'],\n 'name' => $cpOpportunity['name'],\n 'value' => currency_format($cpOpportunity['monetary_value'], $cpOpportunity['monetary_unit']),\n 'won' => $cpOpportunity['status'] === 'Won',\n 'stage' => [\n 'id' => $stage->id_string,\n 'name' => $stage->name,\n ],\n 'recordType' => [\n 'id' => $recordType->id_string,\n 'name' => $recordType->name,\n ],\n ];\n\n if ($ownerId && isset($cpOpportunity['assignee_id']) && $cpOpportunity['assignee_id'] === $ownerId) {\n $ownerData[] = $record;\n }\n\n $data[] = $record;\n }\n }\n\n if (! empty($ownerData)) {\n return $ownerData;\n }\n\n return $data;\n }\n\n /**\n * @inheritdoc\n */\n public function getTasks(string $objectType, string $objectId, ?string $opportunityId): array\n {\n $data = $cpTasks = [];\n\n switch ($objectType) {\n case 'lead':\n $cpTasks = $this->requestWithExceptionHandling(\n fn () => $this->copperClient->leads()->relatedTasks($objectId)\n );\n\n break;\n\n case 'account':\n $cpTasks = $this->copperClient->accounts()->relatedTasks($objectId);\n\n break;\n case 'contact':\n if ($opportunityId) {\n $cpTasks = $this->copperClient->opportunities()->relatedTasks($opportunityId);\n } elseif ($objectType === 'contact') {\n $cpTasks = $this->copperClient->contacts()->relatedTasks($objectId);\n }\n\n break;\n }\n\n foreach ($cpTasks as $cpTask) {\n // We only get the ID itself returned, so pull out the full object.\n try {\n $cpTask = $this->copperClient->tasks()->find($cpTask['id']);\n } catch (Exception $exception) {\n $this->logger->error('[Copper] Error fetching task', [\n 'resource_id' => $cpTask['id'],\n 'error' => $exception->getMessage(),\n ]);\n\n continue;\n }\n\n // Only allow open tasks.\n if ($cpTask['status'] === 'Open') {\n $data[] = [\n 'crmId' => $cpTask['id'],\n 'subject' => $cpTask['name'],\n 'due' => $cpTask['due_date']\n ? Carbon::createFromTimestamp($cpTask['due_date'])->format('Y-m-d\\TH:i:sO')\n : null,\n 'type' => null,\n ];\n }\n }\n\n return $data;\n }\n\n /**\n * Try to find email address in CRM service\n *\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n *}\n */\n public function matchExactlyByEmail(string $email, ?int $userId = null): ?array\n {\n $cpObjectType = 'contact';\n\n $cpObjects = $this->copperClient->contacts()->search([\n 'emails' => $email,\n 'sort_by' => 'date_modified',\n 'sort_direction' => 'desc',\n ]);\n\n if (empty($cpObjects)) {\n $cpObjectType = 'lead';\n $cpObjects = $this->requestWithExceptionHandling(\n fn () => $this->copperClient->leads()->search([\n 'emails' => $email,\n 'sort_by' => 'date_modified',\n 'sort_direction' => 'desc',\n ])\n );\n }\n\n if (empty($cpObjects)) {\n return null;\n }\n\n // Search returns an array of results, but we only want the first\n $cpObject = reset($cpObjects);\n $data = $this->convertCrmData($cpObject, $cpObjectType, $userId);\n\n return ! empty(array_filter($data)) ? $data : null;\n }\n\n /**\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n public function matchByPhone(string $phone, ?string $rawPhoneNumber = null, ?int $userId = null): ?array\n {\n // Check if the user is internal.\n $teamMember = $this->team->users()->where('phone', $phone)->exists();\n\n // Skip the attendee if internal.\n if ($teamMember === false) {\n $cpObjectType = 'contact';\n $cpObject = $this->copperClient->contacts()->search([\n 'phone_number' => $phone,\n 'sort_by' => 'date_modified',\n 'sort_direction' => 'desc',\n ]);\n\n if (\\count($cpObject) === 0) {\n $cpObjectType = 'lead';\n $cpObject = $this->requestWithExceptionHandling(\n fn () => $this->copperClient->leads()->search([\n 'phone_number' => $phone,\n 'sort_by' => 'date_modified',\n 'sort_direction' => 'desc',\n ])\n );\n }\n\n if (empty($cpObject)) {\n return null;\n }\n\n $cpObject = reset($cpObject);\n $data = $this->convertCrmData($cpObject, $cpObjectType, $userId);\n\n return ! empty(array_filter($data)) ? $data : null;\n }\n\n return null;\n }\n\n /**\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n public function matchByName(string $name, ?int $userId = null): ?array\n {\n // Don't waste time searching for single character strings.\n if (\\strlen($name) <= 1) {\n return null;\n }\n\n $result = \\Cache::remember($this->team->id . $name, 60, function () use ($name, $userId) {\n $cpObjectType = 'contact';\n $cpObject = $this->copperClient->contacts()->search([\n 'name' => $name,\n 'sort_by' => 'date_modified',\n 'sort_direction' => 'desc',\n ]);\n\n if (\\count($cpObject) === 0) {\n $cpObjectType = 'lead';\n $cpObject = $this->requestWithExceptionHandling(\n fn () => $this->copperClient->leads()->search([\n 'name' => $name,\n 'sort_by' => 'date_modified',\n 'sort_direction' => 'desc',\n ])\n );\n }\n\n if (! empty($cpObject)) {\n $cpObject = reset($cpObject);\n }\n\n $data = $this->convertCrmData($cpObject, $cpObjectType, $userId);\n\n return ! empty(array_filter($data)) ? $data : false;\n });\n\n return is_array($result) ? $result : null;\n }\n\n private function convertCrmData(array $object, string $objectType, ?int $userId = null): array\n {\n $lead = null;\n $contact = null;\n $opportunity = null;\n $account = null;\n $stage = null;\n $countryCode = null;\n\n if ($objectType === 'lead') {\n $lead = $this->importLead($object);\n\n // Lead might not be imported if the Stage is null for example.\n if ($lead) {\n $countryCode = $lead->country_code;\n $stage = $lead->stage;\n }\n } else {\n $contact = $this->importContact($object);\n $account = $contact->account;\n\n if ($contact->country_code) {\n $countryCode = $contact->country_code;\n } elseif ($account) {\n $countryCode = $account->country_code;\n }\n\n try {\n $cpOpportunities = $this->findOpportunities(\n $account?->crm_provider_id,\n $contact->crm_provider_id,\n $userId,\n );\n\n if (! empty($cpOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($cpOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n } catch (\\Exception $e) {\n // Nothing to see here.\n }\n }\n\n return [\n $lead,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n }\n\n public function saveActivity(Activity $activity): Activity\n {\n switch ($activity->type) {\n case Activity::TYPE_CONFERENCE:\n case Activity::TYPE_SOFTPHONE:\n case Activity::TYPE_SOFTPHONE_INBOUND:\n $activity = $this->buildCallPayload($activity);\n\n break;\n case Activity::TYPE_SMS_INBOUND:\n case Activity::TYPE_SMS_OUTBOUND:\n $activity = $this->buildTextMessagePayload($activity);\n\n break;\n }\n\n return $activity;\n }\n\n private function convertActivityAssociations(Activity $activity): array\n {\n if ($activity->lead) {\n $type = 'lead';\n $id = $activity->lead->crm_provider_id;\n } elseif ($activity->contact) {\n $type = 'person';\n $id = $activity->contact->crm_provider_id;\n } elseif ($activity->opportunity) {\n $type = 'opportunity';\n $id = $activity->opportunity->crm_provider_id;\n } else {\n $type = 'company';\n $id = $activity->account->crm_provider_id;\n }\n\n return [\n 'type' => $type,\n 'id' => $id,\n ];\n }\n\n private function convertActivityType(PlaybookCategory $activityType): array\n {\n return [\n 'category' => 'user',\n 'id' => $this->getCrmType($activityType)->value,\n ];\n }\n\n /**\n * @throws CrmException\n */\n private function buildCallPayload(Activity $activity): Activity\n {\n try {\n if ($activity->crm_provider_id) {\n // The activity should be logged under the existing Task (not Activity).\n $newActivityType = $this->getCrmType($activity->category);\n if ($newActivityType === null) {\n $message = sprintf('[Copper] invalid CRM Custom Activity Type: %s', $activity->category);\n\n throw new CrmException($message, 400);\n }\n $this->copperClient->tasks()->update($activity->crm_provider_id, [\n 'status' => 'Completed',\n 'custom_activity_type_id' => $newActivityType->value,\n 'details' => $this->generateActivityDescription($activity),\n ]);\n } else {\n if ($this->getCrmType($activity->category) === null) {\n $message = sprintf('[Copper] Invalid CRM Activity Type: %s', $activity->category->getName());\n\n throw new CrmException($message, 400);\n }\n\n if ($this->getCrmType($activity->category)->value === null) {\n $message = sprintf('[Copper] Invalid CRM Activity Type: %s value', $activity->category->getName());\n\n throw new CrmException($message, 400);\n }\n // Just create an activity.\n $cpActivity = $this->copperClient->activities()->create([\n 'user_id' => $this->profile->crm_provider_id,\n 'activity_date' => $activity->scheduled_start_time->timestamp,\n 'parent' => $this->convertActivityAssociations($activity),\n 'type' => $this->convertActivityType($activity->category),\n 'details' => $this->generateActivityDescription($activity),\n ]);\n\n $activity->crm_provider_id = $cpActivity['id'];\n $activity->save();\n }\n } catch (ClientException $exception) {\n //Log all errors to Sentry\n \\Sentry::captureException($exception);\n\n $response = $exception->getResponse();\n\n $responseBody = $response->getBody();\n $message = $responseBody;\n $errorCode = $response->getStatusCode();\n\n $jsonResponse = json_decode($responseBody, true);\n if (isset($jsonResponse[0]['message'])) {\n $message = $jsonResponse[0]['message'];\n }\n\n throw new CrmException($message, $errorCode);\n }\n\n return $activity;\n }\n\n private function buildTextMessagePayload(Activity $activity): Activity\n {\n if ($this->getCrmType($activity->category) === null) {\n $message = sprintf('[Copper] Invalid CRM Activity Type: %s', $activity->category->getName());\n\n throw new CrmException($message, 400);\n }\n\n if ($this->getCrmType($activity->category)->value === null) {\n $message = sprintf('[Copper] Invalid CRM Activity Type: %s value', $activity->category->getName());\n\n throw new CrmException($message, 400);\n }\n\n $cpActivity = $this->copperClient->activities()->create([\n 'user_id' => $this->profile->crm_provider_id,\n 'activity_date' => $activity->created_at->timestamp,\n 'parent' => $this->convertActivityAssociations($activity),\n 'type' => $this->convertActivityType($activity->category),\n 'details' => $this->generateActivityDescription($activity),\n ]);\n\n $activity->crm_provider_id = $cpActivity['id'];\n $activity->save();\n\n return $activity;\n }\n\n private function generateActivityDescription(Activity $activity): string\n {\n $description = '';\n\n switch ($activity->type) {\n case Activity::TYPE_SOFTPHONE:\n case Activity::TYPE_SOFTPHONE_INBOUND:\n case Activity::TYPE_CONFERENCE:\n\n $description = 'Jiminny Call Summary' . PHP_EOL . PHP_EOL;\n $description .= 'Call Duration: ' . $activity->duration_for_humans . PHP_EOL . PHP_EOL;\n\n // Tell them which number was dialed.\n if ($activity->to) {\n $description .= 'Dialed: ' . $activity->to->phone_number . PHP_EOL . PHP_EOL;\n }\n\n if ($activity->hasReasonCodeBotKicked()) {\n $description .= 'Notetaker removed from this meeting' . PHP_EOL . PHP_EOL;\n // When we fix the state to be Activity::RECORDING_RECORDED as it should be this can change.\n } elseif ($activity->hasReasonCodeNotCompliant()) {\n $description .= 'Notetaker did not join due to recording consent not being provided by attendees' . PHP_EOL . PHP_EOL;\n } elseif ($activity->canReviewActivity()) {\n $playbackUrl = $activity->user->team->partner->getPlaybackUrl($activity);\n $description .= 'Review Activity:' . PHP_EOL . $playbackUrl . PHP_EOL . PHP_EOL;\n }\n\n if ($activity->getCrmType() === Activity::TYPE_CONFERENCE) {\n $description .= 'Attendees:'\n . PHP_EOL\n . (new FilterJoinedParticipants())->toString($activity);\n }\n\n if (\\count($activity->notes) > 0) {\n $description .= PHP_EOL . PHP_EOL . 'Notes: ' . PHP_EOL;\n\n foreach ($activity->notes as $note) {\n $nTime = (int) ceil($note->time);\n $time = ($nTime > 3600) ? gmdate('H:i:s', $nTime) : gmdate('i:s', $nTime);\n $description .= $time . ' ' . $note->note . PHP_EOL;\n }\n }\n\n // Get all private messages.\n $messages = $activity->messages()\n ->where('is_private', 1)\n ->orderBy('created_at', 'asc');\n\n if ($messages->count() > 0) {\n $description .= PHP_EOL . PHP_EOL . 'Coaching Chat: ' . PHP_EOL;\n\n foreach ($messages->get() as $message) {\n $description .= $message->participant->name . ': ' . $message->message . PHP_EOL;\n }\n }\n\n // Get all public messages.\n $messages = $activity->messages()\n ->where('is_private', 0)\n ->orderBy('created_at', 'asc');\n\n if ($messages->count() > 0) {\n $description .= PHP_EOL . PHP_EOL . 'Customer Chat: ' . PHP_EOL;\n\n foreach ($messages->get() as $message) {\n $description .= $message->participant->name . ': ' . $message->message . PHP_EOL;\n }\n }\n\n if ($activity->summary) {\n $description .= PHP_EOL . PHP_EOL . 'Summary: ' . PHP_EOL . $activity->summary;\n }\n\n break;\n\n case Activity::TYPE_SMS_INBOUND:\n case Activity::TYPE_SMS_OUTBOUND:\n $description = $activity->description;\n\n break;\n }\n\n return $description;\n }\n\n public function saveFollowupActivity(Activity $activity, array $fields): ?string\n {\n // This is the user provided activity subject field.\n if (empty($fields['type'])) {\n return null;\n }\n\n $reminder = null;\n if (empty($fields['reminder_date']) === false) {\n $formatReminder = Carbon::createFromFormat('Y-m-d\\TH:i:s\\Z', $fields['reminder_date']);\n $reminder = $formatReminder->getTimestamp();\n }\n\n $due = null;\n if (empty($fields['due_date']) === false) {\n $formatDue = Carbon::createFromFormat('Y-m-d\\TH:i:s\\Z', $fields['due_date']);\n $due = $formatDue->getTimestamp();\n }\n\n $data = [\n 'custom_activity_type_id' => $fields['type'],\n 'status' => 'Open',\n 'assignee_id' => $this->profile->crm_provider_id,\n 'due_date' => $due,\n 'details' => $fields['details'] ?? '',\n 'reminder_date' => $reminder,\n 'name' => $fields['name'] ?? 'Follow-up',\n 'related_resource' => $this->convertActivityAssociations($activity),\n 'custom_fields' => $this->fetchCustomFieldData($activity, Field::OBJECT_TASK),\n ];\n\n // We don't actually create a corresponding activity object on our side yet.\n return $this->copperClient->tasks()->create($data)['id'];\n }\n\n /**\n * Store transcripts as note.\n *\n * @throws \\Exception\n */\n public function createTranscriptNotes(Activity $activity): void\n {\n // Generate activity transcription.\n $transcriptionData = $this->generateTranscription($activity);\n\n // Truncate Notes with max notes length because transcription text could be very long.\n $transcripts = mb_strimwidth($transcriptionData, 0, static::NOTE_BODY_MAX_LENGTH);\n\n $data = [\n 'user_id' => $this->profile->crm_provider_id,\n 'type' => [\n 'category' => 'user',\n 'id' => 0, // This creates a note.\n ],\n 'activity_date' => time(),\n 'details' => $transcripts,\n 'parent' => $this->convertActivityAssociations($activity),\n ];\n\n $cpActivity = $this->copperClient->activities()->create($data);\n\n // Store CRM Activity ID in transcription.\n $transcription = $activity->getTranscription();\n $transcription->crm_activity_id = $cpActivity['id'];\n $transcription->save();\n }\n\n /**\n * @inheritdoc\n */\n public function updateStage($crmObject, Stage $stage): void\n {\n if ($crmObject instanceof Lead) {\n $this->requestWithExceptionHandling(\n fn () => $this->copperClient->leads()->update($crmObject->crm_provider_id, [\n 'status_id' => $stage->crm_provider_id,\n ])\n );\n } else {\n $this->copperClient->opportunities()->update($crmObject->crm_provider_id, [\n 'pipeline_stage_id' => $stage->crm_provider_id,\n ]);\n }\n }\n\n public function prepareValueForUpdate(array $params): array\n {\n return [$params['fieldName'] => $params['fieldValue']];\n }\n\n /*\n * @inheritdoc\n */\n public function updateRecord(string $objectType, string $objectId, array $data, array $headers = []): void\n {\n $preparedData = $data;\n\n if ($objectType === Field::OBJECT_OPPORTUNITY) {\n $preparedData = $this->prepareOpportunityFieldsForUpdate($data);\n }\n\n try {\n $this->copperClient\n ->createResourceFromObjectType($objectType)\n ->update($objectId, $preparedData)\n ;\n } catch (Exception $e) {\n $this->logger->error(self::RESOURCE_UPDATE_FAILED_ERROR_MESSAGE, [\n 'resource_id' => $objectId,\n 'data' => $preparedData,\n 'error' => $e->getMessage(),\n ]);\n\n throw new CrmException(self::RESOURCE_UPDATE_FAILED_ERROR_MESSAGE, $e->getCode(), $e);\n }\n }\n\n /**\n * @param array<string,string> $fieldData\n *\n * @throws CrmException\n *\n * @return array<string,array<int,array<string,bool|float|int|string|null>>|bool|float|int|string|null>\n */\n private function prepareOpportunityFieldsForUpdate(array $fieldData): array\n {\n $preparedData = [];\n $customFields = [];\n\n foreach ($fieldData as $fieldName => $fieldValue) {\n $field = $this->config\n ->fields()\n ->where('crm_provider_id', $fieldName)\n ->first();\n\n if (! $field instanceof Field) {\n throw new CrmException('Field not found');\n }\n\n $convertedValue = $this->fieldValueConverter->convertToCrm($field->getType(), $fieldValue);\n\n if ($this->isStandardField($fieldName)) {\n $preparedData[$fieldName] = $convertedValue;\n\n continue;\n }\n\n $customFields[] = [\n 'custom_field_definition_id' => $fieldName,\n 'value' => $convertedValue,\n ];\n }\n\n if (! empty($customFields)) {\n $preparedData['custom_fields'] = $customFields;\n }\n\n return $preparedData;\n }\n\n /*\n * @inheritdoc\n */\n public function getRecord(string $objectType, string $objectId, array $fields = []): array\n {\n return $this->copperClient->createResourceFromObjectType($objectType)->find($objectId);\n }\n\n private function fetchCustomFieldData(Activity $activity, string $objectType): array\n {\n $payload = [];\n\n $fieldData = FieldData::where([\n 'object_id' => $activity->id,\n ])->whereHas('field', function ($query) use ($objectType) {\n $query->where('object_type', $objectType);\n })->get();\n\n foreach ($fieldData as $data) {\n // Add the field and value to the payload.\n $payload += [\n 'custom_field_definition_id' => $data->field->crm_provider_id,\n 'value' => $data->value,\n ];\n }\n\n return $payload;\n }\n\n public function generateProviderUrl(string $providerId, string $objectType): ?string\n {\n $baseUrl = 'https://app.prosperworks.com/companies/' . $this->config->crm_provider_id . '/app#/';\n switch ($objectType) {\n case 'lead':\n $url = $baseUrl . 'leads/' . $providerId;\n\n break;\n\n case 'account':\n $url = $baseUrl . 'organization/' . $providerId;\n\n break;\n\n case 'contact':\n $url = $baseUrl . 'contact/' . $providerId;\n\n break;\n\n case 'opportunity':\n $url = $baseUrl . 'deal/' . $providerId;\n\n break;\n\n default:\n case 'task':\n case 'activity':\n // Sadly we can't deeplink to these in Copper UI.\n $url = null;\n }\n\n return $url;\n }\n\n /**\n * Generate transcription for the activity.\n */\n private function generateTranscription(Activity $activity): string\n {\n if (! ($this->config->store_transcript)) {\n // If sending transcription to activity toggle is disabled\n return '';\n }\n\n return $this->transcriptionService\n ->findTranscriptionByActivity($activity)\n ->map(static function (array $transcriptionSegment): string {\n return $transcriptionSegment['formattedStartsAt'] . ' | ' . $transcriptionSegment['transcript'];\n })\n ->implode(PHP_EOL);\n }\n\n /**\n * @param array<string, mixed> $opportunityData\n *\n * @return int|string|bool|float|null\n */\n private function extractFieldValue(array $opportunityData, string $fieldName)\n {\n $fieldValue = $this->isStandardField($fieldName)\n ? $opportunityData[$fieldName]\n : $this->resolveCustomFieldFromOpportunityData($opportunityData, $fieldName);\n\n if (is_array($fieldValue)) {\n $fieldValue = implode(', ', $fieldValue);\n }\n\n return $fieldValue;\n }\n\n private function isStandardField(string $fieldName): bool\n {\n return array_key_exists($fieldName, self::STANDARD_OPPORTUNITY_FIELDS);\n }\n\n /**\n * @param array<string, mixed> $opportunityData\n *\n * @return string|int|float|bool|null|array<string, mixed>\n */\n private function resolveCustomFieldFromOpportunityData(array $opportunityData, string $fieldName)\n {\n $fieldData = array_filter(\n $opportunityData['custom_fields'],\n static fn ($field) => (string) $field['custom_field_definition_id'] === $fieldName\n );\n\n return array_pop($fieldData)['value'] ?? '';\n }\n\n private function isSupportedObjectType(string $objectType): bool\n {\n return $objectType !== self::UNSUPPORTED_OBJECT_TYPE;\n }\n\n private function syncStandardFields(): void\n {\n foreach (self::STANDARD_OPPORTUNITY_FIELDS as $fieldId => $fieldData) {\n $this->config->fields()->updateOrCreate([\n 'crm_provider_id' => mb_strimwidth($fieldId, 0, 128),\n 'object_type' => Field::OBJECT_OPPORTUNITY,\n ], [\n 'label' => mb_strimwidth($fieldData['label'], 0, Field::LABEL_MAX_LENGTH),\n 'type' => $fieldData['type'],\n ]);\n }\n\n $this->importStages([Stage::TYPE_OPPORTUNITY]);\n }\n\n private function requestWithExceptionHandling(\\Closure $apiRequest): iterable\n {\n try {\n return $apiRequest();\n } catch (\\Exception $exception) {\n if ($exception->getCode() === 403) {\n // Object might not be supported\n return [];\n }\n\n \\Sentry::captureException($exception);\n\n return [];\n }\n }\n\n public function verifyTaskExists(Activity $activity): bool\n {\n $crmProviderId = $activity->getCrmProviderId();\n $cacheKey = \"crm_task_exists:{$this->config->getId()}:$crmProviderId\";\n\n return Cache::remember($cacheKey, self::TASK_VERIFICATION_CACHE_TTL, function () use ($crmProviderId) {\n try {\n if ($this->copperClient === null) {\n return false;\n }\n\n $task = $this->copperClient->tasks()->find($crmProviderId);\n\n return ! empty($task);\n } catch (HttpNotFoundException) {\n // Task not found in CRM - this is expected and permanent\n $this->logger->info('[Copper] Task not found during verification', [\n 'task_id' => $crmProviderId,\n 'config_id' => $this->config->getId(),\n ]);\n\n return false;\n } catch (RequestFailedException $e) {\n // Handle 404 responses from Copper API\n if ($e->getCode() === 404) {\n $this->logger->info('[Copper] Task not found during verification (404)', [\n 'task_id' => $crmProviderId,\n 'config_id' => $this->config->getId(),\n ]);\n\n return false;\n }\n\n // Re-throw other Copper exceptions for retry\n throw $e;\n }\n // Let other exceptions (network errors, rate limits, etc.) bubble up for retry\n });\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":true,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Execute","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Explain Plan","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Browse Query History","depth":4,"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"View Parameters","depth":4,"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Open Query Execution Settings…","depth":4,"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"In-Editor Results","depth":4,"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Tx: Auto","depth":4,"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Cancel Running Statements","depth":4,"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Playground","depth":4,"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"jiminny","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.042220745,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"8","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"1","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"3","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"4","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"SELECT\n# DISTINCT\nopp.id as opp_id, opp.uuid, opp.name,\n# COUNT(dr.id) AS deal_risk_count,\n\n# dr.id,\n# cfd.value,\nusr.name AS owner_name,\nopp.value, opp.currency_code, opp.close_date, opp.remotely_created_at, opp.is_closed, opp.is_won,\nusr.uuid as owner_uuid,\nusr.photo_path as owner_photo,\nusr.id AS owner_id,\njt.name as owner_job,\nopp.stage_id, opp.stage_updated_at,\nacc.name AS acc_name, opp.stage_updated_at, acc.crm_provider_id AS acc_provider_id, opp.crm_provider_id AS opp_provider_id,\nrt.business_process_id AS pipeline_id\nFROM opportunities opp\nLEFT JOIN record_types rt ON opp.record_type_id = rt.id\nLEFT JOIN users usr ON opp.user_id = usr.id\nLEFT JOIN accounts acc ON opp.account_id = acc.id\nLEFT JOIN job_titles jt ON usr.job_title_id = jt.id\n\n# LEFT JOIN group_deal_risk_types gdrt ON gdrt.group_id = usr.group_id\n# LEFT JOIN deal_risks dr ON dr.opportunity_id = opp.id AND dr.group_deal_risk_type_id = gdrt.id\n\nLEFT JOIN crm_field_data cfd ON (cfd.object_id = opp.id and cfd.crm_field_id = 66814)\n\nWHERE opp.user_id IS NOT NULL\nAND opp.deleted_at IS NULL\nAND opp.is_closed = 1\nAND opp.is_won = 0\nAND opp.close_date >= '2024-01-01 00:00:00'\nAND opp.close_date <= '2024-12-31 23:59:59'\nAND usr.team_id = 1\n\n# and opp.id = 4823179\n\nGROUP BY opp.id, cfd.value\nORDER BY\n# cfd.value\n CAST(cfd.value AS UNSIGNED)\n# owner_name\n# (SELECT COUNT(dr.id)\n# FROM deal_risks dr\n# JOIN group_deal_risk_types gdrt ON dr.group_deal_risk_type_id = gdrt.id\n# WHERE dr.opportunity_id = opp.id\n# AND dr.is_active = 1\n# AND gdrt.is_enabled = 1)\nDESC\nLIMIT 25\n# OFFSET 0\n;\n\nselect * from crm_fields where crm_configuration_id = 1 and object_type = 'opportunity';\nselect * from crm_layout_entities where crm_layout_id = 1493;\n\nSELECT * FROM opportunities WHERE id = 4584045;\nSELECT * FROM crm_field_data WHERE object_id = 4584045;\n\nSELECT * FROM opportunities where team_id = 1 and crm_provider_id = '0061K00000lfb9IQAQ'\n\nSELECT\n opp.id as opportunity_id,\n opp.name,\n COUNT(dr.id) as risk_count\nFROM opportunities opp\nLEFT JOIN deal_risks dr ON dr.opportunity_id = opp.id\nWHERE opp.id IN (\n 6069574,8900938,9507638,9524799,9660490,9662824,9705675,9749758,4158460,4391812,6450439,4448422,5118945,5590675,4584045,7228149,9002408,9165534,9446720,9641778,9665149,9703344,9709280,5747948,4158491,6182565,6263970,5798120,5111315,4536978,6062352,6548383,6072095,6548225,4480986,5011422,6548381,8760540,8917554,9509986,9514392,9569009,9569011,9578490,9713604,5784397,7276612,7288405,8994421,9118219,9608148,9818911,4510535,6479693,5958049,6271674,6550448,4158331,4158483,6126571,6171615,6540943,4897466,5190896,5796182,5932762,8572433,8723698,8892697,9711001,9789264,4549188,6100831,6170064,6260799,6263653,6449936,6530871,6538978,7777651,8059269,8319918,8787049,8901150,9263153,9453207,9514738,9696700,9791062,5752018,6421452,7439134,7878923,9354763,9369285,9514396,9582506,9889949,9890216,4094311,4158495,4158496,6098128,5585661,3872564,6442149,5891604,6164746,6199593,6583474,6519684,9018490,9809006,4496897,5041324,5829430,6198319,6253504,6555763,7242914,7931055,8024125,8797814,8058559,8673347,8892695,8994420,9616219,9714970,9722004,9809439,9818918,6523177,8134147,9002915,9711422,9892713,9901719,9954210,9978435,5800810,6243518,6416114,6222251,6411974,6512456,5791953,6545606,9914780,5805540,6238986,6463838,6547680,9767049,9809437,9810885,9890855,6673493,9902036,4335521,6379871,6503799,6546077,8018765,9907556,9958433,9905855,9916179,9946741,9957877,5563344,6271838,6450815,7641128,7762567,7780592,8684810,8685786,8685787,8685788\n)\nGROUP BY opp.id;\n\nSELECT COUNT(dr.id)\n FROM deal_risks dr\n JOIN group_deal_risk_types gdrt ON dr.group_deal_risk_type_id = gdrt.id\n WHERE dr.opportunity_id = 5563344\n# AND gdrt.group_id = usr.group_id\n\n\n\nEXPLAIN SELECT\n opp.id as opp_id, opp.uuid, opp.name,\n\ncfd.value,\ncfv.sequence,\ncfv.value,\n# MAX(cfd.value) AS max_cfd_value,\nusr.name AS owner_name,\nopp.value, opp.currency_code, opp.close_date, opp.remotely_created_at, opp.is_closed, opp.is_won,\nusr.uuid as owner_uuid,\nusr.photo_path as owner_photo,\nusr.id AS owner_id,\njt.name as owner_job,\nopp.stage_id, opp.stage_updated_at,\nacc.name AS acc_name, opp.stage_updated_at, acc.crm_provider_id AS acc_provider_id, opp.crm_provider_id AS opp_provider_id,\nrt.business_process_id AS pipeline_id\nFROM opportunities opp\n\nLEFT JOIN record_types rt ON opp.record_type_id = rt.id\nLEFT JOIN users usr ON opp.user_id = usr.id\nLEFT JOIN accounts acc ON opp.account_id = acc.id\nLEFT JOIN job_titles jt ON usr.job_title_id = jt.id\n\nLEFT JOIN crm_field_data cfd ON cfd.id = (SELECT sub_cfd.id\n FROM crm_field_data sub_cfd\n WHERE sub_cfd.object_id = opp.id\n AND sub_cfd.crm_field_id = 66810\n ORDER BY sub_cfd.updated_at DESC\n LIMIT 1)\n\nLEFT JOIN crm_field_values cfv ON cfv.crm_field_id = 66810 AND cfv.value = cfd.value\n\nWHERE opp.user_id IS NOT NULL\nAND opp.deleted_at IS NULL\nAND opp.is_closed = 0\n# AND opp.is_closed = 1\n# AND opp.is_won = 0\nAND opp.close_date >= '2024-01-01 00:00:00'\nAND opp.close_date <= '2024-12-31 23:59:59'\nAND usr.team_id = 1\n\n# and opp.id = 4823179\n\n# GROUP BY opp.id\nORDER BY\n cfv.sequence DESC,\n# opp.name\n# owner_name\n cfd.value\n# CAST(cfd.value AS UNSIGNED)\n# CAST(MAX(cfd.value) AS UNSIGNED)\n# (SELECT COUNT(dr.id)\n# FROM deal_risks dr\n# JOIN group_deal_risk_types gdrt ON dr.group_deal_risk_type_id = gdrt.id\n# WHERE dr.opportunity_id = opp.id\n# AND dr.is_active = 1 AND gdrt.is_enabled = 1)\nDESC\n# LIMIT 75, 25\n;\n\n\n\nSELECT * FROM crm_field_values WHERE crm_field_id = 66810;\n\nSELECT f.id, f.crm_provider_id AS field_name, f.label, fd.object_id AS dealId, fd.value, fd.updated_at, fd.id, fd.created_at\nFROM crm_fields f\nINNER JOIN crm_field_data fd ON fd.crm_field_id = f.id\n WHERE (f.crm_configuration_id = 1)\n AND (f.object_type = 'opportunity')\n# AND (fd.object_id IN (4158331,4158483,4158495,4158496,6069574,6263970,6171615,6540943,6545606,5829430,6198319,6263653,6548381,7242914,8797814,7228149,7439134,7878923,8134147,9002915,9118219,9165534,9354763,9369285,9446720))\n AND (fd.object_id IN (4158460))\n AND (f.crm_provider_id IN ('ForecastCategoryName'))\n ORDER BY fd.object_id ASC, fd.updated_at DESC;\n\nselect * from crm_layouts where crm_configuration_id = 1;\nselect cf.* from crm_fields cf\njoin crm_layout_entities cle on cf.id = cle.crm_field_id\nwhere crm_layout_id = 1493;\n\nSELECT * FROM opportunities WHERE id = 4158460;\nSELECT * FROM crm_field_data WHERE object_id = 4158460;\n\nselect * from users where team_id = 1;\nSELECT * FROM users WHERE id = 7160;\nselect * from role_user where user_id = 3248;\n\nselect * from crm_field_values where crm_field_id = 66824;\n\nSELECT * FROM users WHERE id = 23470;\nselect * from crm_configurations;\n\n# ******************************************\nselect * from teams where id = 1038; # 23521, 966\nselect * from users where team_id = 1038;\nselect * from crm_configurations where id = 966;\nSELECT * FROM activities WHERE uuid_to_bin('e8dc7c34-31f6-4430-bd81-2d9d8f00dd07') = uuid; # 54965762\nSELECT * FROM activities WHERE uuid_to_bin('4b5727c0-69d1-428f-b8d9-b649055166e2') = uuid; # 54965764\nSELECT * FROM participants WHERE activity_id = 54965764;\n\nSELECT id, uuid, title, recording_state FROM activities WHERE uuid_to_bin('355f105d-5606-4379-b0c5-d91daf59f4ce') = uuid; # 54964075\nSELECT * FROM participants WHERE activity_id = 54964075;\nselect f.id, slug, title, tf.team_id from team_features tf JOIN features f on tf.feature_id = f.id\n where tf.team_id = 1038;\n\n\n# ****************************************** PD *************************\nselect * from teams where id = 1029;\nSELECT * FROM crm_configurations WHERE id = 957;\nSELECT * FROM activities WHERE uuid_to_bin('581a33cb-4343-44bd-ac0c-4a5cee71c5ec') = uuid; # 54963423\nSELECT * FROM activities WHERE uuid_to_bin('07238011-25fa-418e-838b-fb21e82b9ea2') = uuid; # 54963433\n\nSELECT * FROM activities WHERE uuid_to_bin('8b6bb2f7-e7a7-4aec-8806-fbfea7093ccd') = uuid; # 54963421\n\n# ****************************************** Close *************************\nselect * from teams where id = 1031;\nSELECT * FROM crm_configurations WHERE id = 959;\nSELECT * FROM activities WHERE uuid_to_bin('59c517e0-96c9-4dad-bfeb-b7bef72f8725') = uuid; # 54963517\n\n\nSELECT * FROM activities WHERE uuid_to_bin('07238011-25fa-418e-838b-fb21e82b9ea2') = uuid; # 54963433\n\nSELECT * FROM activities WHERE uuid_to_bin('d68311e3-ac34-49bd-bf04-88323a7e5352') = uuid; # 54966009\n\n# ****************************************** SF *************************\nSELECT * FROM activities WHERE uuid_to_bin('2bd4cfa3-4eb5-4c77-84a7-9ed46a21c988') = uuid;\n\nselect * from roles;\nselect * from permissions;\nselect * from permission_role where permission_id = 136;\n\nselect * from migrations order by id desc;\n\nselect * from teams where id IN (1, 1037);\nselect * from crm_layouts where crm_configuration_id = 1;\nSELECT * FROM crm_layout_entities WHERE crm_layout_id = 1493;\nSELECT * FROM crm_fields WHERE id IN (1652,1661,66799,66814,66821,66836,66843,66846,66864,84752,182306,323580,380378);\n\nselect * from features;\nselect * from team_features where feature_id = 33;\nselect * from opportunities;\n\nselect * from teams;\nSELECT\n CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,\n u.email,\n sa.*,\n t.owner_id FROM social_accounts sa\nJOIN users u on u.id = sa.sociable_id\nJOIN teams t on t.id = u.team_id\nWHERE u.team_id = 1052 and sa.provider = 'hubspot';\n\nSELECT * FROM accounts where id = 11512582;\nselect * from activities where account_id = 11512582;","depth":4,"on_screen":true,"value":"SELECT\n# DISTINCT\nopp.id as opp_id, opp.uuid, opp.name,\n# COUNT(dr.id) AS deal_risk_count,\n\n# dr.id,\n# cfd.value,\nusr.name AS owner_name,\nopp.value, opp.currency_code, opp.close_date, opp.remotely_created_at, opp.is_closed, opp.is_won,\nusr.uuid as owner_uuid,\nusr.photo_path as owner_photo,\nusr.id AS owner_id,\njt.name as owner_job,\nopp.stage_id, opp.stage_updated_at,\nacc.name AS acc_name, opp.stage_updated_at, acc.crm_provider_id AS acc_provider_id, opp.crm_provider_id AS opp_provider_id,\nrt.business_process_id AS pipeline_id\nFROM opportunities opp\nLEFT JOIN record_types rt ON opp.record_type_id = rt.id\nLEFT JOIN users usr ON opp.user_id = usr.id\nLEFT JOIN accounts acc ON opp.account_id = acc.id\nLEFT JOIN job_titles jt ON usr.job_title_id = jt.id\n\n# LEFT JOIN group_deal_risk_types gdrt ON gdrt.group_id = usr.group_id\n# LEFT JOIN deal_risks dr ON dr.opportunity_id = opp.id AND dr.group_deal_risk_type_id = gdrt.id\n\nLEFT JOIN crm_field_data cfd ON (cfd.object_id = opp.id and cfd.crm_field_id = 66814)\n\nWHERE opp.user_id IS NOT NULL\nAND opp.deleted_at IS NULL\nAND opp.is_closed = 1\nAND opp.is_won = 0\nAND opp.close_date >= '2024-01-01 00:00:00'\nAND opp.close_date <= '2024-12-31 23:59:59'\nAND usr.team_id = 1\n\n# and opp.id = 4823179\n\nGROUP BY opp.id, cfd.value\nORDER BY\n# cfd.value\n CAST(cfd.value AS UNSIGNED)\n# owner_name\n# (SELECT COUNT(dr.id)\n# FROM deal_risks dr\n# JOIN group_deal_risk_types gdrt ON dr.group_deal_risk_type_id = gdrt.id\n# WHERE dr.opportunity_id = opp.id\n# AND dr.is_active = 1\n# AND gdrt.is_enabled = 1)\nDESC\nLIMIT 25\n# OFFSET 0\n;\n\nselect * from crm_fields where crm_configuration_id = 1 and object_type = 'opportunity';\nselect * from crm_layout_entities where crm_layout_id = 1493;\n\nSELECT * FROM opportunities WHERE id = 4584045;\nSELECT * FROM crm_field_data WHERE object_id = 4584045;\n\nSELECT * FROM opportunities where team_id = 1 and crm_provider_id = '0061K00000lfb9IQAQ'\n\nSELECT\n opp.id as opportunity_id,\n opp.name,\n COUNT(dr.id) as risk_count\nFROM opportunities opp\nLEFT JOIN deal_risks dr ON dr.opportunity_id = opp.id\nWHERE opp.id IN (\n 6069574,8900938,9507638,9524799,9660490,9662824,9705675,9749758,4158460,4391812,6450439,4448422,5118945,5590675,4584045,7228149,9002408,9165534,9446720,9641778,9665149,9703344,9709280,5747948,4158491,6182565,6263970,5798120,5111315,4536978,6062352,6548383,6072095,6548225,4480986,5011422,6548381,8760540,8917554,9509986,9514392,9569009,9569011,9578490,9713604,5784397,7276612,7288405,8994421,9118219,9608148,9818911,4510535,6479693,5958049,6271674,6550448,4158331,4158483,6126571,6171615,6540943,4897466,5190896,5796182,5932762,8572433,8723698,8892697,9711001,9789264,4549188,6100831,6170064,6260799,6263653,6449936,6530871,6538978,7777651,8059269,8319918,8787049,8901150,9263153,9453207,9514738,9696700,9791062,5752018,6421452,7439134,7878923,9354763,9369285,9514396,9582506,9889949,9890216,4094311,4158495,4158496,6098128,5585661,3872564,6442149,5891604,6164746,6199593,6583474,6519684,9018490,9809006,4496897,5041324,5829430,6198319,6253504,6555763,7242914,7931055,8024125,8797814,8058559,8673347,8892695,8994420,9616219,9714970,9722004,9809439,9818918,6523177,8134147,9002915,9711422,9892713,9901719,9954210,9978435,5800810,6243518,6416114,6222251,6411974,6512456,5791953,6545606,9914780,5805540,6238986,6463838,6547680,9767049,9809437,9810885,9890855,6673493,9902036,4335521,6379871,6503799,6546077,8018765,9907556,9958433,9905855,9916179,9946741,9957877,5563344,6271838,6450815,7641128,7762567,7780592,8684810,8685786,8685787,8685788\n)\nGROUP BY opp.id;\n\nSELECT COUNT(dr.id)\n FROM deal_risks dr\n JOIN group_deal_risk_types gdrt ON dr.group_deal_risk_type_id = gdrt.id\n WHERE dr.opportunity_id = 5563344\n# AND gdrt.group_id = usr.group_id\n\n\n\nEXPLAIN SELECT\n opp.id as opp_id, opp.uuid, opp.name,\n\ncfd.value,\ncfv.sequence,\ncfv.value,\n# MAX(cfd.value) AS max_cfd_value,\nusr.name AS owner_name,\nopp.value, opp.currency_code, opp.close_date, opp.remotely_created_at, opp.is_closed, opp.is_won,\nusr.uuid as owner_uuid,\nusr.photo_path as owner_photo,\nusr.id AS owner_id,\njt.name as owner_job,\nopp.stage_id, opp.stage_updated_at,\nacc.name AS acc_name, opp.stage_updated_at, acc.crm_provider_id AS acc_provider_id, opp.crm_provider_id AS opp_provider_id,\nrt.business_process_id AS pipeline_id\nFROM opportunities opp\n\nLEFT JOIN record_types rt ON opp.record_type_id = rt.id\nLEFT JOIN users usr ON opp.user_id = usr.id\nLEFT JOIN accounts acc ON opp.account_id = acc.id\nLEFT JOIN job_titles jt ON usr.job_title_id = jt.id\n\nLEFT JOIN crm_field_data cfd ON cfd.id = (SELECT sub_cfd.id\n FROM crm_field_data sub_cfd\n WHERE sub_cfd.object_id = opp.id\n AND sub_cfd.crm_field_id = 66810\n ORDER BY sub_cfd.updated_at DESC\n LIMIT 1)\n\nLEFT JOIN crm_field_values cfv ON cfv.crm_field_id = 66810 AND cfv.value = cfd.value\n\nWHERE opp.user_id IS NOT NULL\nAND opp.deleted_at IS NULL\nAND opp.is_closed = 0\n# AND opp.is_closed = 1\n# AND opp.is_won = 0\nAND opp.close_date >= '2024-01-01 00:00:00'\nAND opp.close_date <= '2024-12-31 23:59:59'\nAND usr.team_id = 1\n\n# and opp.id = 4823179\n\n# GROUP BY opp.id\nORDER BY\n cfv.sequence DESC,\n# opp.name\n# owner_name\n cfd.value\n# CAST(cfd.value AS UNSIGNED)\n# CAST(MAX(cfd.value) AS UNSIGNED)\n# (SELECT COUNT(dr.id)\n# FROM deal_risks dr\n# JOIN group_deal_risk_types gdrt ON dr.group_deal_risk_type_id = gdrt.id\n# WHERE dr.opportunity_id = opp.id\n# AND dr.is_active = 1 AND gdrt.is_enabled = 1)\nDESC\n# LIMIT 75, 25\n;\n\n\n\nSELECT * FROM crm_field_values WHERE crm_field_id = 66810;\n\nSELECT f.id, f.crm_provider_id AS field_name, f.label, fd.object_id AS dealId, fd.value, fd.updated_at, fd.id, fd.created_at\nFROM crm_fields f\nINNER JOIN crm_field_data fd ON fd.crm_field_id = f.id\n WHERE (f.crm_configuration_id = 1)\n AND (f.object_type = 'opportunity')\n# AND (fd.object_id IN (4158331,4158483,4158495,4158496,6069574,6263970,6171615,6540943,6545606,5829430,6198319,6263653,6548381,7242914,8797814,7228149,7439134,7878923,8134147,9002915,9118219,9165534,9354763,9369285,9446720))\n AND (fd.object_id IN (4158460))\n AND (f.crm_provider_id IN ('ForecastCategoryName'))\n ORDER BY fd.object_id ASC, fd.updated_at DESC;\n\nselect * from crm_layouts where crm_configuration_id = 1;\nselect cf.* from crm_fields cf\njoin crm_layout_entities cle on cf.id = cle.crm_field_id\nwhere crm_layout_id = 1493;\n\nSELECT * FROM opportunities WHERE id = 4158460;\nSELECT * FROM crm_field_data WHERE object_id = 4158460;\n\nselect * from users where team_id = 1;\nSELECT * FROM users WHERE id = 7160;\nselect * from role_user where user_id = 3248;\n\nselect * from crm_field_values where crm_field_id = 66824;\n\nSELECT * FROM users WHERE id = 23470;\nselect * from crm_configurations;\n\n# ******************************************\nselect * from teams where id = 1038; # 23521, 966\nselect * from users where team_id = 1038;\nselect * from crm_configurations where id = 966;\nSELECT * FROM activities WHERE uuid_to_bin('e8dc7c34-31f6-4430-bd81-2d9d8f00dd07') = uuid; # 54965762\nSELECT * FROM activities WHERE uuid_to_bin('4b5727c0-69d1-428f-b8d9-b649055166e2') = uuid; # 54965764\nSELECT * FROM participants WHERE activity_id = 54965764;\n\nSELECT id, uuid, title, recording_state FROM activities WHERE uuid_to_bin('355f105d-5606-4379-b0c5-d91daf59f4ce') = uuid; # 54964075\nSELECT * FROM participants WHERE activity_id = 54964075;\nselect f.id, slug, title, tf.team_id from team_features tf JOIN features f on tf.feature_id = f.id\n where tf.team_id = 1038;\n\n\n# ****************************************** PD *************************\nselect * from teams where id = 1029;\nSELECT * FROM crm_configurations WHERE id = 957;\nSELECT * FROM activities WHERE uuid_to_bin('581a33cb-4343-44bd-ac0c-4a5cee71c5ec') = uuid; # 54963423\nSELECT * FROM activities WHERE uuid_to_bin('07238011-25fa-418e-838b-fb21e82b9ea2') = uuid; # 54963433\n\nSELECT * FROM activities WHERE uuid_to_bin('8b6bb2f7-e7a7-4aec-8806-fbfea7093ccd') = uuid; # 54963421\n\n# ****************************************** Close *************************\nselect * from teams where id = 1031;\nSELECT * FROM crm_configurations WHERE id = 959;\nSELECT * FROM activities WHERE uuid_to_bin('59c517e0-96c9-4dad-bfeb-b7bef72f8725') = uuid; # 54963517\n\n\nSELECT * FROM activities WHERE uuid_to_bin('07238011-25fa-418e-838b-fb21e82b9ea2') = uuid; # 54963433\n\nSELECT * FROM activities WHERE uuid_to_bin('d68311e3-ac34-49bd-bf04-88323a7e5352') = uuid; # 54966009\n\n# ****************************************** SF *************************\nSELECT * FROM activities WHERE uuid_to_bin('2bd4cfa3-4eb5-4c77-84a7-9ed46a21c988') = uuid;\n\nselect * from roles;\nselect * from permissions;\nselect * from permission_role where permission_id = 136;\n\nselect * from migrations order by id desc;\n\nselect * from teams where id IN (1, 1037);\nselect * from crm_layouts where crm_configuration_id = 1;\nSELECT * FROM crm_layout_entities WHERE crm_layout_id = 1493;\nSELECT * FROM crm_fields WHERE id IN (1652,1661,66799,66814,66821,66836,66843,66846,66864,84752,182306,323580,380378);\n\nselect * from features;\nselect * from team_features where feature_id = 33;\nselect * from opportunities;\n\nselect * from teams;\nSELECT\n CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,\n u.email,\n sa.*,\n t.owner_id FROM social_accounts sa\nJOIN users u on u.id = sa.sociable_id\nJOIN teams t on t.id = u.team_id\nWHERE u.team_id = 1052 and sa.provider = 'hubspot';\n\nSELECT * FROM accounts where id = 11512582;\nselect * from activities where account_id = 11512582;","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Project","depth":3,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Project","depth":3,"bounds":{"left":0.28224733,"top":1.0,"width":0.024268618,"height":-0.04788506},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"New File or Directory…","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Expand Selected","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Collapse All","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Options","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
8957564869086018857
|
-7959665055215941403
|
click
|
accessibility
|
NULL
|
Project: faVsco.js, menu
#12066 on JY-20725-handle Project: faVsco.js, menu
#12066 on JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
HandleHubspotRateLimitTest
Run 'HandleHubspotRateLimitTest'
Debug 'HandleHubspotRateLimitTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
11
72
1
4
Previous Highlighted Error
Next Highlighted Error
<?php
namespace Jiminny\Services\Crm\Copper;
use Carbon\Carbon;
use Exception;
use GuzzleHttp\Exception\ClientException;
use Illuminate\Support\Facades\Cache;
use Jiminny\Contracts\Services\Crm\LayoutManagementInterface;
use Jiminny\Contracts\Services\Crm\MatchCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\Provider\CopperInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityLookupInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityManipulationInterface;
use Jiminny\Contracts\Services\Crm\SettingsInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmMetadataInterface;
use Jiminny\Contracts\Services\Crm\VerifyTaskExistsInterface;
use Jiminny\Exceptions\CrmException;
use Jiminny\Exceptions\HttpNotFoundException;
use Jiminny\Models\Account;
use Jiminny\Models\Activity;
use Jiminny\Models\Contact;
use Jiminny\Models\Crm\BusinessProcess;
use Jiminny\Models\Crm\Configuration;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\FieldData;
use Jiminny\Models\Crm\FieldValue;
use Jiminny\Models\Crm\LayoutEntity;
use Jiminny\Models\Crm\Profile;
use Jiminny\Models\Lead;
use Jiminny\Models\Opportunity;
use Jiminny\Models\Playbook;
use Jiminny\Models\PlaybookCategory;
use Jiminny\Models\SocialAccount;
use Jiminny\Models\Stage;
use Jiminny\Models\User;
use Jiminny\Repositories\Crm\CrmEntityRepository;
use Jiminny\Repositories\Crm\FieldRepository;
use Jiminny\Repositories\Crm\ProfileRepository;
use Jiminny\Repositories\UserRepository;
use Jiminny\Services\Avatar\ProspectPhotoPathService;
use Jiminny\Services\Crm\BaseService;
use Jiminny\Services\Crm\Copper\Api\Client;
use Jiminny\Services\Crm\Copper\Api\ClientFactory;
use Jiminny\Services\Crm\Copper\Exception\RequestFailedException;
use Jiminny\Services\Crm\Helpers\FilterJoinedParticipants;
use Jiminny\Services\Crm\OpportunitySyncStrategyResolver;
use Jiminny\Services\Crm\Copper\Traits\SearchesCrmEntities;
use Jiminny\Utils\CurrencyFormatter;
use Jiminny\Utils\StringUtil;
class Service extends BaseService implements
CopperInterface,
SyncCrmEntitiesInterface,
SyncCrmMetadataInterface,
RemoteEntityManipulationInterface,
LayoutManagementInterface,
SettingsInterface,
MatchCrmEntitiesInterface,
RemoteEntityLookupInterface,
VerifyTaskExistsInterface
{
use SearchesCrmEntities;
private const int NOTE_BODY_MAX_LENGTH = 3000000;
private const int TASK_VERIFICATION_CACHE_TTL = 86400; // 1 day
private const array STANDARD_OPPORTUNITY_FIELDS = [
'monetary_value' => [
'label' => 'Value',
'available_on' => Field::OBJECT_OPPORTUNITY,
'type' => Field::TYPE_CURRENCY,
],
'pipeline_stage_id' => [
'label' => 'Stage',
'type' => Field::TYPE_PICKLIST,
'available_on' => Field::OBJECT_OPPORTUNITY,
],
'close_date' => [
'label' => 'Close date',
'type' => Field::TYPE_DATE,
'available_on' => Field::OBJECT_OPPORTUNITY,
],
'win_probability' => [
'label' => 'Win probability',
'type' => Field::TYPE_NUMBER,
'available_on' => Field::OBJECT_OPPORTUNITY,
],
];
private const string UNSUPPORTED_OBJECT_TYPE = 'unsupported';
private const string OPPORTUNITY_STATUS_OPEN = 'Open';
private const string OPPORTUNITY_IMPORT_FAILED_ERROR_MESSAGE = 'Opportunity import failed';
private const string RESOURCE_UPDATE_FAILED_ERROR_MESSAGE = 'Failed to update resource';
private FieldValueConverter $fieldValueConverter;
private ClientFactory $clientFactory;
private ?Client $copperClient = null;
public function __construct(
ClientFactory $clientFactory,
FieldValueConverter $fieldValueConverter,
private readonly UserRepository $userRepository,
private readonly ProspectPhotoPathService $prospectPhotoPathService,
private readonly CrmEntityRepository $crmEntityRepository
) {
parent::__construct();
$this->clientFactory = $clientFactory;
$this->fieldValueConverter = $fieldValueConverter;
}
public function getDisplayName(): string
{
return 'Copper';
}
/**
* Get the CRM entity repository (used by SearchesCrmEntities trait)
*/
protected function getCrmEntityRepository(): CrmEntityRepository
{
return $this->crmEntityRepository;
}
public function getMinimumApiVersion(): string
{
return Client::MIN_API_VERSION;
}
protected function getOAuthAccount(User $user): ?SocialAccount
{
// In this case, the Account Owner is always the connection for any API operations.
$owner = $user->team->owner;
return $owner->getSocialAccount(SocialAccount::PROVIDER_COPPER);
}
public function setUser(User $user): void
{
$this->configureSentryScope($user);
$this->team = $user->getTeam();
$this->teamService->setTeam($user->getTeam());
$this->setConfiguration($user->getTeam()->getCrmConfiguration());
// Reset state.
$this->profile = $user->getProfile();
if (! $this->profile instanceof Profile) {
// Try to fetch their profile, maybe it was not yet imported?
$this->profile = $this->getProfile($user);
}
}
public function setConfiguration(Configuration $configuration): void
{
$this->config = $configuration;
$this->copperClient = $this->clientFactory->create($configuration);
if ($this->team === null) {
$this->team = $configuration->getTeam();
}
}
public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string
{
return $this->fieldValueConverter->convertFromCrm($fieldType, $fieldValue);
}
private function mapObjectType(string $objectType): string
{
$objectType = strtolower($objectType);
switch ($objectType) {
case Field::OBJECT_TASK:
case Field::OBJECT_OPPORTUNITY:
case Field::OBJECT_LEAD:
$mappedType = $objectType;
break;
case Field::OBJECT_ACCOUNT:
$mappedType = 'company';
break;
case Field::OBJECT_CONTACT:
$mappedType = 'person';
break;
default:
$mappedType = self::UNSUPPORTED_OBJECT_TYPE;
break;
}
return $mappedType;
}
/**
* @param string $crmObject The name of the CRM object. i.e. Opportunity or Contact
*/
protected function getFields(string $crmObject): array
{
$mappedObjectType = $this->mapObjectType($crmObject);
if (! $this->isSupportedObjectType($mappedObjectType)) {
return [];
}
$customFields = $this->copperClient->fields()->all();
$filteredFields = array_filter(
$customFields,
static fn ($field) => in_array($mappedObjectType, $field['available_on'], true)
);
return array_map(
fn (array $property) => [
'name' => $property['id'],
'label' => $property['name'],
'type' => $this->convertFieldType($property['data_type']),
],
$filteredFields
);
}
/**
* @inheritdoc
*/
public function getDefaultFields(string $activityType): array
{
$fields = [];
if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {
$defaultFields = FieldDefinitions::defaultTaskFields();
// This lazy creates these fields if not already setup.
foreach ($defaultFields as $defaultField) {
$fields[] = $this->config->fields()->firstOrCreate($defaultField);
}
}
return $fields;
}
/**
* @inheritdoc
*/
public function getDefaultActivityField(string $activityType): Field
{
// Setup the activity field as the default Type.
$activityField = $this->config->fields()->where([
'crm_provider_id' => 'type',
'object_type' => $activityType,
])->first();
return $activityField;
}
/**
* @inheritdoc
*/
public function getSupportedPlaybookTypes(): array
{
return [Playbook::ACTIVITY_TYPE_TASK];
}
public function getDealInsightsFields(): array
{
return FieldDefinitions::dealInsightsFields();
}
protected function getDefaultFollowupLayoutFields(string $activityType): array
{
$fields = [];
$fieldRepo = app(FieldRepository::class);
$fieldFilters = FieldDefinitions::taskFollowupFieldsFilter();
foreach ($fieldFilters as $data) {
$field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $data);
// Only add the field if it is created, which it should be.
if ($field) {
$fields[] = $field;
}
}
return $fields;
}
public function syncFields(): void
{
$this->syncStandardFields();
$this->syncCustomFields();
}
public function syncField(Field $field): void
{
if ($field->getCrmProviderId() === 'type') {
/**
* This field is a special case, it is not a custom field and is not returned by the Copper API.
*
* The field is created manually during layout setup.
*
* @see getDefaultFields()
*
* There is a separate endpoint to get the activity types.
* @see importPicklistValues()
*/
return;
}
if ($field->getCrmProviderId() === 'pipeline_stage_id') {
// Not a custom type
return;
}
$crmField = $this->copperClient->fields()->find($field->getCrmProviderId());
$field->label = $crmField['name'];
$field->type = $this->convertFieldType($crmField['data_type']);
$field->save();
}
/**
* @param string $fieldType How the data should be input
*/
private function convertFieldType(string $fieldType): string
{
$fieldType = strtolower($fieldType);
switch ($fieldType) {
case 'dropdown':
$convertedType = Field::TYPE_PICKLIST;
break;
case 'string':
$convertedType = Field::TYPE_TEXT;
break;
case 'float':
case 'percentage':
$convertedType = Field::TYPE_NUMBER;
break;
case 'text':
$convertedType = Field::TYPE_TEXTAREA;
break;
case 'checkbox':
$convertedType = Field::TYPE_BOOLEAN;
break;
case Field::TYPE_DATE:
case Field::TYPE_CURRENCY:
case Field::TYPE_URL:
$convertedType = $fieldType;
break;
default:
$convertedType = Field::TYPE_UNSUPPORTED;
break;
}
return $convertedType;
}
/**
* @inheritdoc
*/
public function importPicklistValues(Field $field): array
{
$fieldValues = [];
if ($field->object_type === 'opportunity' && $field->crm_provider_id === 'pipeline_stage_id') {
return $this->importFieldValuesForDealInsights($field);
}
$values = [];
if ($field->object_type === Field::OBJECT_TASK && $field->crm_provider_id === 'type') {
$activityTypes = $this->copperClient->activityTypes()->all();
/**
* We ignore "system" activity types, disabled activity types and the "Note" activity type.
*/
$activityTypes = array_filter(
$activityTypes['user'],
fn (array $activityType) => ! $activityType['is_disabled'] && $activityType['id'] !== 0
);
// Import all activity values.
$i = 0;
foreach ($activityTypes as $activityType) {
$values[] = [
'value' => $activityType['id'],
'label' => mb_strimwidth($activityType['name'], 0, Field::LABEL_MAX_LENGTH),
'sequence' => $i++,
];
}
} else {
// We just check for the field in the custom definitions.
$crmField = $this->copperClient->fields()->find($field->crm_provider_id);
// Import all values.
foreach ($crmField['options'] as $option) {
$values[] = [
'value' => $option['id'],
'label' => mb_strimwidth($option['name'], 0, 191),
'sequence' => $option['rank'],
];
}
}
// Take the existing set of field values.
$fieldsToPurge = $field->values()->get()->pluck('value')->toArray();
foreach ($values as $value) {
// For all current values, upsert the field.
$value['value'] = substr($value['value'] ?? '', 0, 255);
$fieldValues[] = $field->values()->updateOrCreate([
'value' => $value['value'],
], $value);
// Remove this value from the ones we are going to purge.
$key = array_search($value['value'], $fieldsToPurge, false);
if ($key !== false) {
unset($fieldsToPurge[$key]);
}
}
// Delete the old values that are no longer used.
$field->values()->whereIn('value', $fieldsToPurge)->delete();
return $fieldValues;
}
/**
*
* @return FieldValue[]
*/
private function importFieldValuesForDealInsights(Field $field): array
{
$fieldValues = [];
$pipelines = $this->copperClient->pipelines()->all();
foreach ($pipelines as $pipeline) {
foreach ($pipeline['stages'] as $i => $dealStage) {
$fieldValues[] = $field->values()->updateOrCreate(
['value' => $dealStage['id']],
[
'label' => $dealStage['name'],
'sequence' => $i,
]
);
}
}
return $fieldValues;
}
/**
* @inheritdoc
*/
public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage
{
if ($types === null) {
$types = [Stage::TYPE_LEAD, Stage::TYPE_OPPORTUNITY];
}
$missingStage = null;
foreach ($types as $type) {
if ($type === Stage::TYPE_LEAD) {
$statuses = $this->requestWithExceptionHandling(
fn () => $this->copperClient->leadStatuses()->all()
);
if (count($statuses) > 0) {
$businessProcess = $this->config->businessProcesses()->updateOrCreate([
'crm_provider_id' => BusinessProcess::TYPE_LEAD,
], [
'team_id' => $this->team->id,
'name' => BusinessProcess::TYPE_LEAD,
'type' => BusinessProcess::TYPE_LEAD,
'is_selectable' => true,
]);
// A record type is really a clone of the business process, used to store which record uses which pipeline.
$this->config->recordTypes()->updateOrCreate([
'crm_provider_id' => BusinessProcess::TYPE_LEAD,
], [
'team_id' => $this->team->id,
'name' => BusinessProcess::TYPE_LEAD,
'is_selectable' => true,
'business_process_id' => $businessProcess->id ?? null,
]);
$leadStatuses = [];
foreach ($statuses as $status) {
// Upsert all statuses for the team.
$status = $this->config->stages()->updateOrCreate([
'crm_provider_id' => $status['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($status['name'], 0, 50),
'label' => mb_strimwidth($status['name'], 0, 191),
'type' => Stage::TYPE_LEAD,
'sequence' => $status['order'],
'is_selectable' => true,
]);
if ($missingStageName && $missingStageName === $status['id']) {
$missingStage = $status;
}
$leadStatuses[] = $status['id'];
}
$businessProcess->stages()->sync($leadStatuses);
}
} else {
// We create a business process to contain the pipeline, and later store all stages against it.
foreach ($this->copperClient->pipelines()->all() as $pipeline) {
$businessProcess = $this->config->businessProcesses()->updateOrCreate([
'crm_provider_id' => $pipeline['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($pipeline['name'], 0, 150),
'type' => BusinessProcess::TYPE_OPPORTUNITY,
'is_selectable' => true,
]);
// A record type is really a clone of the business process, used to store which record uses which pipeline.
$this->config->recordTypes()->updateOrCreate([
'crm_provider_id' => $pipeline['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($pipeline['name'], 0, 150),
'is_selectable' => true,
'business_process_id' => $businessProcess->id ?? null,
]);
$stages = [];
foreach ($pipeline['stages'] as $i => $dealStage) {
// Upsert all stages for the team.
$stage = $this->config->stages()->updateOrCreate([
'crm_provider_id' => $dealStage['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($dealStage['name'], 0, 50),
'label' => mb_strimwidth($dealStage['name'], 0, 191),
'type' => Stage::TYPE_OPPORTUNITY,
'sequence' => $i,
'is_selectable' => true,
'probability' => $dealStage['win_probability'],
]);
if ($missingStageName && $missingStageName === $dealStage['id']) {
$missingStage = $stage;
}
$stages[] = $stage['id'];
}
$businessProcess->stages()->sync($stages);
}
}
}
return $missingStage;
}
/**
* @inheritdoc
*/
public function syncLeads(Carbon $since, ?Carbon $to = null, ?string $crmProfileId = null): int
{
$syncCount = 0;
$cpLeads = $this->requestWithExceptionHandling(
fn () => $this->copperClient->leads()->paginate([
'minimum_modified_date' => $since->timestamp,
'sort_by' => 'date_modified',
])
);
foreach ($cpLeads as $cpLead) {
// Only sync if previously imported.
if ($this->hasLead($cpLead['id']) === false) {
$this->importLead($cpLead);
$syncCount++;
}
}
return $syncCount;
}
/**
* @inheritdoc
*/
public function syncLead(string $crmId): ?Lead
{
$cpLead = $this->requestWithExceptionHandling(
fn () => $this->copperClient->leads()->find($crmId)
);
if (\count($cpLead) === 0) {
return null;
}
return $this->importLead($cpLead);
}
/**
* @inheritdoc
*/
private function importLead($crmData): ?Lead
{
$countryCode = null;
if (! empty($crmData['address']) && isset($crmData['address']['country'])) {
$countryCode = $this->convertCountryNameToCode($crmData['address']['country']);
}
$mobilePhone = $parsedNumber = null;
if (! empty($crmData['phone_numbers'])) {
foreach ($crmData['phone_numbers'] as $phoneNumber) {
if ($phoneNumber['category'] === 'mobile') {
$mobilePhone = $this->buildContactMobilePhone($countryCode, $phoneNumber['number']);
} else {
$parsedNumber = $this->buildContactPhone($countryCode, $phoneNumber['number']);
}
}
}
$name = $crmData['name'];
if (empty($crmData['company_name'])) {
$company = 'Unknown';
} else {
$company = mb_strimwidth($crmData['company_name'], 0, 191);
}
$domain = null;
if (empty($crmData['websites']) === false) {
$domain = mb_strimwidth($crmData['websites'][0]['url'], 0, 191);
$domain = StringUtil::resolveDomain($domain);
}
$email = null;
if (empty($crmData['email']) === false) {
$email = mb_strimwidth($crmData['email']['email'], 0, 80);
}
$stage = $this->config
->stages()
->where('crm_provider_id', (string) $crmData['status_id'])
->where('type', Stage::TYPE_LEAD)
->first();
if ($stage === null) {
// Import it.
$stage = $this->importStages([Stage::TYPE_LEAD], $crmData['status_id']);
}
if (! $stage instanceof Stage) {
return null;
}
$profile = $this->config->profiles()->where('crm_provider_id', (string) $crmData['assignee_id'])->first();
$data = [
'team_id' => $this->team->id,
'user_id' => $profile ? $profile->user_id : null,
'owner_id' => $crmData['assignee_id'],
'name' => mb_strimwidth($name, 0, 191),
'company' => $company,
'domain' => $domain,
'title' => $crmData['title'] ? mb_strimwidth($crmData['title'], 0, 128) : null,
'email' => $email,
'phone' => $parsedNumber['phone'] ?? null,
'ext' => $parsedNumber['ext'] ?? null,
'mobile_phone' => $mobilePhone,
'stage_id' => $stage->id,
'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(
crmConfiguration: $this->config,
crmProviderId: $crmData['id'],
modelType: Lead::class,
fileName: $crmData['id'],
avatarText: $crmData['name']
),
'country_code' => $countryCode,
'remotely_created_at' => $crmData['date_created'],
];
return $this->config->leads()->updateOrCreate(['crm_provider_id' => (string) $crmData['id']], $data);
}
/**
* @inheritdoc
*/
public function syncAccounts(Carbon $since, ?Carbon $to = null): int
{
$syncCount = 0;
try {
$cpAccounts = $this->copperClient->accounts()->paginate([
'minimum_modified_date' => $since->timestamp,
'sort_by' => 'date_modified',
]);
foreach ($cpAccounts as $cpAccount) {
// Only sync if previously imported.
if ($this->hasAccount($cpAccount['id'])) {
$this->importAccount($cpAccount);
$syncCount++;
}
}
} catch (\Exception $exception) {
// Do nothing for now.
}
return $syncCount;
}
/**
* @inheritdoc
*/
public function syncAccount(string $crmId): ?Account
{
$cpAccount = $this->copperClient->accounts()->find($crmId);
if (empty($cpAccount)) {
return null;
}
return $this->importAccount($cpAccount);
}
/**
* @inheritdoc
*/
private function importAccount($crmData): Account
{
$countryCode = null;
if (empty($crmData['address']) === false && $crmData['address']['country']) {
$countryCode = $this->convertCountryNameToCode($crmData['address']['country']);
}
$name = $crmData['name'];
$parsedNumber = null;
foreach ($crmData['phone_numbers'] as $phoneNumber) {
$parsedNumber = parsePhoneNumber($countryCode, $phoneNumber['number']);
}
$domain = null;
if (empty($crmData['websites']) === false) {
$domain = mb_strimwidth($crmData['websites'][0]['url'], 0, 191);
$domain = StringUtil::resolveDomain($domain);
}
$profile = $this->config->profiles()->where('crm_provider_id', (string) $crmData['assignee_id'])->first();
$data = [
'team_id' => $this->team->id,
'user_id' => $profile ? $profile->user_id : null,
'owner_id' => $crmData['assignee_id'],
'name' => mb_strimwidth($name, 0, 191),
'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(
crmConfiguration: $this->config,
crmProviderId: $crmData['id'],
modelType: Account::class,
fileName: $crmData['id'],
avatarText: $crmData['name']
),
'country_code' => $countryCode,
'domain' => $domain,
'phone' => $parsedNumber['phone'] ?? null,
'ext' => $parsedNumber['ext'] ?? null,
'remotely_created_at' => $crmData['date_created'],
];
return $this->config->accounts()->updateOrCreate(['crm_provider_id' => (string) $crmData['id']], $data);
}
public function syncOpportunities(array $parameters, ?string $strategy = null): int
{
$strategyResolver = app(OpportunitySyncStrategyResolver::class, [
'client' => $this->copperClient,
]);
$strategies = $strategyResolver->getStrategies($this->config, $strategy);
$syncCount = 0;
try {
foreach ($strategies as $syncStrategy) {
foreach ($syncStrategy->fetchOpportunities($parameters) as $opportunityData) {
$this->importOpportunity($opportunityData);
$syncCount++;
}
}
} catch (\Exception $exception) {
// Do nothing for now.
$this->logger->error(self::OPPORTUNITY_IMPORT_FAILED_ERROR_MESSAGE, [
'error' => $exception->getMessage(),
]);
}
return $syncCount;
}
public function syncOpportunity(string $crmId): ?Opportunity
{
$strategyResolver = app(OpportunitySyncStrategyResolver::class, [
'client' => $this->copperClient,
]);
$strategy = $strategyResolver->resolve(
$this->config,
OpportunitySyncStrategyResolver::SINGLE_SYNC_OPPORTUNITY_STRATEGY
);
$parameters['crm_id'] = $crmId;
$cpOpportunity = $strategy->fetchOpportunities($parameters);
return $this->importOpportunity($cpOpportunity);
}
private function importOpportunity($crmData): ?Opportunity
{
$companyId = (string) $crmData['company_id'];
if (empty($companyId)) {
$this->logger->error(self::OPPORTUNITY_IMPORT_FAILED_ERROR_MESSAGE, [
'error' => 'No company id',
]);
return null;
}
$account = $this->config->accounts()->where('crm_provider_id', $companyId)->first();
if ($account === null) {
$account = $this->syncAccount($companyId);
}
$profile = $this->config->profiles()->where('crm_provider_id', (string) $crmData['assignee_id'])->first();
$businessProcess = $this->config
->businessProcesses()
->where('crm_provider_id', (string) $crmData['pipeline_id'])
->first();
if ($businessProcess === null) {
// It is possible for some deals to not be in a pipeline.
$this->logger->warning(self::OPPORTUNITY_IMPORT_FAILED_ERROR_MESSAGE, [
'error' => 'Missing pipeline',
]);
return null;
}
$stage = $businessProcess
->stages()
->where('crm_provider_id', (string) $crmData['pipeline_stage_id'])
->where('type', Stage::TYPE_OPPORTUNITY)
->first();
if ($stage === null) {
$this->logger->info('Stage not found, importing', [
'pipeline_id' => $crmData['pipeline_id'],
'stage_id' => $crmData['pipeline_stage_id'],
]);
$stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $crmData['pipeline_stage_id']);
}
$recordType = $businessProcess->recordTypes()->first();
$closeDate = null;
$isClosed = $crmData['status'] !== self::OPPORTUNITY_STATUS_OPEN;
if ($crmData['close_date']) {
$closeDate = Carbon::parse($crmData['close_date'])->format('Y-m-d');
}
$data = [
'team_id' => $this->team->getId(),
'account_id' => $account->id,
'user_id' => $profile->user_id ?? null,
'owner_id' => $crmData['assignee_id'],
'name' => mb_strimwidth($crmData['name'], 0, 128),
'value' => $crmData['monetary_value'],
'currency_code' => CurrencyFormatter::formatCode($crmData['monetary_unit']),
'close_date' => $closeDate,
'is_closed' => $isClosed,
'is_won' => $crmData['status'] === 'Won',
'stage_id' => $stage->id,
'record_type_id' => $recordType->id ?? null,
'remotely_created_at' => $crmData['date_created'],
];
/** @var Opportunity $opportunity */
$opportunity = $this->config
->opportunities()
->updateOrCreate(['crm_provider_id' => (string) $crmData['id']], $data)
;
// import external fields into crm_field_data if present
$dealInsightsFields = $this->getOpportunitySyncableFields();
$this->importOpportunityCrmFieldData($crmData, $dealInsightsFields, $opportunity->id);
return $opportunity;
}
/**
* @param array<string, mixed> $opportunityData
* @param string[] $dealInsightsFields
*/
public function importOpportunityCrmFieldData(
array $opportunityData,
array $dealInsightsFields,
int $opportunityId
): void {
foreach ($dealInsightsFields as $fieldName) {
$field = $this->config->fields()->with('entities')
->where('crm_provider_id', $fieldName)
->where('object_type', Field::OBJECT_OPPORTUNITY)
->first();
if (! $field instanceof Field) {
$this->logger->warning('Field not found', [
'field' => $fieldName,
'opportunity_id' => $opportunityId,
]);
continue;
}
$entity = $field->getEntities()->first();
if (! $entity instanceof LayoutEntity) {
continue;
}
$fieldValue = $this->extractFieldValue($opportunityData, $fieldName);
$normalizedValue = $this->normalizeValue($field->getType(), (string) $fieldValue, true);
$field->data()
->updateOrCreate([
'crm_layout_entity_id' => $entity->id,
'crm_field_id' => $field->id,
'object_type' => 'opportunity',
'object_id' => $opportunityId,
], ['value' => $normalizedValue])
;
}
}
/**
* @inheritdoc
*/
public function syncContacts(Carbon $since, ?Carbon $to = null): int
{
$syncCount = 0;
try {
$cpContacts = $this->copperClient->contacts()->paginate([
'minimum_interaction_date' => $since->timestamp,
'sort_by' => 'date_modified',
]);
foreach ($cpContacts as $cpContact) {
// Only sync if previously imported.
if ($this->hasContact($cpContact['id'])) {
$this->importContact($cpContact);
$syncCount++;
}
}
} catch (\Exception $exception) {
// Do nothing for now.
throw $exception;
}
return $syncCount;
}
/**
* @inheritdoc
*/
public function syncContact(string $crmId): ?Contact
{
$cpContact = $this->copperClient->contacts()->find($crmId);
if (empty($cpContact)) {
return null;
}
return $this->importContact($cpContact);
}
/**
* @inheritdoc
*/
private function importContact($crmData): Contact
{
$account = null;
if ($crmData['company_id']) {
$account = $this->team
->crm
->accounts()
->where('crm_provider_id', (string) $crmData['company_id'])
->first();
if ($account === null) {
$account = $this->syncAccount($crmData['company_id']);
}
}
$countryCode = null;
if (empty($crmData['address']) === false && $crmData['address']['country']) {
$countryCode = $this->convertCountryNameToCode($crmData['address']['country']);
}
$mobilePhone = $parsedNumber = null;
foreach ($crmData['phone_numbers'] as $phoneNumber) {
if ($phoneNumber['category'] === 'mobile') {
$mobilePhone = $this->buildContactMobilePhone($countryCode, $phoneNumber['number']);
} else {
$parsedNumber = $this->buildContactPhone($countryCode, $phoneNumber['number']);
}
}
$email = null;
if (empty($crmData['emails']) === false) {
$email = mb_strimwidth($crmData['emails'][0]['email'], 0, 80);
}
$profile = $this->config->profiles()->where('crm_provider_id', (string) $crmData['assignee_id'])->first();
$data = [
'team_id' => $this->team->id,
'account_id' => $account->id ?? null,
'user_id' => $profile ? $profile->user_id : null,
'owner_id' => $crmData['assignee_id'],
'name' => $crmData['name'],
'email' => $email,
'phone' => $parsedNumber['phone'] ?? null,
'mobile_phone' => $mobilePhone ?? null,
'ext' => $parsedNumber['ext'] ?? null,
'title' => $crmData['title'],
'country_code' => $countryCode,
'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(
crmConfiguration: $this->config,
crmProviderId: $crmData['id'],
modelType: Contact::class,
fileName: $crmData['id'],
avatarText: $crmData['name']
),
'remotely_created_at' => $crmData['date_created'],
];
return $this->config->contacts()->updateOrCreate(['crm_provider_id' => (string) $crmData['id']], $data);
}
private function buildContactPhone(?string $countryCode, ?string $number): ?array
{
if ($number) {
// Trim to our width and attempt to parse it.
$number = mb_strimwidth($number, 0, 25);
$parsedNumber = parsePhoneNumber($countryCode, $number);
} else {
$parsedNumber = [];
}
return $parsedNumber;
}
private function buildContactMobilePhone(?string $countryCode, ?string $number): ?string
{
return $number ? mb_strimwidth(phone_e164($countryCode, $number), 0, 25) : null;
}
/**
* @inheritdoc
*/
public function syncOrganization(): void
{
$organization = $this->copperClient->organisation()->all();
if ($this->config->crm_provider_id !== $organization['id']) {
$this->config->update(['crm_provider_id' => $organization['id']]);
}
}
private function syncCustomFields(): void
{
foreach ($this->getFieldTypes() as $fieldType) {
$currentFields = $this->getFields($fieldType);
foreach ($currentFields as $field) {
/** @var Field $field */
$field = $this->config->fields()->updateOrCreate([
'crm_provider_id' => $field['name'],
'object_type' => $fieldType,
], [
'label' => mb_strimwidth($field['label'], 0, Field::LABEL_MAX_LENGTH),
'type' => $field['type'],
]);
if ($field->getType() === Field::TYPE_PICKLIST) {
$this->importPicklistValues($field);
}
}
}
$this->config->save();
}
protected function getFieldTypes(): array
{
return [
parent::OBJECT_TASK,
parent::OBJECT_LEAD,
parent::OBJECT_ACCOUNT,
parent::OBJECT_CONTACT,
parent::OBJECT_OPPORTUNITY,
];
}
/**
* @inheritdoc
*/
public function syncProfiles(?User $userToSearch = null): ?Profile
{
$profiles = $this->copperClient->users()->all();
foreach ($profiles as $profile) {
$user = $this->userRepository->findByTeamAndEmailAddress($this->team, $profile['email'], onlyActive: true);
// Only import profiles for users we have locally.
if ($user) {
$profile = $user->crmProfile()->updateOrCreate([
'crm_configuration_id' => $this->config->id,
'crm_provider_id' => $profile['id'],
]);
if ($userToSearch && $userToSearch->id === $user->id) {
return $profile;
}
}
}
return null;
}
/**
* @inheritdoc
*/
public function syncProfileFields(): void
{
$fieldTypes = [
'task',
'lead',
'account',
'contact',
'opportunity',
];
foreach ($fieldTypes as $fieldType) {
try {
$currentFields = $this->getFields(ucfirst($fieldType));
$newFields = array_column($currentFields, 'name');
$changedFields = array_merge(
array_diff($this->profile->getFieldsAsArray($fieldType), $newFields),
array_diff($newFields, $this->profile->getFieldsAsArray($fieldType))
);
if (\count($changedFields) > 0) {
$fields = implode(',', $newFields);
$this->logger->debug(
ucfirst($fieldType)
. ' fields changed from '
. $this->profile->{$fieldType . '_fields'}
. ' to: '
. $fields
);
$this->profile->{$fieldType . '_fields'} = $fields;
}
} catch (\Exception $exception) {
$this->logger->error('No access to ' . $fieldType . ' object, skipping...');
continue;
}
}
$this->profile->save();
}
/**
* @inheritdoc
*/
public function find(string $name, array $scopes): array
{
$count = $this->limit ?? 20;
$offset = $this->offset ?? 0;
$scopeValues = array_map(fn ($scope) => is_string($scope) ? $scope : $scope->value, $scopes);
$cacheKey = sprintf('copper-search-%d-%s-%s-%d-%d', $this->team->id, $name, implode('-', $scopeValues), $count, $offset);
return Cache::remember($cacheKey, 300, function () use ($name, $scopes, $count, $offset) {
// Try local search first
$localResults = $this->searchLocally($name, $scopes, $count, $offset);
// If no local results, fall back to remote API search
if (empty($localResults)) {
return $this->searchRemotely($name, $scopes, $count, $offset);
}
return $localResults;
});
}
/**
* @inheritdoc
*/
public function findOpportunities(?string $crmAccountId, ?string $crmContactId, ?int $userId = null): array
{
$data = [];
$ownerData = [];
$ownerId = null;
$contact = null;
$account = null;
if ($crmAccountId) {
$account = $this->config->accounts()->where('crm_provider_id', $crmAccountId)->first();
if ($account === null) {
$account = $this->syncAccount($crmAccountId);
}
}
if ($crmContactId) {
$contact = $this->config->contacts()->where('crm_provider_id', $crmContactId)->first();
if ($contact === null) {
$contact = $this->syncContact($crmContactId);
}
}
if ($contact || $account) {
if ($contact && $account === null) {
$account = $contact->account;
}
if ($account === null) {
return [];
}
$params = [
'company_ids' => [$account->crm_provider_id],
'status_ids' => [0], // The integer values are 0, 1, 2, 3, for "Open", "Won", "Lost", and "Abandoned".
'sort_by' => 'date_modified',
'sort_direction' => 'desc',
];
switch ($this->config->opportunity_assignment_rule) {
case Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_UPDATED:
$params['sort_by'] = 'date_modified';
$params['sort_direction'] = 'desc';
break;
case Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_CREATED:
$params['sort_by'] = 'date_created';
$params['sort_direction'] = 'desc';
break;
case Configuration::OPP_ASSIGNMENT_ALL_OPEN_OLDEST_CREATED:
$params['sort_by'] = 'date_created';
$params['sort_direction'] = 'asc';
break;
case Configuration::OPP_ASSIGNMENT_ALL_RECENTLY_UPDATED:
$params['sort_by'] = 'date_modified';
$params['sort_direction'] = 'desc';
$params['status_ids'] = [0, 1, 2, 3];
}
$cpOpportunities = $this->copperClient->opportunities()->search($params);
if ($userId) {
$profileRepository = app(ProfileRepository::class);
$profile = $profileRepository->findProfileByUserId($this->config, $userId);
$ownerId = $profile instanceof Profile ? $profile->getCrmProviderId() : null;
}
foreach ($cpOpportunities as $cpOpportunity) {
$businessProcess = $this->config
->businessProcesses()
->where('crm_provider_id', (string) $cpOpportunity['pipeline_id'])
->first();
if ($businessProcess === null) {
// Import it.
$businessProcess = $this->importStages(
[Stage::TYPE_OPPORTUNITY],
$cpOpportunity['pipeline_stage_id']
);
}
$stage = $businessProcess
->stages()
->where('crm_provider_id', (string) $cpOpportunity['pipeline_stage_id'])
->where('type', Stage::TYPE_OPPORTUNITY)
->first();
if ($stage === null) {
// Import it.
$stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $cpOpportunity['pipeline_stage_id']);
}
$recordType = $businessProcess->recordTypes()->first();
$record = [
'crmId' => $cpOpportunity['id'],
'name' => $cpOpportunity['name'],
'value' => currency_format($cpOpportunity['monetary_value'], $cpOpportunity['monetary_unit']),
'won' => $cpOpportunity['status'] === 'Won',
'stage' => [
'id' => $stage->id_string,
'name' => $stage->name,
],
'recordType' => [
'id' => $recordType->id_string,
'name' => $recordType->name,
],
];
if ($ownerId && isset($cpOpportunity['assignee_id']) && $cpOpportunity['assignee_id'] === $ownerId) {
$ownerData[] = $record;
}
$data[] = $record;
}
}
if (! empty($ownerData)) {
return $ownerData;
}
return $data;
}
/**
* @inheritdoc
*/
public function getTasks(string $objectType, string $objectId, ?string $opportunityId): array
{
$data = $cpTasks = [];
switch ($objectType) {
case 'lead':
$cpTasks = $this->requestWithExceptionHandling(
fn () => $this->copperClient->leads()->relatedTasks($objectId)
);
break;
case 'account':
$cpTasks = $this->copperClient->accounts()->relatedTasks($objectId);
break;
case 'contact':
if ($opportunityId) {
$cpTasks = $this->copperClient->opportunities()->relatedTasks($opportunityId);
} elseif ($objectType === 'contact') {
$cpTasks = $this->copperClient->contacts()->relatedTasks($objectId);
}
break;
}
foreach ($cpTasks as $cpTask) {
// We only get the ID itself returned, so pull out the full object.
try {
$cpTask = $this->copperClient->tasks()->find($cpTask['id']);
} catch (Exception $exception) {
$this->logger->error('[Copper] Error fetching task', [
'resource_id' => $cpTask['id'],
'error' => $exception->getMessage(),
]);
continue;
}
// Only allow open tasks.
if ($cpTask['status'] === 'Open') {
$data[] = [
'crmId' => $cpTask['id'],
'subject' => $cpTask['name'],
'due' => $cpTask['due_date']
? Carbon::createFromTimestamp($cpTask['due_date'])->format('Y-m-d\TH:i:sO')
: null,
'type' => null,
];
}
}
return $data;
}
/**
* Try to find email address in CRM service
*
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
*}
*/
public function matchExactlyByEmail(string $email, ?int $userId = null): ?array
{
$cpObjectType = 'contact';
$cpObjects = $this->copperClient->contacts()->search([
'emails' => $email,
'sort_by' => 'date_modified',
'sort_direction' => 'desc',
]);
if (empty($cpObjects)) {
$cpObjectType = 'lead';
$cpObjects = $this->requestWithExceptionHandling(
fn () => $this->copperClient->leads()->search([
'emails' => ...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
45377
|
1625
|
26
|
2026-05-14T14:33:28.043331+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-14/1778 /Users/lukas/.screenpipe/data/data/2026-05-14/1778769208043_m1.jpg...
|
PhpStorm
|
faVsco.js – Copper/Service.php
|
1
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
#12066 on JY-20725-handle Project: faVsco.js, menu
#12066 on JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
HandleHubspotRateLimitTest
Run 'HandleHubspotRateLimitTest'
Debug 'HandleHubspotRateLimitTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide...
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"bounds":{"left":0.054166667,"top":0.027777778,"width":0.08055556,"height":0.035555556},"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"#12066 on JY-20725-handle-HS-search-rate-limit, menu","depth":5,"bounds":{"left":0.13472222,"top":0.027777778,"width":0.25555557,"height":0.035555556},"on_screen":true,"help_text":"Pull request #12066 exists for current branch JY-20725-handle-HS-search-rate-limit, but local branch is out of sync with remote","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"bounds":{"left":0.6326389,"top":0.027777778,"width":0.023611112,"height":0.035555556},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"HandleHubspotRateLimitTest","depth":6,"bounds":{"left":0.6645833,"top":0.027777778,"width":0.15902779,"height":0.035555556},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'HandleHubspotRateLimitTest'","depth":6,"bounds":{"left":0.82361114,"top":0.027777778,"width":0.023611112,"height":0.035555556},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'HandleHubspotRateLimitTest'","depth":6,"bounds":{"left":0.8472222,"top":0.027777778,"width":0.023611112,"height":0.035555556},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"bounds":{"left":0.87083334,"top":0.027777778,"width":0.023611112,"height":0.035555556},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"bounds":{"left":0.9291667,"top":0.027777778,"width":0.023611112,"height":0.035555556},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"bounds":{"left":0.9527778,"top":0.027777778,"width":0.023611112,"height":0.035555556},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"IDE and Project Settings","depth":5,"bounds":{"left":0.9763889,"top":0.027777778,"width":0.023611112,"height":0.035555556},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.088194445,"height":0.027777778},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
6814957943380363182
|
-8708761519200826421
|
click
|
hybrid
|
NULL
|
Project: faVsco.js, menu
#12066 on JY-20725-handle Project: faVsco.js, menu
#12066 on JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
HandleHubspotRateLimitTest
Run 'HandleHubspotRateLimitTest'
Debug 'HandleHubspotRateLimitTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
PhpStormFileEditViewNavigateCodeLaravelRefactorRunToolsGitWindowHelp•FV faVsco.jsv* #12066 on JY-20725-handle-HS-search-rate-limit K vabl19HandleHubspotRateLimitTest100% C8• Thu 14 May 17:33:27* :QProjectv=custom.logAutomatedReportGenerated.phpTrackAutomatedReportGeneratedEvent.phpPlaybackController.php© FieldDefinitions.phpUserAutomatedReportsController.phpPlanhatService.phpAutomatedReportResult.phpSendReportJob.php© FieldValueConverter.php• DeleteCrmEntityTrait.phpDeleteAccountJob.php© ImportActivityTypes.phpT WriteCrmTrait.php© Service.php© DecorateActivity.php> D CrmObjects© Salesforce/Service.php© LogActivityTrait.php© Pipedrive/Service.php© Close/Service.phpCopper/Service.php X• DecorateActivity© PlainTextDecorateActivity.phpT ActivityPlaybookTrait.php© CrmHelperRepository.php© AccountController.php18© BaseDecorateActivity.php© PlainTextDecorateActivity.plE.env© DetachActivityObject.php> D Dummyv HelpersT ActivityPlaybookTrait.php521612© Arraylterator.phpT IntegrationAppTrait.php=.env.staging© MatchActivityCrmData.php© Client.php© HubspotPaginationService.phpclass Service extends BaseService implementspublic functionsaveActivity(Activity $activity): ActivityRematchActivityOnCrmObjectDetach.php© HandleHubspotRateLimit.php811 472 M1 м4 лT ConnectionStateTrait.php1627© CrmHelperRepository.php1628return Sactivity:© FilterJoinedParticipants.php1629+ OpportunitySyncableFieldsT1630v D Hubspot4 usages• AccountSyncStrategy> OJ Actions |16311652private function convertActivityAssociations(Activity $activity): array{...}• ContactSyncStrategy2 usages> ODTO1653private function convertActivityType(PlaybookCategory $activityType): array› Fields1654› 0 Journal1655• Metadata1656v C OpportunitySyncStrategy1657return ['category' => 'user','id'=> $this->getCrmType($activityType)->value,› D Concerns16581;© HubspotLastModifiedByF 1659}© HubspotLastModifiedCre1660© HubspotLastModifiedCre1661HubspotLastModifiedOpe1662© HubspotLastModifiedSyn1663© HubspotSingleSyncStrate/***/1 usage* @throws CrmException© HubspotSyncStrategyBa: 1664private function buildCallPayload(Activity $activity): Activity© HubspotWebhookBatchS1665-v [ Pagination1666try {© HubspotPaginationServic1667if (Sactivity->crm_provider_id) {A DaninatinnCanfin nhn1668f The activity should be loqged under the existing lask (not Activity)=Workspace associated with branch 'JY-20725-handle-HS-search-rate-limit' has been restored // Rollback // Configure... (today 16:17)W Windsurf Teams=laravel.logSF [jiminny@localhost]A HS_local [jiminny@localho:W4 console [QAI PROD] X4 console [PROD]A console (EU]DGo jiminny084143 ×4 л215•m_layout_id =2162,1661,66799,66217218219=id = 33;220221==222E 223=224id THEN •(own225)=226= 227228- 229I230T231"hubspot';— 232- 233 v= 11512582;=2341653:22UTF-8Ca 4 spaces...
|
45375
|
NULL
|
NULL
|
NULL
|
|
45378
|
1625
|
27
|
2026-05-14T14:33:35.653395+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-14/1778 /Users/lukas/.screenpipe/data/data/2026-05-14/1778769215653_m1.jpg...
|
PhpStorm
|
|
1
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
All Places
Preview
Filter
Open in Find Tool Window All Places
Preview
Filter
Open in Find Tool Window
bhse
HubspotPaginationServiceTest.php .../tests/.../Hubspot/Pagination/HubspotPaginationServiceTest.php, class
HubspotPaginationService.php .../app/Services/Crm/Hubspot/Pagination/HubspotPaginationService.php, class
BladeHasSectionTest.php vendor/laravel/framework/tests/View/Blade/BladeHasSectionTest.php, class
HubSpotServicePreparationException.php .../.../Crm/Hubspot/HubSpotServicePreparationException.php, exception class
HubspotDealWebhookService.php .../app/.../Crm/Hubspot/Journal/HubspotDealWebhookService.php
HubspotDealWebhookServiceTest.php .../.../Hubspot/Journal/HubspotDealWebhookServiceTest.php, class
HubspotJournalPollingService.php .../app/.../Crm/Hubspot/Journal/HubspotJournalPollingService.php, class
HubspotJournalPollingServiceTest.php .../.../Hubspot/Journal/HubspotJournalPollingServiceTest.php, class
HubspotWebhookMetricsService.php .../app/.../Hubspot/Webhook/HubspotWebhookMetricsService.php, class
HubspotWebhookMetricsServiceTest.php .../.../Webhook/HubspotWebhookMetricsServiceTest.php, class
HubspotWebhookSubscriptionService.php .../.../Journal/HubspotWebhookSubscriptionService.php, class
HubspotWebhookSubscriptionServiceTest.php .../Journal/HubspotWebhookSubscriptionServiceTest.php, class
HasAvailableSeats.php.html build/coverage/Rules
HttpSessionExpiredException.php.html build/coverage/Exceptions
ItemHydratorServiceInterface.php.html build/coverage/Component/LiveFeed/Hydrator
… more
HubspotPaginationServiceTest.php .../tests/.../Hubspot/Pagination/HubspotPaginationServiceTest.php, class
HubspotPaginationService.php .../app/Services/Crm/Hubspot/Pagination/HubspotPaginationService.php, class
BladeHasSectionTest.php vendor/laravel/framework/tests/View/Blade/BladeHasSectionTest.php, class
HubSpotServicePreparationException.php .../.../Crm/Hubspot/HubSpotServicePreparationException.php, exception class
HubspotDealWebhookService.php .../app/.../Crm/Hubspot/Journal/HubspotDealWebhookService.php
HubspotDealWebhookServiceTest.php .../.../Hubspot/Journal/HubspotDealWebhookServiceTest.php, class
HubspotJournalPollingService.php .../app/.../Crm/Hubspot/Journal/HubspotJournalPollingService.php, class
HubspotJournalPollingServiceTest.php .../.../Hubspot/Journal/HubspotJournalPollingServiceTest.php, class
HubspotWebhookMetricsService.php .../app/.../Hubspot/Webhook/HubspotWebhookMetricsService.php, class
HubspotWebhookMetricsServiceTest.php .../.../Webhook/HubspotWebhookMetricsServiceTest.php, class
HubspotWebhookSubscriptionService.php .../.../Journal/HubspotWebhookSubscriptionService.php, class
HubspotWebhookSubscriptionServiceTest.php .../Journal/HubspotWebhookSubscriptionServiceTest.php, class
HasAvailableSeats.php.html build/coverage/Rules
HttpSessionExpiredException.php.html build/coverage/Exceptions
ItemHydratorServiceInterface.php.html build/coverage/Component/LiveFeed/Hydrator
… more
Unit/Services/Crm/Hubspot/Pagination/HubspotPaginationServiceTest.php
Open In Right Split...
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"All Places","depth":2,"bounds":{"left":0.8423611,"top":0.25,"width":0.061805554,"height":0.026666667},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Preview","depth":2,"bounds":{"left":0.90416664,"top":0.25,"width":0.018055556,"height":0.026666667},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Filter","depth":2,"bounds":{"left":0.9222222,"top":0.25,"width":0.018055556,"height":0.026666667},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Open in Find Tool Window","depth":2,"bounds":{"left":0.94027776,"top":0.25,"width":0.018055556,"height":0.026666667},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextField","text":"bhse","depth":1,"bounds":{"left":0.50555557,"top":0.29222223,"width":0.45277777,"height":0.032222223},"on_screen":true,"value":"bhse","role_description":"text field","is_enabled":true,"is_focused":true,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"HubspotPaginationServiceTest.php .../tests/.../Hubspot/Pagination/HubspotPaginationServiceTest.php, class","depth":2,"bounds":{"left":0.49930555,"top":0.33666667,"width":0.4652778,"height":0.024444444},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"HubspotPaginationService.php .../app/Services/Crm/Hubspot/Pagination/HubspotPaginationService.php, class","depth":2,"bounds":{"left":0.49930555,"top":0.3611111,"width":0.4652778,"height":0.024444444},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"BladeHasSectionTest.php vendor/laravel/framework/tests/View/Blade/BladeHasSectionTest.php, class","depth":2,"bounds":{"left":0.49930555,"top":0.38555557,"width":0.4652778,"height":0.024444444},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"HubSpotServicePreparationException.php .../.../Crm/Hubspot/HubSpotServicePreparationException.php, exception class","depth":2,"bounds":{"left":0.49930555,"top":0.41,"width":0.4652778,"height":0.024444444},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"HubspotDealWebhookService.php .../app/.../Crm/Hubspot/Journal/HubspotDealWebhookService.php","depth":2,"bounds":{"left":0.49930555,"top":0.43444446,"width":0.4652778,"height":0.024444444},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"HubspotDealWebhookServiceTest.php .../.../Hubspot/Journal/HubspotDealWebhookServiceTest.php, class","depth":2,"bounds":{"left":0.49930555,"top":0.4588889,"width":0.4652778,"height":0.024444444},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"HubspotJournalPollingService.php .../app/.../Crm/Hubspot/Journal/HubspotJournalPollingService.php, class","depth":2,"bounds":{"left":0.49930555,"top":0.48333332,"width":0.4652778,"height":0.024444444},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"HubspotJournalPollingServiceTest.php .../.../Hubspot/Journal/HubspotJournalPollingServiceTest.php, class","depth":2,"bounds":{"left":0.49930555,"top":0.50777775,"width":0.4652778,"height":0.024444444},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"HubspotWebhookMetricsService.php .../app/.../Hubspot/Webhook/HubspotWebhookMetricsService.php, class","depth":2,"bounds":{"left":0.49930555,"top":0.5322222,"width":0.4652778,"height":0.024444444},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"HubspotWebhookMetricsServiceTest.php .../.../Webhook/HubspotWebhookMetricsServiceTest.php, class","depth":2,"bounds":{"left":0.49930555,"top":0.5566667,"width":0.4652778,"height":0.024444444},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"HubspotWebhookSubscriptionService.php .../.../Journal/HubspotWebhookSubscriptionService.php, class","depth":2,"bounds":{"left":0.49930555,"top":0.58111113,"width":0.4652778,"height":0.024444444},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"HubspotWebhookSubscriptionServiceTest.php .../Journal/HubspotWebhookSubscriptionServiceTest.php, class","depth":2,"bounds":{"left":0.49930555,"top":0.60555553,"width":0.4652778,"height":0.024444444},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"HasAvailableSeats.php.html build/coverage/Rules","depth":2,"bounds":{"left":0.49930555,"top":0.63,"width":0.4652778,"height":0.024444444},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"HttpSessionExpiredException.php.html build/coverage/Exceptions","depth":2,"bounds":{"left":0.49930555,"top":0.65444446,"width":0.4652778,"height":0.024444444},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"ItemHydratorServiceInterface.php.html build/coverage/Component/LiveFeed/Hydrator","depth":2,"bounds":{"left":0.49930555,"top":0.6788889,"width":0.4652778,"height":0.024444444},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"… more","depth":2,"bounds":{"left":0.49930555,"top":0.7033333,"width":0.4652778,"height":0.024444444},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"HubspotPaginationServiceTest.php .../tests/.../Hubspot/Pagination/HubspotPaginationServiceTest.php, class","depth":4,"bounds":{"left":0.49930555,"top":0.33666667,"width":0.4652778,"height":0.024444444},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"HubspotPaginationService.php .../app/Services/Crm/Hubspot/Pagination/HubspotPaginationService.php, class","depth":4,"bounds":{"left":0.49930555,"top":0.3611111,"width":0.4652778,"height":0.024444444},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"BladeHasSectionTest.php vendor/laravel/framework/tests/View/Blade/BladeHasSectionTest.php, class","depth":4,"bounds":{"left":0.49930555,"top":0.38555557,"width":0.4652778,"height":0.024444444},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"HubSpotServicePreparationException.php .../.../Crm/Hubspot/HubSpotServicePreparationException.php, exception class","depth":4,"bounds":{"left":0.49930555,"top":0.41,"width":0.4652778,"height":0.024444444},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"HubspotDealWebhookService.php .../app/.../Crm/Hubspot/Journal/HubspotDealWebhookService.php","depth":4,"bounds":{"left":0.49930555,"top":0.43444446,"width":0.4652778,"height":0.024444444},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"HubspotDealWebhookServiceTest.php .../.../Hubspot/Journal/HubspotDealWebhookServiceTest.php, class","depth":4,"bounds":{"left":0.49930555,"top":0.4588889,"width":0.4652778,"height":0.024444444},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"HubspotJournalPollingService.php .../app/.../Crm/Hubspot/Journal/HubspotJournalPollingService.php, class","depth":4,"bounds":{"left":0.49930555,"top":0.48333332,"width":0.4652778,"height":0.024444444},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"HubspotJournalPollingServiceTest.php .../.../Hubspot/Journal/HubspotJournalPollingServiceTest.php, class","depth":4,"bounds":{"left":0.49930555,"top":0.50777775,"width":0.4652778,"height":0.024444444},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"HubspotWebhookMetricsService.php .../app/.../Hubspot/Webhook/HubspotWebhookMetricsService.php, class","depth":4,"bounds":{"left":0.49930555,"top":0.5322222,"width":0.4652778,"height":0.024444444},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"HubspotWebhookMetricsServiceTest.php .../.../Webhook/HubspotWebhookMetricsServiceTest.php, class","depth":4,"bounds":{"left":0.49930555,"top":0.5566667,"width":0.4652778,"height":0.024444444},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"HubspotWebhookSubscriptionService.php .../.../Journal/HubspotWebhookSubscriptionService.php, class","depth":4,"bounds":{"left":0.49930555,"top":0.58111113,"width":0.4652778,"height":0.024444444},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"HubspotWebhookSubscriptionServiceTest.php .../Journal/HubspotWebhookSubscriptionServiceTest.php, class","depth":4,"bounds":{"left":0.49930555,"top":0.60555553,"width":0.4652778,"height":0.024444444},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"HasAvailableSeats.php.html build/coverage/Rules","depth":4,"bounds":{"left":0.49930555,"top":0.63,"width":0.4652778,"height":0.024444444},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"HttpSessionExpiredException.php.html build/coverage/Exceptions","depth":4,"bounds":{"left":0.49930555,"top":0.65444446,"width":0.4652778,"height":0.024444444},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"ItemHydratorServiceInterface.php.html build/coverage/Component/LiveFeed/Hydrator","depth":4,"bounds":{"left":0.49930555,"top":0.6788889,"width":0.4652778,"height":0.024444444},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"… more","depth":4,"bounds":{"left":0.49930555,"top":0.7033333,"width":0.4652778,"height":0.024444444},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Unit/Services/Crm/Hubspot/Pagination/HubspotPaginationServiceTest.php","depth":1,"bounds":{"left":0.51319444,"top":0.74777776,"width":0.29375,"height":0.018888889},"on_screen":true,"help_text":"Unit/Services/Crm/Hubspot/Pagination/HubspotPaginationServiceTest.php","role_description":"text"},{"role":"AXLink","text":"Open In Right Split","depth":1,"bounds":{"left":0.8673611,"top":0.74777776,"width":0.08055556,"height":0.018888889},"on_screen":true,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
-8644524876484404324
|
921475024034100431
|
visual_change
|
accessibility
|
NULL
|
All Places
Preview
Filter
Open in Find Tool Window All Places
Preview
Filter
Open in Find Tool Window
bhse
HubspotPaginationServiceTest.php .../tests/.../Hubspot/Pagination/HubspotPaginationServiceTest.php, class
HubspotPaginationService.php .../app/Services/Crm/Hubspot/Pagination/HubspotPaginationService.php, class
BladeHasSectionTest.php vendor/laravel/framework/tests/View/Blade/BladeHasSectionTest.php, class
HubSpotServicePreparationException.php .../.../Crm/Hubspot/HubSpotServicePreparationException.php, exception class
HubspotDealWebhookService.php .../app/.../Crm/Hubspot/Journal/HubspotDealWebhookService.php
HubspotDealWebhookServiceTest.php .../.../Hubspot/Journal/HubspotDealWebhookServiceTest.php, class
HubspotJournalPollingService.php .../app/.../Crm/Hubspot/Journal/HubspotJournalPollingService.php, class
HubspotJournalPollingServiceTest.php .../.../Hubspot/Journal/HubspotJournalPollingServiceTest.php, class
HubspotWebhookMetricsService.php .../app/.../Hubspot/Webhook/HubspotWebhookMetricsService.php, class
HubspotWebhookMetricsServiceTest.php .../.../Webhook/HubspotWebhookMetricsServiceTest.php, class
HubspotWebhookSubscriptionService.php .../.../Journal/HubspotWebhookSubscriptionService.php, class
HubspotWebhookSubscriptionServiceTest.php .../Journal/HubspotWebhookSubscriptionServiceTest.php, class
HasAvailableSeats.php.html build/coverage/Rules
HttpSessionExpiredException.php.html build/coverage/Exceptions
ItemHydratorServiceInterface.php.html build/coverage/Component/LiveFeed/Hydrator
… more
HubspotPaginationServiceTest.php .../tests/.../Hubspot/Pagination/HubspotPaginationServiceTest.php, class
HubspotPaginationService.php .../app/Services/Crm/Hubspot/Pagination/HubspotPaginationService.php, class
BladeHasSectionTest.php vendor/laravel/framework/tests/View/Blade/BladeHasSectionTest.php, class
HubSpotServicePreparationException.php .../.../Crm/Hubspot/HubSpotServicePreparationException.php, exception class
HubspotDealWebhookService.php .../app/.../Crm/Hubspot/Journal/HubspotDealWebhookService.php
HubspotDealWebhookServiceTest.php .../.../Hubspot/Journal/HubspotDealWebhookServiceTest.php, class
HubspotJournalPollingService.php .../app/.../Crm/Hubspot/Journal/HubspotJournalPollingService.php, class
HubspotJournalPollingServiceTest.php .../.../Hubspot/Journal/HubspotJournalPollingServiceTest.php, class
HubspotWebhookMetricsService.php .../app/.../Hubspot/Webhook/HubspotWebhookMetricsService.php, class
HubspotWebhookMetricsServiceTest.php .../.../Webhook/HubspotWebhookMetricsServiceTest.php, class
HubspotWebhookSubscriptionService.php .../.../Journal/HubspotWebhookSubscriptionService.php, class
HubspotWebhookSubscriptionServiceTest.php .../Journal/HubspotWebhookSubscriptionServiceTest.php, class
HasAvailableSeats.php.html build/coverage/Rules
HttpSessionExpiredException.php.html build/coverage/Exceptions
ItemHydratorServiceInterface.php.html build/coverage/Component/LiveFeed/Hydrator
… more
Unit/Services/Crm/Hubspot/Pagination/HubspotPaginationServiceTest.php
Open In Right Split...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
45379
|
1625
|
28
|
2026-05-14T14:33:38.664867+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-14/1778 /Users/lukas/.screenpipe/data/data/2026-05-14/1778769218664_m1.jpg...
|
PhpStorm
|
|
1
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
Project Files
Preview
Filter
Open in Find Tool Win Project Files
Preview
Filter
Open in Find Tool Window
bootstrap
Open In Right Split...
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project Files","depth":2,"bounds":{"left":0.8298611,"top":0.25,"width":0.07430556,"height":0.026666667},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Preview","depth":2,"bounds":{"left":0.90416664,"top":0.25,"width":0.018055556,"height":0.026666667},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Filter","depth":2,"bounds":{"left":0.9222222,"top":0.25,"width":0.018055556,"height":0.026666667},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Open in Find Tool Window","depth":2,"bounds":{"left":0.94027776,"top":0.25,"width":0.018055556,"height":0.026666667},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"bootstrap","depth":1,"bounds":{"left":0.51319444,"top":0.30888888,"width":0.038194444,"height":0.018888889},"on_screen":true,"help_text":"bootstrap","role_description":"text"},{"role":"AXLink","text":"Open In Right Split","depth":1,"bounds":{"left":0.8673611,"top":0.30888888,"width":0.08055556,"height":0.018888889},"on_screen":true,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
-2254067575783512575
|
-9222643463564451140
|
visual_change
|
hybrid
|
NULL
|
Project Files
Preview
Filter
Open in Find Tool Win Project Files
Preview
Filter
Open in Find Tool Window
bootstrap
Open In Right Split
PhpStormFileEditViewNavigateCodeLaravelRefactorRunToolsGitWindowHelpablFV faVsco.js* #12066 on JY-20725-handle-HS-search-rate-limit k vHandleHubspotRateLimitTest100% (C478• Thu 14 May 17:33:38QProjectv:= custom.logAutomatedReportGenerated.phpTrackAutomatedReportGeneratedEvent.phpPlaybackController.php© FieldDefinitions.phpUserAutomatedReportsController.phpPlanhatService.phpAutomatedReportResult.phpSendReportJob.php=laravel.log© FieldValueConverter.php• DeleteCrmEntityTrait.phpDeleteAccountJob.phpImportActivityTypes.phpT WriteCrmTrait.php© Service.php© DecorateActivity.phpSF [jiminny@localhost]> D CrmObjects© Salesforce/Service.php© LogActivityTrait.phpPipedrive/Service.phpClose/Service.phpCopper/Service.php XA HS_local [jiminny@localho:W• DecorateActivity© PlainTextDecorateActivity.phpT ActivityPlaybookTrait.phpCrmHelperRepository.phpAccountController.php4 console [QAI PROD] X18© BaseDecorateActivity.php© PlainTextDecorateActivity.plT IntegrationAppTrait.php.env.staging.env© DeiAllClassesFilesSymbolsActionsTextProject Files v> Dummyv HelpersT ActivityPlaybookTrait.php521664© Arraylterator.phpT ConnectionStateTrait.php1679© CrmHelperRepository.php1680© MatchActivityCrmData.php© Client.php© HubspotPaginaclass Service extends BaseService implementsprivate function buildCallPayload(Activity Sactivity): Activity1);} else {© FilterJoinedParticipants.php1681© OpportunitySyncableFieldsT 1682v D Hubspot1683• AccountSyncStrategy1684throw new CrmException($message,code: 400):> @ Actions|1685• ContactSyncStrategy1686> ODTO1687› Fields1688> 0J Journal1689• Metadata1690v C OpportunitySyncStrategy1691> D Concerns1692© HubspotLastModifiedByF 1693© HubspotLastModifiedCre1694© HubspotLastModifiedCre1695HubspotLastModifiedOpe1696© HubspotLastModifiedSyn16971698215216if (Sthis-~getCrmType(Sactivitx~~categocx)mmllnt217Smessage = sprintf( format: "[Copper] Invalid CRM Activity Type: %S', Sactivity->category≥.218219=220221==222if ($this->getCrmType($activity->category)->value === null) 1E223Smessage = sprintf( format: '[Copper] Invalid CRM Activity Type: %s value', $activity->cate 224225)=226= 227228= 229= 230-231+252+ 233 v23408 41A3 ×4 ^•m_layout_id =2,1661,66799,66id = 33;id THEN •(ownthrow new CrmException(Smessage,code: 400):© HubspotSingleSyncStrate// Just create an activity.$cpActivity = $this->copperClient-›activities()->create(['user_id' => $this->profile->crm_provider_id,'activity_date' => $activity->scheduled_start_time->timestamp,'parent' => $this->convertActivityAssoCiations($activity),'type' => $this->convertActivityType(Sactivity->category),'details' => Sthis->generateActivityDescription(Sactivity),hubspot':= 11512582;© HubspotSyncStrategyBa:16991):© HubspotWebhookBatchS1700v [ Pagination1701© HubspotPaginationServic1702Sactivity->crm_provider_id = $cpActivityl'id'];Sactivity->saveO;A Daninstinnfanfin nhn1703=Workspace associated with branch 'JY-20725-handle-HS-search-rate-limit' has been restored // Rollback // Configure... (today 16:17)W Windsurf Teams1697:45UTF-8Ca 4 spaces...
|
45378
|
NULL
|
NULL
|
NULL
|
|
45380
|
1625
|
29
|
2026-05-14T14:33:41.693591+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-14/1778 /Users/lukas/.screenpipe/data/data/2026-05-14/1778769221693_m1.jpg...
|
PhpStorm
|
|
1
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
Project Files
Preview
Filter
Open in Find Tool Win Project Files
Preview
Filter
Open in Find Tool Window
bullhorn/
Bullhorn app/Services/Crm/Bullhorn
Bullhorn tests/Unit/Services/Crm/Bullhorn
Bullhorn build/coverage/Services/Crm/Bullhorn
BullhornClient.php .../app/Services/Crm/Bullhorn/BullhornClient.php, class
BullhornInterface.php .../app/Contracts/Services/Crm/Provider/BullhornInterface.php, interface
BullhornClientInterface.php .../app/Services/Crm/Bullhorn/BullhornClientInterface.php, interface
BullhornCommandAbstract.php .../app/Console/Commands/Crm/BullhornCommandAbstract.php, abstract class
BullhornDefaultStrategy.php .../app/.../Bullhorn/ProspectSearchStrategy/BullhornDefaultStrategy.php, class
BullhornLastModifiedSyncStrategy.php .../BullhornLastModifiedSyncStrategy.php, class
BullhornLastModifiedByProfileSyncStrategy.php .../BullhornLastModifiedByProfileSyncStrategy.php, class
BullhornClient.php.html build/coverage/Services/Crm/Bullhorn
BullhornClientInterface.php.html build/coverage/Services/Crm/Bullhorn
BullhornCommandAbstract.php.html build/coverage/Console/Commands/Crm
BullhornDefaultStrategy.php.html build/coverage/Services/Crm/Bullhorn/ProspectSearchStrategy
BullhornLastModifiedSyncStrategy.php.html build/coverage/.../Crm/Bullhorn/OpportunitySyncStrategy
Bullhorn app/Services/Crm/Bullhorn
Bullhorn tests/Unit/Services/Crm/Bullhorn
Bullhorn build/coverage/Services/Crm/Bullhorn
Services/Crm/Bullhorn
Open In Right Split...
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project Files","depth":2,"bounds":{"left":0.8298611,"top":0.25,"width":0.07430556,"height":0.026666667},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Preview","depth":2,"bounds":{"left":0.90416664,"top":0.25,"width":0.018055556,"height":0.026666667},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Filter","depth":2,"bounds":{"left":0.9222222,"top":0.25,"width":0.018055556,"height":0.026666667},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Open in Find Tool Window","depth":2,"bounds":{"left":0.94027776,"top":0.25,"width":0.018055556,"height":0.026666667},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextField","text":"bullhorn/","depth":1,"bounds":{"left":0.50555557,"top":0.29222223,"width":0.45277777,"height":0.032222223},"on_screen":true,"value":"bullhorn/","role_description":"text field","is_enabled":true,"is_focused":true,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Bullhorn app/Services/Crm/Bullhorn","depth":2,"bounds":{"left":0.49930555,"top":0.33666667,"width":0.4652778,"height":0.024444444},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Bullhorn tests/Unit/Services/Crm/Bullhorn","depth":2,"bounds":{"left":0.49930555,"top":0.3611111,"width":0.4652778,"height":0.024444444},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Bullhorn build/coverage/Services/Crm/Bullhorn","depth":2,"bounds":{"left":0.49930555,"top":0.38555557,"width":0.4652778,"height":0.024444444},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"BullhornClient.php .../app/Services/Crm/Bullhorn/BullhornClient.php, class","depth":2,"bounds":{"left":0.49930555,"top":0.41,"width":0.4652778,"height":0.024444444},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"BullhornInterface.php .../app/Contracts/Services/Crm/Provider/BullhornInterface.php, interface","depth":2,"bounds":{"left":0.49930555,"top":0.43444446,"width":0.4652778,"height":0.024444444},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"BullhornClientInterface.php .../app/Services/Crm/Bullhorn/BullhornClientInterface.php, interface","depth":2,"bounds":{"left":0.49930555,"top":0.4588889,"width":0.4652778,"height":0.024444444},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"BullhornCommandAbstract.php .../app/Console/Commands/Crm/BullhornCommandAbstract.php, abstract class","depth":2,"bounds":{"left":0.49930555,"top":0.48333332,"width":0.4652778,"height":0.024444444},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"BullhornDefaultStrategy.php .../app/.../Bullhorn/ProspectSearchStrategy/BullhornDefaultStrategy.php, class","depth":2,"bounds":{"left":0.49930555,"top":0.50777775,"width":0.4652778,"height":0.024444444},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"BullhornLastModifiedSyncStrategy.php .../BullhornLastModifiedSyncStrategy.php, class","depth":2,"bounds":{"left":0.49930555,"top":0.5322222,"width":0.4652778,"height":0.024444444},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"BullhornLastModifiedByProfileSyncStrategy.php .../BullhornLastModifiedByProfileSyncStrategy.php, class","depth":2,"bounds":{"left":0.49930555,"top":0.5566667,"width":0.4652778,"height":0.024444444},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"BullhornClient.php.html build/coverage/Services/Crm/Bullhorn","depth":2,"bounds":{"left":0.49930555,"top":0.58111113,"width":0.4652778,"height":0.024444444},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"BullhornClientInterface.php.html build/coverage/Services/Crm/Bullhorn","depth":2,"bounds":{"left":0.49930555,"top":0.60555553,"width":0.4652778,"height":0.024444444},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"BullhornCommandAbstract.php.html build/coverage/Console/Commands/Crm","depth":2,"bounds":{"left":0.49930555,"top":0.63,"width":0.4652778,"height":0.024444444},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"BullhornDefaultStrategy.php.html build/coverage/Services/Crm/Bullhorn/ProspectSearchStrategy","depth":2,"bounds":{"left":0.49930555,"top":0.65444446,"width":0.4652778,"height":0.024444444},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"BullhornLastModifiedSyncStrategy.php.html build/coverage/.../Crm/Bullhorn/OpportunitySyncStrategy","depth":2,"bounds":{"left":0.49930555,"top":0.6788889,"width":0.4652778,"height":0.024444444},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Bullhorn app/Services/Crm/Bullhorn","depth":4,"bounds":{"left":0.49930555,"top":0.33666667,"width":0.4652778,"height":0.024444444},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Bullhorn tests/Unit/Services/Crm/Bullhorn","depth":4,"bounds":{"left":0.49930555,"top":0.3611111,"width":0.4652778,"height":0.024444444},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Bullhorn build/coverage/Services/Crm/Bullhorn","depth":4,"bounds":{"left":0.49930555,"top":0.38555557,"width":0.4652778,"height":0.024444444},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Services/Crm/Bullhorn","depth":1,"bounds":{"left":0.51319444,"top":0.74777776,"width":0.0875,"height":0.018888889},"on_screen":true,"help_text":"Services/Crm/Bullhorn","role_description":"text"},{"role":"AXLink","text":"Open In Right Split","depth":1,"bounds":{"left":0.8673611,"top":0.74777776,"width":0.08055556,"height":0.018888889},"on_screen":true,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
2774422303295767760
|
-7342671818821146656
|
visual_change
|
accessibility
|
NULL
|
Project Files
Preview
Filter
Open in Find Tool Win Project Files
Preview
Filter
Open in Find Tool Window
bullhorn/
Bullhorn app/Services/Crm/Bullhorn
Bullhorn tests/Unit/Services/Crm/Bullhorn
Bullhorn build/coverage/Services/Crm/Bullhorn
BullhornClient.php .../app/Services/Crm/Bullhorn/BullhornClient.php, class
BullhornInterface.php .../app/Contracts/Services/Crm/Provider/BullhornInterface.php, interface
BullhornClientInterface.php .../app/Services/Crm/Bullhorn/BullhornClientInterface.php, interface
BullhornCommandAbstract.php .../app/Console/Commands/Crm/BullhornCommandAbstract.php, abstract class
BullhornDefaultStrategy.php .../app/.../Bullhorn/ProspectSearchStrategy/BullhornDefaultStrategy.php, class
BullhornLastModifiedSyncStrategy.php .../BullhornLastModifiedSyncStrategy.php, class
BullhornLastModifiedByProfileSyncStrategy.php .../BullhornLastModifiedByProfileSyncStrategy.php, class
BullhornClient.php.html build/coverage/Services/Crm/Bullhorn
BullhornClientInterface.php.html build/coverage/Services/Crm/Bullhorn
BullhornCommandAbstract.php.html build/coverage/Console/Commands/Crm
BullhornDefaultStrategy.php.html build/coverage/Services/Crm/Bullhorn/ProspectSearchStrategy
BullhornLastModifiedSyncStrategy.php.html build/coverage/.../Crm/Bullhorn/OpportunitySyncStrategy
Bullhorn app/Services/Crm/Bullhorn
Bullhorn tests/Unit/Services/Crm/Bullhorn
Bullhorn build/coverage/Services/Crm/Bullhorn
Services/Crm/Bullhorn
Open In Right Split...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
45381
|
1625
|
30
|
2026-05-14T14:33:44.720557+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-14/1778 /Users/lukas/.screenpipe/data/data/2026-05-14/1778769224720_m1.jpg...
|
PhpStorm
|
|
1
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
PhpStormFileEditViewNavigateCodeLaravelRefactorRun PhpStormFileEditViewNavigateCodeLaravelRefactorRunToolsGitWindowHelplhl100% С•FV faVsco.jsv* #12066 on JY-20725-handle-HS-search-rate-limit k vHandleHubspotRateLimitTest8• Thu 14 May 17:33:44* :QProjectvAutomatedReportGenerated.phpTrackAutomatedReportGeneratedEvent.php© UserAutomatedReportsController.php:=custom.log© FieldDefinitions.phpPlanhatService.phpAutomatedReportResult.phpSendReportJob.phpDeleteCrmEntityTrait.phpDeleteAccountJob.php© FieldValueConverter.php© ImportActivityTypes.phpT WriteCrmTrait.php© DecorateActivity.php© Salesforce/Service.phpT LogActivityTrait.php© Service.php> D CrmObjects© Pipedrive/Service.phpClose/Service.phpCopper/Service.phpBullhornService.php X© PlainTextDecorateActivity.php• DecorateActivity© ActivityPlaybookTrait.phpCrmHelperRepository.php© AccountController.phpT IntegrationAppTrait.php= .env.staging18© BaseDecorateActivity.php© PlainTextDecorateActivity.plE.envDetachActivityObject.phpRematchActivityOnCrmObjectDetach.php© MatchActivityCrmData.phpClient.php› @ Dummyv Helpers© HubspotPaginationService.phpk?phpHandleHubspotRateLimit.php1T ActivityPlaybookTrait.php© Arraylterator.php3namespace Jiminny (Services \Crm\Bullhorn;T ConnectionStateTrait.php© CrmHelperRepository.phpuse© FilterJoinedParticipants.php46+ OpportunitySyncableFieldsTv D Hubspot• AccountSyncStrategy> OJ Actions |• ContactSyncStrategy> ODTO› Fields› 0 Journal|D Metadatav C OpportunitySyncStrategy> D Concerns© HubspotLastModifiedByF© HubspotLastModifiedCre474849505152535455565758class BullhornService extends BaseService implementsBullhornInterface,SyncCrmEntitiesInterface,SyncCrmMetadataInterface,RemoteEntityManipulationInterface,FetchRelatedActivityInterface,LayoutManagementInterface,SettingsInterface,MatchCrmEntitiesInterface,RemoteEntityLookupInterface,VerifyTaskExistsInterface© HubspotLastModifiedCre© HubspotLastModifiedOpe© HubspotLastModifiedSyn© HubspotSingleSyncStrate5960private const int TASK_VERIFICATION_CACHE_TTL = 86400; // 1 day© HubspotSyncStrategyBa:61private const string ENTITY_LEAD = 'Candidate';© HubspotWebhookBatchSv [ Pagination62private const string ENTITY_OPPORTUNITY = 'JobOrder';© HubspotPaginationServicA DaninatinnCanfin nhnWorkspace associated with branch 'JY-20725-handle-HS-search-rate-limit' has been restored // Rollback // Configure... (today 16:17)=laravel.logSF [jiminny@localhost]A HS_local [jiminny@localho:W4 console [QAI PROD] X4 console [PROD]A console (EU]Analyzing…DGo jiminny084143 ×4 л215|•m_layout_id =2162,1661,66799,66217218219.id = 33;220221222223224id THEN •(own225226227228229230hubspot':-231232233 V= 11512582;234W Windsurf Teams 1:1UTF-8Ca 4 spaces...
|
NULL
|
-2090151520793946511
|
NULL
|
visual_change
|
ocr
|
NULL
|
PhpStormFileEditViewNavigateCodeLaravelRefactorRun PhpStormFileEditViewNavigateCodeLaravelRefactorRunToolsGitWindowHelplhl100% С•FV faVsco.jsv* #12066 on JY-20725-handle-HS-search-rate-limit k vHandleHubspotRateLimitTest8• Thu 14 May 17:33:44* :QProjectvAutomatedReportGenerated.phpTrackAutomatedReportGeneratedEvent.php© UserAutomatedReportsController.php:=custom.log© FieldDefinitions.phpPlanhatService.phpAutomatedReportResult.phpSendReportJob.phpDeleteCrmEntityTrait.phpDeleteAccountJob.php© FieldValueConverter.php© ImportActivityTypes.phpT WriteCrmTrait.php© DecorateActivity.php© Salesforce/Service.phpT LogActivityTrait.php© Service.php> D CrmObjects© Pipedrive/Service.phpClose/Service.phpCopper/Service.phpBullhornService.php X© PlainTextDecorateActivity.php• DecorateActivity© ActivityPlaybookTrait.phpCrmHelperRepository.php© AccountController.phpT IntegrationAppTrait.php= .env.staging18© BaseDecorateActivity.php© PlainTextDecorateActivity.plE.envDetachActivityObject.phpRematchActivityOnCrmObjectDetach.php© MatchActivityCrmData.phpClient.php› @ Dummyv Helpers© HubspotPaginationService.phpk?phpHandleHubspotRateLimit.php1T ActivityPlaybookTrait.php© Arraylterator.php3namespace Jiminny (Services \Crm\Bullhorn;T ConnectionStateTrait.php© CrmHelperRepository.phpuse© FilterJoinedParticipants.php46+ OpportunitySyncableFieldsTv D Hubspot• AccountSyncStrategy> OJ Actions |• ContactSyncStrategy> ODTO› Fields› 0 Journal|D Metadatav C OpportunitySyncStrategy> D Concerns© HubspotLastModifiedByF© HubspotLastModifiedCre474849505152535455565758class BullhornService extends BaseService implementsBullhornInterface,SyncCrmEntitiesInterface,SyncCrmMetadataInterface,RemoteEntityManipulationInterface,FetchRelatedActivityInterface,LayoutManagementInterface,SettingsInterface,MatchCrmEntitiesInterface,RemoteEntityLookupInterface,VerifyTaskExistsInterface© HubspotLastModifiedCre© HubspotLastModifiedOpe© HubspotLastModifiedSyn© HubspotSingleSyncStrate5960private const int TASK_VERIFICATION_CACHE_TTL = 86400; // 1 day© HubspotSyncStrategyBa:61private const string ENTITY_LEAD = 'Candidate';© HubspotWebhookBatchSv [ Pagination62private const string ENTITY_OPPORTUNITY = 'JobOrder';© HubspotPaginationServicA DaninatinnCanfin nhnWorkspace associated with branch 'JY-20725-handle-HS-search-rate-limit' has been restored // Rollback // Configure... (today 16:17)=laravel.logSF [jiminny@localhost]A HS_local [jiminny@localho:W4 console [QAI PROD] X4 console [PROD]A console (EU]Analyzing…DGo jiminny084143 ×4 л215|•m_layout_id =2162,1661,66799,66217218219.id = 33;220221222223224id THEN •(own225226227228229230hubspot':-231232233 V= 11512582;234W Windsurf Teams 1:1UTF-8Ca 4 spaces...
|
45380
|
NULL
|
NULL
|
NULL
|
|
45382
|
1625
|
31
|
2026-05-14T14:33:47.768633+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-14/1778 /Users/lukas/.screenpipe/data/data/2026-05-14/1778769227768_m1.jpg...
|
PhpStorm
|
|
1
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
s
Inherited members (⌘R)
Anonymous Classes (⌘I)
La s
Inherited members (⌘R)
Anonymous Classes (⌘I)
Lambdas (⌘L)
BullhornService, class
ACCOUNT_FIELDS: array|string[] = [...], private
CONTACT_FIELDS: array|string[] = [...], private
ENTITY_ACCOUNT: string = 'ClientCorporation', private
ENTITY_ACTIVITY: string = 'Note', private
ENTITY_CONTACT: string = 'ClientContact', private
ENTITY_LEAD: string = 'Candidate', private
ENTITY_OPPORTUNITY: string = 'JobOrder', private
LEAD_FIELDS: array|string[] = [...], private
OPPORTUNITY_FIELDS: array|string[] = [...], private
TASK_FIELDS: array|string[] = [...], private
TASK_VERIFICATION_CACHE_TTL: int = 86400, private
prospectPhotoPathService: ProspectPhotoPathService, parameter
userRepository: UserRepository, parameter
__construct(client: BullhornClientInterface, userRepository: UserRepository, prospectPhotoPathService: ProspectPhotoPathService) ↑BaseService, method
convertCrmData(entities: array, [userId: int|null = null]): array, method
convertFieldType(type: null|string, specialization: null|string, inputType: null|string): string, method
convertObjectTypeToResource(objectType: string): string, method
convertResourceToObjectType(resource: string): string, method
createTranscriptNotes(activity: Activity): void ↑RemoteEntityManipulationInterface, method
fetchAndAssociateRelatedActivity(activity: Activity): Activity|null ↑FetchRelatedActivityInterface, public method
filterOutOfBoundDates(rawDateCandidate: string): Carbon|null, method
find(name: string, scopes: array): array ↑RemoteEntityLookupInterface, method
findOpportunities(crmAccountId: null|string, crmContactId: null|string, [userId: int|null = null]): array ↑RemoteEntityLookupInterface, method
generateActivityDescription(activity: Activity): string, method
generateProviderUrl(providerId: string, objectType: string): null|string ↑SettingsInterface, method
getCacheKey(object: string, [userId: int|null = null]): null|string, method
getDealInsightsFields(): array|string[][] ↑LayoutManagementInterface, method
getDefaultActivityField(activityType: string): Field ↑LayoutManagementInterface, method
getDefaultFields(activityType: string): array ↑LayoutManagementInterface, method
getDefaultFollowupLayoutFields(activityType: string): array ↑BaseService, method
getDisplayName(): string ↑SettingsInterface, method
getFields(crmObject: string): array ↑BaseService, method
getFieldsAsString(fields: array): string, private method
getFieldTypes(): array ↑BaseService, method
getOAuthAccount(user: User): SocialAccount|null ↑BaseService, method
getObjectFields(objectType: string): array, method
getOwnerKeySuffix([userId: int|null = null]): string, method
getRecord(objectType: string, objectId: string, [fields: array|string[] = [...]]): array ↑RemoteEntityLookupInterface, method
getSupportedPlaybookTypes(): array ↑SettingsInterface, method
getTasks(objectType: string, objectId: string, opportunityId: null|string): array ↑RemoteEntityLookupInterface, method
importPicklistValues(field: Field): array ↑SyncCrmEntitiesInterface, method
importStages([types: array|null = null], [missingStageName: null|string = null]): Stage|null ↑SyncCrmEntitiesInterface, public method
matchByName(name: string, [userId: int|null = null]): array|null ↑MatchCrmEntitiesInterface, method
matchByPhone(phone: string, [rawPhoneNumber: null|string = null], [userId: int|null = null]): array|null ↑MatchCrmEntitiesInterface, method
matchExactlyByEmail(email: string, [userId: int|null = null]): array|null ↑MatchCrmEntitiesInterface, method
normalizeValue(fieldType: string, fieldValue: string, [internal: bool = false]): string ↑SettingsInterface, method
saveActivity(activity: Activity): Activity ↑RemoteEntityManipulationInterface, method
saveFollowupActivity(activity: Activity, fields: array): null|string ↑RemoteEntityManipulationInterface, method
syncAccount(crmId: string): Account|null ↑SyncCrmEntitiesInterface, method
syncAccounts(since: \Carbon\Carbon, [to: \Carbon\Carbon|null = null]): int ↑SyncCrmEntitiesInterface, method
syncContact(crmId: string): Contact|null ↑SyncCrmEntitiesInterface, method
syncContacts(since: \Carbon\Carbon, [to: \Carbon\Carbon|null = null]): int ↑SyncCrmEntitiesInterface, method
syncField(field: Field): void ↑SyncCrmEntitiesInterface, method
syncLead(crmId: string): Lead|null ↑SyncCrmEntitiesInterface, method
syncLeads(since: \Carbon\Carbon, [to: \Carbon\Carbon|null = null], [crmProfileId: null|string = null]): int ↑SyncCrmEntitiesInterface, method
syncOpportunities(parameters: array, [strategy: null|string = null]): int ↑SyncCrmEntitiesInterface, method
syncOpportunity(crmId: string): Opportunity|null ↑SyncCrmEntitiesInterface, method
syncOrganization(): void ↑SyncCrmMetadataInterface, method
syncProfileFields(): void ↑SyncCrmMetadataInterface, method
syncProfiles([userToSearch: User|null = null]): Profile|null ↑SyncCrmMetadataInterface, method
updateRecord(objectType: string, objectId: string, data: array, [headers: array = [...]]): void ↑RemoteEntityManipulationInterface, method...
|
[{"role":"AXTextField","text [{"role":"AXTextField","text":"s","depth":1,"bounds":{"left":0.52152777,"top":0.32,"width":0.06458333,"height":0.031111112},"on_screen":true,"value":"s","role_description":"text field","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Inherited members (⌘R)","depth":1,"bounds":{"left":0.5277778,"top":0.35555556,"width":0.10972222,"height":0.031111112},"on_screen":true,"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Anonymous Classes (⌘I)","depth":1,"bounds":{"left":0.64444447,"top":0.35555556,"width":0.10972222,"height":0.031111112},"on_screen":true,"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Lambdas (⌘L)","depth":1,"bounds":{"left":0.76111114,"top":0.35555556,"width":0.10972222,"height":0.031111112},"on_screen":true,"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"BullhornService, class","depth":4,"bounds":{"left":0.5395833,"top":0.39,"width":0.083333336,"height":0.024444444},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"ACCOUNT_FIELDS: array|string[] = [...], private","depth":5,"bounds":{"left":0.55277777,"top":0.41444445,"width":0.19375,"height":0.024444444},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"CONTACT_FIELDS: array|string[] = [...], private","depth":5,"bounds":{"left":0.55277777,"top":0.43888888,"width":0.19305556,"height":0.024444444},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"ENTITY_ACCOUNT: string = 'ClientCorporation', private","depth":5,"bounds":{"left":0.55277777,"top":0.46333334,"width":0.22986111,"height":0.024444444},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"ENTITY_ACTIVITY: string = 'Note', private","depth":5,"bounds":{"left":0.55277777,"top":0.48777777,"width":0.17083333,"height":0.024444444},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"ENTITY_CONTACT: string = 'ClientContact', private","depth":5,"bounds":{"left":0.55277777,"top":0.51222223,"width":0.21180555,"height":0.024444444},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"ENTITY_LEAD: string = 'Candidate', private","depth":5,"bounds":{"left":0.55277777,"top":0.5366667,"width":0.17569445,"height":0.024444444},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"ENTITY_OPPORTUNITY: string = 'JobOrder', private","depth":5,"bounds":{"left":0.55277777,"top":0.5611111,"width":0.21319444,"height":0.024444444},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"LEAD_FIELDS: array|string[] = [...], private","depth":5,"bounds":{"left":0.55277777,"top":0.58555555,"width":0.17152777,"height":0.024444444},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"OPPORTUNITY_FIELDS: array|string[] = [...], private","depth":5,"bounds":{"left":0.55277777,"top":0.61,"width":0.2125,"height":0.024444444},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"TASK_FIELDS: array|string[] = [...], private","depth":5,"bounds":{"left":0.55277777,"top":0.6344444,"width":0.17222223,"height":0.024444444},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"TASK_VERIFICATION_CACHE_TTL: int = 86400, private","depth":5,"bounds":{"left":0.55277777,"top":0.6588889,"width":0.22847222,"height":0.024444444},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"prospectPhotoPathService: ProspectPhotoPathService, parameter","depth":5,"bounds":{"left":0.55277777,"top":0.68333334,"width":0.25138888,"height":0.024444444},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"userRepository: UserRepository, parameter","depth":5,"bounds":{"left":0.55277777,"top":0.7077778,"width":0.15208334,"height":0.024444444},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"__construct(client: BullhornClientInterface, userRepository: UserRepository, prospectPhotoPathService: ProspectPhotoPathService) ↑BaseService, method","depth":5,"bounds":{"left":0.55277777,"top":0.7322222,"width":0.44722223,"height":0.024444444},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"convertCrmData(entities: array, [userId: int|null = null]): array, method","depth":5,"bounds":{"left":0.55277777,"top":0.75666666,"width":0.27638888,"height":0.024444444},"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"convertFieldType(type: null|string, specialization: null|string, inputType: null|string): string, method","depth":5,"bounds":{"left":0.55277777,"top":0.7811111,"width":0.39930555,"height":0.024444444},"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"convertObjectTypeToResource(objectType: string): string, method","depth":5,"bounds":{"left":0.55277777,"top":0.8055556,"width":0.2638889,"height":0.024444444},"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"convertResourceToObjectType(resource: string): string, method","depth":5,"bounds":{"left":0.55277777,"top":0.83,"width":0.2534722,"height":0.024444444},"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"createTranscriptNotes(activity: Activity): void ↑RemoteEntityManipulationInterface, method","depth":5,"bounds":{"left":0.55277777,"top":0.85444444,"width":0.37430555,"height":0.024444444},"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"fetchAndAssociateRelatedActivity(activity: Activity): Activity|null ↑FetchRelatedActivityInterface, public method","depth":5,"bounds":{"left":0.55277777,"top":0.8788889,"width":0.44236112,"height":0.024444444},"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"filterOutOfBoundDates(rawDateCandidate: string): Carbon|null, method","depth":5,"bounds":{"left":0.55277777,"top":0.9033333,"width":0.28402779,"height":0.024444444},"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"find(name: string, scopes: array): array ↑RemoteEntityLookupInterface, method","depth":5,"bounds":{"left":0.55277777,"top":0.92777777,"width":0.32222223,"height":0.024444444},"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"findOpportunities(crmAccountId: null|string, crmContactId: null|string, [userId: int|null = null]): array ↑RemoteEntityLookupInterface, method","depth":5,"bounds":{"left":0.55277777,"top":0.9522222,"width":0.44722223,"height":0.024444444},"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"generateActivityDescription(activity: Activity): string, method","depth":5,"bounds":{"left":0.55277777,"top":0.9766667,"width":0.23958333,"height":0.023333311},"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"generateProviderUrl(providerId: string, objectType: string): null|string ↑SettingsInterface, method","depth":5,"bounds":{"left":0.55277777,"top":1.0,"width":0.3986111,"height":-0.0011111498},"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"getCacheKey(object: string, [userId: int|null = null]): null|string, method","depth":5,"bounds":{"left":0.55277777,"top":1.0,"width":0.28194445,"height":-0.02555561},"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"getDealInsightsFields(): array|string[][] ↑LayoutManagementInterface, method","depth":5,"bounds":{"left":0.55277777,"top":1.0,"width":0.31875,"height":-0.049999952},"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"getDefaultActivityField(activityType: string): Field ↑LayoutManagementInterface, method","depth":5,"bounds":{"left":0.55277777,"top":1.0,"width":0.36458334,"height":-0.07444441},"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"getDefaultFields(activityType: string): array ↑LayoutManagementInterface, method","depth":5,"bounds":{"left":0.55277777,"top":1.0,"width":0.33819443,"height":-0.098888874},"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"getDefaultFollowupLayoutFields(activityType: string): array ↑BaseService, method","depth":5,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"getDisplayName(): string ↑SettingsInterface, method","depth":5,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"getFields(crmObject: string): array ↑BaseService, method","depth":5,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"getFieldsAsString(fields: array): string, private method","depth":5,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"getFieldTypes(): array ↑BaseService, method","depth":5,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"getOAuthAccount(user: User): SocialAccount|null ↑BaseService, method","depth":5,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"getObjectFields(objectType: string): array, method","depth":5,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"getOwnerKeySuffix([userId: int|null = null]): string, method","depth":5,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"getRecord(objectType: string, objectId: string, [fields: array|string[] = [...]]): array ↑RemoteEntityLookupInterface, method","depth":5,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"getSupportedPlaybookTypes(): array ↑SettingsInterface, method","depth":5,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"getTasks(objectType: string, objectId: string, opportunityId: null|string): array ↑RemoteEntityLookupInterface, method","depth":5,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"importPicklistValues(field: Field): array ↑SyncCrmEntitiesInterface, method","depth":5,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"importStages([types: array|null = null], [missingStageName: null|string = null]): Stage|null ↑SyncCrmEntitiesInterface, public method","depth":5,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"matchByName(name: string, [userId: int|null = null]): array|null ↑MatchCrmEntitiesInterface, method","depth":5,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"matchByPhone(phone: string, [rawPhoneNumber: null|string = null], [userId: int|null = null]): array|null ↑MatchCrmEntitiesInterface, method","depth":5,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"matchExactlyByEmail(email: string, [userId: int|null = null]): array|null ↑MatchCrmEntitiesInterface, method","depth":5,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"normalizeValue(fieldType: string, fieldValue: string, [internal: bool = false]): string ↑SettingsInterface, method","depth":5,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"saveActivity(activity: Activity): Activity ↑RemoteEntityManipulationInterface, method","depth":5,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"saveFollowupActivity(activity: Activity, fields: array): null|string ↑RemoteEntityManipulationInterface, method","depth":5,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"syncAccount(crmId: string): Account|null ↑SyncCrmEntitiesInterface, method","depth":5,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"syncAccounts(since: \\Carbon\\Carbon, [to: \\Carbon\\Carbon|null = null]): int ↑SyncCrmEntitiesInterface, method","depth":5,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"syncContact(crmId: string): Contact|null ↑SyncCrmEntitiesInterface, method","depth":5,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"syncContacts(since: \\Carbon\\Carbon, [to: \\Carbon\\Carbon|null = null]): int ↑SyncCrmEntitiesInterface, method","depth":5,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"syncField(field: Field): void ↑SyncCrmEntitiesInterface, method","depth":5,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"syncLead(crmId: string): Lead|null ↑SyncCrmEntitiesInterface, method","depth":5,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"syncLeads(since: \\Carbon\\Carbon, [to: \\Carbon\\Carbon|null = null], [crmProfileId: null|string = null]): int ↑SyncCrmEntitiesInterface, method","depth":5,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"syncOpportunities(parameters: array, [strategy: null|string = null]): int ↑SyncCrmEntitiesInterface, method","depth":5,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"syncOpportunity(crmId: string): Opportunity|null ↑SyncCrmEntitiesInterface, method","depth":5,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"syncOrganization(): void ↑SyncCrmMetadataInterface, method","depth":5,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"syncProfileFields(): void ↑SyncCrmMetadataInterface, method","depth":5,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"syncProfiles([userToSearch: User|null = null]): Profile|null ↑SyncCrmMetadataInterface, method","depth":5,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"updateRecord(objectType: string, objectId: string, data: array, [headers: array = [...]]): void ↑RemoteEntityManipulationInterface, method","depth":5,"on_screen":false,"role_description":"text"}]...
|
-3989282872089185655
|
-5101129426422577461
|
visual_change
|
accessibility
|
NULL
|
s
Inherited members (⌘R)
Anonymous Classes (⌘I)
La s
Inherited members (⌘R)
Anonymous Classes (⌘I)
Lambdas (⌘L)
BullhornService, class
ACCOUNT_FIELDS: array|string[] = [...], private
CONTACT_FIELDS: array|string[] = [...], private
ENTITY_ACCOUNT: string = 'ClientCorporation', private
ENTITY_ACTIVITY: string = 'Note', private
ENTITY_CONTACT: string = 'ClientContact', private
ENTITY_LEAD: string = 'Candidate', private
ENTITY_OPPORTUNITY: string = 'JobOrder', private
LEAD_FIELDS: array|string[] = [...], private
OPPORTUNITY_FIELDS: array|string[] = [...], private
TASK_FIELDS: array|string[] = [...], private
TASK_VERIFICATION_CACHE_TTL: int = 86400, private
prospectPhotoPathService: ProspectPhotoPathService, parameter
userRepository: UserRepository, parameter
__construct(client: BullhornClientInterface, userRepository: UserRepository, prospectPhotoPathService: ProspectPhotoPathService) ↑BaseService, method
convertCrmData(entities: array, [userId: int|null = null]): array, method
convertFieldType(type: null|string, specialization: null|string, inputType: null|string): string, method
convertObjectTypeToResource(objectType: string): string, method
convertResourceToObjectType(resource: string): string, method
createTranscriptNotes(activity: Activity): void ↑RemoteEntityManipulationInterface, method
fetchAndAssociateRelatedActivity(activity: Activity): Activity|null ↑FetchRelatedActivityInterface, public method
filterOutOfBoundDates(rawDateCandidate: string): Carbon|null, method
find(name: string, scopes: array): array ↑RemoteEntityLookupInterface, method
findOpportunities(crmAccountId: null|string, crmContactId: null|string, [userId: int|null = null]): array ↑RemoteEntityLookupInterface, method
generateActivityDescription(activity: Activity): string, method
generateProviderUrl(providerId: string, objectType: string): null|string ↑SettingsInterface, method
getCacheKey(object: string, [userId: int|null = null]): null|string, method
getDealInsightsFields(): array|string[][] ↑LayoutManagementInterface, method
getDefaultActivityField(activityType: string): Field ↑LayoutManagementInterface, method
getDefaultFields(activityType: string): array ↑LayoutManagementInterface, method
getDefaultFollowupLayoutFields(activityType: string): array ↑BaseService, method
getDisplayName(): string ↑SettingsInterface, method
getFields(crmObject: string): array ↑BaseService, method
getFieldsAsString(fields: array): string, private method
getFieldTypes(): array ↑BaseService, method
getOAuthAccount(user: User): SocialAccount|null ↑BaseService, method
getObjectFields(objectType: string): array, method
getOwnerKeySuffix([userId: int|null = null]): string, method
getRecord(objectType: string, objectId: string, [fields: array|string[] = [...]]): array ↑RemoteEntityLookupInterface, method
getSupportedPlaybookTypes(): array ↑SettingsInterface, method
getTasks(objectType: string, objectId: string, opportunityId: null|string): array ↑RemoteEntityLookupInterface, method
importPicklistValues(field: Field): array ↑SyncCrmEntitiesInterface, method
importStages([types: array|null = null], [missingStageName: null|string = null]): Stage|null ↑SyncCrmEntitiesInterface, public method
matchByName(name: string, [userId: int|null = null]): array|null ↑MatchCrmEntitiesInterface, method
matchByPhone(phone: string, [rawPhoneNumber: null|string = null], [userId: int|null = null]): array|null ↑MatchCrmEntitiesInterface, method
matchExactlyByEmail(email: string, [userId: int|null = null]): array|null ↑MatchCrmEntitiesInterface, method
normalizeValue(fieldType: string, fieldValue: string, [internal: bool = false]): string ↑SettingsInterface, method
saveActivity(activity: Activity): Activity ↑RemoteEntityManipulationInterface, method
saveFollowupActivity(activity: Activity, fields: array): null|string ↑RemoteEntityManipulationInterface, method
syncAccount(crmId: string): Account|null ↑SyncCrmEntitiesInterface, method
syncAccounts(since: \Carbon\Carbon, [to: \Carbon\Carbon|null = null]): int ↑SyncCrmEntitiesInterface, method
syncContact(crmId: string): Contact|null ↑SyncCrmEntitiesInterface, method
syncContacts(since: \Carbon\Carbon, [to: \Carbon\Carbon|null = null]): int ↑SyncCrmEntitiesInterface, method
syncField(field: Field): void ↑SyncCrmEntitiesInterface, method
syncLead(crmId: string): Lead|null ↑SyncCrmEntitiesInterface, method
syncLeads(since: \Carbon\Carbon, [to: \Carbon\Carbon|null = null], [crmProfileId: null|string = null]): int ↑SyncCrmEntitiesInterface, method
syncOpportunities(parameters: array, [strategy: null|string = null]): int ↑SyncCrmEntitiesInterface, method
syncOpportunity(crmId: string): Opportunity|null ↑SyncCrmEntitiesInterface, method
syncOrganization(): void ↑SyncCrmMetadataInterface, method
syncProfileFields(): void ↑SyncCrmMetadataInterface, method
syncProfiles([userToSearch: User|null = null]): Profile|null ↑SyncCrmMetadataInterface, method
updateRecord(objectType: string, objectId: string, data: array, [headers: array = [...]]): void ↑RemoteEntityManipulationInterface, method...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
45383
|
1625
|
32
|
2026-05-14T14:33:50.810101+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-14/1778 /Users/lukas/.screenpipe/data/data/2026-05-14/1778769230810_m1.jpg...
|
PhpStorm
|
faVsco.js – BullhornService.php
|
1
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
PhpStormFileEditViewNavigateCodeLaravelRefactorRun PhpStormFileEditViewNavigateCodeLaravelRefactorRunToolsGitWindowHelplhl100% C•FV faVsco.jsv#12066 on JY-20725-handle-HS-search-rate-limit K vHandleHubspotRateLimitTest8• Thu 14 May 17:33:50* :QProjectv© UserAutomatedReportsController.php:=custom.logAutomatedReportGenerated.phpTrackAutomatedReportGeneratedEvent.php© BullhornLoginHandler.phpPlanhatService.phpAutomatedReportResult.phpSendReportJob.phpDeleteCrmEntityTrait.phpDeleteAccountJob.php© BullhornLoginHandlerInterfa© ImportActivityTypes.phpT WriteCrmTrait.php© DecorateActivity.php© Salesforce/Service.phpT LogActivityTrait.php© BullhornService.php© BullhornSessionDecorator.plPipedrive/Service.phpClose/Service.phpCopper/Service.phpBullhornService.php X© PlainTextDecorateActivity.php© BullhornSessionManager.ph|© ActivityPlaybookTrait.phpCrmHelperRepository.php© AccountController.phpT IntegrationAppTrait.php= .env.staging18® BullhornSessionManagerInte© FieldDefinitions.phpE.envDetachActivityObject.phpRematchActivityOnCrmObjectDetach.phpMatchActivityCrmData.phpClient.phpv @ Close> OpportunitySyncStrategy© HubspotPaginationService.phpHandleHubspotRateLimit.phpclass BullhornServiceextends BaseServiceimplements> O Processor> D ProspectSearchStrategy> O Translator© Client.php© CloseException.php4712741275 GГ ›12981299130642 413 M3 ^public function matchByName(string $name, ?int $userId = null): ?array{...-protected function getCacheKey(string $object, ?int $userId = null): ?stringf...}© FieldDefinitions.php1 usage© FieldValueConverter.php1307private function getOwnerKeySuffix(?int $userId = null): string{..© Service.php1311© StandardFieldMetadata.php1312 €public function saveActivity(Activity $activity): Activityv Copper1313> C Apі|1314> D Exception1315> D OpportunitySyncStrategy1316if ($activity->hasCrmProviderId()) {return Sactivity;> D ProspectSearchStrategy1317C Traits1318© FieldDefinitions.php1319© FieldValueConverter.php1320© Service.php1321> @ CrmObjects1322D DecorateActivity1323© BaseDecorateActivity.php1324© PlainTextDecorateActivity.pl1325> C Dummyswitch (Sactivity->type) {case Activity:: TYPE_CONFERENCE:case Activity:: TYPE_SOFTPHONE:case Activity: : TYPE_SOFTPHONE_INBOUND:case Activity::TYPE_SMS_INBOUND:case Activity::TYPE_SMS_OUTBOUND:Sactivity = Sthis->buildNotePayload(Sactivity);1326v D Helpers1327break;T ActivityPlaybookTrait.php1328© Arraylterator.php1329# CannantionCtstaTrait nhnWorkspace associated with branch 'JY-20725-handle-HS-search-rate-limit' has been restored // Rollback // Configure... (today 16:17)=laravel.logSF [jiminny@localhost]A HS_local [jiminny@localho:W4 console [QAI PROD] X4 console [PROD]A console (EU]DGo jiminny084143 ×4 л215|•m_layout_id =2162,1661,66799,66217218219id = 33;220221222223224id THEN •(own225226227228229=230hubspot':-231232233 VT234= 11512582;W Windsurf Teams1312:21UTF-8Ca 4 spaces...
|
NULL
|
1783969437749759195
|
NULL
|
visual_change
|
ocr
|
NULL
|
PhpStormFileEditViewNavigateCodeLaravelRefactorRun PhpStormFileEditViewNavigateCodeLaravelRefactorRunToolsGitWindowHelplhl100% C•FV faVsco.jsv#12066 on JY-20725-handle-HS-search-rate-limit K vHandleHubspotRateLimitTest8• Thu 14 May 17:33:50* :QProjectv© UserAutomatedReportsController.php:=custom.logAutomatedReportGenerated.phpTrackAutomatedReportGeneratedEvent.php© BullhornLoginHandler.phpPlanhatService.phpAutomatedReportResult.phpSendReportJob.phpDeleteCrmEntityTrait.phpDeleteAccountJob.php© BullhornLoginHandlerInterfa© ImportActivityTypes.phpT WriteCrmTrait.php© DecorateActivity.php© Salesforce/Service.phpT LogActivityTrait.php© BullhornService.php© BullhornSessionDecorator.plPipedrive/Service.phpClose/Service.phpCopper/Service.phpBullhornService.php X© PlainTextDecorateActivity.php© BullhornSessionManager.ph|© ActivityPlaybookTrait.phpCrmHelperRepository.php© AccountController.phpT IntegrationAppTrait.php= .env.staging18® BullhornSessionManagerInte© FieldDefinitions.phpE.envDetachActivityObject.phpRematchActivityOnCrmObjectDetach.phpMatchActivityCrmData.phpClient.phpv @ Close> OpportunitySyncStrategy© HubspotPaginationService.phpHandleHubspotRateLimit.phpclass BullhornServiceextends BaseServiceimplements> O Processor> D ProspectSearchStrategy> O Translator© Client.php© CloseException.php4712741275 GГ ›12981299130642 413 M3 ^public function matchByName(string $name, ?int $userId = null): ?array{...-protected function getCacheKey(string $object, ?int $userId = null): ?stringf...}© FieldDefinitions.php1 usage© FieldValueConverter.php1307private function getOwnerKeySuffix(?int $userId = null): string{..© Service.php1311© StandardFieldMetadata.php1312 €public function saveActivity(Activity $activity): Activityv Copper1313> C Apі|1314> D Exception1315> D OpportunitySyncStrategy1316if ($activity->hasCrmProviderId()) {return Sactivity;> D ProspectSearchStrategy1317C Traits1318© FieldDefinitions.php1319© FieldValueConverter.php1320© Service.php1321> @ CrmObjects1322D DecorateActivity1323© BaseDecorateActivity.php1324© PlainTextDecorateActivity.pl1325> C Dummyswitch (Sactivity->type) {case Activity:: TYPE_CONFERENCE:case Activity:: TYPE_SOFTPHONE:case Activity: : TYPE_SOFTPHONE_INBOUND:case Activity::TYPE_SMS_INBOUND:case Activity::TYPE_SMS_OUTBOUND:Sactivity = Sthis->buildNotePayload(Sactivity);1326v D Helpers1327break;T ActivityPlaybookTrait.php1328© Arraylterator.php1329# CannantionCtstaTrait nhnWorkspace associated with branch 'JY-20725-handle-HS-search-rate-limit' has been restored // Rollback // Configure... (today 16:17)=laravel.logSF [jiminny@localhost]A HS_local [jiminny@localho:W4 console [QAI PROD] X4 console [PROD]A console (EU]DGo jiminny084143 ×4 л215|•m_layout_id =2162,1661,66799,66217218219id = 33;220221222223224id THEN •(own225226227228229=230hubspot':-231232233 VT234= 11512582;W Windsurf Teams1312:21UTF-8Ca 4 spaces...
|
45382
|
NULL
|
NULL
|
NULL
|
|
45384
|
1626
|
18
|
2026-05-14T14:33:52.328607+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-14/1778 /Users/lukas/.screenpipe/data/data/2026-05-14/1778769232328_m2.jpg...
|
PhpStorm
|
faVsco.js – BullhornService.php
|
1
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
#12066 on JY-20725-handle Project: faVsco.js, menu
#12066 on JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
HandleHubspotRateLimitTest
Run 'HandleHubspotRateLimitTest'
Debug 'HandleHubspotRateLimitTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
2
13
3
Previous Highlighted Error
Next Highlighted Error
<?php
namespace Jiminny\Services\Crm\Bullhorn;
use Carbon\Carbon;
use Exception;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
use Jiminny\Contracts\Services\Crm\MatchCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\FetchRelatedActivityInterface;
use Jiminny\Contracts\Services\Crm\LayoutManagementInterface;
use Jiminny\Contracts\Services\Crm\Provider\BullhornInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityLookupInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityManipulationInterface;
use Jiminny\Contracts\Services\Crm\SettingsInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmMetadataInterface;
use Jiminny\Contracts\Services\Crm\VerifyTaskExistsInterface;
use Jiminny\Exceptions\ApplicationException;
use Jiminny\Exceptions\CrmException;
use Jiminny\Exceptions\DomainException;
use Jiminny\Exceptions\HttpBadRequestException;
use Jiminny\Exceptions\HttpNotFoundException;
use Jiminny\Exceptions\ServiceUnavailableException;
use Jiminny\Models\Account;
use Jiminny\Models\Activity;
use Jiminny\Models\Contact;
use Jiminny\Models\Crm\Configuration;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\FieldData;
use Jiminny\Models\Crm\Profile;
use Jiminny\Models\Lead;
use Jiminny\Models\Opportunity;
use Jiminny\Models\Playbook;
use Jiminny\Models\SocialAccount;
use Jiminny\Models\Stage;
use Jiminny\Models\User;
use Jiminny\Repositories\Crm\ProfileRepository;
use Jiminny\Repositories\UserRepository;
use Jiminny\Services\Avatar\ProspectPhotoPathService;
use Jiminny\Services\Crm\BaseService;
use Jiminny\Services\Crm\OpportunitySyncStrategyResolver;
use Jiminny\Utils\CurrencyFormatter;
use Jiminny\Utils\StringUtil;
use Ramsey\Uuid\Uuid;
class BullhornService extends BaseService implements
BullhornInterface,
SyncCrmEntitiesInterface,
SyncCrmMetadataInterface,
RemoteEntityManipulationInterface,
FetchRelatedActivityInterface,
LayoutManagementInterface,
SettingsInterface,
MatchCrmEntitiesInterface,
RemoteEntityLookupInterface,
VerifyTaskExistsInterface
{
private const int TASK_VERIFICATION_CACHE_TTL = 86400; // 1 day
private const string ENTITY_LEAD = 'Candidate';
private const string ENTITY_OPPORTUNITY = 'JobOrder';
private const string ENTITY_ACCOUNT = 'ClientCorporation';
private const string ENTITY_CONTACT = 'ClientContact';
private const string ENTITY_ACTIVITY = 'Note';
private const array OPPORTUNITY_FIELDS = [
'id',
'owner',
'status',
'title',
'salary',
'salaryUnit',
'payRate',
'isOpen',
'employmentType',
'dateClosed',
'startDate',
'dateLastModified',
'dateAdded',
'clientCorporation',
'correlatedCustomText10', // currency
];
private const array TASK_FIELDS = [
'id',
'comments',
'dateAdded',
'dateLastModified',
'externalID',
'isDeleted',
'corporateUsers',
'clientContacts',
'commentingPerson',
'candidates',
'jobOrder',
'minutesSpent',
'action',
];
private const array LEAD_FIELDS = [
'id',
'name',
'owner',
'occupation',
'companyName',
'companyURL',
'mobile',
'phone',
'phone2',
'phone3',
'workPhone',
'status',
'address',
'dateAdded',
'dateLastModified',
];
private const array CONTACT_FIELDS = [
'id',
'name',
'occupation',
'mobile',
'phone',
'phone2',
'phone3',
'address',
'clientCorporation',
'dateAdded',
'owner',
];
private const array ACCOUNT_FIELDS = [
'id',
'name',
'phone',
'industryList',
'address',
'companyURL',
'dateAdded',
];
public function __construct(
BullhornClientInterface $client,
private readonly UserRepository $userRepository,
private readonly ProspectPhotoPathService $prospectPhotoPathService
) {
parent::__construct();
$this->client = $client;
}
protected function getOAuthAccount(User $user): ?SocialAccount
{
// In this case, the Account Owner is always the connection for any API operations.
$owner = $user->team->owner;
return $owner->getSocialAccount(SocialAccount::PROVIDER_BULLHORN);
}
public function getDisplayName(): string
{
return 'Bullhorn';
}
public function createTranscriptNotes(Activity $activity): void
{
// Not done until a customer requests it.
}
public function syncProfileFields(): void
{
$fieldTypes = [
'lead',
'account',
'contact',
'opportunity',
'task',
];
foreach ($fieldTypes as $fieldType) {
try {
$currentFields = $this->getFields($this->convertObjectTypeToResource($fieldType));
$newFields = array_column($currentFields, 'name');
$changedFields = array_merge(
array_diff($this->profile->getFieldsAsArray($fieldType), $newFields),
array_diff($newFields, $this->profile->getFieldsAsArray($fieldType))
);
if (! empty($changedFields)) {
$fields = implode(',', $newFields);
Log::debug(
sprintf(
'%s fields changed from %s to: %s',
ucfirst($fieldType),
$this->profile->{$fieldType . '_fields'},
$fields,
),
);
$this->profile->{$fieldType . '_fields'} = $fields;
}
} catch (\Exception $exception) {
\Log::error('No access to ' . $fieldType . ' object, skipping...');
// XXX: should we log this fact somewhere?
continue;
}
}
$this->profile->save();
}
protected function getFieldTypes(): array
{
return [
parent::OBJECT_TASK,
//parent::OBJECT_ACCOUNT,
//parent::OBJECT_CONTACT,
//parent::OBJECT_OPPORTUNITY,
//parent::OBJECT_LEAD,
];
}
protected function getFields(string $crmObject): array
{
$fields = [];
foreach ($this->getObjectFields($crmObject) as $row) {
$fields[] = ['label' => $row['label'] ?? $row['name'], 'name' => $row['name']];
}
return $fields;
}
private function getObjectFields(string $objectType): array
{
$entityType = $this->convertObjectTypeToResource($objectType);
return Cache::remember(
key: 'crm.profile#' . $this->profile->id . '.fieldType:' . $objectType,
ttl: 60 * 60,
callback: function () use ($entityType) {
$entity = $this->client->get('meta/' . $entityType, [
'fields' => '*',
]);
return $entity['fields'];
},
);
}
public function getDefaultFields(string $activityType): array
{
$fields = [];
$defaultFields = FieldDefinitions::defaultFields();
foreach ($defaultFields as $defaultField) {
$fields[] = $this->config->fields()->firstOrCreate($defaultField);
}
return $fields;
}
public function getDefaultActivityField(string $activityType): Field
{
/** @var Field $activityField */
$activityField = $this->config->fields()->where([
'crm_provider_id' => 'action',
'object_type' => $activityType,
])->first();
return $activityField;
}
public function getDealInsightsFields(): array
{
return FieldDefinitions::dealInsightsFields();
}
public function getSupportedPlaybookTypes(): array
{
return [Playbook::ACTIVITY_TYPE_TASK];
}
protected function getDefaultFollowupLayoutFields(string $activityType): array
{
// We are not yet supporting follow-ups for this system.
return [];
}
public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string
{
return $fieldValue;
}
public function syncField(Field $field): void
{
$resource = $this->convertObjectTypeToResource($field->object_type);
$entity = $this->client->get('meta/' . $resource, [
'fields' => $field->crm_provider_id,
'meta' => 'full',
]);
$bhField = $entity['fields'][0];
$isMandatory = (empty($bhField['optional']) == false && $bhField['optional'] === false);
$description = null;
if (empty($bhField['description']) === false) {
$description = mb_strimwidth($bhField['description'], 0, 191);
}
$label = 'Unknown';
if (empty($bhField['label']) === false) {
$label = mb_strimwidth($bhField['label'], 0, Field::LABEL_MAX_LENGTH);
}
$field->length = $bhField['maxLength'] ?? null;
$field->is_mandatory = $isMandatory;
$field->description = $description;
$field->label = $label;
$field->type = $this->convertFieldType(
$bhField['dataType'] ?? null,
$bhField['dataSpecialization'] ?? null,
$bhField['inputType'] ?? null
);
$field->save();
if (empty($bhField['options']) === false) {
$values = [];
// Import all values.
foreach ($bhField['options'] as $i => $option) {
$values[] = [
'value' => $option['value'],
'label' => mb_strimwidth($option['label'], 0, Field::LABEL_MAX_LENGTH),
'sequence' => $i,
];
}
// Take the existing set of field values.
$fieldsToPurge = $field->values()->get()->pluck('value')->toArray();
foreach ($values as $value) {
// For all current values, upsert the field.
$field->values()->updateOrCreate([
'value' => $value['value'],
], $value);
// Remove this value from the ones we are going to purge.
$key = array_search($value['value'], $fieldsToPurge, false);
if ($key !== false) {
unset($fieldsToPurge[$key]);
}
}
// Delete the old values that are no longer used.
$field->values()->whereIn('value', $fieldsToPurge)->delete();
}
}
public function importPicklistValues(Field $field): array
{
// Taken care of in syncField.
return [];
}
private function convertFieldType(?string $type, ?string $specialization, ?string $inputType): string
{
if ($type === null) {
return Field::TYPE_UNSUPPORTED;
}
$type = strtolower($type);
$specialization = strtolower($specialization);
$inputType = strtolower($inputType);
switch ($type) {
case 'boolean':
return Field::TYPE_BOOLEAN;
case 'integer':
switch ($specialization) {
case 'integer':
return Field::TYPE_NUMBER;
default:
return Field::TYPE_NUMBER;
}
// no break
case 'bigdecimal':
switch ($specialization) {
case 'money':
return Field::TYPE_CURRENCY;
default:
return Field::TYPE_NUMBER;
}
// no break
case 'double':
switch ($specialization) {
case 'float':
return Field::TYPE_NUMBER;
default:
return Field::TYPE_NUMBER;
}
// no break
case 'timestamp':
switch ($specialization) {
case 'date':
return Field::TYPE_DATE;
case 'time':
return Field::TYPE_TIME;
case 'datetime':
return Field::TYPE_DATETIME;
default:
return Field::TYPE_DATE;
}
// no break
case 'string':
switch ($inputType) {
case 'textarea':
return Field::TYPE_TEXTAREA;
case 'select':
return Field::TYPE_PICKLIST;
default:
return Field::TYPE_TEXT;
}
// no break
default:
// This is not actually a supported field.
return Field::TYPE_UNSUPPORTED;
}
}
private function importContact($crmData): Contact
{
$account = $mobilePhone = $phone = $email = null;
if (empty($crmData['clientCorporation']) === false) {
$account = $this->team
->accounts()
->where('crm_provider_id', $crmData['clientCorporation']['id'])
->first();
if ($account === null) {
$account = $this->syncAccount($crmData['clientCorporation']['id']);
}
}
$countryCode = $this->convertCountryNameToCode($crmData['address']['countryCode']);
foreach (['mobile', 'phone', 'phone2', 'phone3'] as $type) {
if (empty($crmData[$type])) {
continue;
}
if ($type === 'mobile') {
$mobilePhone = parsePhoneNumber($countryCode, $crmData[$type]);
} else {
$phone = parsePhoneNumber($countryCode, $crmData[$type]);
}
break;
}
foreach (['email', 'email1', 'email2'] as $type) {
if (empty($crmData[$type])) {
continue;
}
$email = mb_strimwidth($crmData[$type], 0, 80);
break;
}
$profile = $this->config->profiles()->where('crm_provider_id', (string) $crmData['owner']['id'])->first();
$data = [
'team_id' => $this->team->id,
'account_id' => $account->id ?? null,
'user_id' => $profile ? $profile->user_id : null,
'owner_id' => $crmData['owner']['id'],
'name' => $crmData['name'],
'email' => $email,
'phone' => empty($phone['phone']) ? null : mb_strimwidth($phone['phone'], 0, 25),
'ext' => $phone['ext'] ?? null,
'mobile_phone' => empty($mobilePhone['phone']) ? null : mb_strimwidth($mobilePhone['phone'], 0, 25),
'title' => $crmData['occupation'],
'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(
crmConfiguration: $this->config,
crmProviderId: $crmData['id'],
modelType: Contact::class,
fileName: $crmData['id'],
avatarText: $crmData['name'] ?? 'Unknown'
),
'remotely_created_at' => $this->filterOutOfBoundDates($crmData['dateAdded']),
];
return $this->config->contacts()->updateOrCreate(['crm_provider_id' => (string) $crmData['id']], $data);
}
private function importOpportunity($crmData): ?Opportunity
{
if ($crmData['clientCorporation']['id']) {
$account = $this->config->accounts()->where('crm_provider_id', (string) $crmData['clientCorporation']['id'])->first();
if ($account === null) {
$account = $this->syncAccount($crmData['clientCorporation']['id']);
}
} else {
// If there is nothing to associate this with, don't import it.
return null;
}
$profile = $this->config->profiles()->where('crm_provider_id', (string) $crmData['owner']['id'])->first();
$stage = $this->config->stages()
->where('crm_provider_id', $crmData['status'])
->where('type', Stage::TYPE_OPPORTUNITY)
->first();
if ($stage === null) {
$stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $crmData['status']);
}
$data = [
'team_id' => $this->team->id,
'account_id' => $account->id,
'user_id' => $profile ? $profile->user_id : null,
'owner_id' => $crmData['owner']['id'],
'name' => mb_strimwidth($crmData['title'], 0, 128),
'value' => $crmData['salary'] ?? $crmData['payRate'],
'currency_code' => CurrencyFormatter::formatCode($crmData['correlatedCustomText10'] ?? 'USD'),
'close_date' => $this->filterOutOfBoundDates($crmData['startDate']),
'is_closed' => $crmData['isOpen'] !== true,
'is_won' => strtolower($crmData['status']) === 'placed',
'stage_id' => $stage->id,
'record_type_id' => null,
'remotely_created_at' => $this->filterOutOfBoundDates($crmData['dateAdded']),
];
return $this->config->opportunities()->updateOrCreate(['crm_provider_id' => (string) $crmData['id']], $data);
}
private function importAccount($crmData): Account
{
$countryCode = $domain = null;
if (empty($crmData['address']['countryCode'])) {
$countryCode = $this->convertCountryNameToCode($crmData['address']['countryCode']);
}
$name = $crmData['name'] ?? 'Unknown';
if ($crmData['companyURL'] !== null) {
$domain = mb_strimwidth($crmData['companyURL'], 0, 191);
$domain = StringUtil::resolveDomain($domain);
}
$data = [
'team_id' => $this->team->id,
'user_id' => null,
'owner_id' => null,
'name' => mb_strimwidth($name, 0, 191),
'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(
crmConfiguration: $this->config,
crmProviderId: $crmData['id'],
modelType: Account::class,
fileName: $crmData['id'],
avatarText: $crmData['name'] ?? 'Unknown'
),
'country_code' => $countryCode,
'domain' => $domain,
'remotely_created_at' => $this->filterOutOfBoundDates($crmData['dateAdded']),
];
return $this->config->accounts()->updateOrCreate(['crm_provider_id' => (string) $crmData['id']], $data);
}
private function importLead($crmData): ?Lead
{
// Get the current stage.
$stage = $this->config->stages()->where('name', $crmData['status'])->where('type', Stage::TYPE_LEAD)->first();
if ($stage === null) {
$stage = $this->importStages([Stage::TYPE_LEAD], $crmData['status']);
}
// If we have no way of importing this, just return null :(
if ($stage === null) {
return null;
}
$countryCode = null;
if (empty($crmData['address']['countryCode'])) {
$countryCode = $this->convertCountryNameToCode($crmData['address']['countryCode']);
}
$mobilePhone = $phone = $email = null;
foreach (['mobile', 'phone', 'phone2', 'phone3', 'workPhone'] as $type) {
if (empty($crmData[$type])) {
continue;
}
if ($type === 'mobile') {
$mobilePhone = parsePhoneNumber($countryCode, $crmData[$type]);
} else {
$phone = parsePhoneNumber($countryCode, $crmData[$type]);
}
break;
}
foreach (['email', 'email1', 'email2'] as $type) {
if (empty($crmData[$type])) {
continue;
}
$email = mb_strimwidth($crmData[$type], 0, 80);
break;
}
$company = 'Unknown';
if (empty($crmData['companyName']) === false) {
$companyName = is_array($crmData['companyName']) ? $crmData['companyName'][0] : $crmData['companyName'];
$company = mb_strimwidth($companyName, 0, 191);
}
$domain = null;
if (empty($crmData['companyURL']) === false) {
$domain = mb_strimwidth($crmData['companyURL'], 0, 191);
$domain = StringUtil::resolveDomain($domain);
}
$profile = $this->config->profiles()->where('crm_provider_id', (string) $crmData['owner']['id'])->first();
$data = [
'team_id' => $this->team->id,
'user_id' => $profile ? $profile->user_id : null,
'owner_id' => $crmData['owner']['id'],
'company' => $company,
'domain' => $domain,
'name' => mb_strimwidth($crmData['name'], 0, 191),
'title' => mb_strimwidth($crmData['occupation'], 0, 128),
'email' => mb_strimwidth($email, 0, 80),
'phone' => empty($phone['phone']) ? null : mb_strimwidth($phone['phone'], 0, 25),
'ext' => $phone['ext'] ?? null,
'mobile_phone' => empty($mobilePhone['phone']) ? null : mb_strimwidth($mobilePhone['phone'], 0, 25),
'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(
crmConfiguration: $this->config,
crmProviderId: $crmData['id'],
modelType: Lead::class,
fileName: $crmData['id'],
avatarText: $crmData['name'] ?? 'Unknown'
),
'stage_id' => $stage->id,
'country_code' => $countryCode,
'remotely_created_at' => $this->filterOutOfBoundDates($crmData['dateAdded']),
];
return $this->config->leads()->updateOrCreate(['crm_provider_id' => (string) $crmData['id']], $data);
}
/**
* @throws ApplicationException
*/
public function syncContacts(Carbon $since, ?Carbon $to = null): int
{
$syncCount = 0;
$params = [
'sort' => '-dateLastModified',
'fields' => $this->getFieldsAsString(self::CONTACT_FIELDS),
'query' => 'isDeleted: false AND dateLastModified:[' . $since->format('YmdHis') . ' TO *]',
];
try {
$bhContacts = $this->client->get('search/ClientContact', $params);
foreach ($bhContacts['data'] as $bhContact) {
// Only sync if previously imported.
if ($this->hasContact($bhContact['id'])) {
$this->importContact($bhContact);
$syncCount++;
}
}
} catch (\Exception $exception) {
throw new ApplicationException('Failed to sync contacts', $exception->getCode(), $exception);
}
return $syncCount;
}
public function syncOpportunities(array $parameters, ?string $strategy = null): int
{
$strategyResolver = app(OpportunitySyncStrategyResolver::class, [
'client' => $this->client,
]);
$strategies = $strategyResolver->getStrategies($this->config, $strategy);
$syncCount = 0;
$total = 0;
$count = 0;
$offset = 0;
try {
$bhOpportunities = [];
foreach ($strategies as $syncStrategy) {
$opportunitiesData = $syncStrategy->fetchOpportunities($parameters);
$total = (int) ($opportunitiesData['total'] ?? 0);
$count = (int) ($opportunitiesData['count'] ?? 0);
$offset = (int) ($opportunitiesData['start'] ?? 0);
$bhOpportunities[] = $opportunitiesData['data'] ?? [];
}
$bhOpportunities = array_merge(...$bhOpportunities);
foreach ($bhOpportunities as $bhOpportunity) {
$this->importOpportunity($bhOpportunity);
$syncCount++;
}
} catch (\Exception $exception) {
$this->logger->info('Failed to sync opportunities', [
'params' => $parameters,
'exception' => $exception->getMessage(),
]);
}
// only log if there are one sync strategy
if (count($strategies) === 1 && $total > $count + $offset) {
$this->logger->info('[Bullhorn] Sync Opportunities - count warning', [
'team_id' => $this->team->getId(),
'total' => $total,
'count' => $count,
'offset' => $offset,
]);
}
return $syncCount;
}
/**
* @throws ApplicationException
*/
public function syncAccounts(Carbon $since, ?Carbon $to = null): int
{
$syncCount = 0;
$params = [
'sort' => '-dateLastModified',
'fields' => $this->getFieldsAsString(self::ACCOUNT_FIELDS),
'query' => 'isDeleted: false AND dateLastModified:[' . $since->format('YmdHis') . ' TO *]',
];
try {
$bhAccounts = $this->client->get('search/ClientCorporation', $params);
foreach ($bhAccounts['data'] as $bhAccount) {
// Only sync if previously imported.
if ($this->hasAccount($bhAccount['id'])) {
$this->importAccount($bhAccount);
$syncCount++;
}
}
} catch (\Exception $exception) {
throw new ApplicationException('Failed to sync accounts', $exception->getCode(), $exception);
}
return $syncCount;
}
/**
* @throws ApplicationException
*/
public function syncLeads(Carbon $since, ?Carbon $to = null, ?string $crmProfileId = null): int
{
$syncCount = 0;
$params = [
'sort' => '-dateLastModified',
'fields' => $this->getFieldsAsString(self::LEAD_FIELDS),
'query' => 'isDeleted: false AND dateLastModified:[' . $since->format('YmdHis') . ' TO *]',
];
try {
$bhLeads = $this->client->get('search/Candidate', $params);
if ($bhLeads === null) {
return $syncCount;
}
foreach ($bhLeads['data'] as $bhLead) {
// Only sync if previously imported.
if ($this->hasLead($bhLead['id'])) {
$this->importLead($bhLead);
$syncCount++;
}
}
} catch (\Exception $exception) {
throw new ApplicationException('Failed to sync leads', $exception->getCode(), $exception);
}
return $syncCount;
}
public function syncContact(string $crmId): ?Contact
{
try {
$data = $this->getRecord(FieldData::OBJECT_CONTACT, $crmId, self::CONTACT_FIELDS);
} catch (HttpNotFoundException $exception) {
return null;
}
return $this->importContact($data['data']);
}
public function syncOpportunity(string $crmId): ?Opportunity
{
$strategyResolver = app(OpportunitySyncStrategyResolver::class, [
'client' => $this->client,
]);
$strategy = $strategyResolver->resolve(
$this->config,
OpportunitySyncStrategyResolver::SINGLE_SYNC_OPPORTUNITY_STRATEGY
);
try {
$data = $strategy->fetchOpportunities(['crm_id' => $crmId]);
} catch (HttpNotFoundException $exception) {
$this->logger->info('[Bullhorn] Opportunity not found', [
'crm_id' => $crmId,
'reason' => $exception->getMessage(),
]);
}
return $this->importOpportunity($data['data'] ?? []);
}
public function syncAccount(string $crmId): ?Account
{
try {
$data = $this->getRecord(FieldData::OBJECT_ACCOUNT, $crmId, self::ACCOUNT_FIELDS);
} catch (HttpNotFoundException $exception) {
return null;
}
return $this->importAccount($data['data']);
}
public function syncLead(string $crmId): ?Lead
{
try {
$data = $this->getRecord(FieldData::OBJECT_LEAD, $crmId, self::LEAD_FIELDS);
} catch (HttpNotFoundException $exception) {
return null;
}
return $this->importLead($data['data']);
}
public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage
{
$missingStage = null;
if ($types === null) {
$types = [Stage::TYPE_LEAD, Stage::TYPE_OPPORTUNITY];
}
foreach ($types as $type) {
$resource = $this->convertObjectTypeToResource($type);
$entity = $this->client->get('meta/' . $resource, [
'fields' => 'status',
'meta' => 'full',
]);
$missingStage = null;
if (! is_array($entity['fields']) || count($entity['fields']) !== 1 || $entity['fields'][0]['name'] !== 'status') {
throw new \UnexpectedValueException('Invalid response from Bullhorn API');
}
// Upsert all stages for the team.
foreach ($entity['fields'][0]['options'] as $i => $option) {
$stage = $this->config->stages()->updateOrCreate([
'crm_provider_id' => $option['value'],
'type' => $type,
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($option['value'], 0, 50),
'label' => mb_strimwidth($option['label'], 0, 191),
'sequence' => $i,
'is_selectable' => 1,
]);
if ($missingStageName && $missingStageName === $option['value']) {
$missingStage = $stage;
}
}
if ($missingStageName && $missingStage === null) {
// If they requested a stage that still doesn't exist, it must be inactive so lazy create it.
$missingStage = $this->config->stages()->create([
'team_id' => $this->team->id,
'crm_provider_id' => Uuid::uuid4(),
'name' => mb_strimwidth($missingStageName, 0, 50),
'label' => mb_strimwidth($missingStageName, 0, 191),
'type' => $type,
'sequence' => 0,
'is_selectable' => 0,
]);
}
}
return $missingStage;
}
public function syncOrganization(): void
{
// There appears to be nothing we can sync/map to.
}
public function find(string $name, array $scopes): array
{
$count = $this->limit ?? 20;
$offset = $this->offset ?? 0;
$data = \Cache::remember($this->team->id . $name . $count . $offset, 300, function () use ($name, $count) {
$result = [];
try {
$objects = $this->client->get('find', [
'query' => $name,
'meta' => 'basic',
'countPerEntity' => $count,
]);
} catch (\GuzzleHttp\Exception\ServerException $exception) {
throw new ServiceUnavailableException($exception->getMessage());
}
foreach ($objects['data'] as $object) {
try {
$prospectType = $this->convertResourceToObjectType($object['entityType']);
} catch (\UnexpectedValueException $exception) {
// We don't support this, so skip it.
continue;
}
$record = [
'crmId' => $object['entityId'],
'crmUrl' => $this->generateProviderUrl($object['entityId'], $object['entityType']),
'prospectType' => $prospectType,
'phoneNumbers' => [],
];
// Unfortunately we need to go and query each entity itself to get the data we need.
switch ($object['entityType']) {
case self::ENTITY_ACCOUNT:
$data = $this->getRecord(FieldData::OBJECT_ACCOUNT, $object['entityId'], self::ACCOUNT_FIELDS);
$entity = $data['data'];
$record['name'] = $entity['name'];
$record['industry'] = $entity['industryList'];
if (empty($entity['phone']) === false) {
$record['phoneNumbers'][] = [
'number' => $entity['phone'],
'nationalFormat' => phone_national(null, $entity['phone']),
'type' => 'phone',
];
}
break;
case self::ENTITY_CONTACT:
$data = $this->getRecord(FieldData::OBJECT_CONTACT, $object['entityId'], self::CONTACT_FIELDS);
$entity = $data['data'];
$record['name'] = $entity['name'];
$record['organization'] = $entity['clientCorporation'] ? $entity['clientCorporation']['name'] : null;
$record['title'] = $entity['occupation'];
foreach (['mobile', 'phone', 'phone2', 'phone3'] as $phone) {
if (empty($entity[$phone]) === false) {
$record['phoneNumbers'][] = [
'number' => $entity[$phone],
'nationalFormat' => phone_national(null, $entity[$phone]),
'type' => $phone === 'mobile' ? 'mobile' : 'phone',
];
}
}
break;
case self::ENTITY_LEAD:
$bhData = $this->getRecord(FieldData::OBJECT_LEAD, $object['entityId'], self::LEAD_FIELDS);
$entity = $bhData['data'];
$record['name'] = $entity['name'];
$record['organization'] = $entity['companyName'];
$record['title'] = $entity['occupation'];
foreach (['mobile', 'phone', 'phone2', 'phone3', 'workPhone'] as $phone) {
if (empty($entity[$phone]) === false) {
$record['phoneNumbers'][] = [
'number' => $entity[$phone],
'nationalFormat' => phone_national(null, $entity[$phone]),
'type' => $phone === 'mobile' ? 'mobile' : 'phone',
];
}
}
break;
default:
// We are not interested in this entity type so skip it.
continue 2;
}
$result[] = $record;
}
return $result;
});
return $data;
}
public function getClient(): BullhornClientInterface
{
return $this->client;
}
private function getFieldsAsString(array $fields)
{
return implode(', ', $fields);
}
public function findOpportunities(?string $crmAccountId, ?string $crmContactId, ?int $userId = null): array
{
$data = [];
$ownerData = [];
$ownerId = null;
$contact = null;
$account = null;
if ($crmAccountId) {
$account = $this->config->accounts()->where('crm_provider_id', $crmAccountId)->first();
if ($account === null) {
$account = $this->syncAccount($crmAccountId);
}
}
if ($crmContactId) {
$contact = $this->config->contacts()->where('crm_provider_id', $crmContactId)->first();
if ($contact === null) {
$contact = $this->syncContact($crmContactId);
}
}
if ($contact || $account) {
if ($contact && $account === null) {
$account = $contact->account;
}
if ($account === null) {
return [];
}
$where = 'isDeleted: false AND clientCorporation.id:' . (int) $account->crm_provider_id;
$params = [
'sort' => '-dateLastModified',
'fields' => $this->getFieldsAsString(self::OPPORTUNITY_FIELDS),
];
$onlyOpen = true;
switch ($this->config->opportunity_assignment_rule) {
case Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_UPDATED:
$params['sort'] = '-dateLastModified';
break;
case Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_CREATED:
$params['sort'] = '-dateAdded';
break;
case Configuration::OPP_ASSIGNMENT_ALL_OPEN_OLDEST_CREATED:
$params['sort'] = '+dateAdded';
break;
case Configuration::OPP_ASSIGNMENT_ALL_RECENTLY_UPDATED:
$params['sort'] = '-dateLastModified';
$onlyOpen = false;
}
if ($onlyOpen) {
$where .= ' AND isOpen: true';
}
$params['query'] = $where;
$bhOpportunities = $this->client->get('search/JobOrder', $params);
if ($userId) {
$profileRepository = app(ProfileRepository::class);
$profile = $profileRepository->findProfileByUserId($this->config, $userId);
$ownerId = $profile instanceof Profile ? $profile->getCrmProviderId() : null;
}
foreach ($bhOpportunities['data'] as $bhOpportunity) {
$stage = $this->config
->stages()
->where('crm_provider_id', $bhOpportunity['status'])
->first();
if ($stage === null) {
// Import it.
$stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $bhOpportunity['status']);
}
$record = [
'crmId' => $bhOpportunity['id'],
'name' => $bhOpportunity['title'],
'value' => currency_format($bhOpportunity['salary'], $bhOpportunity['correlatedCustomText10'] ?? 'USD'),
'won' => strtolower($bhOpportunity['status']) === 'placed',
'closed' => $bhOpportunity['isOpen'] !== true,
'stage' => [
'id' => $stage->id_string,
'name' => $stage->name,
],
'recordType' => [],
];
if ($ownerId && isset($bhOpportunity['owner']['id']) && $bhOpportunity['owner']['id'] === (int) $ownerId) {
$ownerData[] = $record;
}
$data[] = $record;
}
}
if (! empty($ownerData)) {
return $ownerData;
}
return $data;
}
public function getTasks(string $objectType, string $objectId, ?string $opportunityId): array
{
$data = [];
// For now it has not been agreed on how to implement this e.g. appointments or tasks?
return [];
if ($objectType === 'account' && $opportunityId === null) {
// You cannot attach tasks to only accounts.
return [];
}
// Query should default to any open task.
$where = 'isDeleted: false AND isCompleted: false AND (' . $this->convertObjectTypeToResource($objectType) . 'UserId: ' . (int) $objectId;
// If we are also going to check on a specific job order, set that up.
if ($opportunityId) {
$where .= ' OR JobOrder: ' . (int) $opportunityId;
}
$where .= ')';
$params = [
'sort' => '-dateLastModified',
'fields' => $this->getFieldsAsString(self::TASK_FIELDS),
'query' => $where,
];
$bhTasks = $this->client->get('search/Task', $params);
foreach ($bhTasks['data'] as $bhTask) {
$data[] = [
'crmId' => $bhTask['id'],
'subject' => $bhTask['subject'],
'due' => Carbon::createFromTimestampMs($bhTask['dateEnd'])->toIso8601String(),
'type' => null,
];
}
return $data;
}
/**
* @url [URL_WITH_CREDENTIALS] [URL_WITH_CREDENTIALS] [URL_WITH_CREDENTIALS] CrmException
* @throws DomainException
*/
private function buildNotePayload(Activity $activity): Activity
{
if ($activity->hasActivityType() === false) {
throw new DomainException('[Bullhorn] No activity type assigned');
}
$action = $this->getCrmType($activity->getActivityType());
if ($action === null) {
throw new DomainException('[Bullhorn] No mapped CRM type for "' . $activity->category->name . '"');
}
if ($activity->lead_id === null && $activity->contact_id === null) {
throw new DomainException('[Bullhorn] Cannot save note without candidate or DM');
}
$payload = [
'comments' => $this->generateActivityDescription($activity),
'action' => $action->value,
'commentingPerson' => [
'id' => (int) $this->profile->crm_provider_id,
'type' => 'CorporateUser',
],
'externalID' => $activity->id_string,
'minutesSpent' => $activity->duration,
'dateAdded' => $activity->hasActualEndTime() ? $activity->getActualEndTime()?->valueOf() : now()->valueOf(),
];
if ($activity->lead_id) {
$payload['personReference'] = [
'id' => (int) $activity->lead->crm_provider_id,
'type' => 'Candidate',
];
}
if ($activity->contact_id) {
$payload['personReference'] = [
'id' => (int) $activity->contact->crm_provider_id,
'type' => 'ClientContact',
];
}
if ($activity->opportunity_id) {
$payload['jobOrders'] = [
'replaceAll' => [
(int) $activity->opportunity->crm_provider_id,
],
];
}
$bhActivity = $this->client->put('entity/Note', $payload);
$activity->crm_provider_id = $bhActivity['changedEntityId'];
$activity->save();
return $activity;
}
private function generateActivityDescription(Activity $activity): string
{
$description = '';
switch ($activity->type) {
case Activity::TYPE_SOFTPHONE:
case Activity::TYPE_SOFTPHONE_INBOUND:
case Activity::TYPE_CONFERENCE:
if ($activity->isTypeSoftPhone() && $activity->getProvider() !== Activity::PROVIDER_UPLOADER) {
// Dialer calls have no native field for the subject.
if ($activity->title) {
$description .= sprintf(
'<p><span style="font-size: 16px; line-height: 19.2px;">%s</span></p>',
$activity->title,
);
}
// Conference meetings log duration into a distinct field, so log this in the main text.
$description .= sprintf(
'<p><span style="font-size: 13.3333px; line-height: 16px;">Call Duration: %s</span></p>',
$activity->duration_for_humans,
);
// Tell them which number was dialed.
if ($activity->to) {
$description .= sprintf(
'<p><span style="font-size: 13.3333px; line-height: 16px;">Dialed: %s</span></p>',
$activity->to->phone_number,
);
}
$description .= '<p></p>';
}
if ($activity->isTypeSoftphoneInbound() && $activity->getProvider() !== Activity::PROVIDER_UPLOADER) {
...
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"bounds":{"left":0.2962101,"top":1.0,"width":0.03856383,"height":-0.019952059},"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"#12066 on JY-20725-handle-HS-search-rate-limit, menu","depth":5,"bounds":{"left":0.33477393,"top":1.0,"width":0.122340426,"height":-0.019952059},"on_screen":true,"help_text":"Pull request #12066 exists for current branch JY-20725-handle-HS-search-rate-limit, but local branch is out of sync with remote","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"bounds":{"left":0.5731383,"top":1.0,"width":0.011303191,"height":-0.019952059},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"HandleHubspotRateLimitTest","depth":6,"bounds":{"left":0.5884308,"top":1.0,"width":0.076130316,"height":-0.019952059},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'HandleHubspotRateLimitTest'","depth":6,"bounds":{"left":0.66456115,"top":1.0,"width":0.011303191,"height":-0.019952059},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'HandleHubspotRateLimitTest'","depth":6,"bounds":{"left":0.67586434,"top":1.0,"width":0.011303191,"height":-0.019952059},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"bounds":{"left":0.6871675,"top":1.0,"width":0.011303191,"height":-0.019952059},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"bounds":{"left":0.7150931,"top":1.0,"width":0.011303191,"height":-0.019952059},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"bounds":{"left":0.72639626,"top":1.0,"width":0.011303191,"height":-0.019952059},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"IDE and Project Settings","depth":5,"bounds":{"left":0.73769945,"top":1.0,"width":0.011303191,"height":-0.019952059},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.042220745,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"2","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"13","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"3","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\nnamespace Jiminny\\Services\\Crm\\Bullhorn;\n\nuse Carbon\\Carbon;\nuse Exception;\nuse Illuminate\\Support\\Facades\\Cache;\nuse Illuminate\\Support\\Facades\\Log;\nuse Jiminny\\Contracts\\Services\\Crm\\MatchCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\FetchRelatedActivityInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\LayoutManagementInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\Provider\\BullhornInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityLookupInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityManipulationInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SettingsInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmMetadataInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\VerifyTaskExistsInterface;\nuse Jiminny\\Exceptions\\ApplicationException;\nuse Jiminny\\Exceptions\\CrmException;\nuse Jiminny\\Exceptions\\DomainException;\nuse Jiminny\\Exceptions\\HttpBadRequestException;\nuse Jiminny\\Exceptions\\HttpNotFoundException;\nuse Jiminny\\Exceptions\\ServiceUnavailableException;\nuse Jiminny\\Models\\Account;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Contact;\nuse Jiminny\\Models\\Crm\\Configuration;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Models\\Crm\\FieldData;\nuse Jiminny\\Models\\Crm\\Profile;\nuse Jiminny\\Models\\Lead;\nuse Jiminny\\Models\\Opportunity;\nuse Jiminny\\Models\\Playbook;\nuse Jiminny\\Models\\SocialAccount;\nuse Jiminny\\Models\\Stage;\nuse Jiminny\\Models\\User;\nuse Jiminny\\Repositories\\Crm\\ProfileRepository;\nuse Jiminny\\Repositories\\UserRepository;\nuse Jiminny\\Services\\Avatar\\ProspectPhotoPathService;\nuse Jiminny\\Services\\Crm\\BaseService;\nuse Jiminny\\Services\\Crm\\OpportunitySyncStrategyResolver;\nuse Jiminny\\Utils\\CurrencyFormatter;\nuse Jiminny\\Utils\\StringUtil;\nuse Ramsey\\Uuid\\Uuid;\n\nclass BullhornService extends BaseService implements\n BullhornInterface,\n SyncCrmEntitiesInterface,\n SyncCrmMetadataInterface,\n RemoteEntityManipulationInterface,\n FetchRelatedActivityInterface,\n LayoutManagementInterface,\n SettingsInterface,\n MatchCrmEntitiesInterface,\n RemoteEntityLookupInterface,\n VerifyTaskExistsInterface\n{\n private const int TASK_VERIFICATION_CACHE_TTL = 86400; // 1 day\n\n private const string ENTITY_LEAD = 'Candidate';\n private const string ENTITY_OPPORTUNITY = 'JobOrder';\n private const string ENTITY_ACCOUNT = 'ClientCorporation';\n private const string ENTITY_CONTACT = 'ClientContact';\n private const string ENTITY_ACTIVITY = 'Note';\n\n private const array OPPORTUNITY_FIELDS = [\n 'id',\n 'owner',\n 'status',\n 'title',\n 'salary',\n 'salaryUnit',\n 'payRate',\n 'isOpen',\n 'employmentType',\n 'dateClosed',\n 'startDate',\n 'dateLastModified',\n 'dateAdded',\n 'clientCorporation',\n 'correlatedCustomText10', // currency\n ];\n\n private const array TASK_FIELDS = [\n 'id',\n 'comments',\n 'dateAdded',\n 'dateLastModified',\n 'externalID',\n 'isDeleted',\n 'corporateUsers',\n 'clientContacts',\n 'commentingPerson',\n 'candidates',\n 'jobOrder',\n 'minutesSpent',\n 'action',\n ];\n\n private const array LEAD_FIELDS = [\n 'id',\n 'name',\n 'owner',\n 'occupation',\n 'companyName',\n 'companyURL',\n 'mobile',\n 'phone',\n 'phone2',\n 'phone3',\n 'workPhone',\n 'status',\n 'address',\n 'dateAdded',\n 'dateLastModified',\n ];\n\n private const array CONTACT_FIELDS = [\n 'id',\n 'name',\n 'occupation',\n 'mobile',\n 'phone',\n 'phone2',\n 'phone3',\n 'address',\n 'clientCorporation',\n 'dateAdded',\n 'owner',\n ];\n\n private const array ACCOUNT_FIELDS = [\n 'id',\n 'name',\n 'phone',\n 'industryList',\n 'address',\n 'companyURL',\n 'dateAdded',\n ];\n\n public function __construct(\n BullhornClientInterface $client,\n private readonly UserRepository $userRepository,\n private readonly ProspectPhotoPathService $prospectPhotoPathService\n ) {\n parent::__construct();\n\n $this->client = $client;\n }\n\n protected function getOAuthAccount(User $user): ?SocialAccount\n {\n // In this case, the Account Owner is always the connection for any API operations.\n $owner = $user->team->owner;\n\n return $owner->getSocialAccount(SocialAccount::PROVIDER_BULLHORN);\n }\n\n public function getDisplayName(): string\n {\n return 'Bullhorn';\n }\n\n public function createTranscriptNotes(Activity $activity): void\n {\n // Not done until a customer requests it.\n }\n\n public function syncProfileFields(): void\n {\n $fieldTypes = [\n 'lead',\n 'account',\n 'contact',\n 'opportunity',\n 'task',\n ];\n\n foreach ($fieldTypes as $fieldType) {\n try {\n $currentFields = $this->getFields($this->convertObjectTypeToResource($fieldType));\n\n $newFields = array_column($currentFields, 'name');\n $changedFields = array_merge(\n array_diff($this->profile->getFieldsAsArray($fieldType), $newFields),\n array_diff($newFields, $this->profile->getFieldsAsArray($fieldType))\n );\n\n if (! empty($changedFields)) {\n $fields = implode(',', $newFields);\n Log::debug(\n sprintf(\n '%s fields changed from %s to: %s',\n ucfirst($fieldType),\n $this->profile->{$fieldType . '_fields'},\n $fields,\n ),\n );\n\n $this->profile->{$fieldType . '_fields'} = $fields;\n }\n } catch (\\Exception $exception) {\n \\Log::error('No access to ' . $fieldType . ' object, skipping...');\n\n // XXX: should we log this fact somewhere?\n continue;\n }\n }\n\n $this->profile->save();\n }\n\n protected function getFieldTypes(): array\n {\n return [\n parent::OBJECT_TASK,\n //parent::OBJECT_ACCOUNT,\n //parent::OBJECT_CONTACT,\n //parent::OBJECT_OPPORTUNITY,\n //parent::OBJECT_LEAD,\n ];\n }\n\n protected function getFields(string $crmObject): array\n {\n $fields = [];\n foreach ($this->getObjectFields($crmObject) as $row) {\n $fields[] = ['label' => $row['label'] ?? $row['name'], 'name' => $row['name']];\n }\n\n return $fields;\n }\n\n private function getObjectFields(string $objectType): array\n {\n $entityType = $this->convertObjectTypeToResource($objectType);\n\n return Cache::remember(\n key: 'crm.profile#' . $this->profile->id . '.fieldType:' . $objectType,\n ttl: 60 * 60,\n callback: function () use ($entityType) {\n $entity = $this->client->get('meta/' . $entityType, [\n 'fields' => '*',\n ]);\n\n return $entity['fields'];\n },\n );\n }\n\n public function getDefaultFields(string $activityType): array\n {\n $fields = [];\n $defaultFields = FieldDefinitions::defaultFields();\n\n foreach ($defaultFields as $defaultField) {\n $fields[] = $this->config->fields()->firstOrCreate($defaultField);\n }\n\n return $fields;\n }\n\n public function getDefaultActivityField(string $activityType): Field\n {\n /** @var Field $activityField */\n $activityField = $this->config->fields()->where([\n 'crm_provider_id' => 'action',\n 'object_type' => $activityType,\n ])->first();\n\n return $activityField;\n }\n\n public function getDealInsightsFields(): array\n {\n return FieldDefinitions::dealInsightsFields();\n }\n\n public function getSupportedPlaybookTypes(): array\n {\n return [Playbook::ACTIVITY_TYPE_TASK];\n }\n\n protected function getDefaultFollowupLayoutFields(string $activityType): array\n {\n // We are not yet supporting follow-ups for this system.\n return [];\n }\n\n public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string\n {\n return $fieldValue;\n }\n\n public function syncField(Field $field): void\n {\n $resource = $this->convertObjectTypeToResource($field->object_type);\n $entity = $this->client->get('meta/' . $resource, [\n 'fields' => $field->crm_provider_id,\n 'meta' => 'full',\n ]);\n\n $bhField = $entity['fields'][0];\n\n $isMandatory = (empty($bhField['optional']) == false && $bhField['optional'] === false);\n $description = null;\n if (empty($bhField['description']) === false) {\n $description = mb_strimwidth($bhField['description'], 0, 191);\n }\n\n $label = 'Unknown';\n if (empty($bhField['label']) === false) {\n $label = mb_strimwidth($bhField['label'], 0, Field::LABEL_MAX_LENGTH);\n }\n\n $field->length = $bhField['maxLength'] ?? null;\n $field->is_mandatory = $isMandatory;\n $field->description = $description;\n $field->label = $label;\n $field->type = $this->convertFieldType(\n $bhField['dataType'] ?? null,\n $bhField['dataSpecialization'] ?? null,\n $bhField['inputType'] ?? null\n );\n\n $field->save();\n\n if (empty($bhField['options']) === false) {\n $values = [];\n\n // Import all values.\n foreach ($bhField['options'] as $i => $option) {\n $values[] = [\n 'value' => $option['value'],\n 'label' => mb_strimwidth($option['label'], 0, Field::LABEL_MAX_LENGTH),\n 'sequence' => $i,\n ];\n }\n\n // Take the existing set of field values.\n $fieldsToPurge = $field->values()->get()->pluck('value')->toArray();\n\n foreach ($values as $value) {\n // For all current values, upsert the field.\n $field->values()->updateOrCreate([\n 'value' => $value['value'],\n ], $value);\n\n // Remove this value from the ones we are going to purge.\n $key = array_search($value['value'], $fieldsToPurge, false);\n if ($key !== false) {\n unset($fieldsToPurge[$key]);\n }\n }\n\n // Delete the old values that are no longer used.\n $field->values()->whereIn('value', $fieldsToPurge)->delete();\n }\n }\n\n public function importPicklistValues(Field $field): array\n {\n // Taken care of in syncField.\n return [];\n }\n\n private function convertFieldType(?string $type, ?string $specialization, ?string $inputType): string\n {\n if ($type === null) {\n return Field::TYPE_UNSUPPORTED;\n }\n\n $type = strtolower($type);\n $specialization = strtolower($specialization);\n $inputType = strtolower($inputType);\n\n switch ($type) {\n case 'boolean':\n return Field::TYPE_BOOLEAN;\n\n case 'integer':\n switch ($specialization) {\n case 'integer':\n return Field::TYPE_NUMBER;\n default:\n return Field::TYPE_NUMBER;\n }\n\n // no break\n case 'bigdecimal':\n switch ($specialization) {\n case 'money':\n return Field::TYPE_CURRENCY;\n default:\n return Field::TYPE_NUMBER;\n }\n\n // no break\n case 'double':\n switch ($specialization) {\n case 'float':\n return Field::TYPE_NUMBER;\n default:\n return Field::TYPE_NUMBER;\n }\n\n // no break\n case 'timestamp':\n switch ($specialization) {\n case 'date':\n return Field::TYPE_DATE;\n case 'time':\n return Field::TYPE_TIME;\n case 'datetime':\n return Field::TYPE_DATETIME;\n default:\n return Field::TYPE_DATE;\n }\n\n // no break\n case 'string':\n switch ($inputType) {\n case 'textarea':\n return Field::TYPE_TEXTAREA;\n case 'select':\n return Field::TYPE_PICKLIST;\n default:\n return Field::TYPE_TEXT;\n }\n\n // no break\n default:\n // This is not actually a supported field.\n return Field::TYPE_UNSUPPORTED;\n }\n }\n\n private function importContact($crmData): Contact\n {\n $account = $mobilePhone = $phone = $email = null;\n if (empty($crmData['clientCorporation']) === false) {\n $account = $this->team\n ->accounts()\n ->where('crm_provider_id', $crmData['clientCorporation']['id'])\n ->first();\n\n if ($account === null) {\n $account = $this->syncAccount($crmData['clientCorporation']['id']);\n }\n }\n\n $countryCode = $this->convertCountryNameToCode($crmData['address']['countryCode']);\n\n foreach (['mobile', 'phone', 'phone2', 'phone3'] as $type) {\n if (empty($crmData[$type])) {\n continue;\n }\n\n if ($type === 'mobile') {\n $mobilePhone = parsePhoneNumber($countryCode, $crmData[$type]);\n } else {\n $phone = parsePhoneNumber($countryCode, $crmData[$type]);\n }\n\n break;\n }\n\n foreach (['email', 'email1', 'email2'] as $type) {\n if (empty($crmData[$type])) {\n continue;\n }\n\n $email = mb_strimwidth($crmData[$type], 0, 80);\n\n break;\n }\n\n $profile = $this->config->profiles()->where('crm_provider_id', (string) $crmData['owner']['id'])->first();\n\n $data = [\n 'team_id' => $this->team->id,\n 'account_id' => $account->id ?? null,\n 'user_id' => $profile ? $profile->user_id : null,\n 'owner_id' => $crmData['owner']['id'],\n 'name' => $crmData['name'],\n 'email' => $email,\n 'phone' => empty($phone['phone']) ? null : mb_strimwidth($phone['phone'], 0, 25),\n 'ext' => $phone['ext'] ?? null,\n 'mobile_phone' => empty($mobilePhone['phone']) ? null : mb_strimwidth($mobilePhone['phone'], 0, 25),\n 'title' => $crmData['occupation'],\n 'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(\n crmConfiguration: $this->config,\n crmProviderId: $crmData['id'],\n modelType: Contact::class,\n fileName: $crmData['id'],\n avatarText: $crmData['name'] ?? 'Unknown'\n ),\n 'remotely_created_at' => $this->filterOutOfBoundDates($crmData['dateAdded']),\n ];\n\n return $this->config->contacts()->updateOrCreate(['crm_provider_id' => (string) $crmData['id']], $data);\n }\n\n private function importOpportunity($crmData): ?Opportunity\n {\n if ($crmData['clientCorporation']['id']) {\n $account = $this->config->accounts()->where('crm_provider_id', (string) $crmData['clientCorporation']['id'])->first();\n\n if ($account === null) {\n $account = $this->syncAccount($crmData['clientCorporation']['id']);\n }\n } else {\n // If there is nothing to associate this with, don't import it.\n return null;\n }\n\n $profile = $this->config->profiles()->where('crm_provider_id', (string) $crmData['owner']['id'])->first();\n\n $stage = $this->config->stages()\n ->where('crm_provider_id', $crmData['status'])\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->first();\n\n if ($stage === null) {\n $stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $crmData['status']);\n }\n\n $data = [\n 'team_id' => $this->team->id,\n 'account_id' => $account->id,\n 'user_id' => $profile ? $profile->user_id : null,\n 'owner_id' => $crmData['owner']['id'],\n 'name' => mb_strimwidth($crmData['title'], 0, 128),\n 'value' => $crmData['salary'] ?? $crmData['payRate'],\n 'currency_code' => CurrencyFormatter::formatCode($crmData['correlatedCustomText10'] ?? 'USD'),\n 'close_date' => $this->filterOutOfBoundDates($crmData['startDate']),\n 'is_closed' => $crmData['isOpen'] !== true,\n 'is_won' => strtolower($crmData['status']) === 'placed',\n 'stage_id' => $stage->id,\n 'record_type_id' => null,\n 'remotely_created_at' => $this->filterOutOfBoundDates($crmData['dateAdded']),\n ];\n\n return $this->config->opportunities()->updateOrCreate(['crm_provider_id' => (string) $crmData['id']], $data);\n }\n\n private function importAccount($crmData): Account\n {\n $countryCode = $domain = null;\n if (empty($crmData['address']['countryCode'])) {\n $countryCode = $this->convertCountryNameToCode($crmData['address']['countryCode']);\n }\n\n $name = $crmData['name'] ?? 'Unknown';\n\n if ($crmData['companyURL'] !== null) {\n $domain = mb_strimwidth($crmData['companyURL'], 0, 191);\n $domain = StringUtil::resolveDomain($domain);\n }\n\n $data = [\n 'team_id' => $this->team->id,\n 'user_id' => null,\n 'owner_id' => null,\n 'name' => mb_strimwidth($name, 0, 191),\n 'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(\n crmConfiguration: $this->config,\n crmProviderId: $crmData['id'],\n modelType: Account::class,\n fileName: $crmData['id'],\n avatarText: $crmData['name'] ?? 'Unknown'\n ),\n 'country_code' => $countryCode,\n 'domain' => $domain,\n 'remotely_created_at' => $this->filterOutOfBoundDates($crmData['dateAdded']),\n ];\n\n return $this->config->accounts()->updateOrCreate(['crm_provider_id' => (string) $crmData['id']], $data);\n }\n\n private function importLead($crmData): ?Lead\n {\n // Get the current stage.\n $stage = $this->config->stages()->where('name', $crmData['status'])->where('type', Stage::TYPE_LEAD)->first();\n if ($stage === null) {\n $stage = $this->importStages([Stage::TYPE_LEAD], $crmData['status']);\n }\n\n // If we have no way of importing this, just return null :(\n if ($stage === null) {\n return null;\n }\n\n $countryCode = null;\n if (empty($crmData['address']['countryCode'])) {\n $countryCode = $this->convertCountryNameToCode($crmData['address']['countryCode']);\n }\n\n $mobilePhone = $phone = $email = null;\n foreach (['mobile', 'phone', 'phone2', 'phone3', 'workPhone'] as $type) {\n if (empty($crmData[$type])) {\n continue;\n }\n\n if ($type === 'mobile') {\n $mobilePhone = parsePhoneNumber($countryCode, $crmData[$type]);\n } else {\n $phone = parsePhoneNumber($countryCode, $crmData[$type]);\n }\n\n break;\n }\n\n foreach (['email', 'email1', 'email2'] as $type) {\n if (empty($crmData[$type])) {\n continue;\n }\n\n $email = mb_strimwidth($crmData[$type], 0, 80);\n\n break;\n }\n\n $company = 'Unknown';\n if (empty($crmData['companyName']) === false) {\n $companyName = is_array($crmData['companyName']) ? $crmData['companyName'][0] : $crmData['companyName'];\n $company = mb_strimwidth($companyName, 0, 191);\n }\n\n $domain = null;\n if (empty($crmData['companyURL']) === false) {\n $domain = mb_strimwidth($crmData['companyURL'], 0, 191);\n $domain = StringUtil::resolveDomain($domain);\n }\n\n $profile = $this->config->profiles()->where('crm_provider_id', (string) $crmData['owner']['id'])->first();\n\n $data = [\n 'team_id' => $this->team->id,\n 'user_id' => $profile ? $profile->user_id : null,\n 'owner_id' => $crmData['owner']['id'],\n 'company' => $company,\n 'domain' => $domain,\n 'name' => mb_strimwidth($crmData['name'], 0, 191),\n 'title' => mb_strimwidth($crmData['occupation'], 0, 128),\n 'email' => mb_strimwidth($email, 0, 80),\n 'phone' => empty($phone['phone']) ? null : mb_strimwidth($phone['phone'], 0, 25),\n 'ext' => $phone['ext'] ?? null,\n 'mobile_phone' => empty($mobilePhone['phone']) ? null : mb_strimwidth($mobilePhone['phone'], 0, 25),\n 'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(\n crmConfiguration: $this->config,\n crmProviderId: $crmData['id'],\n modelType: Lead::class,\n fileName: $crmData['id'],\n avatarText: $crmData['name'] ?? 'Unknown'\n ),\n 'stage_id' => $stage->id,\n 'country_code' => $countryCode,\n 'remotely_created_at' => $this->filterOutOfBoundDates($crmData['dateAdded']),\n ];\n\n return $this->config->leads()->updateOrCreate(['crm_provider_id' => (string) $crmData['id']], $data);\n }\n\n /**\n * @throws ApplicationException\n */\n public function syncContacts(Carbon $since, ?Carbon $to = null): int\n {\n $syncCount = 0;\n $params = [\n 'sort' => '-dateLastModified',\n 'fields' => $this->getFieldsAsString(self::CONTACT_FIELDS),\n 'query' => 'isDeleted: false AND dateLastModified:[' . $since->format('YmdHis') . ' TO *]',\n ];\n\n try {\n $bhContacts = $this->client->get('search/ClientContact', $params);\n\n foreach ($bhContacts['data'] as $bhContact) {\n // Only sync if previously imported.\n if ($this->hasContact($bhContact['id'])) {\n $this->importContact($bhContact);\n $syncCount++;\n }\n }\n } catch (\\Exception $exception) {\n throw new ApplicationException('Failed to sync contacts', $exception->getCode(), $exception);\n }\n\n return $syncCount;\n }\n\n public function syncOpportunities(array $parameters, ?string $strategy = null): int\n {\n $strategyResolver = app(OpportunitySyncStrategyResolver::class, [\n 'client' => $this->client,\n ]);\n $strategies = $strategyResolver->getStrategies($this->config, $strategy);\n\n $syncCount = 0;\n $total = 0;\n $count = 0;\n $offset = 0;\n\n try {\n $bhOpportunities = [];\n foreach ($strategies as $syncStrategy) {\n $opportunitiesData = $syncStrategy->fetchOpportunities($parameters);\n $total = (int) ($opportunitiesData['total'] ?? 0);\n $count = (int) ($opportunitiesData['count'] ?? 0);\n $offset = (int) ($opportunitiesData['start'] ?? 0);\n $bhOpportunities[] = $opportunitiesData['data'] ?? [];\n }\n\n $bhOpportunities = array_merge(...$bhOpportunities);\n\n foreach ($bhOpportunities as $bhOpportunity) {\n $this->importOpportunity($bhOpportunity);\n $syncCount++;\n }\n } catch (\\Exception $exception) {\n $this->logger->info('Failed to sync opportunities', [\n 'params' => $parameters,\n 'exception' => $exception->getMessage(),\n ]);\n }\n\n // only log if there are one sync strategy\n if (count($strategies) === 1 && $total > $count + $offset) {\n $this->logger->info('[Bullhorn] Sync Opportunities - count warning', [\n 'team_id' => $this->team->getId(),\n 'total' => $total,\n 'count' => $count,\n 'offset' => $offset,\n ]);\n }\n\n return $syncCount;\n }\n\n /**\n * @throws ApplicationException\n */\n public function syncAccounts(Carbon $since, ?Carbon $to = null): int\n {\n $syncCount = 0;\n $params = [\n 'sort' => '-dateLastModified',\n 'fields' => $this->getFieldsAsString(self::ACCOUNT_FIELDS),\n 'query' => 'isDeleted: false AND dateLastModified:[' . $since->format('YmdHis') . ' TO *]',\n ];\n\n try {\n $bhAccounts = $this->client->get('search/ClientCorporation', $params);\n\n foreach ($bhAccounts['data'] as $bhAccount) {\n // Only sync if previously imported.\n if ($this->hasAccount($bhAccount['id'])) {\n $this->importAccount($bhAccount);\n $syncCount++;\n }\n }\n } catch (\\Exception $exception) {\n throw new ApplicationException('Failed to sync accounts', $exception->getCode(), $exception);\n }\n\n return $syncCount;\n }\n\n /**\n * @throws ApplicationException\n */\n public function syncLeads(Carbon $since, ?Carbon $to = null, ?string $crmProfileId = null): int\n {\n $syncCount = 0;\n $params = [\n 'sort' => '-dateLastModified',\n 'fields' => $this->getFieldsAsString(self::LEAD_FIELDS),\n 'query' => 'isDeleted: false AND dateLastModified:[' . $since->format('YmdHis') . ' TO *]',\n ];\n\n try {\n $bhLeads = $this->client->get('search/Candidate', $params);\n if ($bhLeads === null) {\n return $syncCount;\n }\n\n foreach ($bhLeads['data'] as $bhLead) {\n // Only sync if previously imported.\n if ($this->hasLead($bhLead['id'])) {\n $this->importLead($bhLead);\n $syncCount++;\n }\n }\n } catch (\\Exception $exception) {\n throw new ApplicationException('Failed to sync leads', $exception->getCode(), $exception);\n }\n\n return $syncCount;\n }\n\n\n public function syncContact(string $crmId): ?Contact\n {\n try {\n $data = $this->getRecord(FieldData::OBJECT_CONTACT, $crmId, self::CONTACT_FIELDS);\n } catch (HttpNotFoundException $exception) {\n return null;\n }\n\n return $this->importContact($data['data']);\n }\n\n public function syncOpportunity(string $crmId): ?Opportunity\n {\n $strategyResolver = app(OpportunitySyncStrategyResolver::class, [\n 'client' => $this->client,\n ]);\n $strategy = $strategyResolver->resolve(\n $this->config,\n OpportunitySyncStrategyResolver::SINGLE_SYNC_OPPORTUNITY_STRATEGY\n );\n\n try {\n $data = $strategy->fetchOpportunities(['crm_id' => $crmId]);\n } catch (HttpNotFoundException $exception) {\n $this->logger->info('[Bullhorn] Opportunity not found', [\n 'crm_id' => $crmId,\n 'reason' => $exception->getMessage(),\n ]);\n }\n\n return $this->importOpportunity($data['data'] ?? []);\n }\n\n public function syncAccount(string $crmId): ?Account\n {\n try {\n $data = $this->getRecord(FieldData::OBJECT_ACCOUNT, $crmId, self::ACCOUNT_FIELDS);\n } catch (HttpNotFoundException $exception) {\n return null;\n }\n\n return $this->importAccount($data['data']);\n }\n\n public function syncLead(string $crmId): ?Lead\n {\n try {\n $data = $this->getRecord(FieldData::OBJECT_LEAD, $crmId, self::LEAD_FIELDS);\n } catch (HttpNotFoundException $exception) {\n return null;\n }\n\n return $this->importLead($data['data']);\n }\n\n public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage\n {\n $missingStage = null;\n\n if ($types === null) {\n $types = [Stage::TYPE_LEAD, Stage::TYPE_OPPORTUNITY];\n }\n\n foreach ($types as $type) {\n $resource = $this->convertObjectTypeToResource($type);\n $entity = $this->client->get('meta/' . $resource, [\n 'fields' => 'status',\n 'meta' => 'full',\n ]);\n\n $missingStage = null;\n\n if (! is_array($entity['fields']) || count($entity['fields']) !== 1 || $entity['fields'][0]['name'] !== 'status') {\n throw new \\UnexpectedValueException('Invalid response from Bullhorn API');\n }\n\n // Upsert all stages for the team.\n foreach ($entity['fields'][0]['options'] as $i => $option) {\n $stage = $this->config->stages()->updateOrCreate([\n 'crm_provider_id' => $option['value'],\n 'type' => $type,\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($option['value'], 0, 50),\n 'label' => mb_strimwidth($option['label'], 0, 191),\n 'sequence' => $i,\n 'is_selectable' => 1,\n ]);\n\n if ($missingStageName && $missingStageName === $option['value']) {\n $missingStage = $stage;\n }\n }\n\n if ($missingStageName && $missingStage === null) {\n // If they requested a stage that still doesn't exist, it must be inactive so lazy create it.\n $missingStage = $this->config->stages()->create([\n 'team_id' => $this->team->id,\n 'crm_provider_id' => Uuid::uuid4(),\n 'name' => mb_strimwidth($missingStageName, 0, 50),\n 'label' => mb_strimwidth($missingStageName, 0, 191),\n 'type' => $type,\n 'sequence' => 0,\n 'is_selectable' => 0,\n ]);\n }\n }\n\n return $missingStage;\n }\n\n public function syncOrganization(): void\n {\n // There appears to be nothing we can sync/map to.\n }\n\n public function find(string $name, array $scopes): array\n {\n $count = $this->limit ?? 20;\n $offset = $this->offset ?? 0;\n\n $data = \\Cache::remember($this->team->id . $name . $count . $offset, 300, function () use ($name, $count) {\n $result = [];\n\n try {\n $objects = $this->client->get('find', [\n 'query' => $name,\n 'meta' => 'basic',\n 'countPerEntity' => $count,\n ]);\n } catch (\\GuzzleHttp\\Exception\\ServerException $exception) {\n throw new ServiceUnavailableException($exception->getMessage());\n }\n\n foreach ($objects['data'] as $object) {\n try {\n $prospectType = $this->convertResourceToObjectType($object['entityType']);\n } catch (\\UnexpectedValueException $exception) {\n // We don't support this, so skip it.\n continue;\n }\n\n $record = [\n 'crmId' => $object['entityId'],\n 'crmUrl' => $this->generateProviderUrl($object['entityId'], $object['entityType']),\n 'prospectType' => $prospectType,\n 'phoneNumbers' => [],\n ];\n\n // Unfortunately we need to go and query each entity itself to get the data we need.\n switch ($object['entityType']) {\n case self::ENTITY_ACCOUNT:\n $data = $this->getRecord(FieldData::OBJECT_ACCOUNT, $object['entityId'], self::ACCOUNT_FIELDS);\n $entity = $data['data'];\n\n $record['name'] = $entity['name'];\n $record['industry'] = $entity['industryList'];\n if (empty($entity['phone']) === false) {\n $record['phoneNumbers'][] = [\n 'number' => $entity['phone'],\n 'nationalFormat' => phone_national(null, $entity['phone']),\n 'type' => 'phone',\n ];\n }\n\n break;\n\n case self::ENTITY_CONTACT:\n $data = $this->getRecord(FieldData::OBJECT_CONTACT, $object['entityId'], self::CONTACT_FIELDS);\n $entity = $data['data'];\n\n $record['name'] = $entity['name'];\n $record['organization'] = $entity['clientCorporation'] ? $entity['clientCorporation']['name'] : null;\n $record['title'] = $entity['occupation'];\n\n foreach (['mobile', 'phone', 'phone2', 'phone3'] as $phone) {\n if (empty($entity[$phone]) === false) {\n $record['phoneNumbers'][] = [\n 'number' => $entity[$phone],\n 'nationalFormat' => phone_national(null, $entity[$phone]),\n 'type' => $phone === 'mobile' ? 'mobile' : 'phone',\n ];\n }\n }\n\n break;\n\n case self::ENTITY_LEAD:\n $bhData = $this->getRecord(FieldData::OBJECT_LEAD, $object['entityId'], self::LEAD_FIELDS);\n $entity = $bhData['data'];\n\n $record['name'] = $entity['name'];\n $record['organization'] = $entity['companyName'];\n $record['title'] = $entity['occupation'];\n\n foreach (['mobile', 'phone', 'phone2', 'phone3', 'workPhone'] as $phone) {\n if (empty($entity[$phone]) === false) {\n $record['phoneNumbers'][] = [\n 'number' => $entity[$phone],\n 'nationalFormat' => phone_national(null, $entity[$phone]),\n 'type' => $phone === 'mobile' ? 'mobile' : 'phone',\n ];\n }\n }\n\n break;\n\n default:\n // We are not interested in this entity type so skip it.\n continue 2;\n }\n\n $result[] = $record;\n }\n\n return $result;\n });\n\n return $data;\n }\n\n public function getClient(): BullhornClientInterface\n {\n return $this->client;\n }\n\n private function getFieldsAsString(array $fields)\n {\n return implode(', ', $fields);\n }\n\n public function findOpportunities(?string $crmAccountId, ?string $crmContactId, ?int $userId = null): array\n {\n $data = [];\n $ownerData = [];\n $ownerId = null;\n $contact = null;\n $account = null;\n\n if ($crmAccountId) {\n $account = $this->config->accounts()->where('crm_provider_id', $crmAccountId)->first();\n\n if ($account === null) {\n $account = $this->syncAccount($crmAccountId);\n }\n }\n\n if ($crmContactId) {\n $contact = $this->config->contacts()->where('crm_provider_id', $crmContactId)->first();\n\n if ($contact === null) {\n $contact = $this->syncContact($crmContactId);\n }\n }\n\n if ($contact || $account) {\n if ($contact && $account === null) {\n $account = $contact->account;\n }\n\n if ($account === null) {\n return [];\n }\n\n $where = 'isDeleted: false AND clientCorporation.id:' . (int) $account->crm_provider_id;\n\n $params = [\n 'sort' => '-dateLastModified',\n 'fields' => $this->getFieldsAsString(self::OPPORTUNITY_FIELDS),\n ];\n\n $onlyOpen = true;\n switch ($this->config->opportunity_assignment_rule) {\n case Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_UPDATED:\n $params['sort'] = '-dateLastModified';\n\n break;\n\n case Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_CREATED:\n $params['sort'] = '-dateAdded';\n\n break;\n\n case Configuration::OPP_ASSIGNMENT_ALL_OPEN_OLDEST_CREATED:\n $params['sort'] = '+dateAdded';\n\n break;\n\n case Configuration::OPP_ASSIGNMENT_ALL_RECENTLY_UPDATED:\n $params['sort'] = '-dateLastModified';\n $onlyOpen = false;\n }\n\n if ($onlyOpen) {\n $where .= ' AND isOpen: true';\n }\n\n $params['query'] = $where;\n\n $bhOpportunities = $this->client->get('search/JobOrder', $params);\n\n if ($userId) {\n $profileRepository = app(ProfileRepository::class);\n $profile = $profileRepository->findProfileByUserId($this->config, $userId);\n\n $ownerId = $profile instanceof Profile ? $profile->getCrmProviderId() : null;\n }\n\n foreach ($bhOpportunities['data'] as $bhOpportunity) {\n $stage = $this->config\n ->stages()\n ->where('crm_provider_id', $bhOpportunity['status'])\n ->first();\n\n if ($stage === null) {\n // Import it.\n $stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $bhOpportunity['status']);\n }\n\n $record = [\n 'crmId' => $bhOpportunity['id'],\n 'name' => $bhOpportunity['title'],\n 'value' => currency_format($bhOpportunity['salary'], $bhOpportunity['correlatedCustomText10'] ?? 'USD'),\n 'won' => strtolower($bhOpportunity['status']) === 'placed',\n 'closed' => $bhOpportunity['isOpen'] !== true,\n 'stage' => [\n 'id' => $stage->id_string,\n 'name' => $stage->name,\n ],\n 'recordType' => [],\n ];\n\n if ($ownerId && isset($bhOpportunity['owner']['id']) && $bhOpportunity['owner']['id'] === (int) $ownerId) {\n $ownerData[] = $record;\n }\n\n $data[] = $record;\n }\n }\n\n if (! empty($ownerData)) {\n return $ownerData;\n }\n\n return $data;\n }\n\n public function getTasks(string $objectType, string $objectId, ?string $opportunityId): array\n {\n $data = [];\n\n // For now it has not been agreed on how to implement this e.g. appointments or tasks?\n return [];\n\n if ($objectType === 'account' && $opportunityId === null) {\n // You cannot attach tasks to only accounts.\n return [];\n }\n\n // Query should default to any open task.\n $where = 'isDeleted: false AND isCompleted: false AND (' . $this->convertObjectTypeToResource($objectType) . 'UserId: ' . (int) $objectId;\n\n // If we are also going to check on a specific job order, set that up.\n if ($opportunityId) {\n $where .= ' OR JobOrder: ' . (int) $opportunityId;\n }\n\n $where .= ')';\n\n $params = [\n 'sort' => '-dateLastModified',\n 'fields' => $this->getFieldsAsString(self::TASK_FIELDS),\n 'query' => $where,\n ];\n\n $bhTasks = $this->client->get('search/Task', $params);\n\n foreach ($bhTasks['data'] as $bhTask) {\n $data[] = [\n 'crmId' => $bhTask['id'],\n 'subject' => $bhTask['subject'],\n 'due' => Carbon::createFromTimestampMs($bhTask['dateEnd'])->toIso8601String(),\n 'type' => null,\n ];\n }\n\n return $data;\n }\n\n /**\n * @url https://bullhorn.github.io/rest-api-docs/index.html#get-find\n *\n * @return null|array{\n * ?Lead,\n * ?Account,\n * ?Opportunity,\n * ?Contact,\n * ?Stage,\n * ?string\n *}\n */\n public function matchExactlyByEmail(string $email, ?int $userId = null): ?array\n {\n $bhObject = $this->client->get('find', [\n 'query' => $email,\n 'meta' => 'basic',\n 'countPerEntity' => 10,\n ]);\n\n $data = $this->convertCrmData($bhObject['data'], $userId);\n\n return ! empty(array_filter($data)) ? $data : null;\n }\n\n /**\n * @url https://bullhorn.github.io/rest-api-docs/index.html#get-find\n *\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n *}\n */\n public function matchByPhone(string $phone, ?string $rawPhoneNumber = null, ?int $userId = null): ?array\n {\n // Check if the user is internal.\n $teamMember = $this->team->users()->where('phone', $phone)->exists();\n\n // Skip the attendee if internal.\n if ($teamMember === false) {\n $bhObject = $this->client->get('find', [\n 'query' => $rawPhoneNumber ?? $phone,\n 'meta' => 'basic',\n 'countPerEntity' => 10,\n ]);\n\n if (empty($bhObject['data'])) {\n $nationalPhone = phone_national(null, $rawPhoneNumber ?? $phone);\n $formattedPhone = ltrim($nationalPhone, '+0');\n $bhObject = $this->client->get('find', [\n 'query' => $formattedPhone,\n 'meta' => 'basic',\n 'countPerEntity' => 10,\n ]);\n }\n\n $data = $this->convertCrmData($bhObject['data'], $userId);\n\n return ! empty(array_filter($data)) ? $data : null;\n }\n\n return null;\n }\n\n /**\n * @url https://bullhorn.github.io/rest-api-docs/index.html#get-find\n *\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n public function matchByName(string $name, ?int $userId = null): ?array\n {\n // Don't waste time searching for single character strings.\n if (\\strlen($name) <= 3) {\n return null;\n }\n\n $cacheKey = $this->getCacheKey($name, $userId);\n\n $result = \\Cache::remember($cacheKey, 60, function () use ($name, $userId) {\n $bhObject = $this->client->get('find', [\n 'query' => $name,\n 'meta' => 'basic',\n 'countPerEntity' => 10,\n ]);\n\n $data = $this->convertCrmData($bhObject['data'], $userId);\n\n return ! empty(array_filter($data)) ? $data : false;\n });\n\n return is_array($result) ? $result : null;\n }\n\n protected function getCacheKey(string $object, ?int $userId = null): ?string\n {\n $key = $this->team->getId() . $object;\n $keySuffix = $this->getOwnerKeySuffix($userId);\n\n return $key . $keySuffix;\n }\n\n private function getOwnerKeySuffix(?int $userId = null): string\n {\n return $userId === null ? '' : (string) $userId;\n }\n\n public function saveActivity(Activity $activity): Activity\n {\n\n if ($activity->hasCrmProviderId()) {\n return $activity;\n }\n\n switch ($activity->type) {\n case Activity::TYPE_CONFERENCE:\n case Activity::TYPE_SOFTPHONE:\n case Activity::TYPE_SOFTPHONE_INBOUND:\n case Activity::TYPE_SMS_INBOUND:\n case Activity::TYPE_SMS_OUTBOUND:\n $activity = $this->buildNotePayload($activity);\n\n break;\n }\n\n return $activity;\n }\n\n /**\n * @throws CrmException\n * @throws DomainException\n */\n private function buildNotePayload(Activity $activity): Activity\n {\n if ($activity->hasActivityType() === false) {\n throw new DomainException('[Bullhorn] No activity type assigned');\n }\n\n $action = $this->getCrmType($activity->getActivityType());\n if ($action === null) {\n throw new DomainException('[Bullhorn] No mapped CRM type for \"' . $activity->category->name . '\"');\n }\n\n if ($activity->lead_id === null && $activity->contact_id === null) {\n throw new DomainException('[Bullhorn] Cannot save note without candidate or DM');\n }\n\n $payload = [\n 'comments' => $this->generateActivityDescription($activity),\n 'action' => $action->value,\n 'commentingPerson' => [\n 'id' => (int) $this->profile->crm_provider_id,\n 'type' => 'CorporateUser',\n ],\n 'externalID' => $activity->id_string,\n 'minutesSpent' => $activity->duration,\n 'dateAdded' => $activity->hasActualEndTime() ? $activity->getActualEndTime()?->valueOf() : now()->valueOf(),\n ];\n\n if ($activity->lead_id) {\n $payload['personReference'] = [\n 'id' => (int) $activity->lead->crm_provider_id,\n 'type' => 'Candidate',\n ];\n }\n\n if ($activity->contact_id) {\n $payload['personReference'] = [\n 'id' => (int) $activity->contact->crm_provider_id,\n 'type' => 'ClientContact',\n ];\n }\n\n if ($activity->opportunity_id) {\n $payload['jobOrders'] = [\n 'replaceAll' => [\n (int) $activity->opportunity->crm_provider_id,\n ],\n ];\n }\n\n $bhActivity = $this->client->put('entity/Note', $payload);\n\n $activity->crm_provider_id = $bhActivity['changedEntityId'];\n $activity->save();\n\n return $activity;\n }\n\n private function generateActivityDescription(Activity $activity): string\n {\n $description = '';\n switch ($activity->type) {\n case Activity::TYPE_SOFTPHONE:\n case Activity::TYPE_SOFTPHONE_INBOUND:\n case Activity::TYPE_CONFERENCE:\n\n if ($activity->isTypeSoftPhone() && $activity->getProvider() !== Activity::PROVIDER_UPLOADER) {\n // Dialer calls have no native field for the subject.\n if ($activity->title) {\n $description .= sprintf(\n '<p><span style=\"font-size: 16px; line-height: 19.2px;\">%s</span></p>',\n $activity->title,\n );\n }\n\n // Conference meetings log duration into a distinct field, so log this in the main text.\n $description .= sprintf(\n '<p><span style=\"font-size: 13.3333px; line-height: 16px;\">Call Duration: %s</span></p>',\n $activity->duration_for_humans,\n );\n\n // Tell them which number was dialed.\n if ($activity->to) {\n $description .= sprintf(\n '<p><span style=\"font-size: 13.3333px; line-height: 16px;\">Dialed: %s</span></p>',\n $activity->to->phone_number,\n );\n }\n $description .= '<p></p>';\n }\n\n if ($activity->isTypeSoftphoneInbound() && $activity->getProvider() !== Activity::PROVIDER_UPLOADER) {\n // Dialer calls have no native field for the subject.\n if ($activity->title) {\n $description .= sprintf(\n '<p><span style=\"font-size: 16px; line-height: 19.2px;\">%s</span></p>',\n $activity->title,\n );\n }\n\n // Conference meetings log duration into a distinct field, so log this in the main text.\n $description .= sprintf(\n '<p><span style=\"font-size: 13.3333px; line-height: 16px;\">Call Duration: %s</span></p>',\n $activity->duration_for_humans,\n );\n $description .= sprintf(\n '<p><span style=\"font-size: 13.3333px; line-height: 16px;\">Caller: %s</span></p>',\n $activity->from->phone_number,\n );\n $description .= '<p></p>';\n }\n\n if ($activity->hasReasonCodeBotKicked()) {\n $description .= '<p>'\n . '<span style=\"font-size: 13.3333px; line-height: 16px;\">'\n . 'Notetaker removed from this meeting'\n . '</span>'\n . '</p>';\n // When we fix the state to be Activity::RECORDING_RECORDED as it should be this can change.\n } elseif ($activity->hasReasonCodeNotCompliant()) {\n $description .= '<p>'\n . '<span style=\"font-size: 13.3333px; line-height: 16px;\">'\n . 'Notetaker did not join due to recording consent not being provided by attendees'\n . '</span>'\n . '</p>';\n } elseif ($activity->canReviewActivity()) {\n $playbackUrl = $activity->user->team->partner->getPlaybackUrl($activity);\n $description .= sprintf(\n '<p>'\n . '<span style=\"font-size: 13.3333px; line-height: 16px;\">'\n . '<a href=\"%s\" target=\"_blank\">'\n . 'Review in Jiminny'\n . '</a>'\n . '️</span>'\n . '</p>',\n $playbackUrl,\n );\n }\n\n if ($activity->isTypeConference()) {\n $description .= '<p><strong>Attendees</strong></p><ul>';\n\n // Only include participants that joined.\n $participants = $activity->participants()->ghost(false)->whereNotNull('enter_time')->get();\n foreach ($participants as $participant) {\n $attendee = '<li>';\n if ($participant->name) {\n $attendee .= $participant->name;\n if ($participant->phone_number) {\n $attendee .= ' (' . $participant->phone_number . ')';\n } elseif ($participant->email) {\n $attendee .= ' (' . $participant->email . ')';\n }\n } else {\n $attendee .= $participant->phone_number;\n }\n\n if ($participant->is_ghost) {\n $attendee .= ' <strong>coach</coach>';\n }\n\n if ($participant->getTitle()) {\n $attendee .= ', ' . $participant->getTitle();\n }\n\n $description .= $attendee . '</li>';\n }\n $description .= '</ul>';\n }\n\n if (count($activity->notes) > 0) {\n $description .= '<p><strong>Notes</strong></p>';\n\n foreach ($activity->notes as $note) {\n $time = ($note->time > 3600) ? gmdate('H:i:s', $note->time) : gmdate('i:s', $note->time);\n $description .= '<p>' . $time . ' ' . $note->note . '</p>';\n }\n $description .= '</p>';\n }\n\n // Get all private messages.\n $messages = $activity->messages()\n ->where('is_private', 1)\n ->orderBy('created_at', 'asc');\n\n if ($messages->count() > 0) {\n $description .= '<p><strong>Coaching Chat</strong></p>';\n\n foreach ($messages->get() as $message) {\n $description .= '<p>' . $message->participant->name . ': ' . $message->message . '</p>';\n }\n $description .= '</p>';\n }\n\n // Get all public messages.\n $messages = $activity->messages()\n ->where('is_private', 0)\n ->orderBy('created_at', 'asc');\n\n if ($messages->count() > 0) {\n $description .= '<p><strong>Customer Chat</strong></p>';\n\n foreach ($messages->get() as $message) {\n $description .= '<p>' . $message->participant->name . ': ' . $message->message . '</p>';\n }\n $description .= '</p>';\n }\n\n if ($activity->summary) {\n $description .= '<p><strong>Summary</strong></p><p>' . $activity->summary . '</p><p></p>';\n }\n\n break;\n\n case Activity::TYPE_SMS_INBOUND:\n case Activity::TYPE_SMS_OUTBOUND:\n // Use Rich-text formatting.\n $description = sprintf(\n '<p><span style=\"font-size: 16px; line-height: 19.2px;\"><strong>%s</strong></span></p>',\n $activity->title,\n );\n $description .= sprintf(\n '<p><span style=\"font-size: 16px; line-height: 19.2px;\">%s</span></p>',\n $activity->description,\n );\n\n break;\n }\n\n return $description;\n }\n\n\n public function saveFollowupActivity(Activity $activity, array $fields): ?string\n {\n // Not yet required by current customers.\n return null;\n }\n\n public function updateStage($crmObject, Stage $stage): void\n {\n $objectId = $crmObject->crm_provider_id;\n\n $this->updateRecord($stage->type, $objectId, ['status' => $stage->crm_provider_id]);\n }\n\n public function syncProfiles(?User $userToSearch = null): ?Profile\n {\n $start = 0;\n $payload = [\n 'fields' => 'id,email',\n 'where' => 'isDeleted=false',\n 'count' => 500,\n ];\n\n $payload['start'] = $start;\n $profiles = $this->client->get('query/CorporateUser', $payload);\n\n foreach ($profiles['data'] as $profile) {\n if (empty($profile['email'])) {\n continue;\n }\n\n $user = $this->userRepository->findByTeamAndEmailAddress($this->team, $profile['email'], onlyActive: true);\n\n // Only import profiles for users we have locally.\n if ($user) {\n $profile = $user->crmProfile()->updateOrCreate([\n 'crm_configuration_id' => $this->config->id,\n 'crm_provider_id' => $profile['id'],\n ]);\n\n if ($userToSearch && $userToSearch->id === $user->id) {\n return $profile;\n }\n }\n }\n\n return null;\n }\n\n protected function convertCrmData(array $entities, ?int $userId = null): array\n {\n $lead = null;\n $contact = null;\n $opportunity = null;\n $account = null;\n $stage = null;\n $countryCode = null;\n\n $mappedEntities = [];\n foreach ($entities as $entity) {\n if (in_array($entity['entityType'], [self::ENTITY_LEAD, self::ENTITY_CONTACT, self::ENTITY_ACCOUNT])) {\n $mappedEntities[] = $entity;\n }\n }\n\n // If there is only one match, sync the data and return the object(s).\n if (count($mappedEntities) === 1) {\n $entity = $mappedEntities[0];\n\n if ($entity['entityType'] === self::ENTITY_LEAD) {\n $lead = $this->syncLead($entity['entityId']);\n\n // Lead might not be imported if the Stage is null for example.\n if ($lead) {\n $countryCode = $lead->country_code;\n $stage = $lead->stage;\n }\n } elseif (in_array($entity['entityType'], [self::ENTITY_CONTACT, self::ENTITY_ACCOUNT])) {\n if ($entity['entityType'] === self::ENTITY_CONTACT) {\n $contact = $this->syncContact($entity['entityId']);\n $account = $contact->account;\n } else {\n $account = $this->syncAccount($entity['entityId']);\n }\n\n if ($contact?->country_code) {\n $countryCode = $contact->country_code;\n } elseif ($account) {\n $countryCode = $account->country_code;\n }\n\n try {\n $bhOpportunities = $this->findOpportunities(\n $account?->crm_provider_id,\n $contact?->crm_provider_id,\n $userId,\n );\n\n // Take the first opportunity, which will be ordered as priority based on their settings.\n if (! empty($bhOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($bhOpportunities[0]['crmId']);\n $stage = $opportunity?->stage;\n }\n } catch (Exception) {\n // Nothing to see here.\n }\n }\n }\n\n return [\n $lead,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n }\n\n public function generateProviderUrl(string $providerId, string $objectType): ?string\n {\n // There doesn't appear to be a way to deeplink to anything on https://app.bullhornstaffing.com.\n return null;\n }\n\n public function updateRecord(string $objectType, string $objectId, array $data, array $headers = []): void\n {\n $this->client->post('entity/' . $this->convertObjectTypeToResource($objectType) . '/' . $objectId, $data);\n }\n\n public function getRecord(string $objectType, string $objectId, array $fields = ['id']): array\n {\n return $this->client->get(\n url: 'entity/' . $this->convertObjectTypeToResource($objectType) . '/' . $objectId,\n data: ['fields' => $this->getFieldsAsString($fields)],\n );\n }\n\n private function convertObjectTypeToResource(string $objectType)\n {\n switch ($objectType) {\n case FieldData::OBJECT_OPPORTUNITY:\n return self::ENTITY_OPPORTUNITY;\n\n case FieldData::OBJECT_CONTACT:\n return self::ENTITY_CONTACT;\n\n case FieldData::OBJECT_LEAD:\n return self::ENTITY_LEAD;\n\n case FieldData::OBJECT_ACCOUNT:\n return self::ENTITY_ACCOUNT;\n\n case FieldData::OBJECT_TASK:\n return self::ENTITY_ACTIVITY;\n\n default:\n throw new \\UnexpectedValueException('Unsupported object type \"' . $objectType . '\"');\n }\n }\n\n private function convertResourceToObjectType(string $resource): string\n {\n switch ($resource) {\n case self::ENTITY_OPPORTUNITY:\n return FieldData::OBJECT_OPPORTUNITY;\n\n case self::ENTITY_CONTACT:\n return FieldData::OBJECT_CONTACT;\n\n case self::ENTITY_LEAD:\n return FieldData::OBJECT_LEAD;\n\n case self::ENTITY_ACCOUNT:\n return FieldData::OBJECT_ACCOUNT;\n\n case self::ENTITY_ACTIVITY:\n return FieldData::OBJECT_TASK;\n\n default:\n throw new \\UnexpectedValueException('Unsupported resource \"' . $resource . '\"');\n }\n }\n\n public function fetchAndAssociateRelatedActivity(Activity $activity): ?Activity\n {\n // We need to know when it started to determine when to look for related activity.\n if ($activity->hasActualStartTime() === false) {\n return null;\n }\n\n try {\n $bhNote = $this->fetchRelatedActivity($activity);\n if (empty($bhNote)) {\n $this->logger->info('[Bullhorn] No related activity found', [\n 'activity' => $activity->getUuid(),\n ]);\n\n return null;\n }\n\n $activityType = $this->getPlaybookCategory($this->getPlaybook($activity->user), $bhNote['action']);\n $comments = $bhNote['comments'] . '<br />//<br />' . $this->generateActivityDescription($activity);\n // Now update the Note to contain our data.\n $payload = [\n 'comments' => $comments,\n 'externalID' => $activity->id_string,\n 'minutesSpent' => $activity->duration,\n ];\n\n $this->logger->info('[Bullhorn] updating related activity', [\n 'activity' => $activity->getUuid(),\n 'activityType' => $activityType->id ?? null,\n ]);\n\n $this->client->post('entity/Note/' . $bhNote['id'], $payload);\n\n $playbookCategoryId = null;\n if ($activity->getCategory() !== null) {\n $playbookCategoryId = $activity->getCategory()->getId();\n }\n\n // Update our activity to reflect the remote data.\n $activity->update([\n 'crm_provider_id' => $bhNote['id'],\n 'summary' => $bhNote['comments'],\n 'playbook_category_id' => $activityType->id ?? $playbookCategoryId,\n ]);\n\n return $activity;\n } catch (\\Exception $exception) {\n \\Sentry::captureException($exception);\n\n throw $exception;\n }\n }\n\n /**\n * Returns the most recently modified note within time range (if any).\n *\n * @return array A Note record from Bullhorn.\n */\n public function fetchRelatedActivity(Activity $activity): array\n {\n $from = $activity->hasScheduledStartTime()\n ? $activity->getScheduledStartTime()\n : $activity->getActualStartTime();\n $to = $activity->hasActualEndTime()\n ? $activity->getActualEndTime()?->addMinutes(15)\n : $from?->clone()->addMinutes(15);\n\n $filterStatements = [\n 'isDeleted: false',\n '*:* -externalID:[* TO *]', // externalID should be null e.g. we did not already relate it.\n sprintf('commentingPerson: %d', (int) $this->profile->crm_provider_id),\n ];\n\n /**\n * @XXX: Bullhorn does not specify the time zone in their docs. I've found a forum thread in which someone from\n * staff asserts it's UTC, however in my field test I've found it to be GMT-0300 (USA east coast).\n * To make the code immune to such glitches we will add 24h to start/end boundaries and will filter out\n * any excess items in code\n */\n $filterStatements[] = sprintf(\n 'dateLastModified:[%s TO %s]',\n $from?->clone()->subHours(24)->format('YmdHis'),\n $to->clone()->addHours(24)->format('YmdHis'),\n );\n\n if ($activity->lead_id) {\n $filterStatements[] = sprintf('candidateUserID: %d', (int) $activity->lead->crm_provider_id);\n }\n\n if ($activity->contact_id) {\n $filterStatements[] = sprintf('clientContactUserID: %d', (int) $activity->contact->crm_provider_id);\n }\n\n // if there is no contact or lead associated to the activity exit\n if ($activity->contact_id === null && $activity->lead_id === null) {\n return [];\n }\n\n $params = [\n 'fields' => $this->getFieldsAsString(self::TASK_FIELDS),\n 'query' => implode(' AND ', $filterStatements),\n 'sort' => '-dateLastModified',\n ];\n\n $bhNotes = $this->client->get('search/Note', $params);\n\n $startStamp = $from?->getTimestamp() * 1000;\n $endStamp = $to->getTimestamp() * 1000;\n\n $filteredNotes = array_values(\n array_filter(\n $bhNotes['data'],\n static fn (array $bhNote): bool\n => $bhNote['dateLastModified'] >= $startStamp && $startStamp <= $endStamp,\n ),\n );\n\n return ! empty($filteredNotes) ? array_shift($filteredNotes) : [];\n }\n\n /**\n * Select how to create the date out of the Bullhorn payload.\n * Do not allow dates beyond 9999-12-31 (max mysql DATE)\n */\n private function filterOutOfBoundDates(string $rawDateCandidate): ?Carbon\n {\n if (strlen($rawDateCandidate) > 12) {\n // A unix timestamp represented with more than 12 digits i\n // s definitely including milliseconds\n $dateFormatted = Carbon::createFromTimestampMs($rawDateCandidate);\n } else {\n $dateFormatted = Carbon::createFromTimestamp($rawDateCandidate);\n }\n\n $minDate = Carbon::createFromFormat('Y-m-d', '2000-01-01');\n if ($dateFormatted->lt($minDate)) {\n return null;\n }\n\n $maxDate = Carbon::createFromFormat('Y-m-d', '9999-12-31');\n if ($dateFormatted->gt($maxDate)) {\n $this->logger->warning('[Bullhorn] An out of bound date is provided', [\n 'date' => $dateFormatted,\n 'rawDate' => $rawDateCandidate,\n ]);\n\n return $maxDate;\n }\n\n return $dateFormatted;\n }\n\n public function verifyTaskExists(Activity $activity): bool\n {\n $crmProviderId = $activity->getCrmProviderId();\n $cacheKey = \"crm_task_exists:{$this->config->getId()}:$crmProviderId\";\n\n return Cache::remember($cacheKey, self::TASK_VERIFICATION_CACHE_TTL, function () use ($crmProviderId) {\n try {\n $note = $this->getRecord('Note', $crmProviderId, ['id', 'isDeleted']);\n\n return ! empty($note) && ($note['isDeleted'] ?? false) === false;\n } catch (HttpNotFoundException|HttpBadRequestException) {\n // Note not found in CRM - this is expected and permanent\n $this->logger->info('[Bullhorn] Note not found during verification', [\n 'note_id' => $crmProviderId,\n 'config_id' => $this->config->getId(),\n ]);\n\n return false;\n }\n // Let other exceptions (network errors, rate limits, etc.) bubble up for retry\n });\n }\n}","depth":4,"on_screen":true,"value":"<?php\n\nnamespace Jiminny\\Services\\Crm\\Bullhorn;\n\nuse Carbon\\Carbon;\nuse Exception;\nuse Illuminate\\Support\\Facades\\Cache;\nuse Illuminate\\Support\\Facades\\Log;\nuse Jiminny\\Contracts\\Services\\Crm\\MatchCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\FetchRelatedActivityInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\LayoutManagementInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\Provider\\BullhornInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityLookupInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityManipulationInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SettingsInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmMetadataInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\VerifyTaskExistsInterface;\nuse Jiminny\\Exceptions\\ApplicationException;\nuse Jiminny\\Exceptions\\CrmException;\nuse Jiminny\\Exceptions\\DomainException;\nuse Jiminny\\Exceptions\\HttpBadRequestException;\nuse Jiminny\\Exceptions\\HttpNotFoundException;\nuse Jiminny\\Exceptions\\ServiceUnavailableException;\nuse Jiminny\\Models\\Account;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Contact;\nuse Jiminny\\Models\\Crm\\Configuration;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Models\\Crm\\FieldData;\nuse Jiminny\\Models\\Crm\\Profile;\nuse Jiminny\\Models\\Lead;\nuse Jiminny\\Models\\Opportunity;\nuse Jiminny\\Models\\Playbook;\nuse Jiminny\\Models\\SocialAccount;\nuse Jiminny\\Models\\Stage;\nuse Jiminny\\Models\\User;\nuse Jiminny\\Repositories\\Crm\\ProfileRepository;\nuse Jiminny\\Repositories\\UserRepository;\nuse Jiminny\\Services\\Avatar\\ProspectPhotoPathService;\nuse Jiminny\\Services\\Crm\\BaseService;\nuse Jiminny\\Services\\Crm\\OpportunitySyncStrategyResolver;\nuse Jiminny\\Utils\\CurrencyFormatter;\nuse Jiminny\\Utils\\StringUtil;\nuse Ramsey\\Uuid\\Uuid;\n\nclass BullhornService extends BaseService implements\n BullhornInterface,\n SyncCrmEntitiesInterface,\n SyncCrmMetadataInterface,\n RemoteEntityManipulationInterface,\n FetchRelatedActivityInterface,\n LayoutManagementInterface,\n SettingsInterface,\n MatchCrmEntitiesInterface,\n RemoteEntityLookupInterface,\n VerifyTaskExistsInterface\n{\n private const int TASK_VERIFICATION_CACHE_TTL = 86400; // 1 day\n\n private const string ENTITY_LEAD = 'Candidate';\n private const string ENTITY_OPPORTUNITY = 'JobOrder';\n private const string ENTITY_ACCOUNT = 'ClientCorporation';\n private const string ENTITY_CONTACT = 'ClientContact';\n private const string ENTITY_ACTIVITY = 'Note';\n\n private const array OPPORTUNITY_FIELDS = [\n 'id',\n 'owner',\n 'status',\n 'title',\n 'salary',\n 'salaryUnit',\n 'payRate',\n 'isOpen',\n 'employmentType',\n 'dateClosed',\n 'startDate',\n 'dateLastModified',\n 'dateAdded',\n 'clientCorporation',\n 'correlatedCustomText10', // currency\n ];\n\n private const array TASK_FIELDS = [\n 'id',\n 'comments',\n 'dateAdded',\n 'dateLastModified',\n 'externalID',\n 'isDeleted',\n 'corporateUsers',\n 'clientContacts',\n 'commentingPerson',\n 'candidates',\n 'jobOrder',\n 'minutesSpent',\n 'action',\n ];\n\n private const array LEAD_FIELDS = [\n 'id',\n 'name',\n 'owner',\n 'occupation',\n 'companyName',\n 'companyURL',\n 'mobile',\n 'phone',\n 'phone2',\n 'phone3',\n 'workPhone',\n 'status',\n 'address',\n 'dateAdded',\n 'dateLastModified',\n ];\n\n private const array CONTACT_FIELDS = [\n 'id',\n 'name',\n 'occupation',\n 'mobile',\n 'phone',\n 'phone2',\n 'phone3',\n 'address',\n 'clientCorporation',\n 'dateAdded',\n 'owner',\n ];\n\n private const array ACCOUNT_FIELDS = [\n 'id',\n 'name',\n 'phone',\n 'industryList',\n 'address',\n 'companyURL',\n 'dateAdded',\n ];\n\n public function __construct(\n BullhornClientInterface $client,\n private readonly UserRepository $userRepository,\n private readonly ProspectPhotoPathService $prospectPhotoPathService\n ) {\n parent::__construct();\n\n $this->client = $client;\n }\n\n protected function getOAuthAccount(User $user): ?SocialAccount\n {\n // In this case, the Account Owner is always the connection for any API operations.\n $owner = $user->team->owner;\n\n return $owner->getSocialAccount(SocialAccount::PROVIDER_BULLHORN);\n }\n\n public function getDisplayName(): string\n {\n return 'Bullhorn';\n }\n\n public function createTranscriptNotes(Activity $activity): void\n {\n // Not done until a customer requests it.\n }\n\n public function syncProfileFields(): void\n {\n $fieldTypes = [\n 'lead',\n 'account',\n 'contact',\n 'opportunity',\n 'task',\n ];\n\n foreach ($fieldTypes as $fieldType) {\n try {\n $currentFields = $this->getFields($this->convertObjectTypeToResource($fieldType));\n\n $newFields = array_column($currentFields, 'name');\n $changedFields = array_merge(\n array_diff($this->profile->getFieldsAsArray($fieldType), $newFields),\n array_diff($newFields, $this->profile->getFieldsAsArray($fieldType))\n );\n\n if (! empty($changedFields)) {\n $fields = implode(',', $newFields);\n Log::debug(\n sprintf(\n '%s fields changed from %s to: %s',\n ucfirst($fieldType),\n $this->profile->{$fieldType . '_fields'},\n $fields,\n ),\n );\n\n $this->profile->{$fieldType . '_fields'} = $fields;\n }\n } catch (\\Exception $exception) {\n \\Log::error('No access to ' . $fieldType . ' object, skipping...');\n\n // XXX: should we log this fact somewhere?\n continue;\n }\n }\n\n $this->profile->save();\n }\n\n protected function getFieldTypes(): array\n {\n return [\n parent::OBJECT_TASK,\n //parent::OBJECT_ACCOUNT,\n //parent::OBJECT_CONTACT,\n //parent::OBJECT_OPPORTUNITY,\n //parent::OBJECT_LEAD,\n ];\n }\n\n protected function getFields(string $crmObject): array\n {\n $fields = [];\n foreach ($this->getObjectFields($crmObject) as $row) {\n $fields[] = ['label' => $row['label'] ?? $row['name'], 'name' => $row['name']];\n }\n\n return $fields;\n }\n\n private function getObjectFields(string $objectType): array\n {\n $entityType = $this->convertObjectTypeToResource($objectType);\n\n return Cache::remember(\n key: 'crm.profile#' . $this->profile->id . '.fieldType:' . $objectType,\n ttl: 60 * 60,\n callback: function () use ($entityType) {\n $entity = $this->client->get('meta/' . $entityType, [\n 'fields' => '*',\n ]);\n\n return $entity['fields'];\n },\n );\n }\n\n public function getDefaultFields(string $activityType): array\n {\n $fields = [];\n $defaultFields = FieldDefinitions::defaultFields();\n\n foreach ($defaultFields as $defaultField) {\n $fields[] = $this->config->fields()->firstOrCreate($defaultField);\n }\n\n return $fields;\n }\n\n public function getDefaultActivityField(string $activityType): Field\n {\n /** @var Field $activityField */\n $activityField = $this->config->fields()->where([\n 'crm_provider_id' => 'action',\n 'object_type' => $activityType,\n ])->first();\n\n return $activityField;\n }\n\n public function getDealInsightsFields(): array\n {\n return FieldDefinitions::dealInsightsFields();\n }\n\n public function getSupportedPlaybookTypes(): array\n {\n return [Playbook::ACTIVITY_TYPE_TASK];\n }\n\n protected function getDefaultFollowupLayoutFields(string $activityType): array\n {\n // We are not yet supporting follow-ups for this system.\n return [];\n }\n\n public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string\n {\n return $fieldValue;\n }\n\n public function syncField(Field $field): void\n {\n $resource = $this->convertObjectTypeToResource($field->object_type);\n $entity = $this->client->get('meta/' . $resource, [\n 'fields' => $field->crm_provider_id,\n 'meta' => 'full',\n ]);\n\n $bhField = $entity['fields'][0];\n\n $isMandatory = (empty($bhField['optional']) == false && $bhField['optional'] === false);\n $description = null;\n if (empty($bhField['description']) === false) {\n $description = mb_strimwidth($bhField['description'], 0, 191);\n }\n\n $label = 'Unknown';\n if (empty($bhField['label']) === false) {\n $label = mb_strimwidth($bhField['label'], 0, Field::LABEL_MAX_LENGTH);\n }\n\n $field->length = $bhField['maxLength'] ?? null;\n $field->is_mandatory = $isMandatory;\n $field->description = $description;\n $field->label = $label;\n $field->type = $this->convertFieldType(\n $bhField['dataType'] ?? null,\n $bhField['dataSpecialization'] ?? null,\n $bhField['inputType'] ?? null\n );\n\n $field->save();\n\n if (empty($bhField['options']) === false) {\n $values = [];\n\n // Import all values.\n foreach ($bhField['options'] as $i => $option) {\n $values[] = [\n 'value' => $option['value'],\n 'label' => mb_strimwidth($option['label'], 0, Field::LABEL_MAX_LENGTH),\n 'sequence' => $i,\n ];\n }\n\n // Take the existing set of field values.\n $fieldsToPurge = $field->values()->get()->pluck('value')->toArray();\n\n foreach ($values as $value) {\n // For all current values, upsert the field.\n $field->values()->updateOrCreate([\n 'value' => $value['value'],\n ], $value);\n\n // Remove this value from the ones we are going to purge.\n $key = array_search($value['value'], $fieldsToPurge, false);\n if ($key !== false) {\n unset($fieldsToPurge[$key]);\n }\n }\n\n // Delete the old values that are no longer used.\n $field->values()->whereIn('value', $fieldsToPurge)->delete();\n }\n }\n\n public function importPicklistValues(Field $field): array\n {\n // Taken care of in syncField.\n return [];\n }\n\n private function convertFieldType(?string $type, ?string $specialization, ?string $inputType): string\n {\n if ($type === null) {\n return Field::TYPE_UNSUPPORTED;\n }\n\n $type = strtolower($type);\n $specialization = strtolower($specialization);\n $inputType = strtolower($inputType);\n\n switch ($type) {\n case 'boolean':\n return Field::TYPE_BOOLEAN;\n\n case 'integer':\n switch ($specialization) {\n case 'integer':\n return Field::TYPE_NUMBER;\n default:\n return Field::TYPE_NUMBER;\n }\n\n // no break\n case 'bigdecimal':\n switch ($specialization) {\n case 'money':\n return Field::TYPE_CURRENCY;\n default:\n return Field::TYPE_NUMBER;\n }\n\n // no break\n case 'double':\n switch ($specialization) {\n case 'float':\n return Field::TYPE_NUMBER;\n default:\n return Field::TYPE_NUMBER;\n }\n\n // no break\n case 'timestamp':\n switch ($specialization) {\n case 'date':\n return Field::TYPE_DATE;\n case 'time':\n return Field::TYPE_TIME;\n case 'datetime':\n return Field::TYPE_DATETIME;\n default:\n return Field::TYPE_DATE;\n }\n\n // no break\n case 'string':\n switch ($inputType) {\n case 'textarea':\n return Field::TYPE_TEXTAREA;\n case 'select':\n return Field::TYPE_PICKLIST;\n default:\n return Field::TYPE_TEXT;\n }\n\n // no break\n default:\n // This is not actually a supported field.\n return Field::TYPE_UNSUPPORTED;\n }\n }\n\n private function importContact($crmData): Contact\n {\n $account = $mobilePhone = $phone = $email = null;\n if (empty($crmData['clientCorporation']) === false) {\n $account = $this->team\n ->accounts()\n ->where('crm_provider_id', $crmData['clientCorporation']['id'])\n ->first();\n\n if ($account === null) {\n $account = $this->syncAccount($crmData['clientCorporation']['id']);\n }\n }\n\n $countryCode = $this->convertCountryNameToCode($crmData['address']['countryCode']);\n\n foreach (['mobile', 'phone', 'phone2', 'phone3'] as $type) {\n if (empty($crmData[$type])) {\n continue;\n }\n\n if ($type === 'mobile') {\n $mobilePhone = parsePhoneNumber($countryCode, $crmData[$type]);\n } else {\n $phone = parsePhoneNumber($countryCode, $crmData[$type]);\n }\n\n break;\n }\n\n foreach (['email', 'email1', 'email2'] as $type) {\n if (empty($crmData[$type])) {\n continue;\n }\n\n $email = mb_strimwidth($crmData[$type], 0, 80);\n\n break;\n }\n\n $profile = $this->config->profiles()->where('crm_provider_id', (string) $crmData['owner']['id'])->first();\n\n $data = [\n 'team_id' => $this->team->id,\n 'account_id' => $account->id ?? null,\n 'user_id' => $profile ? $profile->user_id : null,\n 'owner_id' => $crmData['owner']['id'],\n 'name' => $crmData['name'],\n 'email' => $email,\n 'phone' => empty($phone['phone']) ? null : mb_strimwidth($phone['phone'], 0, 25),\n 'ext' => $phone['ext'] ?? null,\n 'mobile_phone' => empty($mobilePhone['phone']) ? null : mb_strimwidth($mobilePhone['phone'], 0, 25),\n 'title' => $crmData['occupation'],\n 'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(\n crmConfiguration: $this->config,\n crmProviderId: $crmData['id'],\n modelType: Contact::class,\n fileName: $crmData['id'],\n avatarText: $crmData['name'] ?? 'Unknown'\n ),\n 'remotely_created_at' => $this->filterOutOfBoundDates($crmData['dateAdded']),\n ];\n\n return $this->config->contacts()->updateOrCreate(['crm_provider_id' => (string) $crmData['id']], $data);\n }\n\n private function importOpportunity($crmData): ?Opportunity\n {\n if ($crmData['clientCorporation']['id']) {\n $account = $this->config->accounts()->where('crm_provider_id', (string) $crmData['clientCorporation']['id'])->first();\n\n if ($account === null) {\n $account = $this->syncAccount($crmData['clientCorporation']['id']);\n }\n } else {\n // If there is nothing to associate this with, don't import it.\n return null;\n }\n\n $profile = $this->config->profiles()->where('crm_provider_id', (string) $crmData['owner']['id'])->first();\n\n $stage = $this->config->stages()\n ->where('crm_provider_id', $crmData['status'])\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->first();\n\n if ($stage === null) {\n $stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $crmData['status']);\n }\n\n $data = [\n 'team_id' => $this->team->id,\n 'account_id' => $account->id,\n 'user_id' => $profile ? $profile->user_id : null,\n 'owner_id' => $crmData['owner']['id'],\n 'name' => mb_strimwidth($crmData['title'], 0, 128),\n 'value' => $crmData['salary'] ?? $crmData['payRate'],\n 'currency_code' => CurrencyFormatter::formatCode($crmData['correlatedCustomText10'] ?? 'USD'),\n 'close_date' => $this->filterOutOfBoundDates($crmData['startDate']),\n 'is_closed' => $crmData['isOpen'] !== true,\n 'is_won' => strtolower($crmData['status']) === 'placed',\n 'stage_id' => $stage->id,\n 'record_type_id' => null,\n 'remotely_created_at' => $this->filterOutOfBoundDates($crmData['dateAdded']),\n ];\n\n return $this->config->opportunities()->updateOrCreate(['crm_provider_id' => (string) $crmData['id']], $data);\n }\n\n private function importAccount($crmData): Account\n {\n $countryCode = $domain = null;\n if (empty($crmData['address']['countryCode'])) {\n $countryCode = $this->convertCountryNameToCode($crmData['address']['countryCode']);\n }\n\n $name = $crmData['name'] ?? 'Unknown';\n\n if ($crmData['companyURL'] !== null) {\n $domain = mb_strimwidth($crmData['companyURL'], 0, 191);\n $domain = StringUtil::resolveDomain($domain);\n }\n\n $data = [\n 'team_id' => $this->team->id,\n 'user_id' => null,\n 'owner_id' => null,\n 'name' => mb_strimwidth($name, 0, 191),\n 'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(\n crmConfiguration: $this->config,\n crmProviderId: $crmData['id'],\n modelType: Account::class,\n fileName: $crmData['id'],\n avatarText: $crmData['name'] ?? 'Unknown'\n ),\n 'country_code' => $countryCode,\n 'domain' => $domain,\n 'remotely_created_at' => $this->filterOutOfBoundDates($crmData['dateAdded']),\n ];\n\n return $this->config->accounts()->updateOrCreate(['crm_provider_id' => (string) $crmData['id']], $data);\n }\n\n private function importLead($crmData): ?Lead\n {\n // Get the current stage.\n $stage = $this->config->stages()->where('name', $crmData['status'])->where('type', Stage::TYPE_LEAD)->first();\n if ($stage === null) {\n $stage = $this->importStages([Stage::TYPE_LEAD], $crmData['status']);\n }\n\n // If we have no way of importing this, just return null :(\n if ($stage === null) {\n return null;\n }\n\n $countryCode = null;\n if (empty($crmData['address']['countryCode'])) {\n $countryCode = $this->convertCountryNameToCode($crmData['address']['countryCode']);\n }\n\n $mobilePhone = $phone = $email = null;\n foreach (['mobile', 'phone', 'phone2', 'phone3', 'workPhone'] as $type) {\n if (empty($crmData[$type])) {\n continue;\n }\n\n if ($type === 'mobile') {\n $mobilePhone = parsePhoneNumber($countryCode, $crmData[$type]);\n } else {\n $phone = parsePhoneNumber($countryCode, $crmData[$type]);\n }\n\n break;\n }\n\n foreach (['email', 'email1', 'email2'] as $type) {\n if (empty($crmData[$type])) {\n continue;\n }\n\n $email = mb_strimwidth($crmData[$type], 0, 80);\n\n break;\n }\n\n $company = 'Unknown';\n if (empty($crmData['companyName']) === false) {\n $companyName = is_array($crmData['companyName']) ? $crmData['companyName'][0] : $crmData['companyName'];\n $company = mb_strimwidth($companyName, 0, 191);\n }\n\n $domain = null;\n if (empty($crmData['companyURL']) === false) {\n $domain = mb_strimwidth($crmData['companyURL'], 0, 191);\n $domain = StringUtil::resolveDomain($domain);\n }\n\n $profile = $this->config->profiles()->where('crm_provider_id', (string) $crmData['owner']['id'])->first();\n\n $data = [\n 'team_id' => $this->team->id,\n 'user_id' => $profile ? $profile->user_id : null,\n 'owner_id' => $crmData['owner']['id'],\n 'company' => $company,\n 'domain' => $domain,\n 'name' => mb_strimwidth($crmData['name'], 0, 191),\n 'title' => mb_strimwidth($crmData['occupation'], 0, 128),\n 'email' => mb_strimwidth($email, 0, 80),\n 'phone' => empty($phone['phone']) ? null : mb_strimwidth($phone['phone'], 0, 25),\n 'ext' => $phone['ext'] ?? null,\n 'mobile_phone' => empty($mobilePhone['phone']) ? null : mb_strimwidth($mobilePhone['phone'], 0, 25),\n 'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(\n crmConfiguration: $this->config,\n crmProviderId: $crmData['id'],\n modelType: Lead::class,\n fileName: $crmData['id'],\n avatarText: $crmData['name'] ?? 'Unknown'\n ),\n 'stage_id' => $stage->id,\n 'country_code' => $countryCode,\n 'remotely_created_at' => $this->filterOutOfBoundDates($crmData['dateAdded']),\n ];\n\n return $this->config->leads()->updateOrCreate(['crm_provider_id' => (string) $crmData['id']], $data);\n }\n\n /**\n * @throws ApplicationException\n */\n public function syncContacts(Carbon $since, ?Carbon $to = null): int\n {\n $syncCount = 0;\n $params = [\n 'sort' => '-dateLastModified',\n 'fields' => $this->getFieldsAsString(self::CONTACT_FIELDS),\n 'query' => 'isDeleted: false AND dateLastModified:[' . $since->format('YmdHis') . ' TO *]',\n ];\n\n try {\n $bhContacts = $this->client->get('search/ClientContact', $params);\n\n foreach ($bhContacts['data'] as $bhContact) {\n // Only sync if previously imported.\n if ($this->hasContact($bhContact['id'])) {\n $this->importContact($bhContact);\n $syncCount++;\n }\n }\n } catch (\\Exception $exception) {\n throw new ApplicationException('Failed to sync contacts', $exception->getCode(), $exception);\n }\n\n return $syncCount;\n }\n\n public function syncOpportunities(array $parameters, ?string $strategy = null): int\n {\n $strategyResolver = app(OpportunitySyncStrategyResolver::class, [\n 'client' => $this->client,\n ]);\n $strategies = $strategyResolver->getStrategies($this->config, $strategy);\n\n $syncCount = 0;\n $total = 0;\n $count = 0;\n $offset = 0;\n\n try {\n $bhOpportunities = [];\n foreach ($strategies as $syncStrategy) {\n $opportunitiesData = $syncStrategy->fetchOpportunities($parameters);\n $total = (int) ($opportunitiesData['total'] ?? 0);\n $count = (int) ($opportunitiesData['count'] ?? 0);\n $offset = (int) ($opportunitiesData['start'] ?? 0);\n $bhOpportunities[] = $opportunitiesData['data'] ?? [];\n }\n\n $bhOpportunities = array_merge(...$bhOpportunities);\n\n foreach ($bhOpportunities as $bhOpportunity) {\n $this->importOpportunity($bhOpportunity);\n $syncCount++;\n }\n } catch (\\Exception $exception) {\n $this->logger->info('Failed to sync opportunities', [\n 'params' => $parameters,\n 'exception' => $exception->getMessage(),\n ]);\n }\n\n // only log if there are one sync strategy\n if (count($strategies) === 1 && $total > $count + $offset) {\n $this->logger->info('[Bullhorn] Sync Opportunities - count warning', [\n 'team_id' => $this->team->getId(),\n 'total' => $total,\n 'count' => $count,\n 'offset' => $offset,\n ]);\n }\n\n return $syncCount;\n }\n\n /**\n * @throws ApplicationException\n */\n public function syncAccounts(Carbon $since, ?Carbon $to = null): int\n {\n $syncCount = 0;\n $params = [\n 'sort' => '-dateLastModified',\n 'fields' => $this->getFieldsAsString(self::ACCOUNT_FIELDS),\n 'query' => 'isDeleted: false AND dateLastModified:[' . $since->format('YmdHis') . ' TO *]',\n ];\n\n try {\n $bhAccounts = $this->client->get('search/ClientCorporation', $params);\n\n foreach ($bhAccounts['data'] as $bhAccount) {\n // Only sync if previously imported.\n if ($this->hasAccount($bhAccount['id'])) {\n $this->importAccount($bhAccount);\n $syncCount++;\n }\n }\n } catch (\\Exception $exception) {\n throw new ApplicationException('Failed to sync accounts', $exception->getCode(), $exception);\n }\n\n return $syncCount;\n }\n\n /**\n * @throws ApplicationException\n */\n public function syncLeads(Carbon $since, ?Carbon $to = null, ?string $crmProfileId = null): int\n {\n $syncCount = 0;\n $params = [\n 'sort' => '-dateLastModified',\n 'fields' => $this->getFieldsAsString(self::LEAD_FIELDS),\n 'query' => 'isDeleted: false AND dateLastModified:[' . $since->format('YmdHis') . ' TO *]',\n ];\n\n try {\n $bhLeads = $this->client->get('search/Candidate', $params);\n if ($bhLeads === null) {\n return $syncCount;\n }\n\n foreach ($bhLeads['data'] as $bhLead) {\n // Only sync if previously imported.\n if ($this->hasLead($bhLead['id'])) {\n $this->importLead($bhLead);\n $syncCount++;\n }\n }\n } catch (\\Exception $exception) {\n throw new ApplicationException('Failed to sync leads', $exception->getCode(), $exception);\n }\n\n return $syncCount;\n }\n\n\n public function syncContact(string $crmId): ?Contact\n {\n try {\n $data = $this->getRecord(FieldData::OBJECT_CONTACT, $crmId, self::CONTACT_FIELDS);\n } catch (HttpNotFoundException $exception) {\n return null;\n }\n\n return $this->importContact($data['data']);\n }\n\n public function syncOpportunity(string $crmId): ?Opportunity\n {\n $strategyResolver = app(OpportunitySyncStrategyResolver::class, [\n 'client' => $this->client,\n ]);\n $strategy = $strategyResolver->resolve(\n $this->config,\n OpportunitySyncStrategyResolver::SINGLE_SYNC_OPPORTUNITY_STRATEGY\n );\n\n try {\n $data = $strategy->fetchOpportunities(['crm_id' => $crmId]);\n } catch (HttpNotFoundException $exception) {\n $this->logger->info('[Bullhorn] Opportunity not found', [\n 'crm_id' => $crmId,\n 'reason' => $exception->getMessage(),\n ]);\n }\n\n return $this->importOpportunity($data['data'] ?? []);\n }\n\n public function syncAccount(string $crmId): ?Account\n {\n try {\n $data = $this->getRecord(FieldData::OBJECT_ACCOUNT, $crmId, self::ACCOUNT_FIELDS);\n } catch (HttpNotFoundException $exception) {\n return null;\n }\n\n return $this->importAccount($data['data']);\n }\n\n public function syncLead(string $crmId): ?Lead\n {\n try {\n $data = $this->getRecord(FieldData::OBJECT_LEAD, $crmId, self::LEAD_FIELDS);\n } catch (HttpNotFoundException $exception) {\n return null;\n }\n\n return $this->importLead($data['data']);\n }\n\n public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage\n {\n $missingStage = null;\n\n if ($types === null) {\n $types = [Stage::TYPE_LEAD, Stage::TYPE_OPPORTUNITY];\n }\n\n foreach ($types as $type) {\n $resource = $this->convertObjectTypeToResource($type);\n $entity = $this->client->get('meta/' . $resource, [\n 'fields' => 'status',\n 'meta' => 'full',\n ]);\n\n $missingStage = null;\n\n if (! is_array($entity['fields']) || count($entity['fields']) !== 1 || $entity['fields'][0]['name'] !== 'status') {\n throw new \\UnexpectedValueException('Invalid response from Bullhorn API');\n }\n\n // Upsert all stages for the team.\n foreach ($entity['fields'][0]['options'] as $i => $option) {\n $stage = $this->config->stages()->updateOrCreate([\n 'crm_provider_id' => $option['value'],\n 'type' => $type,\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($option['value'], 0, 50),\n 'label' => mb_strimwidth($option['label'], 0, 191),\n 'sequence' => $i,\n 'is_selectable' => 1,\n ]);\n\n if ($missingStageName && $missingStageName === $option['value']) {\n $missingStage = $stage;\n }\n }\n\n if ($missingStageName && $missingStage === null) {\n // If they requested a stage that still doesn't exist, it must be inactive so lazy create it.\n $missingStage = $this->config->stages()->create([\n 'team_id' => $this->team->id,\n 'crm_provider_id' => Uuid::uuid4(),\n 'name' => mb_strimwidth($missingStageName, 0, 50),\n 'label' => mb_strimwidth($missingStageName, 0, 191),\n 'type' => $type,\n 'sequence' => 0,\n 'is_selectable' => 0,\n ]);\n }\n }\n\n return $missingStage;\n }\n\n public function syncOrganization(): void\n {\n // There appears to be nothing we can sync/map to.\n }\n\n public function find(string $name, array $scopes): array\n {\n $count = $this->limit ?? 20;\n $offset = $this->offset ?? 0;\n\n $data = \\Cache::remember($this->team->id . $name . $count . $offset, 300, function () use ($name, $count) {\n $result = [];\n\n try {\n $objects = $this->client->get('find', [\n 'query' => $name,\n 'meta' => 'basic',\n 'countPerEntity' => $count,\n ]);\n } catch (\\GuzzleHttp\\Exception\\ServerException $exception) {\n throw new ServiceUnavailableException($exception->getMessage());\n }\n\n foreach ($objects['data'] as $object) {\n try {\n $prospectType = $this->convertResourceToObjectType($object['entityType']);\n } catch (\\UnexpectedValueException $exception) {\n // We don't support this, so skip it.\n continue;\n }\n\n $record = [\n 'crmId' => $object['entityId'],\n 'crmUrl' => $this->generateProviderUrl($object['entityId'], $object['entityType']),\n 'prospectType' => $prospectType,\n 'phoneNumbers' => [],\n ];\n\n // Unfortunately we need to go and query each entity itself to get the data we need.\n switch ($object['entityType']) {\n case self::ENTITY_ACCOUNT:\n $data = $this->getRecord(FieldData::OBJECT_ACCOUNT, $object['entityId'], self::ACCOUNT_FIELDS);\n $entity = $data['data'];\n\n $record['name'] = $entity['name'];\n $record['industry'] = $entity['industryList'];\n if (empty($entity['phone']) === false) {\n $record['phoneNumbers'][] = [\n 'number' => $entity['phone'],\n 'nationalFormat' => phone_national(null, $entity['phone']),\n 'type' => 'phone',\n ];\n }\n\n break;\n\n case self::ENTITY_CONTACT:\n $data = $this->getRecord(FieldData::OBJECT_CONTACT, $object['entityId'], self::CONTACT_FIELDS);\n $entity = $data['data'];\n\n $record['name'] = $entity['name'];\n $record['organization'] = $entity['clientCorporation'] ? $entity['clientCorporation']['name'] : null;\n $record['title'] = $entity['occupation'];\n\n foreach (['mobile', 'phone', 'phone2', 'phone3'] as $phone) {\n if (empty($entity[$phone]) === false) {\n $record['phoneNumbers'][] = [\n 'number' => $entity[$phone],\n 'nationalFormat' => phone_national(null, $entity[$phone]),\n 'type' => $phone === 'mobile' ? 'mobile' : 'phone',\n ];\n }\n }\n\n break;\n\n case self::ENTITY_LEAD:\n $bhData = $this->getRecord(FieldData::OBJECT_LEAD, $object['entityId'], self::LEAD_FIELDS);\n $entity = $bhData['data'];\n\n $record['name'] = $entity['name'];\n $record['organization'] = $entity['companyName'];\n $record['title'] = $entity['occupation'];\n\n foreach (['mobile', 'phone', 'phone2', 'phone3', 'workPhone'] as $phone) {\n if (empty($entity[$phone]) === false) {\n $record['phoneNumbers'][] = [\n 'number' => $entity[$phone],\n 'nationalFormat' => phone_national(null, $entity[$phone]),\n 'type' => $phone === 'mobile' ? 'mobile' : 'phone',\n ];\n }\n }\n\n break;\n\n default:\n // We are not interested in this entity type so skip it.\n continue 2;\n }\n\n $result[] = $record;\n }\n\n return $result;\n });\n\n return $data;\n }\n\n public function getClient(): BullhornClientInterface\n {\n return $this->client;\n }\n\n private function getFieldsAsString(array $fields)\n {\n return implode(', ', $fields);\n }\n\n public function findOpportunities(?string $crmAccountId, ?string $crmContactId, ?int $userId = null): array\n {\n $data = [];\n $ownerData = [];\n $ownerId = null;\n $contact = null;\n $account = null;\n\n if ($crmAccountId) {\n $account = $this->config->accounts()->where('crm_provider_id', $crmAccountId)->first();\n\n if ($account === null) {\n $account = $this->syncAccount($crmAccountId);\n }\n }\n\n if ($crmContactId) {\n $contact = $this->config->contacts()->where('crm_provider_id', $crmContactId)->first();\n\n if ($contact === null) {\n $contact = $this->syncContact($crmContactId);\n }\n }\n\n if ($contact || $account) {\n if ($contact && $account === null) {\n $account = $contact->account;\n }\n\n if ($account === null) {\n return [];\n }\n\n $where = 'isDeleted: false AND clientCorporation.id:' . (int) $account->crm_provider_id;\n\n $params = [\n 'sort' => '-dateLastModified',\n 'fields' => $this->getFieldsAsString(self::OPPORTUNITY_FIELDS),\n ];\n\n $onlyOpen = true;\n switch ($this->config->opportunity_assignment_rule) {\n case Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_UPDATED:\n $params['sort'] = '-dateLastModified';\n\n break;\n\n case Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_CREATED:\n $params['sort'] = '-dateAdded';\n\n break;\n\n case Configuration::OPP_ASSIGNMENT_ALL_OPEN_OLDEST_CREATED:\n $params['sort'] = '+dateAdded';\n\n break;\n\n case Configuration::OPP_ASSIGNMENT_ALL_RECENTLY_UPDATED:\n $params['sort'] = '-dateLastModified';\n $onlyOpen = false;\n }\n\n if ($onlyOpen) {\n $where .= ' AND isOpen: true';\n }\n\n $params['query'] = $where;\n\n $bhOpportunities = $this->client->get('search/JobOrder', $params);\n\n if ($userId) {\n $profileRepository = app(ProfileRepository::class);\n $profile = $profileRepository->findProfileByUserId($this->config, $userId);\n\n $ownerId = $profile instanceof Profile ? $profile->getCrmProviderId() : null;\n }\n\n foreach ($bhOpportunities['data'] as $bhOpportunity) {\n $stage = $this->config\n ->stages()\n ->where('crm_provider_id', $bhOpportunity['status'])\n ->first();\n\n if ($stage === null) {\n // Import it.\n $stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $bhOpportunity['status']);\n }\n\n $record = [\n 'crmId' => $bhOpportunity['id'],\n 'name' => $bhOpportunity['title'],\n 'value' => currency_format($bhOpportunity['salary'], $bhOpportunity['correlatedCustomText10'] ?? 'USD'),\n 'won' => strtolower($bhOpportunity['status']) === 'placed',\n 'closed' => $bhOpportunity['isOpen'] !== true,\n 'stage' => [\n 'id' => $stage->id_string,\n 'name' => $stage->name,\n ],\n 'recordType' => [],\n ];\n\n if ($ownerId && isset($bhOpportunity['owner']['id']) && $bhOpportunity['owner']['id'] === (int) $ownerId) {\n $ownerData[] = $record;\n }\n\n $data[] = $record;\n }\n }\n\n if (! empty($ownerData)) {\n return $ownerData;\n }\n\n return $data;\n }\n\n public function getTasks(string $objectType, string $objectId, ?string $opportunityId): array\n {\n $data = [];\n\n // For now it has not been agreed on how to implement this e.g. appointments or tasks?\n return [];\n\n if ($objectType === 'account' && $opportunityId === null) {\n // You cannot attach tasks to only accounts.\n return [];\n }\n\n // Query should default to any open task.\n $where = 'isDeleted: false AND isCompleted: false AND (' . $this->convertObjectTypeToResource($objectType) . 'UserId: ' . (int) $objectId;\n\n // If we are also going to check on a specific job order, set that up.\n if ($opportunityId) {\n $where .= ' OR JobOrder: ' . (int) $opportunityId;\n }\n\n $where .= ')';\n\n $params = [\n 'sort' => '-dateLastModified',\n 'fields' => $this->getFieldsAsString(self::TASK_FIELDS),\n 'query' => $where,\n ];\n\n $bhTasks = $this->client->get('search/Task', $params);\n\n foreach ($bhTasks['data'] as $bhTask) {\n $data[] = [\n 'crmId' => $bhTask['id'],\n 'subject' => $bhTask['subject'],\n 'due' => Carbon::createFromTimestampMs($bhTask['dateEnd'])->toIso8601String(),\n 'type' => null,\n ];\n }\n\n return $data;\n }\n\n /**\n * @url https://bullhorn.github.io/rest-api-docs/index.html#get-find\n *\n * @return null|array{\n * ?Lead,\n * ?Account,\n * ?Opportunity,\n * ?Contact,\n * ?Stage,\n * ?string\n *}\n */\n public function matchExactlyByEmail(string $email, ?int $userId = null): ?array\n {\n $bhObject = $this->client->get('find', [\n 'query' => $email,\n 'meta' => 'basic',\n 'countPerEntity' => 10,\n ]);\n\n $data = $this->convertCrmData($bhObject['data'], $userId);\n\n return ! empty(array_filter($data)) ? $data : null;\n }\n\n /**\n * @url https://bullhorn.github.io/rest-api-docs/index.html#get-find\n *\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n *}\n */\n public function matchByPhone(string $phone, ?string $rawPhoneNumber = null, ?int $userId = null): ?array\n {\n // Check if the user is internal.\n $teamMember = $this->team->users()->where('phone', $phone)->exists();\n\n // Skip the attendee if internal.\n if ($teamMember === false) {\n $bhObject = $this->client->get('find', [\n 'query' => $rawPhoneNumber ?? $phone,\n 'meta' => 'basic',\n 'countPerEntity' => 10,\n ]);\n\n if (empty($bhObject['data'])) {\n $nationalPhone = phone_national(null, $rawPhoneNumber ?? $phone);\n $formattedPhone = ltrim($nationalPhone, '+0');\n $bhObject = $this->client->get('find', [\n 'query' => $formattedPhone,\n 'meta' => 'basic',\n 'countPerEntity' => 10,\n ]);\n }\n\n $data = $this->convertCrmData($bhObject['data'], $userId);\n\n return ! empty(array_filter($data)) ? $data : null;\n }\n\n return null;\n }\n\n /**\n * @url https://bullhorn.github.io/rest-api-docs/index.html#get-find\n *\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n public function matchByName(string $name, ?int $userId = null): ?array\n {\n // Don't waste time searching for single character strings.\n if (\\strlen($name) <= 3) {\n return null;\n }\n\n $cacheKey = $this->getCacheKey($name, $userId);\n\n $result = \\Cache::remember($cacheKey, 60, function () use ($name, $userId) {\n $bhObject = $this->client->get('find', [\n 'query' => $name,\n 'meta' => 'basic',\n 'countPerEntity' => 10,\n ]);\n\n $data = $this->convertCrmData($bhObject['data'], $userId);\n\n return ! empty(array_filter($data)) ? $data : false;\n });\n\n return is_array($result) ? $result : null;\n }\n\n protected function getCacheKey(string $object, ?int $userId = null): ?string\n {\n $key = $this->team->getId() . $object;\n $keySuffix = $this->getOwnerKeySuffix($userId);\n\n return $key . $keySuffix;\n }\n\n private function getOwnerKeySuffix(?int $userId = null): string\n {\n return $userId === null ? '' : (string) $userId;\n }\n\n public function saveActivity(Activity $activity): Activity\n {\n\n if ($activity->hasCrmProviderId()) {\n return $activity;\n }\n\n switch ($activity->type) {\n case Activity::TYPE_CONFERENCE:\n case Activity::TYPE_SOFTPHONE:\n case Activity::TYPE_SOFTPHONE_INBOUND:\n case Activity::TYPE_SMS_INBOUND:\n case Activity::TYPE_SMS_OUTBOUND:\n $activity = $this->buildNotePayload($activity);\n\n break;\n }\n\n return $activity;\n }\n\n /**\n * @throws CrmException\n * @throws DomainException\n */\n private function buildNotePayload(Activity $activity): Activity\n {\n if ($activity->hasActivityType() === false) {\n throw new DomainException('[Bullhorn] No activity type assigned');\n }\n\n $action = $this->getCrmType($activity->getActivityType());\n if ($action === null) {\n throw new DomainException('[Bullhorn] No mapped CRM type for \"' . $activity->category->name . '\"');\n }\n\n if ($activity->lead_id === null && $activity->contact_id === null) {\n throw new DomainException('[Bullhorn] Cannot save note without candidate or DM');\n }\n\n $payload = [\n 'comments' => $this->generateActivityDescription($activity),\n 'action' => $action->value,\n 'commentingPerson' => [\n 'id' => (int) $this->profile->crm_provider_id,\n 'type' => 'CorporateUser',\n ],\n 'externalID' => $activity->id_string,\n 'minutesSpent' => $activity->duration,\n 'dateAdded' => $activity->hasActualEndTime() ? $activity->getActualEndTime()?->valueOf() : now()->valueOf(),\n ];\n\n if ($activity->lead_id) {\n $payload['personReference'] = [\n 'id' => (int) $activity->lead->crm_provider_id,\n 'type' => 'Candidate',\n ];\n }\n\n if ($activity->contact_id) {\n $payload['personReference'] = [\n 'id' => (int) $activity->contact->crm_provider_id,\n 'type' => 'ClientContact',\n ];\n }\n\n if ($activity->opportunity_id) {\n $payload['jobOrders'] = [\n 'replaceAll' => [\n (int) $activity->opportunity->crm_provider_id,\n ],\n ];\n }\n\n $bhActivity = $this->client->put('entity/Note', $payload);\n\n $activity->crm_provider_id = $bhActivity['changedEntityId'];\n $activity->save();\n\n return $activity;\n }\n\n private function generateActivityDescription(Activity $activity): string\n {\n $description = '';\n switch ($activity->type) {\n case Activity::TYPE_SOFTPHONE:\n case Activity::TYPE_SOFTPHONE_INBOUND:\n case Activity::TYPE_CONFERENCE:\n\n if ($activity->isTypeSoftPhone() && $activity->getProvider() !== Activity::PROVIDER_UPLOADER) {\n // Dialer calls have no native field for the subject.\n if ($activity->title) {\n $description .= sprintf(\n '<p><span style=\"font-size: 16px; line-height: 19.2px;\">%s</span></p>',\n $activity->title,\n );\n }\n\n // Conference meetings log duration into a distinct field, so log this in the main text.\n $description .= sprintf(\n '<p><span style=\"font-size: 13.3333px; line-height: 16px;\">Call Duration: %s</span></p>',\n $activity->duration_for_humans,\n );\n\n // Tell them which number was dialed.\n if ($activity->to) {\n $description .= sprintf(\n '<p><span style=\"font-size: 13.3333px; line-height: 16px;\">Dialed: %s</span></p>',\n $activity->to->phone_number,\n );\n }\n $description .= '<p></p>';\n }\n\n if ($activity->isTypeSoftphoneInbound() && $activity->getProvider() !== Activity::PROVIDER_UPLOADER) {\n // Dialer calls have no native field for the subject.\n if ($activity->title) {\n $description .= sprintf(\n '<p><span style=\"font-size: 16px; line-height: 19.2px;\">%s</span></p>',\n $activity->title,\n );\n }\n\n // Conference meetings log duration into a distinct field, so log this in the main text.\n $description .= sprintf(\n '<p><span style=\"font-size: 13.3333px; line-height: 16px;\">Call Duration: %s</span></p>',\n $activity->duration_for_humans,\n );\n $description .= sprintf(\n '<p><span style=\"font-size: 13.3333px; line-height: 16px;\">Caller: %s</span></p>',\n $activity->from->phone_number,\n );\n $description .= '<p></p>';\n }\n\n if ($activity->hasReasonCodeBotKicked()) {\n $description .= '<p>'\n . '<span style=\"font-size: 13.3333px; line-height: 16px;\">'\n . 'Notetaker removed from this meeting'\n . '</span>'\n . '</p>';\n // When we fix the state to be Activity::RECORDING_RECORDED as it should be this can change.\n } elseif ($activity->hasReasonCodeNotCompliant()) {\n $description .= '<p>'\n . '<span style=\"font-size: 13.3333px; line-height: 16px;\">'\n . 'Notetaker did not join due to recording consent not being provided by attendees'\n . '</span>'\n . '</p>';\n } elseif ($activity->canReviewActivity()) {\n $playbackUrl = $activity->user->team->partner->getPlaybackUrl($activity);\n $description .= sprintf(\n '<p>'\n . '<span style=\"font-size: 13.3333px; line-height: 16px;\">'\n . '<a href=\"%s\" target=\"_blank\">'\n . 'Review in Jiminny'\n . '</a>'\n . '️</span>'\n . '</p>',\n $playbackUrl,\n );\n }\n\n if ($activity->isTypeConference()) {\n $description .= '<p><strong>Attendees</strong></p><ul>';\n\n // Only include participants that joined.\n $participants = $activity->participants()->ghost(false)->whereNotNull('enter_time')->get();\n foreach ($participants as $participant) {\n $attendee = '<li>';\n if ($participant->name) {\n $attendee .= $participant->name;\n if ($participant->phone_number) {\n $attendee .= ' (' . $participant->phone_number . ')';\n } elseif ($participant->email) {\n $attendee .= ' (' . $participant->email . ')';\n }\n } else {\n $attendee .= $participant->phone_number;\n }\n\n if ($participant->is_ghost) {\n $attendee .= ' <strong>coach</coach>';\n }\n\n if ($participant->getTitle()) {\n $attendee .= ', ' . $participant->getTitle();\n }\n\n $description .= $attendee . '</li>';\n }\n $description .= '</ul>';\n }\n\n if (count($activity->notes) > 0) {\n $description .= '<p><strong>Notes</strong></p>';\n\n foreach ($activity->notes as $note) {\n $time = ($note->time > 3600) ? gmdate('H:i:s', $note->time) : gmdate('i:s', $note->time);\n $description .= '<p>' . $time . ' ' . $note->note . '</p>';\n }\n $description .= '</p>';\n }\n\n // Get all private messages.\n $messages = $activity->messages()\n ->where('is_private', 1)\n ->orderBy('created_at', 'asc');\n\n if ($messages->count() > 0) {\n $description .= '<p><strong>Coaching Chat</strong></p>';\n\n foreach ($messages->get() as $message) {\n $description .= '<p>' . $message->participant->name . ': ' . $message->message . '</p>';\n }\n $description .= '</p>';\n }\n\n // Get all public messages.\n $messages = $activity->messages()\n ->where('is_private', 0)\n ->orderBy('created_at', 'asc');\n\n if ($messages->count() > 0) {\n $description .= '<p><strong>Customer Chat</strong></p>';\n\n foreach ($messages->get() as $message) {\n $description .= '<p>' . $message->participant->name . ': ' . $message->message . '</p>';\n }\n $description .= '</p>';\n }\n\n if ($activity->summary) {\n $description .= '<p><strong>Summary</strong></p><p>' . $activity->summary . '</p><p></p>';\n }\n\n break;\n\n case Activity::TYPE_SMS_INBOUND:\n case Activity::TYPE_SMS_OUTBOUND:\n // Use Rich-text formatting.\n $description = sprintf(\n '<p><span style=\"font-size: 16px; line-height: 19.2px;\"><strong>%s</strong></span></p>',\n $activity->title,\n );\n $description .= sprintf(\n '<p><span style=\"font-size: 16px; line-height: 19.2px;\">%s</span></p>',\n $activity->description,\n );\n\n break;\n }\n\n return $description;\n }\n\n\n public function saveFollowupActivity(Activity $activity, array $fields): ?string\n {\n // Not yet required by current customers.\n return null;\n }\n\n public function updateStage($crmObject, Stage $stage): void\n {\n $objectId = $crmObject->crm_provider_id;\n\n $this->updateRecord($stage->type, $objectId, ['status' => $stage->crm_provider_id]);\n }\n\n public function syncProfiles(?User $userToSearch = null): ?Profile\n {\n $start = 0;\n $payload = [\n 'fields' => 'id,email',\n 'where' => 'isDeleted=false',\n 'count' => 500,\n ];\n\n $payload['start'] = $start;\n $profiles = $this->client->get('query/CorporateUser', $payload);\n\n foreach ($profiles['data'] as $profile) {\n if (empty($profile['email'])) {\n continue;\n }\n\n $user = $this->userRepository->findByTeamAndEmailAddress($this->team, $profile['email'], onlyActive: true);\n\n // Only import profiles for users we have locally.\n if ($user) {\n $profile = $user->crmProfile()->updateOrCreate([\n 'crm_configuration_id' => $this->config->id,\n 'crm_provider_id' => $profile['id'],\n ]);\n\n if ($userToSearch && $userToSearch->id === $user->id) {\n return $profile;\n }\n }\n }\n\n return null;\n }\n\n protected function convertCrmData(array $entities, ?int $userId = null): array\n {\n $lead = null;\n $contact = null;\n $opportunity = null;\n $account = null;\n $stage = null;\n $countryCode = null;\n\n $mappedEntities = [];\n foreach ($entities as $entity) {\n if (in_array($entity['entityType'], [self::ENTITY_LEAD, self::ENTITY_CONTACT, self::ENTITY_ACCOUNT])) {\n $mappedEntities[] = $entity;\n }\n }\n\n // If there is only one match, sync the data and return the object(s).\n if (count($mappedEntities) === 1) {\n $entity = $mappedEntities[0];\n\n if ($entity['entityType'] === self::ENTITY_LEAD) {\n $lead = $this->syncLead($entity['entityId']);\n\n // Lead might not be imported if the Stage is null for example.\n if ($lead) {\n $countryCode = $lead->country_code;\n $stage = $lead->stage;\n }\n } elseif (in_array($entity['entityType'], [self::ENTITY_CONTACT, self::ENTITY_ACCOUNT])) {\n if ($entity['entityType'] === self::ENTITY_CONTACT) {\n $contact = $this->syncContact($entity['entityId']);\n $account = $contact->account;\n } else {\n $account = $this->syncAccount($entity['entityId']);\n }\n\n if ($contact?->country_code) {\n $countryCode = $contact->country_code;\n } elseif ($account) {\n $countryCode = $account->country_code;\n }\n\n try {\n $bhOpportunities = $this->findOpportunities(\n $account?->crm_provider_id,\n $contact?->crm_provider_id,\n $userId,\n );\n\n // Take the first opportunity, which will be ordered as priority based on their settings.\n if (! empty($bhOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($bhOpportunities[0]['crmId']);\n $stage = $opportunity?->stage;\n }\n } catch (Exception) {\n // Nothing to see here.\n }\n }\n }\n\n return [\n $lead,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n }\n\n public function generateProviderUrl(string $providerId, string $objectType): ?string\n {\n // There doesn't appear to be a way to deeplink to anything on https://app.bullhornstaffing.com.\n return null;\n }\n\n public function updateRecord(string $objectType, string $objectId, array $data, array $headers = []): void\n {\n $this->client->post('entity/' . $this->convertObjectTypeToResource($objectType) . '/' . $objectId, $data);\n }\n\n public function getRecord(string $objectType, string $objectId, array $fields = ['id']): array\n {\n return $this->client->get(\n url: 'entity/' . $this->convertObjectTypeToResource($objectType) . '/' . $objectId,\n data: ['fields' => $this->getFieldsAsString($fields)],\n );\n }\n\n private function convertObjectTypeToResource(string $objectType)\n {\n switch ($objectType) {\n case FieldData::OBJECT_OPPORTUNITY:\n return self::ENTITY_OPPORTUNITY;\n\n case FieldData::OBJECT_CONTACT:\n return self::ENTITY_CONTACT;\n\n case FieldData::OBJECT_LEAD:\n return self::ENTITY_LEAD;\n\n case FieldData::OBJECT_ACCOUNT:\n return self::ENTITY_ACCOUNT;\n\n case FieldData::OBJECT_TASK:\n return self::ENTITY_ACTIVITY;\n\n default:\n throw new \\UnexpectedValueException('Unsupported object type \"' . $objectType . '\"');\n }\n }\n\n private function convertResourceToObjectType(string $resource): string\n {\n switch ($resource) {\n case self::ENTITY_OPPORTUNITY:\n return FieldData::OBJECT_OPPORTUNITY;\n\n case self::ENTITY_CONTACT:\n return FieldData::OBJECT_CONTACT;\n\n case self::ENTITY_LEAD:\n return FieldData::OBJECT_LEAD;\n\n case self::ENTITY_ACCOUNT:\n return FieldData::OBJECT_ACCOUNT;\n\n case self::ENTITY_ACTIVITY:\n return FieldData::OBJECT_TASK;\n\n default:\n throw new \\UnexpectedValueException('Unsupported resource \"' . $resource . '\"');\n }\n }\n\n public function fetchAndAssociateRelatedActivity(Activity $activity): ?Activity\n {\n // We need to know when it started to determine when to look for related activity.\n if ($activity->hasActualStartTime() === false) {\n return null;\n }\n\n try {\n $bhNote = $this->fetchRelatedActivity($activity);\n if (empty($bhNote)) {\n $this->logger->info('[Bullhorn] No related activity found', [\n 'activity' => $activity->getUuid(),\n ]);\n\n return null;\n }\n\n $activityType = $this->getPlaybookCategory($this->getPlaybook($activity->user), $bhNote['action']);\n $comments = $bhNote['comments'] . '<br />//<br />' . $this->generateActivityDescription($activity);\n // Now update the Note to contain our data.\n $payload = [\n 'comments' => $comments,\n 'externalID' => $activity->id_string,\n 'minutesSpent' => $activity->duration,\n ];\n\n $this->logger->info('[Bullhorn] updating related activity', [\n 'activity' => $activity->getUuid(),\n 'activityType' => $activityType->id ?? null,\n ]);\n\n $this->client->post('entity/Note/' . $bhNote['id'], $payload);\n\n $playbookCategoryId = null;\n if ($activity->getCategory() !== null) {\n $playbookCategoryId = $activity->getCategory()->getId();\n }\n\n // Update our activity to reflect the remote data.\n $activity->update([\n 'crm_provider_id' => $bhNote['id'],\n 'summary' => $bhNote['comments'],\n 'playbook_category_id' => $activityType->id ?? $playbookCategoryId,\n ]);\n\n return $activity;\n } catch (\\Exception $exception) {\n \\Sentry::captureException($exception);\n\n throw $exception;\n }\n }\n\n /**\n * Returns the most recently modified note within time range (if any).\n *\n * @return array A Note record from Bullhorn.\n */\n public function fetchRelatedActivity(Activity $activity): array\n {\n $from = $activity->hasScheduledStartTime()\n ? $activity->getScheduledStartTime()\n : $activity->getActualStartTime();\n $to = $activity->hasActualEndTime()\n ? $activity->getActualEndTime()?->addMinutes(15)\n : $from?->clone()->addMinutes(15);\n\n $filterStatements = [\n 'isDeleted: false',\n '*:* -externalID:[* TO *]', // externalID should be null e.g. we did not already relate it.\n sprintf('commentingPerson: %d', (int) $this->profile->crm_provider_id),\n ];\n\n /**\n * @XXX: Bullhorn does not specify the time zone in their docs. I've found a forum thread in which someone from\n * staff asserts it's UTC, however in my field test I've found it to be GMT-0300 (USA east coast).\n * To make the code immune to such glitches we will add 24h to start/end boundaries and will filter out\n * any excess items in code\n */\n $filterStatements[] = sprintf(\n 'dateLastModified:[%s TO %s]',\n $from?->clone()->subHours(24)->format('YmdHis'),\n $to->clone()->addHours(24)->format('YmdHis'),\n );\n\n if ($activity->lead_id) {\n $filterStatements[] = sprintf('candidateUserID: %d', (int) $activity->lead->crm_provider_id);\n }\n\n if ($activity->contact_id) {\n $filterStatements[] = sprintf('clientContactUserID: %d', (int) $activity->contact->crm_provider_id);\n }\n\n // if there is no contact or lead associated to the activity exit\n if ($activity->contact_id === null && $activity->lead_id === null) {\n return [];\n }\n\n $params = [\n 'fields' => $this->getFieldsAsString(self::TASK_FIELDS),\n 'query' => implode(' AND ', $filterStatements),\n 'sort' => '-dateLastModified',\n ];\n\n $bhNotes = $this->client->get('search/Note', $params);\n\n $startStamp = $from?->getTimestamp() * 1000;\n $endStamp = $to->getTimestamp() * 1000;\n\n $filteredNotes = array_values(\n array_filter(\n $bhNotes['data'],\n static fn (array $bhNote): bool\n => $bhNote['dateLastModified'] >= $startStamp && $startStamp <= $endStamp,\n ),\n );\n\n return ! empty($filteredNotes) ? array_shift($filteredNotes) : [];\n }\n\n /**\n * Select how to create the date out of the Bullhorn payload.\n * Do not allow dates beyond 9999-12-31 (max mysql DATE)\n */\n private function filterOutOfBoundDates(string $rawDateCandidate): ?Carbon\n {\n if (strlen($rawDateCandidate) > 12) {\n // A unix timestamp represented with more than 12 digits i\n // s definitely including milliseconds\n $dateFormatted = Carbon::createFromTimestampMs($rawDateCandidate);\n } else {\n $dateFormatted = Carbon::createFromTimestamp($rawDateCandidate);\n }\n\n $minDate = Carbon::createFromFormat('Y-m-d', '2000-01-01');\n if ($dateFormatted->lt($minDate)) {\n return null;\n }\n\n $maxDate = Carbon::createFromFormat('Y-m-d', '9999-12-31');\n if ($dateFormatted->gt($maxDate)) {\n $this->logger->warning('[Bullhorn] An out of bound date is provided', [\n 'date' => $dateFormatted,\n 'rawDate' => $rawDateCandidate,\n ]);\n\n return $maxDate;\n }\n\n return $dateFormatted;\n }\n\n public function verifyTaskExists(Activity $activity): bool\n {\n $crmProviderId = $activity->getCrmProviderId();\n $cacheKey = \"crm_task_exists:{$this->config->getId()}:$crmProviderId\";\n\n return Cache::remember($cacheKey, self::TASK_VERIFICATION_CACHE_TTL, function () use ($crmProviderId) {\n try {\n $note = $this->getRecord('Note', $crmProviderId, ['id', 'isDeleted']);\n\n return ! empty($note) && ($note['isDeleted'] ?? false) === false;\n } catch (HttpNotFoundException|HttpBadRequestException) {\n // Note not found in CRM - this is expected and permanent\n $this->logger->info('[Bullhorn] Note not found during verification', [\n 'note_id' => $crmProviderId,\n 'config_id' => $this->config->getId(),\n ]);\n\n return false;\n }\n // Let other exceptions (network errors, rate limits, etc.) bubble up for retry\n });\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":true,"is_selected":false,"is_expanded":false}]...
|
2798564629323822362
|
-3411827625184589122
|
click
|
accessibility
|
NULL
|
Project: faVsco.js, menu
#12066 on JY-20725-handle Project: faVsco.js, menu
#12066 on JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
HandleHubspotRateLimitTest
Run 'HandleHubspotRateLimitTest'
Debug 'HandleHubspotRateLimitTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
2
13
3
Previous Highlighted Error
Next Highlighted Error
<?php
namespace Jiminny\Services\Crm\Bullhorn;
use Carbon\Carbon;
use Exception;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
use Jiminny\Contracts\Services\Crm\MatchCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\FetchRelatedActivityInterface;
use Jiminny\Contracts\Services\Crm\LayoutManagementInterface;
use Jiminny\Contracts\Services\Crm\Provider\BullhornInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityLookupInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityManipulationInterface;
use Jiminny\Contracts\Services\Crm\SettingsInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmMetadataInterface;
use Jiminny\Contracts\Services\Crm\VerifyTaskExistsInterface;
use Jiminny\Exceptions\ApplicationException;
use Jiminny\Exceptions\CrmException;
use Jiminny\Exceptions\DomainException;
use Jiminny\Exceptions\HttpBadRequestException;
use Jiminny\Exceptions\HttpNotFoundException;
use Jiminny\Exceptions\ServiceUnavailableException;
use Jiminny\Models\Account;
use Jiminny\Models\Activity;
use Jiminny\Models\Contact;
use Jiminny\Models\Crm\Configuration;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\FieldData;
use Jiminny\Models\Crm\Profile;
use Jiminny\Models\Lead;
use Jiminny\Models\Opportunity;
use Jiminny\Models\Playbook;
use Jiminny\Models\SocialAccount;
use Jiminny\Models\Stage;
use Jiminny\Models\User;
use Jiminny\Repositories\Crm\ProfileRepository;
use Jiminny\Repositories\UserRepository;
use Jiminny\Services\Avatar\ProspectPhotoPathService;
use Jiminny\Services\Crm\BaseService;
use Jiminny\Services\Crm\OpportunitySyncStrategyResolver;
use Jiminny\Utils\CurrencyFormatter;
use Jiminny\Utils\StringUtil;
use Ramsey\Uuid\Uuid;
class BullhornService extends BaseService implements
BullhornInterface,
SyncCrmEntitiesInterface,
SyncCrmMetadataInterface,
RemoteEntityManipulationInterface,
FetchRelatedActivityInterface,
LayoutManagementInterface,
SettingsInterface,
MatchCrmEntitiesInterface,
RemoteEntityLookupInterface,
VerifyTaskExistsInterface
{
private const int TASK_VERIFICATION_CACHE_TTL = 86400; // 1 day
private const string ENTITY_LEAD = 'Candidate';
private const string ENTITY_OPPORTUNITY = 'JobOrder';
private const string ENTITY_ACCOUNT = 'ClientCorporation';
private const string ENTITY_CONTACT = 'ClientContact';
private const string ENTITY_ACTIVITY = 'Note';
private const array OPPORTUNITY_FIELDS = [
'id',
'owner',
'status',
'title',
'salary',
'salaryUnit',
'payRate',
'isOpen',
'employmentType',
'dateClosed',
'startDate',
'dateLastModified',
'dateAdded',
'clientCorporation',
'correlatedCustomText10', // currency
];
private const array TASK_FIELDS = [
'id',
'comments',
'dateAdded',
'dateLastModified',
'externalID',
'isDeleted',
'corporateUsers',
'clientContacts',
'commentingPerson',
'candidates',
'jobOrder',
'minutesSpent',
'action',
];
private const array LEAD_FIELDS = [
'id',
'name',
'owner',
'occupation',
'companyName',
'companyURL',
'mobile',
'phone',
'phone2',
'phone3',
'workPhone',
'status',
'address',
'dateAdded',
'dateLastModified',
];
private const array CONTACT_FIELDS = [
'id',
'name',
'occupation',
'mobile',
'phone',
'phone2',
'phone3',
'address',
'clientCorporation',
'dateAdded',
'owner',
];
private const array ACCOUNT_FIELDS = [
'id',
'name',
'phone',
'industryList',
'address',
'companyURL',
'dateAdded',
];
public function __construct(
BullhornClientInterface $client,
private readonly UserRepository $userRepository,
private readonly ProspectPhotoPathService $prospectPhotoPathService
) {
parent::__construct();
$this->client = $client;
}
protected function getOAuthAccount(User $user): ?SocialAccount
{
// In this case, the Account Owner is always the connection for any API operations.
$owner = $user->team->owner;
return $owner->getSocialAccount(SocialAccount::PROVIDER_BULLHORN);
}
public function getDisplayName(): string
{
return 'Bullhorn';
}
public function createTranscriptNotes(Activity $activity): void
{
// Not done until a customer requests it.
}
public function syncProfileFields(): void
{
$fieldTypes = [
'lead',
'account',
'contact',
'opportunity',
'task',
];
foreach ($fieldTypes as $fieldType) {
try {
$currentFields = $this->getFields($this->convertObjectTypeToResource($fieldType));
$newFields = array_column($currentFields, 'name');
$changedFields = array_merge(
array_diff($this->profile->getFieldsAsArray($fieldType), $newFields),
array_diff($newFields, $this->profile->getFieldsAsArray($fieldType))
);
if (! empty($changedFields)) {
$fields = implode(',', $newFields);
Log::debug(
sprintf(
'%s fields changed from %s to: %s',
ucfirst($fieldType),
$this->profile->{$fieldType . '_fields'},
$fields,
),
);
$this->profile->{$fieldType . '_fields'} = $fields;
}
} catch (\Exception $exception) {
\Log::error('No access to ' . $fieldType . ' object, skipping...');
// XXX: should we log this fact somewhere?
continue;
}
}
$this->profile->save();
}
protected function getFieldTypes(): array
{
return [
parent::OBJECT_TASK,
//parent::OBJECT_ACCOUNT,
//parent::OBJECT_CONTACT,
//parent::OBJECT_OPPORTUNITY,
//parent::OBJECT_LEAD,
];
}
protected function getFields(string $crmObject): array
{
$fields = [];
foreach ($this->getObjectFields($crmObject) as $row) {
$fields[] = ['label' => $row['label'] ?? $row['name'], 'name' => $row['name']];
}
return $fields;
}
private function getObjectFields(string $objectType): array
{
$entityType = $this->convertObjectTypeToResource($objectType);
return Cache::remember(
key: 'crm.profile#' . $this->profile->id . '.fieldType:' . $objectType,
ttl: 60 * 60,
callback: function () use ($entityType) {
$entity = $this->client->get('meta/' . $entityType, [
'fields' => '*',
]);
return $entity['fields'];
},
);
}
public function getDefaultFields(string $activityType): array
{
$fields = [];
$defaultFields = FieldDefinitions::defaultFields();
foreach ($defaultFields as $defaultField) {
$fields[] = $this->config->fields()->firstOrCreate($defaultField);
}
return $fields;
}
public function getDefaultActivityField(string $activityType): Field
{
/** @var Field $activityField */
$activityField = $this->config->fields()->where([
'crm_provider_id' => 'action',
'object_type' => $activityType,
])->first();
return $activityField;
}
public function getDealInsightsFields(): array
{
return FieldDefinitions::dealInsightsFields();
}
public function getSupportedPlaybookTypes(): array
{
return [Playbook::ACTIVITY_TYPE_TASK];
}
protected function getDefaultFollowupLayoutFields(string $activityType): array
{
// We are not yet supporting follow-ups for this system.
return [];
}
public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string
{
return $fieldValue;
}
public function syncField(Field $field): void
{
$resource = $this->convertObjectTypeToResource($field->object_type);
$entity = $this->client->get('meta/' . $resource, [
'fields' => $field->crm_provider_id,
'meta' => 'full',
]);
$bhField = $entity['fields'][0];
$isMandatory = (empty($bhField['optional']) == false && $bhField['optional'] === false);
$description = null;
if (empty($bhField['description']) === false) {
$description = mb_strimwidth($bhField['description'], 0, 191);
}
$label = 'Unknown';
if (empty($bhField['label']) === false) {
$label = mb_strimwidth($bhField['label'], 0, Field::LABEL_MAX_LENGTH);
}
$field->length = $bhField['maxLength'] ?? null;
$field->is_mandatory = $isMandatory;
$field->description = $description;
$field->label = $label;
$field->type = $this->convertFieldType(
$bhField['dataType'] ?? null,
$bhField['dataSpecialization'] ?? null,
$bhField['inputType'] ?? null
);
$field->save();
if (empty($bhField['options']) === false) {
$values = [];
// Import all values.
foreach ($bhField['options'] as $i => $option) {
$values[] = [
'value' => $option['value'],
'label' => mb_strimwidth($option['label'], 0, Field::LABEL_MAX_LENGTH),
'sequence' => $i,
];
}
// Take the existing set of field values.
$fieldsToPurge = $field->values()->get()->pluck('value')->toArray();
foreach ($values as $value) {
// For all current values, upsert the field.
$field->values()->updateOrCreate([
'value' => $value['value'],
], $value);
// Remove this value from the ones we are going to purge.
$key = array_search($value['value'], $fieldsToPurge, false);
if ($key !== false) {
unset($fieldsToPurge[$key]);
}
}
// Delete the old values that are no longer used.
$field->values()->whereIn('value', $fieldsToPurge)->delete();
}
}
public function importPicklistValues(Field $field): array
{
// Taken care of in syncField.
return [];
}
private function convertFieldType(?string $type, ?string $specialization, ?string $inputType): string
{
if ($type === null) {
return Field::TYPE_UNSUPPORTED;
}
$type = strtolower($type);
$specialization = strtolower($specialization);
$inputType = strtolower($inputType);
switch ($type) {
case 'boolean':
return Field::TYPE_BOOLEAN;
case 'integer':
switch ($specialization) {
case 'integer':
return Field::TYPE_NUMBER;
default:
return Field::TYPE_NUMBER;
}
// no break
case 'bigdecimal':
switch ($specialization) {
case 'money':
return Field::TYPE_CURRENCY;
default:
return Field::TYPE_NUMBER;
}
// no break
case 'double':
switch ($specialization) {
case 'float':
return Field::TYPE_NUMBER;
default:
return Field::TYPE_NUMBER;
}
// no break
case 'timestamp':
switch ($specialization) {
case 'date':
return Field::TYPE_DATE;
case 'time':
return Field::TYPE_TIME;
case 'datetime':
return Field::TYPE_DATETIME;
default:
return Field::TYPE_DATE;
}
// no break
case 'string':
switch ($inputType) {
case 'textarea':
return Field::TYPE_TEXTAREA;
case 'select':
return Field::TYPE_PICKLIST;
default:
return Field::TYPE_TEXT;
}
// no break
default:
// This is not actually a supported field.
return Field::TYPE_UNSUPPORTED;
}
}
private function importContact($crmData): Contact
{
$account = $mobilePhone = $phone = $email = null;
if (empty($crmData['clientCorporation']) === false) {
$account = $this->team
->accounts()
->where('crm_provider_id', $crmData['clientCorporation']['id'])
->first();
if ($account === null) {
$account = $this->syncAccount($crmData['clientCorporation']['id']);
}
}
$countryCode = $this->convertCountryNameToCode($crmData['address']['countryCode']);
foreach (['mobile', 'phone', 'phone2', 'phone3'] as $type) {
if (empty($crmData[$type])) {
continue;
}
if ($type === 'mobile') {
$mobilePhone = parsePhoneNumber($countryCode, $crmData[$type]);
} else {
$phone = parsePhoneNumber($countryCode, $crmData[$type]);
}
break;
}
foreach (['email', 'email1', 'email2'] as $type) {
if (empty($crmData[$type])) {
continue;
}
$email = mb_strimwidth($crmData[$type], 0, 80);
break;
}
$profile = $this->config->profiles()->where('crm_provider_id', (string) $crmData['owner']['id'])->first();
$data = [
'team_id' => $this->team->id,
'account_id' => $account->id ?? null,
'user_id' => $profile ? $profile->user_id : null,
'owner_id' => $crmData['owner']['id'],
'name' => $crmData['name'],
'email' => $email,
'phone' => empty($phone['phone']) ? null : mb_strimwidth($phone['phone'], 0, 25),
'ext' => $phone['ext'] ?? null,
'mobile_phone' => empty($mobilePhone['phone']) ? null : mb_strimwidth($mobilePhone['phone'], 0, 25),
'title' => $crmData['occupation'],
'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(
crmConfiguration: $this->config,
crmProviderId: $crmData['id'],
modelType: Contact::class,
fileName: $crmData['id'],
avatarText: $crmData['name'] ?? 'Unknown'
),
'remotely_created_at' => $this->filterOutOfBoundDates($crmData['dateAdded']),
];
return $this->config->contacts()->updateOrCreate(['crm_provider_id' => (string) $crmData['id']], $data);
}
private function importOpportunity($crmData): ?Opportunity
{
if ($crmData['clientCorporation']['id']) {
$account = $this->config->accounts()->where('crm_provider_id', (string) $crmData['clientCorporation']['id'])->first();
if ($account === null) {
$account = $this->syncAccount($crmData['clientCorporation']['id']);
}
} else {
// If there is nothing to associate this with, don't import it.
return null;
}
$profile = $this->config->profiles()->where('crm_provider_id', (string) $crmData['owner']['id'])->first();
$stage = $this->config->stages()
->where('crm_provider_id', $crmData['status'])
->where('type', Stage::TYPE_OPPORTUNITY)
->first();
if ($stage === null) {
$stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $crmData['status']);
}
$data = [
'team_id' => $this->team->id,
'account_id' => $account->id,
'user_id' => $profile ? $profile->user_id : null,
'owner_id' => $crmData['owner']['id'],
'name' => mb_strimwidth($crmData['title'], 0, 128),
'value' => $crmData['salary'] ?? $crmData['payRate'],
'currency_code' => CurrencyFormatter::formatCode($crmData['correlatedCustomText10'] ?? 'USD'),
'close_date' => $this->filterOutOfBoundDates($crmData['startDate']),
'is_closed' => $crmData['isOpen'] !== true,
'is_won' => strtolower($crmData['status']) === 'placed',
'stage_id' => $stage->id,
'record_type_id' => null,
'remotely_created_at' => $this->filterOutOfBoundDates($crmData['dateAdded']),
];
return $this->config->opportunities()->updateOrCreate(['crm_provider_id' => (string) $crmData['id']], $data);
}
private function importAccount($crmData): Account
{
$countryCode = $domain = null;
if (empty($crmData['address']['countryCode'])) {
$countryCode = $this->convertCountryNameToCode($crmData['address']['countryCode']);
}
$name = $crmData['name'] ?? 'Unknown';
if ($crmData['companyURL'] !== null) {
$domain = mb_strimwidth($crmData['companyURL'], 0, 191);
$domain = StringUtil::resolveDomain($domain);
}
$data = [
'team_id' => $this->team->id,
'user_id' => null,
'owner_id' => null,
'name' => mb_strimwidth($name, 0, 191),
'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(
crmConfiguration: $this->config,
crmProviderId: $crmData['id'],
modelType: Account::class,
fileName: $crmData['id'],
avatarText: $crmData['name'] ?? 'Unknown'
),
'country_code' => $countryCode,
'domain' => $domain,
'remotely_created_at' => $this->filterOutOfBoundDates($crmData['dateAdded']),
];
return $this->config->accounts()->updateOrCreate(['crm_provider_id' => (string) $crmData['id']], $data);
}
private function importLead($crmData): ?Lead
{
// Get the current stage.
$stage = $this->config->stages()->where('name', $crmData['status'])->where('type', Stage::TYPE_LEAD)->first();
if ($stage === null) {
$stage = $this->importStages([Stage::TYPE_LEAD], $crmData['status']);
}
// If we have no way of importing this, just return null :(
if ($stage === null) {
return null;
}
$countryCode = null;
if (empty($crmData['address']['countryCode'])) {
$countryCode = $this->convertCountryNameToCode($crmData['address']['countryCode']);
}
$mobilePhone = $phone = $email = null;
foreach (['mobile', 'phone', 'phone2', 'phone3', 'workPhone'] as $type) {
if (empty($crmData[$type])) {
continue;
}
if ($type === 'mobile') {
$mobilePhone = parsePhoneNumber($countryCode, $crmData[$type]);
} else {
$phone = parsePhoneNumber($countryCode, $crmData[$type]);
}
break;
}
foreach (['email', 'email1', 'email2'] as $type) {
if (empty($crmData[$type])) {
continue;
}
$email = mb_strimwidth($crmData[$type], 0, 80);
break;
}
$company = 'Unknown';
if (empty($crmData['companyName']) === false) {
$companyName = is_array($crmData['companyName']) ? $crmData['companyName'][0] : $crmData['companyName'];
$company = mb_strimwidth($companyName, 0, 191);
}
$domain = null;
if (empty($crmData['companyURL']) === false) {
$domain = mb_strimwidth($crmData['companyURL'], 0, 191);
$domain = StringUtil::resolveDomain($domain);
}
$profile = $this->config->profiles()->where('crm_provider_id', (string) $crmData['owner']['id'])->first();
$data = [
'team_id' => $this->team->id,
'user_id' => $profile ? $profile->user_id : null,
'owner_id' => $crmData['owner']['id'],
'company' => $company,
'domain' => $domain,
'name' => mb_strimwidth($crmData['name'], 0, 191),
'title' => mb_strimwidth($crmData['occupation'], 0, 128),
'email' => mb_strimwidth($email, 0, 80),
'phone' => empty($phone['phone']) ? null : mb_strimwidth($phone['phone'], 0, 25),
'ext' => $phone['ext'] ?? null,
'mobile_phone' => empty($mobilePhone['phone']) ? null : mb_strimwidth($mobilePhone['phone'], 0, 25),
'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(
crmConfiguration: $this->config,
crmProviderId: $crmData['id'],
modelType: Lead::class,
fileName: $crmData['id'],
avatarText: $crmData['name'] ?? 'Unknown'
),
'stage_id' => $stage->id,
'country_code' => $countryCode,
'remotely_created_at' => $this->filterOutOfBoundDates($crmData['dateAdded']),
];
return $this->config->leads()->updateOrCreate(['crm_provider_id' => (string) $crmData['id']], $data);
}
/**
* @throws ApplicationException
*/
public function syncContacts(Carbon $since, ?Carbon $to = null): int
{
$syncCount = 0;
$params = [
'sort' => '-dateLastModified',
'fields' => $this->getFieldsAsString(self::CONTACT_FIELDS),
'query' => 'isDeleted: false AND dateLastModified:[' . $since->format('YmdHis') . ' TO *]',
];
try {
$bhContacts = $this->client->get('search/ClientContact', $params);
foreach ($bhContacts['data'] as $bhContact) {
// Only sync if previously imported.
if ($this->hasContact($bhContact['id'])) {
$this->importContact($bhContact);
$syncCount++;
}
}
} catch (\Exception $exception) {
throw new ApplicationException('Failed to sync contacts', $exception->getCode(), $exception);
}
return $syncCount;
}
public function syncOpportunities(array $parameters, ?string $strategy = null): int
{
$strategyResolver = app(OpportunitySyncStrategyResolver::class, [
'client' => $this->client,
]);
$strategies = $strategyResolver->getStrategies($this->config, $strategy);
$syncCount = 0;
$total = 0;
$count = 0;
$offset = 0;
try {
$bhOpportunities = [];
foreach ($strategies as $syncStrategy) {
$opportunitiesData = $syncStrategy->fetchOpportunities($parameters);
$total = (int) ($opportunitiesData['total'] ?? 0);
$count = (int) ($opportunitiesData['count'] ?? 0);
$offset = (int) ($opportunitiesData['start'] ?? 0);
$bhOpportunities[] = $opportunitiesData['data'] ?? [];
}
$bhOpportunities = array_merge(...$bhOpportunities);
foreach ($bhOpportunities as $bhOpportunity) {
$this->importOpportunity($bhOpportunity);
$syncCount++;
}
} catch (\Exception $exception) {
$this->logger->info('Failed to sync opportunities', [
'params' => $parameters,
'exception' => $exception->getMessage(),
]);
}
// only log if there are one sync strategy
if (count($strategies) === 1 && $total > $count + $offset) {
$this->logger->info('[Bullhorn] Sync Opportunities - count warning', [
'team_id' => $this->team->getId(),
'total' => $total,
'count' => $count,
'offset' => $offset,
]);
}
return $syncCount;
}
/**
* @throws ApplicationException
*/
public function syncAccounts(Carbon $since, ?Carbon $to = null): int
{
$syncCount = 0;
$params = [
'sort' => '-dateLastModified',
'fields' => $this->getFieldsAsString(self::ACCOUNT_FIELDS),
'query' => 'isDeleted: false AND dateLastModified:[' . $since->format('YmdHis') . ' TO *]',
];
try {
$bhAccounts = $this->client->get('search/ClientCorporation', $params);
foreach ($bhAccounts['data'] as $bhAccount) {
// Only sync if previously imported.
if ($this->hasAccount($bhAccount['id'])) {
$this->importAccount($bhAccount);
$syncCount++;
}
}
} catch (\Exception $exception) {
throw new ApplicationException('Failed to sync accounts', $exception->getCode(), $exception);
}
return $syncCount;
}
/**
* @throws ApplicationException
*/
public function syncLeads(Carbon $since, ?Carbon $to = null, ?string $crmProfileId = null): int
{
$syncCount = 0;
$params = [
'sort' => '-dateLastModified',
'fields' => $this->getFieldsAsString(self::LEAD_FIELDS),
'query' => 'isDeleted: false AND dateLastModified:[' . $since->format('YmdHis') . ' TO *]',
];
try {
$bhLeads = $this->client->get('search/Candidate', $params);
if ($bhLeads === null) {
return $syncCount;
}
foreach ($bhLeads['data'] as $bhLead) {
// Only sync if previously imported.
if ($this->hasLead($bhLead['id'])) {
$this->importLead($bhLead);
$syncCount++;
}
}
} catch (\Exception $exception) {
throw new ApplicationException('Failed to sync leads', $exception->getCode(), $exception);
}
return $syncCount;
}
public function syncContact(string $crmId): ?Contact
{
try {
$data = $this->getRecord(FieldData::OBJECT_CONTACT, $crmId, self::CONTACT_FIELDS);
} catch (HttpNotFoundException $exception) {
return null;
}
return $this->importContact($data['data']);
}
public function syncOpportunity(string $crmId): ?Opportunity
{
$strategyResolver = app(OpportunitySyncStrategyResolver::class, [
'client' => $this->client,
]);
$strategy = $strategyResolver->resolve(
$this->config,
OpportunitySyncStrategyResolver::SINGLE_SYNC_OPPORTUNITY_STRATEGY
);
try {
$data = $strategy->fetchOpportunities(['crm_id' => $crmId]);
} catch (HttpNotFoundException $exception) {
$this->logger->info('[Bullhorn] Opportunity not found', [
'crm_id' => $crmId,
'reason' => $exception->getMessage(),
]);
}
return $this->importOpportunity($data['data'] ?? []);
}
public function syncAccount(string $crmId): ?Account
{
try {
$data = $this->getRecord(FieldData::OBJECT_ACCOUNT, $crmId, self::ACCOUNT_FIELDS);
} catch (HttpNotFoundException $exception) {
return null;
}
return $this->importAccount($data['data']);
}
public function syncLead(string $crmId): ?Lead
{
try {
$data = $this->getRecord(FieldData::OBJECT_LEAD, $crmId, self::LEAD_FIELDS);
} catch (HttpNotFoundException $exception) {
return null;
}
return $this->importLead($data['data']);
}
public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage
{
$missingStage = null;
if ($types === null) {
$types = [Stage::TYPE_LEAD, Stage::TYPE_OPPORTUNITY];
}
foreach ($types as $type) {
$resource = $this->convertObjectTypeToResource($type);
$entity = $this->client->get('meta/' . $resource, [
'fields' => 'status',
'meta' => 'full',
]);
$missingStage = null;
if (! is_array($entity['fields']) || count($entity['fields']) !== 1 || $entity['fields'][0]['name'] !== 'status') {
throw new \UnexpectedValueException('Invalid response from Bullhorn API');
}
// Upsert all stages for the team.
foreach ($entity['fields'][0]['options'] as $i => $option) {
$stage = $this->config->stages()->updateOrCreate([
'crm_provider_id' => $option['value'],
'type' => $type,
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($option['value'], 0, 50),
'label' => mb_strimwidth($option['label'], 0, 191),
'sequence' => $i,
'is_selectable' => 1,
]);
if ($missingStageName && $missingStageName === $option['value']) {
$missingStage = $stage;
}
}
if ($missingStageName && $missingStage === null) {
// If they requested a stage that still doesn't exist, it must be inactive so lazy create it.
$missingStage = $this->config->stages()->create([
'team_id' => $this->team->id,
'crm_provider_id' => Uuid::uuid4(),
'name' => mb_strimwidth($missingStageName, 0, 50),
'label' => mb_strimwidth($missingStageName, 0, 191),
'type' => $type,
'sequence' => 0,
'is_selectable' => 0,
]);
}
}
return $missingStage;
}
public function syncOrganization(): void
{
// There appears to be nothing we can sync/map to.
}
public function find(string $name, array $scopes): array
{
$count = $this->limit ?? 20;
$offset = $this->offset ?? 0;
$data = \Cache::remember($this->team->id . $name . $count . $offset, 300, function () use ($name, $count) {
$result = [];
try {
$objects = $this->client->get('find', [
'query' => $name,
'meta' => 'basic',
'countPerEntity' => $count,
]);
} catch (\GuzzleHttp\Exception\ServerException $exception) {
throw new ServiceUnavailableException($exception->getMessage());
}
foreach ($objects['data'] as $object) {
try {
$prospectType = $this->convertResourceToObjectType($object['entityType']);
} catch (\UnexpectedValueException $exception) {
// We don't support this, so skip it.
continue;
}
$record = [
'crmId' => $object['entityId'],
'crmUrl' => $this->generateProviderUrl($object['entityId'], $object['entityType']),
'prospectType' => $prospectType,
'phoneNumbers' => [],
];
// Unfortunately we need to go and query each entity itself to get the data we need.
switch ($object['entityType']) {
case self::ENTITY_ACCOUNT:
$data = $this->getRecord(FieldData::OBJECT_ACCOUNT, $object['entityId'], self::ACCOUNT_FIELDS);
$entity = $data['data'];
$record['name'] = $entity['name'];
$record['industry'] = $entity['industryList'];
if (empty($entity['phone']) === false) {
$record['phoneNumbers'][] = [
'number' => $entity['phone'],
'nationalFormat' => phone_national(null, $entity['phone']),
'type' => 'phone',
];
}
break;
case self::ENTITY_CONTACT:
$data = $this->getRecord(FieldData::OBJECT_CONTACT, $object['entityId'], self::CONTACT_FIELDS);
$entity = $data['data'];
$record['name'] = $entity['name'];
$record['organization'] = $entity['clientCorporation'] ? $entity['clientCorporation']['name'] : null;
$record['title'] = $entity['occupation'];
foreach (['mobile', 'phone', 'phone2', 'phone3'] as $phone) {
if (empty($entity[$phone]) === false) {
$record['phoneNumbers'][] = [
'number' => $entity[$phone],
'nationalFormat' => phone_national(null, $entity[$phone]),
'type' => $phone === 'mobile' ? 'mobile' : 'phone',
];
}
}
break;
case self::ENTITY_LEAD:
$bhData = $this->getRecord(FieldData::OBJECT_LEAD, $object['entityId'], self::LEAD_FIELDS);
$entity = $bhData['data'];
$record['name'] = $entity['name'];
$record['organization'] = $entity['companyName'];
$record['title'] = $entity['occupation'];
foreach (['mobile', 'phone', 'phone2', 'phone3', 'workPhone'] as $phone) {
if (empty($entity[$phone]) === false) {
$record['phoneNumbers'][] = [
'number' => $entity[$phone],
'nationalFormat' => phone_national(null, $entity[$phone]),
'type' => $phone === 'mobile' ? 'mobile' : 'phone',
];
}
}
break;
default:
// We are not interested in this entity type so skip it.
continue 2;
}
$result[] = $record;
}
return $result;
});
return $data;
}
public function getClient(): BullhornClientInterface
{
return $this->client;
}
private function getFieldsAsString(array $fields)
{
return implode(', ', $fields);
}
public function findOpportunities(?string $crmAccountId, ?string $crmContactId, ?int $userId = null): array
{
$data = [];
$ownerData = [];
$ownerId = null;
$contact = null;
$account = null;
if ($crmAccountId) {
$account = $this->config->accounts()->where('crm_provider_id', $crmAccountId)->first();
if ($account === null) {
$account = $this->syncAccount($crmAccountId);
}
}
if ($crmContactId) {
$contact = $this->config->contacts()->where('crm_provider_id', $crmContactId)->first();
if ($contact === null) {
$contact = $this->syncContact($crmContactId);
}
}
if ($contact || $account) {
if ($contact && $account === null) {
$account = $contact->account;
}
if ($account === null) {
return [];
}
$where = 'isDeleted: false AND clientCorporation.id:' . (int) $account->crm_provider_id;
$params = [
'sort' => '-dateLastModified',
'fields' => $this->getFieldsAsString(self::OPPORTUNITY_FIELDS),
];
$onlyOpen = true;
switch ($this->config->opportunity_assignment_rule) {
case Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_UPDATED:
$params['sort'] = '-dateLastModified';
break;
case Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_CREATED:
$params['sort'] = '-dateAdded';
break;
case Configuration::OPP_ASSIGNMENT_ALL_OPEN_OLDEST_CREATED:
$params['sort'] = '+dateAdded';
break;
case Configuration::OPP_ASSIGNMENT_ALL_RECENTLY_UPDATED:
$params['sort'] = '-dateLastModified';
$onlyOpen = false;
}
if ($onlyOpen) {
$where .= ' AND isOpen: true';
}
$params['query'] = $where;
$bhOpportunities = $this->client->get('search/JobOrder', $params);
if ($userId) {
$profileRepository = app(ProfileRepository::class);
$profile = $profileRepository->findProfileByUserId($this->config, $userId);
$ownerId = $profile instanceof Profile ? $profile->getCrmProviderId() : null;
}
foreach ($bhOpportunities['data'] as $bhOpportunity) {
$stage = $this->config
->stages()
->where('crm_provider_id', $bhOpportunity['status'])
->first();
if ($stage === null) {
// Import it.
$stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $bhOpportunity['status']);
}
$record = [
'crmId' => $bhOpportunity['id'],
'name' => $bhOpportunity['title'],
'value' => currency_format($bhOpportunity['salary'], $bhOpportunity['correlatedCustomText10'] ?? 'USD'),
'won' => strtolower($bhOpportunity['status']) === 'placed',
'closed' => $bhOpportunity['isOpen'] !== true,
'stage' => [
'id' => $stage->id_string,
'name' => $stage->name,
],
'recordType' => [],
];
if ($ownerId && isset($bhOpportunity['owner']['id']) && $bhOpportunity['owner']['id'] === (int) $ownerId) {
$ownerData[] = $record;
}
$data[] = $record;
}
}
if (! empty($ownerData)) {
return $ownerData;
}
return $data;
}
public function getTasks(string $objectType, string $objectId, ?string $opportunityId): array
{
$data = [];
// For now it has not been agreed on how to implement this e.g. appointments or tasks?
return [];
if ($objectType === 'account' && $opportunityId === null) {
// You cannot attach tasks to only accounts.
return [];
}
// Query should default to any open task.
$where = 'isDeleted: false AND isCompleted: false AND (' . $this->convertObjectTypeToResource($objectType) . 'UserId: ' . (int) $objectId;
// If we are also going to check on a specific job order, set that up.
if ($opportunityId) {
$where .= ' OR JobOrder: ' . (int) $opportunityId;
}
$where .= ')';
$params = [
'sort' => '-dateLastModified',
'fields' => $this->getFieldsAsString(self::TASK_FIELDS),
'query' => $where,
];
$bhTasks = $this->client->get('search/Task', $params);
foreach ($bhTasks['data'] as $bhTask) {
$data[] = [
'crmId' => $bhTask['id'],
'subject' => $bhTask['subject'],
'due' => Carbon::createFromTimestampMs($bhTask['dateEnd'])->toIso8601String(),
'type' => null,
];
}
return $data;
}
/**
* @url [URL_WITH_CREDENTIALS] [URL_WITH_CREDENTIALS] [URL_WITH_CREDENTIALS] CrmException
* @throws DomainException
*/
private function buildNotePayload(Activity $activity): Activity
{
if ($activity->hasActivityType() === false) {
throw new DomainException('[Bullhorn] No activity type assigned');
}
$action = $this->getCrmType($activity->getActivityType());
if ($action === null) {
throw new DomainException('[Bullhorn] No mapped CRM type for "' . $activity->category->name . '"');
}
if ($activity->lead_id === null && $activity->contact_id === null) {
throw new DomainException('[Bullhorn] Cannot save note without candidate or DM');
}
$payload = [
'comments' => $this->generateActivityDescription($activity),
'action' => $action->value,
'commentingPerson' => [
'id' => (int) $this->profile->crm_provider_id,
'type' => 'CorporateUser',
],
'externalID' => $activity->id_string,
'minutesSpent' => $activity->duration,
'dateAdded' => $activity->hasActualEndTime() ? $activity->getActualEndTime()?->valueOf() : now()->valueOf(),
];
if ($activity->lead_id) {
$payload['personReference'] = [
'id' => (int) $activity->lead->crm_provider_id,
'type' => 'Candidate',
];
}
if ($activity->contact_id) {
$payload['personReference'] = [
'id' => (int) $activity->contact->crm_provider_id,
'type' => 'ClientContact',
];
}
if ($activity->opportunity_id) {
$payload['jobOrders'] = [
'replaceAll' => [
(int) $activity->opportunity->crm_provider_id,
],
];
}
$bhActivity = $this->client->put('entity/Note', $payload);
$activity->crm_provider_id = $bhActivity['changedEntityId'];
$activity->save();
return $activity;
}
private function generateActivityDescription(Activity $activity): string
{
$description = '';
switch ($activity->type) {
case Activity::TYPE_SOFTPHONE:
case Activity::TYPE_SOFTPHONE_INBOUND:
case Activity::TYPE_CONFERENCE:
if ($activity->isTypeSoftPhone() && $activity->getProvider() !== Activity::PROVIDER_UPLOADER) {
// Dialer calls have no native field for the subject.
if ($activity->title) {
$description .= sprintf(
'<p><span style="font-size: 16px; line-height: 19.2px;">%s</span></p>',
$activity->title,
);
}
// Conference meetings log duration into a distinct field, so log this in the main text.
$description .= sprintf(
'<p><span style="font-size: 13.3333px; line-height: 16px;">Call Duration: %s</span></p>',
$activity->duration_for_humans,
);
// Tell them which number was dialed.
if ($activity->to) {
$description .= sprintf(
'<p><span style="font-size: 13.3333px; line-height: 16px;">Dialed: %s</span></p>',
$activity->to->phone_number,
);
}
$description .= '<p></p>';
}
if ($activity->isTypeSoftphoneInbound() && $activity->getProvider() !== Activity::PROVIDER_UPLOADER) {
...
|
45376
|
NULL
|
NULL
|
NULL
|
|
45385
|
1625
|
33
|
2026-05-14T14:33:52.539527+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-14/1778 /Users/lukas/.screenpipe/data/data/2026-05-14/1778769232539_m1.jpg...
|
PhpStorm
|
faVsco.js – BullhornService.php
|
1
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
#12066 on JY-20725-handle Project: faVsco.js, menu
#12066 on JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
HandleHubspotRateLimitTest
Run 'HandleHubspotRateLimitTest'
Debug 'HandleHubspotRateLimitTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
2
13
3
Previous Highlighted Error
Next Highlighted Error
<?php
namespace Jiminny\Services\Crm\Bullhorn;
use Carbon\Carbon;
use Exception;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
use Jiminny\Contracts\Services\Crm\MatchCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\FetchRelatedActivityInterface;
use Jiminny\Contracts\Services\Crm\LayoutManagementInterface;
use Jiminny\Contracts\Services\Crm\Provider\BullhornInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityLookupInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityManipulationInterface;
use Jiminny\Contracts\Services\Crm\SettingsInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmMetadataInterface;
use Jiminny\Contracts\Services\Crm\VerifyTaskExistsInterface;
use Jiminny\Exceptions\ApplicationException;
use Jiminny\Exceptions\CrmException;
use Jiminny\Exceptions\DomainException;
use Jiminny\Exceptions\HttpBadRequestException;
use Jiminny\Exceptions\HttpNotFoundException;
use Jiminny\Exceptions\ServiceUnavailableException;
use Jiminny\Models\Account;
use Jiminny\Models\Activity;
use Jiminny\Models\Contact;
use Jiminny\Models\Crm\Configuration;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\FieldData;
use Jiminny\Models\Crm\Profile;
use Jiminny\Models\Lead;
use Jiminny\Models\Opportunity;
use Jiminny\Models\Playbook;
use Jiminny\Models\SocialAccount;
use Jiminny\Models\Stage;
use Jiminny\Models\User;
use Jiminny\Repositories\Crm\ProfileRepository;
use Jiminny\Repositories\UserRepository;
use Jiminny\Services\Avatar\ProspectPhotoPathService;
use Jiminny\Services\Crm\BaseService;
use Jiminny\Services\Crm\OpportunitySyncStrategyResolver;
use Jiminny\Utils\CurrencyFormatter;
use Jiminny\Utils\StringUtil;
use Ramsey\Uuid\Uuid;
class BullhornService extends BaseService implements
BullhornInterface,
SyncCrmEntitiesInterface,
SyncCrmMetadataInterface,
RemoteEntityManipulationInterface,
FetchRelatedActivityInterface,
LayoutManagementInterface,
SettingsInterface,
MatchCrmEntitiesInterface,
RemoteEntityLookupInterface,
VerifyTaskExistsInterface
{
private const int TASK_VERIFICATION_CACHE_TTL = 86400; // 1 day
private const string ENTITY_LEAD = 'Candidate';
private const string ENTITY_OPPORTUNITY = 'JobOrder';
private const string ENTITY_ACCOUNT = 'ClientCorporation';
private const string ENTITY_CONTACT = 'ClientContact';
private const string ENTITY_ACTIVITY = 'Note';
private const array OPPORTUNITY_FIELDS = [
'id',
'owner',
'status',
'title',
'salary',
'salaryUnit',
'payRate',
'isOpen',
'employmentType',
'dateClosed',
'startDate',
'dateLastModified',
'dateAdded',
'clientCorporation',
'correlatedCustomText10', // currency
];
private const array TASK_FIELDS = [
'id',
'comments',
'dateAdded',
'dateLastModified',
'externalID',
'isDeleted',
'corporateUsers',
'clientContacts',
'commentingPerson',
'candidates',
'jobOrder',
'minutesSpent',
'action',
];
private const array LEAD_FIELDS = [
'id',
'name',
'owner',
'occupation',
'companyName',
'companyURL',
'mobile',
'phone',
'phone2',
'phone3',
'workPhone',
'status',
'address',
'dateAdded',
'dateLastModified',
];
private const array CONTACT_FIELDS = [
'id',
'name',
'occupation',
'mobile',
'phone',
'phone2',
'phone3',
'address',
'clientCorporation',
'dateAdded',
'owner',
];
private const array ACCOUNT_FIELDS = [
'id',
'name',
'phone',
'industryList',
'address',
'companyURL',
'dateAdded',
];
public function __construct(
BullhornClientInterface $client,
private readonly UserRepository $userRepository,
private readonly ProspectPhotoPathService $prospectPhotoPathService
) {
parent::__construct();
$this->client = $client;
}
protected function getOAuthAccount(User $user): ?SocialAccount
{
// In this case, the Account Owner is always the connection for any API operations.
$owner = $user->team->owner;
return $owner->getSocialAccount(SocialAccount::PROVIDER_BULLHORN);
}
public function getDisplayName(): string
{
return 'Bullhorn';
}
public function createTranscriptNotes(Activity $activity): void
{
// Not done until a customer requests it.
}
public function syncProfileFields(): void
{
$fieldTypes = [
'lead',
'account',
'contact',
'opportunity',
'task',
];
foreach ($fieldTypes as $fieldType) {
try {
$currentFields = $this->getFields($this->convertObjectTypeToResource($fieldType));
$newFields = array_column($currentFields, 'name');
$changedFields = array_merge(
array_diff($this->profile->getFieldsAsArray($fieldType), $newFields),
array_diff($newFields, $this->profile->getFieldsAsArray($fieldType))
);
if (! empty($changedFields)) {
$fields = implode(',', $newFields);
Log::debug(
sprintf(
'%s fields changed from %s to: %s',
ucfirst($fieldType),
$this->profile->{$fieldType . '_fields'},
$fields,
),
);
$this->profile->{$fieldType . '_fields'} = $fields;
}
} catch (\Exception $exception) {
\Log::error('No access to ' . $fieldType . ' object, skipping...');
// XXX: should we log this fact somewhere?
continue;
}
}
$this->profile->save();
}
protected function getFieldTypes(): array
{
return [
parent::OBJECT_TASK,
//parent::OBJECT_ACCOUNT,
//parent::OBJECT_CONTACT,
//parent::OBJECT_OPPORTUNITY,
//parent::OBJECT_LEAD,
];
}
protected function getFields(string $crmObject): array
{
$fields = [];
foreach ($this->getObjectFields($crmObject) as $row) {
$fields[] = ['label' => $row['label'] ?? $row['name'], 'name' => $row['name']];
}
return $fields;
}
private function getObjectFields(string $objectType): array
{
$entityType = $this->convertObjectTypeToResource($objectType);
return Cache::remember(
key: 'crm.profile#' . $this->profile->id . '.fieldType:' . $objectType,
ttl: 60 * 60,
callback: function () use ($entityType) {
$entity = $this->client->get('meta/' . $entityType, [
'fields' => '*',
]);
return $entity['fields'];
},
);
}
public function getDefaultFields(string $activityType): array
{
$fields = [];
$defaultFields = FieldDefinitions::defaultFields();
foreach ($defaultFields as $defaultField) {
$fields[] = $this->config->fields()->firstOrCreate($defaultField);
}
return $fields;
}
public function getDefaultActivityField(string $activityType): Field
{
/** @var Field $activityField */
$activityField = $this->config->fields()->where([
'crm_provider_id' => 'action',
'object_type' => $activityType,
])->first();
return $activityField;
}
public function getDealInsightsFields(): array
{
return FieldDefinitions::dealInsightsFields();
}
public function getSupportedPlaybookTypes(): array
{
return [Playbook::ACTIVITY_TYPE_TASK];
}
protected function getDefaultFollowupLayoutFields(string $activityType): array
{
// We are not yet supporting follow-ups for this system.
return [];
}
public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string
{
return $fieldValue;
}
public function syncField(Field $field): void
{
$resource = $this->convertObjectTypeToResource($field->object_type);
$entity = $this->client->get('meta/' . $resource, [
'fields' => $field->crm_provider_id,
'meta' => 'full',
]);
$bhField = $entity['fields'][0];
$isMandatory = (empty($bhField['optional']) == false && $bhField['optional'] === false);
$description = null;
if (empty($bhField['description']) === false) {
$description = mb_strimwidth($bhField['description'], 0, 191);
}
$label = 'Unknown';
if (empty($bhField['label']) === false) {
$label = mb_strimwidth($bhField['label'], 0, Field::LABEL_MAX_LENGTH);
}
$field->length = $bhField['maxLength'] ?? null;
$field->is_mandatory = $isMandatory;
$field->description = $description;
$field->label = $label;
$field->type = $this->convertFieldType(
$bhField['dataType'] ?? null,
$bhField['dataSpecialization'] ?? null,
$bhField['inputType'] ?? null
);
$field->save();
if (empty($bhField['options']) === false) {
$values = [];
// Import all values.
foreach ($bhField['options'] as $i => $option) {
$values[] = [
'value' => $option['value'],
'label' => mb_strimwidth($option['label'], 0, Field::LABEL_MAX_LENGTH),
'sequence' => $i,
];
}
// Take the existing set of field values.
$fieldsToPurge = $field->values()->get()->pluck('value')->toArray();
foreach ($values as $value) {
// For all current values, upsert the field.
$field->values()->updateOrCreate([
'value' => $value['value'],
], $value);
// Remove this value from the ones we are going to purge.
$key = array_search($value['value'], $fieldsToPurge, false);
if ($key !== false) {
unset($fieldsToPurge[$key]);
}
}
// Delete the old values that are no longer used.
$field->values()->whereIn('value', $fieldsToPurge)->delete();
}
}
public function importPicklistValues(Field $field): array
{
// Taken care of in syncField.
return [];
}
private function convertFieldType(?string $type, ?string $specialization, ?string $inputType): string
{
if ($type === null) {
return Field::TYPE_UNSUPPORTED;
}
$type = strtolower($type);
$specialization = strtolower($specialization);
$inputType = strtolower($inputType);
switch ($type) {
case 'boolean':
return Field::TYPE_BOOLEAN;
case 'integer':
switch ($specialization) {
case 'integer':
return Field::TYPE_NUMBER;
default:
return Field::TYPE_NUMBER;
}
// no break
case 'bigdecimal':
switch ($specialization) {
case 'money':
return Field::TYPE_CURRENCY;
default:
return Field::TYPE_NUMBER;
}
// no break
case 'double':
switch ($specialization) {
case 'float':
return Field::TYPE_NUMBER;
default:
return Field::TYPE_NUMBER;
}
// no break
case 'timestamp':
switch ($specialization) {
case 'date':
return Field::TYPE_DATE;
case 'time':
return Field::TYPE_TIME;
case 'datetime':
return Field::TYPE_DATETIME;
default:
return Field::TYPE_DATE;
}
// no break
case 'string':
switch ($inputType) {
case 'textarea':
return Field::TYPE_TEXTAREA;
case 'select':
return Field::TYPE_PICKLIST;
default:
return Field::TYPE_TEXT;
}
// no break
default:
// This is not actually a supported field.
return Field::TYPE_UNSUPPORTED;
}
}
private function importContact($crmData): Contact
{
$account = $mobilePhone = $phone = $email = null;
if (empty($crmData['clientCorporation']) === false) {
$account = $this->team
->accounts()
->where('crm_provider_id', $crmData['clientCorporation']['id'])
->first();
if ($account === null) {
$account = $this->syncAccount($crmData['clientCorporation']['id']);
}
}
$countryCode = $this->convertCountryNameToCode($crmData['address']['countryCode']);
foreach (['mobile', 'phone', 'phone2', 'phone3'] as $type) {
if (empty($crmData[$type])) {
continue;
}
if ($type === 'mobile') {
$mobilePhone = parsePhoneNumber($countryCode, $crmData[$type]);
} else {
$phone = parsePhoneNumber($countryCode, $crmData[$type]);
}
break;
}
foreach (['email', 'email1', 'email2'] as $type) {
if (empty($crmData[$type])) {
continue;
}
$email = mb_strimwidth($crmData[$type], 0, 80);
break;
}
$profile = $this->config->profiles()->where('crm_provider_id', (string) $crmData['owner']['id'])->first();
$data = [
'team_id' => $this->team->id,
'account_id' => $account->id ?? null,
'user_id' => $profile ? $profile->user_id : null,
'owner_id' => $crmData['owner']['id'],
'name' => $crmData['name'],
'email' => $email,
'phone' => empty($phone['phone']) ? null : mb_strimwidth($phone['phone'], 0, 25),
'ext' => $phone['ext'] ?? null,
'mobile_phone' => empty($mobilePhone['phone']) ? null : mb_strimwidth($mobilePhone['phone'], 0, 25),
'title' => $crmData['occupation'],
'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(
crmConfiguration: $this->config,
crmProviderId: $crmData['id'],
modelType: Contact::class,
fileName: $crmData['id'],
avatarText: $crmData['name'] ?? 'Unknown'
),
'remotely_created_at' => $this->filterOutOfBoundDates($crmData['dateAdded']),
];
return $this->config->contacts()->updateOrCreate(['crm_provider_id' => (string) $crmData['id']], $data);
}
private function importOpportunity($crmData): ?Opportunity
{
if ($crmData['clientCorporation']['id']) {
$account = $this->config->accounts()->where('crm_provider_id', (string) $crmData['clientCorporation']['id'])->first();
if ($account === null) {
$account = $this->syncAccount($crmData['clientCorporation']['id']);
}
} else {
// If there is nothing to associate this with, don't import it.
return null;
}
$profile = $this->config->profiles()->where('crm_provider_id', (string) $crmData['owner']['id'])->first();
$stage = $this->config->stages()
->where('crm_provider_id', $crmData['status'])
->where('type', Stage::TYPE_OPPORTUNITY)
->first();
if ($stage === null) {
$stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $crmData['status']);
}
$data = [
'team_id' => $this->team->id,
'account_id' => $account->id,
'user_id' => $profile ? $profile->user_id : null,
'owner_id' => $crmData['owner']['id'],
'name' => mb_strimwidth($crmData['title'], 0, 128),
'value' => $crmData['salary'] ?? $crmData['payRate'],
'currency_code' => CurrencyFormatter::formatCode($crmData['correlatedCustomText10'] ?? 'USD'),
'close_date' => $this->filterOutOfBoundDates($crmData['startDate']),
'is_closed' => $crmData['isOpen'] !== true,
'is_won' => strtolower($crmData['status']) === 'placed',
'stage_id' => $stage->id,
'record_type_id' => null,
'remotely_created_at' => $this->filterOutOfBoundDates($crmData['dateAdded']),
];
return $this->config->opportunities()->updateOrCreate(['crm_provider_id' => (string) $crmData['id']], $data);
}
private function importAccount($crmData): Account
{
$countryCode = $domain = null;
if (empty($crmData['address']['countryCode'])) {
$countryCode = $this->convertCountryNameToCode($crmData['address']['countryCode']);
}
$name = $crmData['name'] ?? 'Unknown';
if ($crmData['companyURL'] !== null) {
$domain = mb_strimwidth($crmData['companyURL'], 0, 191);
$domain = StringUtil::resolveDomain($domain);
}
$data = [
'team_id' => $this->team->id,
'user_id' => null,
'owner_id' => null,
'name' => mb_strimwidth($name, 0, 191),
'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(
crmConfiguration: $this->config,
crmProviderId: $crmData['id'],
modelType: Account::class,
fileName: $crmData['id'],
avatarText: $crmData['name'] ?? 'Unknown'
),
'country_code' => $countryCode,
'domain' => $domain,
'remotely_created_at' => $this->filterOutOfBoundDates($crmData['dateAdded']),
];
return $this->config->accounts()->updateOrCreate(['crm_provider_id' => (string) $crmData['id']], $data);
}
private function importLead($crmData): ?Lead
{
// Get the current stage.
$stage = $this->config->stages()->where('name', $crmData['status'])->where('type', Stage::TYPE_LEAD)->first();
if ($stage === null) {
$stage = $this->importStages([Stage::TYPE_LEAD], $crmData['status']);
}
// If we have no way of importing this, just return null :(
if ($stage === null) {
return null;
}
$countryCode = null;
if (empty($crmData['address']['countryCode'])) {
$countryCode = $this->convertCountryNameToCode($crmData['address']['countryCode']);
}
$mobilePhone = $phone = $email = null;
foreach (['mobile', 'phone', 'phone2', 'phone3', 'workPhone'] as $type) {
if (empty($crmData[$type])) {
continue;
}
if ($type === 'mobile') {
$mobilePhone = parsePhoneNumber($countryCode, $crmData[$type]);
} else {
$phone = parsePhoneNumber($countryCode, $crmData[$type]);
}
break;
}
foreach (['email', 'email1', 'email2'] as $type) {
if (empty($crmData[$type])) {
continue;
}
$email = mb_strimwidth($crmData[$type], 0, 80);
break;
}
$company = 'Unknown';
if (empty($crmData['companyName']) === false) {
$companyName = is_array($crmData['companyName']) ? $crmData['companyName'][0] : $crmData['companyName'];
$company = mb_strimwidth($companyName, 0, 191);
}
$domain = null;
if (empty($crmData['companyURL']) === false) {
$domain = mb_strimwidth($crmData['companyURL'], 0, 191);
$domain = StringUtil::resolveDomain($domain);
}
$profile = $this->config->profiles()->where('crm_provider_id', (string) $crmData['owner']['id'])->first();
$data = [
'team_id' => $this->team->id,
'user_id' => $profile ? $profile->user_id : null,
'owner_id' => $crmData['owner']['id'],
'company' => $company,
'domain' => $domain,
'name' => mb_strimwidth($crmData['name'], 0, 191),
'title' => mb_strimwidth($crmData['occupation'], 0, 128),
'email' => mb_strimwidth($email, 0, 80),
'phone' => empty($phone['phone']) ? null : mb_strimwidth($phone['phone'], 0, 25),
'ext' => $phone['ext'] ?? null,
'mobile_phone' => empty($mobilePhone['phone']) ? null : mb_strimwidth($mobilePhone['phone'], 0, 25),
'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(
crmConfiguration: $this->config,
crmProviderId: $crmData['id'],
modelType: Lead::class,
fileName: $crmData['id'],
avatarText: $crmData['name'] ?? 'Unknown'
),
'stage_id' => $stage->id,
'country_code' => $countryCode,
'remotely_created_at' => $this->filterOutOfBoundDates($crmData['dateAdded']),
];
return $this->config->leads()->updateOrCreate(['crm_provider_id' => (string) $crmData['id']], $data);
}
/**
* @throws ApplicationException
*/
public function syncContacts(Carbon $since, ?Carbon $to = null): int
{
$syncCount = 0;
$params = [
'sort' => '-dateLastModified',
'fields' => $this->getFieldsAsString(self::CONTACT_FIELDS),
'query' => 'isDeleted: false AND dateLastModified:[' . $since->format('YmdHis') . ' TO *]',
];
try {
$bhContacts = $this->client->get('search/ClientContact', $params);
foreach ($bhContacts['data'] as $bhContact) {
// Only sync if previously imported.
if ($this->hasContact($bhContact['id'])) {
$this->importContact($bhContact);
$syncCount++;
}
}
} catch (\Exception $exception) {
throw new ApplicationException('Failed to sync contacts', $exception->getCode(), $exception);
}
return $syncCount;
}
public function syncOpportunities(array $parameters, ?string $strategy = null): int
{
$strategyResolver = app(OpportunitySyncStrategyResolver::class, [
'client' => $this->client,
]);
$strategies = $strategyResolver->getStrategies($this->config, $strategy);
$syncCount = 0;
$total = 0;
$count = 0;
$offset = 0;
try {
$bhOpportunities = [];
foreach ($strategies as $syncStrategy) {
$opportunitiesData = $syncStrategy->fetchOpportunities($parameters);
$total = (int) ($opportunitiesData['total'] ?? 0);
$count = (int) ($opportunitiesData['count'] ?? 0);
$offset = (int) ($opportunitiesData['start'] ?? 0);
$bhOpportunities[] = $opportunitiesData['data'] ?? [];
}
$bhOpportunities = array_merge(...$bhOpportunities);
foreach ($bhOpportunities as $bhOpportunity) {
$this->importOpportunity($bhOpportunity);
$syncCount++;
}
} catch (\Exception $exception) {
$this->logger->info('Failed to sync opportunities', [
'params' => $parameters,
'exception' => $exception->getMessage(),
]);
}
// only log if there are one sync strategy
if (count($strategies) === 1 && $total > $count + $offset) {
$this->logger->info('[Bullhorn] Sync Opportunities - count warning', [
'team_id' => $this->team->getId(),
'total' => $total,
'count' => $count,
'offset' => $offset,
]);
}
return $syncCount;
}
/**
* @throws ApplicationException
*/
public function syncAccounts(Carbon $since, ?Carbon $to = null): int
{
$syncCount = 0;
$params = [
'sort' => '-dateLastModified',
'fields' => $this->getFieldsAsString(self::ACCOUNT_FIELDS),
'query' => 'isDeleted: false AND dateLastModified:[' . $since->format('YmdHis') . ' TO *]',
];
try {
$bhAccounts = $this->client->get('search/ClientCorporation', $params);
foreach ($bhAccounts['data'] as $bhAccount) {
// Only sync if previously imported.
if ($this->hasAccount($bhAccount['id'])) {
$this->importAccount($bhAccount);
$syncCount++;
}
}
} catch (\Exception $exception) {
throw new ApplicationException('Failed to sync accounts', $exception->getCode(), $exception);
}
return $syncCount;
}
/**
* @throws ApplicationException
*/
public function syncLeads(Carbon $since, ?Carbon $to = null, ?string $crmProfileId = null): int
{
$syncCount = 0;
$params = [
'sort' => '-dateLastModified',
'fields' => $this->getFieldsAsString(self::LEAD_FIELDS),
'query' => 'isDeleted: false AND dateLastModified:[' . $since->format('YmdHis') . ' TO *]',
];
try {
$bhLeads = $this->client->get('search/Candidate', $params);
if ($bhLeads === null) {
return $syncCount;
}
foreach ($bhLeads['data'] as $bhLead) {
// Only sync if previously imported.
if ($this->hasLead($bhLead['id'])) {
$this->importLead($bhLead);
$syncCount++;
}
}
} catch (\Exception $exception) {
throw new ApplicationException('Failed to sync leads', $exception->getCode(), $exception);
}
return $syncCount;
}
public function syncContact(string $crmId): ?Contact
{
try {
$data = $this->getRecord(FieldData::OBJECT_CONTACT, $crmId, self::CONTACT_FIELDS);
} catch (HttpNotFoundException $exception) {
return null;
}
return $this->importContact($data['data']);
}
public function syncOpportunity(string $crmId): ?Opportunity
{
$strategyResolver = app(OpportunitySyncStrategyResolver::class, [
'client' => $this->client,
]);
$strategy = $strategyResolver->resolve(
$this->config,
OpportunitySyncStrategyResolver::SINGLE_SYNC_OPPORTUNITY_STRATEGY
);
try {
$data = $strategy->fetchOpportunities(['crm_id' => $crmId]);
} catch (HttpNotFoundException $exception) {
$this->logger->info('[Bullhorn] Opportunity not found', [
'crm_id' => $crmId,
'reason' => $exception->getMessage(),
]);
}
return $this->importOpportunity($data['data'] ?? []);
}
public function syncAccount(string $crmId): ?Account
{
try {
$data = $this->getRecord(FieldData::OBJECT_ACCOUNT, $crmId, self::ACCOUNT_FIELDS);
} catch (HttpNotFoundException $exception) {
return null;
}
return $this->importAccount($data['data']);
}
public function syncLead(string $crmId): ?Lead
{
try {
$data = $this->getRecord(FieldData::OBJECT_LEAD, $crmId, self::LEAD_FIELDS);
} catch (HttpNotFoundException $exception) {
return null;
}
return $this->importLead($data['data']);
}
public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage
{
$missingStage = null;
if ($types === null) {
$types = [Stage::TYPE_LEAD, Stage::TYPE_OPPORTUNITY];
}
foreach ($types as $type) {
$resource = $this->convertObjectTypeToResource($type);
$entity = $this->client->get('meta/' . $resource, [
'fields' => 'status',
'meta' => 'full',
]);
$missingStage = null;
if (! is_array($entity['fields']) || count($entity['fields']) !== 1 || $entity['fields'][0]['name'] !== 'status') {
throw new \UnexpectedValueException('Invalid response from Bullhorn API');
}
// Upsert all stages for the team.
foreach ($entity['fields'][0]['options'] as $i => $option) {
$stage = $this->config->stages()->updateOrCreate([
'crm_provider_id' => $option['value'],
'type' => $type,
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($option['value'], 0, 50),
'label' => mb_strimwidth($option['label'], 0, 191),
'sequence' => $i,
'is_selectable' => 1,
]);
if ($missingStageName && $missingStageName === $option['value']) {
$missingStage = $stage;
}
}
if ($missingStageName && $missingStage === null) {
// If they requested a stage that still doesn't exist, it must be inactive so lazy create it.
$missingStage = $this->config->stages()->create([
'team_id' => $this->team->id,
'crm_provider_id' => Uuid::uuid4(),
'name' => mb_strimwidth($missingStageName, 0, 50),
'label' => mb_strimwidth($missingStageName, 0, 191),
'type' => $type,
'sequence' => 0,
'is_selectable' => 0,
]);
}
}
return $missingStage;
}
public function syncOrganization(): void
{
// There appears to be nothing we can sync/map to.
}
public function find(string $name, array $scopes): array
{
$count = $this->limit ?? 20;
$offset = $this->offset ?? 0;
$data = \Cache::remember($this->team->id . $name . $count . $offset, 300, function () use ($name, $count) {
$result = [];
try {
$objects = $this->client->get('find', [
'query' => $name,
'meta' => 'basic',
'countPerEntity' => $count,
]);
} catch (\GuzzleHttp\Exception\ServerException $exception) {
throw new ServiceUnavailableException($exception->getMessage());
}
foreach ($objects['data'] as $object) {
try {
$prospectType = $this->convertResourceToObjectType($object['entityType']);
} catch (\UnexpectedValueException $exception) {
// We don't support this, so skip it.
continue;
}
$record = [
'crmId' => $object['entityId'],
'crmUrl' => $this->generateProviderUrl($object['entityId'], $object['entityType']),
'prospectType' => $prospectType,
'phoneNumbers' => [],
];
// Unfortunately we need to go and query each entity itself to get the data we need.
switch ($object['entityType']) {
case self::ENTITY_ACCOUNT:
$data = $this->getRecord(FieldData::OBJECT_ACCOUNT, $object['entityId'], self::ACCOUNT_FIELDS);
$entity = $data['data'];
$record['name'] = $entity['name'];
$record['industry'] = $entity['industryList'];
if (empty($entity['phone']) === false) {
$record['phoneNumbers'][] = [
'number' => $entity['phone'],
'nationalFormat' => phone_national(null, $entity['phone']),
'type' => 'phone',
];
}
break;
case self::ENTITY_CONTACT:
$data = $this->getRecord(FieldData::OBJECT_CONTACT, $object['entityId'], self::CONTACT_FIELDS);
$entity = $data['data'];
$record['name'] = $entity['name'];
$record['organization'] = $entity['clientCorporation'] ? $entity['clientCorporation']['name'] : null;
$record['title'] = $entity['occupation'];
foreach (['mobile', 'phone', 'phone2', 'phone3'] as $phone) {
if (empty($entity[$phone]) === false) {
$record['phoneNumbers'][] = [
'number' => $entity[$phone],
'nationalFormat' => phone_national(null, $entity[$phone]),
'type' => $phone === 'mobile' ? 'mobile' : 'phone',
];
}
}
break;
case self::ENTITY_LEAD:
$bhData = $this->getRecord(FieldData::OBJECT_LEAD, $object['entityId'], self::LEAD_FIELDS);
$entity = $bhData['data'];
$record['name'] = $entity['name'];
$record['organization'] = $entity['companyName'];
$record['title'] = $entity['occupation'];
foreach (['mobile', 'phone', 'phone2', 'phone3', 'workPhone'] as $phone) {
if (empty($entity[$phone]) === false) {
$record['phoneNumbers'][] = [
'number' => $entity[$phone],
'nationalFormat' => phone_national(null, $entity[$phone]),
'type' => $phone === 'mobile' ? 'mobile' : 'phone',
];
}
}
break;
default:
// We are not interested in this entity type so skip it.
continue 2;
}
$result[] = $record;
}
return $result;
});
return $data;
}
public function getClient(): BullhornClientInterface
{
return $this->client;
}
private function getFieldsAsString(array $fields)
{
return implode(', ', $fields);
}
public function findOpportunities(?string $crmAccountId, ?string $crmContactId, ?int $userId = null): array
{
$data = [];
$ownerData = [];
$ownerId = null;
$contact = null;
$account = null;
if ($crmAccountId) {
$account = $this->config->accounts()->where('crm_provider_id', $crmAccountId)->first();
if ($account === null) {
$account = $this->syncAccount($crmAccountId);
}
}
if ($crmContactId) {
$contact = $this->config->contacts()->where('crm_provider_id', $crmContactId)->first();
if ($contact === null) {
$contact = $this->syncContact($crmContactId);
}
}
if ($contact || $account) {
if ($contact && $account === null) {
$account = $contact->account;
}
if ($account === null) {
return [];
}
$where = 'isDeleted: false AND clientCorporation.id:' . (int) $account->crm_provider_id;
$params = [
'sort' => '-dateLastModified',
'fields' => $this->getFieldsAsString(self::OPPORTUNITY_FIELDS),
];
$onlyOpen = true;
switch ($this->config->opportunity_assignment_rule) {
case Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_UPDATED:
$params['sort'] = '-dateLastModified';
break;
case Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_CREATED:
$params['sort'] = '-dateAdded';
break;
case Configuration::OPP_ASSIGNMENT_ALL_OPEN_OLDEST_CREATED:
$params['sort'] = '+dateAdded';
break;
case Configuration::OPP_ASSIGNMENT_ALL_RECENTLY_UPDATED:
$params['sort'] = '-dateLastModified';
$onlyOpen = false;
}
if ($onlyOpen) {
$where .= ' AND isOpen: true';
}
$params['query'] = $where;
$bhOpportunities = $this->client->get('search/JobOrder', $params);
if ($userId) {
$profileRepository = app(ProfileRepository::class);
$profile = $profileRepository->findProfileByUserId($this->config, $userId);
$ownerId = $profile instanceof Profile ? $profile->getCrmProviderId() : null;
}
foreach ($bhOpportunities['data'] as $bhOpportunity) {
$stage = $this->config
->stages()
->where('crm_provider_id', $bhOpportunity['status'])
->first();
if ($stage === null) {
// Import it.
$stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $bhOpportunity['status']);
}
$record = [
'crmId' => $bhOpportunity['id'],
'name' => $bhOpportunity['title'],
'value' => currency_format($bhOpportunity['salary'], $bhOpportunity['correlatedCustomText10'] ?? 'USD'),
'won' => strtolower($bhOpportunity['status']) === 'placed',
'closed' => $bhOpportunity['isOpen'] !== true,
'stage' => [
'id' => $stage->id_string,
'name' => $stage->name,
],
'recordType' => [],
];
if ($ownerId && isset($bhOpportunity['owner']['id']) && $bhOpportunity['owner']['id'] === (int) $ownerId) {
$ownerData[] = $record;
}
$data[] = $record;
}
}
if (! empty($ownerData)) {
return $ownerData;
}
return $data;
}
public function getTasks(string $objectType, string $objectId, ?string $opportunityId): array
{
$data = [];
// For now it has not been agreed on how to implement this e.g. appointments or tasks?
return [];
if ($objectType === 'account' && $opportunityId === null) {
// You cannot attach tasks to only accounts.
return [];
}
// Query should default to any open task.
$where = 'isDeleted: false AND isCompleted: false AND (' . $this->convertObjectTypeToResource($objectType) . 'UserId: ' . (int) $objectId;
// If we are also going to check on a specific job order, set that up.
if ($opportunityId) {
$where .= ' OR JobOrder: ' . (int) $opportunityId;
}
$where .= ')';
$params = [
'sort' => '-dateLastModified',
'fields' => $this->getFieldsAsString(self::TASK_FIELDS),
'query' => $where,
];
$bhTasks = $this->client->get('search/Task', $params);
foreach ($bhTasks['data'] as $bhTask) {
$data[] = [
'crmId' => $bhTask['id'],
'subject' => $bhTask['subject'],
'due' => Carbon::createFromTimestampMs($bhTask['dateEnd'])->toIso8601String(),
'type' => null,
];
}
return $data;
}
/**
* @url [URL_WITH_CREDENTIALS] [URL_WITH_CREDENTIALS] [URL_WITH_CREDENTIALS] CrmException
* @throws DomainException
*/
private function buildNotePayload(Activity $activity): Activity
{
if ($activity->hasActivityType() === false) {
throw new DomainException('[Bullhorn] No activity type assigned');
}
$action = $this->getCrmType($activity->getActivityType());
if ($action === null) {
throw new DomainException('[Bullhorn] No mapped CRM type for "' . $activity->category->name . '"');
}
if ($activity->lead_id === null && $activity->contact_id === null) {
throw new DomainException('[Bullhorn] Cannot save note without candidate or DM');
}
$payload = [
'comments' => $this->generateActivityDescription($activity),
'action' => $action->value,
'commentingPerson' => [
'id' => (int) $this->profile->crm_provider_id,
'type' => 'CorporateUser',
],
'externalID' => $activity->id_string,
'minutesSpent' => $activity->duration,
'dateAdded' => $activity->hasActualEndTime() ? $activity->getActualEndTime()?->valueOf() : now()->valueOf(),
];
if ($activity->lead_id) {
$payload['personReference'] = [
'id' => (int) $activity->lead->crm_provider_id,
'type' => 'Candidate',
];
}
if ($activity->contact_id) {
$payload['personReference'] = [
'id' => (int) $activity->contact->crm_provider_id,
'type' => 'ClientContact',
];
}
if ($activity->opportunity_id) {
$payload['jobOrders'] = [
'replaceAll' => [
(int) $activity->opportunity->crm_provider_id,
],
];
}
$bhActivity = $this->client->put('entity/Note', $payload);
$activity->crm_provider_id = $bhActivity['changedEntityId'];
$activity->save();
return $activity;
}
private function generateActivityDescription(Activity $activity): string
{
$description = '';
switch ($activity->type) {
case Activity::TYPE_SOFTPHONE:
case Activity::TYPE_SOFTPHONE_INBOUND:
case Activity::TYPE_CONFERENCE:
if ($activity->isTypeSoftPhone() && $activity->getProvider() !== Activity::PROVIDER_UPLOADER) {
// Dialer calls have no native field for the subject.
if ($activity->title) {
$description .= sprintf(
'<p><span style="font-size: 16px; line-height: 19.2px;">%s</span></p>',
$activity->title,
);
}
// Conference meetings log duration into a distinct field, so log this in the main text.
$description .= sprintf(
'<p><span style="font-size: 13.3333px; line-height: 16px;">Call Duration: %s</span></p>',
$activity->duration_for_humans,
);
// Tell them which number was dialed.
if ($activity->to) {
$description .= sprintf(
'<p><span style="font-size: 13.3333px; line-height: 16px;">Dialed: %s</span></p>',
$activity->to->phone_number,
);
}
$description .= '<p></p>';
}
if ($activity->isTypeSoftphoneInbound() && $activity->getProvider() !== Activity::PROVIDER_UPLOADER) {
...
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"bounds":{"left":0.054166667,"top":0.027777778,"width":0.08055556,"height":0.035555556},"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"#12066 on JY-20725-handle-HS-search-rate-limit, menu","depth":5,"bounds":{"left":0.13472222,"top":0.027777778,"width":0.25555557,"height":0.035555556},"on_screen":true,"help_text":"Pull request #12066 exists for current branch JY-20725-handle-HS-search-rate-limit, but local branch is out of sync with remote","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"bounds":{"left":0.6326389,"top":0.027777778,"width":0.023611112,"height":0.035555556},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"HandleHubspotRateLimitTest","depth":6,"bounds":{"left":0.6645833,"top":0.027777778,"width":0.15902779,"height":0.035555556},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'HandleHubspotRateLimitTest'","depth":6,"bounds":{"left":0.82361114,"top":0.027777778,"width":0.023611112,"height":0.035555556},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'HandleHubspotRateLimitTest'","depth":6,"bounds":{"left":0.8472222,"top":0.027777778,"width":0.023611112,"height":0.035555556},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"bounds":{"left":0.87083334,"top":0.027777778,"width":0.023611112,"height":0.035555556},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"bounds":{"left":0.9291667,"top":0.027777778,"width":0.023611112,"height":0.035555556},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"bounds":{"left":0.9527778,"top":0.027777778,"width":0.023611112,"height":0.035555556},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"IDE and Project Settings","depth":5,"bounds":{"left":0.9763889,"top":0.027777778,"width":0.023611112,"height":0.035555556},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.088194445,"height":0.027777778},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"2","depth":4,"bounds":{"left":0.75625,"top":0.3122222,"width":0.016666668,"height":0.02111111},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"13","depth":4,"bounds":{"left":0.77708334,"top":0.3122222,"width":0.02013889,"height":0.02111111},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"3","depth":4,"bounds":{"left":0.80138886,"top":0.3122222,"width":0.016666668,"height":0.02111111},"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"bounds":{"left":0.8215278,"top":0.31,"width":0.015277778,"height":0.025555555},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"bounds":{"left":0.8368056,"top":0.31,"width":0.014583333,"height":0.025555555},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\nnamespace Jiminny\\Services\\Crm\\Bullhorn;\n\nuse Carbon\\Carbon;\nuse Exception;\nuse Illuminate\\Support\\Facades\\Cache;\nuse Illuminate\\Support\\Facades\\Log;\nuse Jiminny\\Contracts\\Services\\Crm\\MatchCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\FetchRelatedActivityInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\LayoutManagementInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\Provider\\BullhornInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityLookupInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityManipulationInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SettingsInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmMetadataInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\VerifyTaskExistsInterface;\nuse Jiminny\\Exceptions\\ApplicationException;\nuse Jiminny\\Exceptions\\CrmException;\nuse Jiminny\\Exceptions\\DomainException;\nuse Jiminny\\Exceptions\\HttpBadRequestException;\nuse Jiminny\\Exceptions\\HttpNotFoundException;\nuse Jiminny\\Exceptions\\ServiceUnavailableException;\nuse Jiminny\\Models\\Account;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Contact;\nuse Jiminny\\Models\\Crm\\Configuration;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Models\\Crm\\FieldData;\nuse Jiminny\\Models\\Crm\\Profile;\nuse Jiminny\\Models\\Lead;\nuse Jiminny\\Models\\Opportunity;\nuse Jiminny\\Models\\Playbook;\nuse Jiminny\\Models\\SocialAccount;\nuse Jiminny\\Models\\Stage;\nuse Jiminny\\Models\\User;\nuse Jiminny\\Repositories\\Crm\\ProfileRepository;\nuse Jiminny\\Repositories\\UserRepository;\nuse Jiminny\\Services\\Avatar\\ProspectPhotoPathService;\nuse Jiminny\\Services\\Crm\\BaseService;\nuse Jiminny\\Services\\Crm\\OpportunitySyncStrategyResolver;\nuse Jiminny\\Utils\\CurrencyFormatter;\nuse Jiminny\\Utils\\StringUtil;\nuse Ramsey\\Uuid\\Uuid;\n\nclass BullhornService extends BaseService implements\n BullhornInterface,\n SyncCrmEntitiesInterface,\n SyncCrmMetadataInterface,\n RemoteEntityManipulationInterface,\n FetchRelatedActivityInterface,\n LayoutManagementInterface,\n SettingsInterface,\n MatchCrmEntitiesInterface,\n RemoteEntityLookupInterface,\n VerifyTaskExistsInterface\n{\n private const int TASK_VERIFICATION_CACHE_TTL = 86400; // 1 day\n\n private const string ENTITY_LEAD = 'Candidate';\n private const string ENTITY_OPPORTUNITY = 'JobOrder';\n private const string ENTITY_ACCOUNT = 'ClientCorporation';\n private const string ENTITY_CONTACT = 'ClientContact';\n private const string ENTITY_ACTIVITY = 'Note';\n\n private const array OPPORTUNITY_FIELDS = [\n 'id',\n 'owner',\n 'status',\n 'title',\n 'salary',\n 'salaryUnit',\n 'payRate',\n 'isOpen',\n 'employmentType',\n 'dateClosed',\n 'startDate',\n 'dateLastModified',\n 'dateAdded',\n 'clientCorporation',\n 'correlatedCustomText10', // currency\n ];\n\n private const array TASK_FIELDS = [\n 'id',\n 'comments',\n 'dateAdded',\n 'dateLastModified',\n 'externalID',\n 'isDeleted',\n 'corporateUsers',\n 'clientContacts',\n 'commentingPerson',\n 'candidates',\n 'jobOrder',\n 'minutesSpent',\n 'action',\n ];\n\n private const array LEAD_FIELDS = [\n 'id',\n 'name',\n 'owner',\n 'occupation',\n 'companyName',\n 'companyURL',\n 'mobile',\n 'phone',\n 'phone2',\n 'phone3',\n 'workPhone',\n 'status',\n 'address',\n 'dateAdded',\n 'dateLastModified',\n ];\n\n private const array CONTACT_FIELDS = [\n 'id',\n 'name',\n 'occupation',\n 'mobile',\n 'phone',\n 'phone2',\n 'phone3',\n 'address',\n 'clientCorporation',\n 'dateAdded',\n 'owner',\n ];\n\n private const array ACCOUNT_FIELDS = [\n 'id',\n 'name',\n 'phone',\n 'industryList',\n 'address',\n 'companyURL',\n 'dateAdded',\n ];\n\n public function __construct(\n BullhornClientInterface $client,\n private readonly UserRepository $userRepository,\n private readonly ProspectPhotoPathService $prospectPhotoPathService\n ) {\n parent::__construct();\n\n $this->client = $client;\n }\n\n protected function getOAuthAccount(User $user): ?SocialAccount\n {\n // In this case, the Account Owner is always the connection for any API operations.\n $owner = $user->team->owner;\n\n return $owner->getSocialAccount(SocialAccount::PROVIDER_BULLHORN);\n }\n\n public function getDisplayName(): string\n {\n return 'Bullhorn';\n }\n\n public function createTranscriptNotes(Activity $activity): void\n {\n // Not done until a customer requests it.\n }\n\n public function syncProfileFields(): void\n {\n $fieldTypes = [\n 'lead',\n 'account',\n 'contact',\n 'opportunity',\n 'task',\n ];\n\n foreach ($fieldTypes as $fieldType) {\n try {\n $currentFields = $this->getFields($this->convertObjectTypeToResource($fieldType));\n\n $newFields = array_column($currentFields, 'name');\n $changedFields = array_merge(\n array_diff($this->profile->getFieldsAsArray($fieldType), $newFields),\n array_diff($newFields, $this->profile->getFieldsAsArray($fieldType))\n );\n\n if (! empty($changedFields)) {\n $fields = implode(',', $newFields);\n Log::debug(\n sprintf(\n '%s fields changed from %s to: %s',\n ucfirst($fieldType),\n $this->profile->{$fieldType . '_fields'},\n $fields,\n ),\n );\n\n $this->profile->{$fieldType . '_fields'} = $fields;\n }\n } catch (\\Exception $exception) {\n \\Log::error('No access to ' . $fieldType . ' object, skipping...');\n\n // XXX: should we log this fact somewhere?\n continue;\n }\n }\n\n $this->profile->save();\n }\n\n protected function getFieldTypes(): array\n {\n return [\n parent::OBJECT_TASK,\n //parent::OBJECT_ACCOUNT,\n //parent::OBJECT_CONTACT,\n //parent::OBJECT_OPPORTUNITY,\n //parent::OBJECT_LEAD,\n ];\n }\n\n protected function getFields(string $crmObject): array\n {\n $fields = [];\n foreach ($this->getObjectFields($crmObject) as $row) {\n $fields[] = ['label' => $row['label'] ?? $row['name'], 'name' => $row['name']];\n }\n\n return $fields;\n }\n\n private function getObjectFields(string $objectType): array\n {\n $entityType = $this->convertObjectTypeToResource($objectType);\n\n return Cache::remember(\n key: 'crm.profile#' . $this->profile->id . '.fieldType:' . $objectType,\n ttl: 60 * 60,\n callback: function () use ($entityType) {\n $entity = $this->client->get('meta/' . $entityType, [\n 'fields' => '*',\n ]);\n\n return $entity['fields'];\n },\n );\n }\n\n public function getDefaultFields(string $activityType): array\n {\n $fields = [];\n $defaultFields = FieldDefinitions::defaultFields();\n\n foreach ($defaultFields as $defaultField) {\n $fields[] = $this->config->fields()->firstOrCreate($defaultField);\n }\n\n return $fields;\n }\n\n public function getDefaultActivityField(string $activityType): Field\n {\n /** @var Field $activityField */\n $activityField = $this->config->fields()->where([\n 'crm_provider_id' => 'action',\n 'object_type' => $activityType,\n ])->first();\n\n return $activityField;\n }\n\n public function getDealInsightsFields(): array\n {\n return FieldDefinitions::dealInsightsFields();\n }\n\n public function getSupportedPlaybookTypes(): array\n {\n return [Playbook::ACTIVITY_TYPE_TASK];\n }\n\n protected function getDefaultFollowupLayoutFields(string $activityType): array\n {\n // We are not yet supporting follow-ups for this system.\n return [];\n }\n\n public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string\n {\n return $fieldValue;\n }\n\n public function syncField(Field $field): void\n {\n $resource = $this->convertObjectTypeToResource($field->object_type);\n $entity = $this->client->get('meta/' . $resource, [\n 'fields' => $field->crm_provider_id,\n 'meta' => 'full',\n ]);\n\n $bhField = $entity['fields'][0];\n\n $isMandatory = (empty($bhField['optional']) == false && $bhField['optional'] === false);\n $description = null;\n if (empty($bhField['description']) === false) {\n $description = mb_strimwidth($bhField['description'], 0, 191);\n }\n\n $label = 'Unknown';\n if (empty($bhField['label']) === false) {\n $label = mb_strimwidth($bhField['label'], 0, Field::LABEL_MAX_LENGTH);\n }\n\n $field->length = $bhField['maxLength'] ?? null;\n $field->is_mandatory = $isMandatory;\n $field->description = $description;\n $field->label = $label;\n $field->type = $this->convertFieldType(\n $bhField['dataType'] ?? null,\n $bhField['dataSpecialization'] ?? null,\n $bhField['inputType'] ?? null\n );\n\n $field->save();\n\n if (empty($bhField['options']) === false) {\n $values = [];\n\n // Import all values.\n foreach ($bhField['options'] as $i => $option) {\n $values[] = [\n 'value' => $option['value'],\n 'label' => mb_strimwidth($option['label'], 0, Field::LABEL_MAX_LENGTH),\n 'sequence' => $i,\n ];\n }\n\n // Take the existing set of field values.\n $fieldsToPurge = $field->values()->get()->pluck('value')->toArray();\n\n foreach ($values as $value) {\n // For all current values, upsert the field.\n $field->values()->updateOrCreate([\n 'value' => $value['value'],\n ], $value);\n\n // Remove this value from the ones we are going to purge.\n $key = array_search($value['value'], $fieldsToPurge, false);\n if ($key !== false) {\n unset($fieldsToPurge[$key]);\n }\n }\n\n // Delete the old values that are no longer used.\n $field->values()->whereIn('value', $fieldsToPurge)->delete();\n }\n }\n\n public function importPicklistValues(Field $field): array\n {\n // Taken care of in syncField.\n return [];\n }\n\n private function convertFieldType(?string $type, ?string $specialization, ?string $inputType): string\n {\n if ($type === null) {\n return Field::TYPE_UNSUPPORTED;\n }\n\n $type = strtolower($type);\n $specialization = strtolower($specialization);\n $inputType = strtolower($inputType);\n\n switch ($type) {\n case 'boolean':\n return Field::TYPE_BOOLEAN;\n\n case 'integer':\n switch ($specialization) {\n case 'integer':\n return Field::TYPE_NUMBER;\n default:\n return Field::TYPE_NUMBER;\n }\n\n // no break\n case 'bigdecimal':\n switch ($specialization) {\n case 'money':\n return Field::TYPE_CURRENCY;\n default:\n return Field::TYPE_NUMBER;\n }\n\n // no break\n case 'double':\n switch ($specialization) {\n case 'float':\n return Field::TYPE_NUMBER;\n default:\n return Field::TYPE_NUMBER;\n }\n\n // no break\n case 'timestamp':\n switch ($specialization) {\n case 'date':\n return Field::TYPE_DATE;\n case 'time':\n return Field::TYPE_TIME;\n case 'datetime':\n return Field::TYPE_DATETIME;\n default:\n return Field::TYPE_DATE;\n }\n\n // no break\n case 'string':\n switch ($inputType) {\n case 'textarea':\n return Field::TYPE_TEXTAREA;\n case 'select':\n return Field::TYPE_PICKLIST;\n default:\n return Field::TYPE_TEXT;\n }\n\n // no break\n default:\n // This is not actually a supported field.\n return Field::TYPE_UNSUPPORTED;\n }\n }\n\n private function importContact($crmData): Contact\n {\n $account = $mobilePhone = $phone = $email = null;\n if (empty($crmData['clientCorporation']) === false) {\n $account = $this->team\n ->accounts()\n ->where('crm_provider_id', $crmData['clientCorporation']['id'])\n ->first();\n\n if ($account === null) {\n $account = $this->syncAccount($crmData['clientCorporation']['id']);\n }\n }\n\n $countryCode = $this->convertCountryNameToCode($crmData['address']['countryCode']);\n\n foreach (['mobile', 'phone', 'phone2', 'phone3'] as $type) {\n if (empty($crmData[$type])) {\n continue;\n }\n\n if ($type === 'mobile') {\n $mobilePhone = parsePhoneNumber($countryCode, $crmData[$type]);\n } else {\n $phone = parsePhoneNumber($countryCode, $crmData[$type]);\n }\n\n break;\n }\n\n foreach (['email', 'email1', 'email2'] as $type) {\n if (empty($crmData[$type])) {\n continue;\n }\n\n $email = mb_strimwidth($crmData[$type], 0, 80);\n\n break;\n }\n\n $profile = $this->config->profiles()->where('crm_provider_id', (string) $crmData['owner']['id'])->first();\n\n $data = [\n 'team_id' => $this->team->id,\n 'account_id' => $account->id ?? null,\n 'user_id' => $profile ? $profile->user_id : null,\n 'owner_id' => $crmData['owner']['id'],\n 'name' => $crmData['name'],\n 'email' => $email,\n 'phone' => empty($phone['phone']) ? null : mb_strimwidth($phone['phone'], 0, 25),\n 'ext' => $phone['ext'] ?? null,\n 'mobile_phone' => empty($mobilePhone['phone']) ? null : mb_strimwidth($mobilePhone['phone'], 0, 25),\n 'title' => $crmData['occupation'],\n 'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(\n crmConfiguration: $this->config,\n crmProviderId: $crmData['id'],\n modelType: Contact::class,\n fileName: $crmData['id'],\n avatarText: $crmData['name'] ?? 'Unknown'\n ),\n 'remotely_created_at' => $this->filterOutOfBoundDates($crmData['dateAdded']),\n ];\n\n return $this->config->contacts()->updateOrCreate(['crm_provider_id' => (string) $crmData['id']], $data);\n }\n\n private function importOpportunity($crmData): ?Opportunity\n {\n if ($crmData['clientCorporation']['id']) {\n $account = $this->config->accounts()->where('crm_provider_id', (string) $crmData['clientCorporation']['id'])->first();\n\n if ($account === null) {\n $account = $this->syncAccount($crmData['clientCorporation']['id']);\n }\n } else {\n // If there is nothing to associate this with, don't import it.\n return null;\n }\n\n $profile = $this->config->profiles()->where('crm_provider_id', (string) $crmData['owner']['id'])->first();\n\n $stage = $this->config->stages()\n ->where('crm_provider_id', $crmData['status'])\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->first();\n\n if ($stage === null) {\n $stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $crmData['status']);\n }\n\n $data = [\n 'team_id' => $this->team->id,\n 'account_id' => $account->id,\n 'user_id' => $profile ? $profile->user_id : null,\n 'owner_id' => $crmData['owner']['id'],\n 'name' => mb_strimwidth($crmData['title'], 0, 128),\n 'value' => $crmData['salary'] ?? $crmData['payRate'],\n 'currency_code' => CurrencyFormatter::formatCode($crmData['correlatedCustomText10'] ?? 'USD'),\n 'close_date' => $this->filterOutOfBoundDates($crmData['startDate']),\n 'is_closed' => $crmData['isOpen'] !== true,\n 'is_won' => strtolower($crmData['status']) === 'placed',\n 'stage_id' => $stage->id,\n 'record_type_id' => null,\n 'remotely_created_at' => $this->filterOutOfBoundDates($crmData['dateAdded']),\n ];\n\n return $this->config->opportunities()->updateOrCreate(['crm_provider_id' => (string) $crmData['id']], $data);\n }\n\n private function importAccount($crmData): Account\n {\n $countryCode = $domain = null;\n if (empty($crmData['address']['countryCode'])) {\n $countryCode = $this->convertCountryNameToCode($crmData['address']['countryCode']);\n }\n\n $name = $crmData['name'] ?? 'Unknown';\n\n if ($crmData['companyURL'] !== null) {\n $domain = mb_strimwidth($crmData['companyURL'], 0, 191);\n $domain = StringUtil::resolveDomain($domain);\n }\n\n $data = [\n 'team_id' => $this->team->id,\n 'user_id' => null,\n 'owner_id' => null,\n 'name' => mb_strimwidth($name, 0, 191),\n 'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(\n crmConfiguration: $this->config,\n crmProviderId: $crmData['id'],\n modelType: Account::class,\n fileName: $crmData['id'],\n avatarText: $crmData['name'] ?? 'Unknown'\n ),\n 'country_code' => $countryCode,\n 'domain' => $domain,\n 'remotely_created_at' => $this->filterOutOfBoundDates($crmData['dateAdded']),\n ];\n\n return $this->config->accounts()->updateOrCreate(['crm_provider_id' => (string) $crmData['id']], $data);\n }\n\n private function importLead($crmData): ?Lead\n {\n // Get the current stage.\n $stage = $this->config->stages()->where('name', $crmData['status'])->where('type', Stage::TYPE_LEAD)->first();\n if ($stage === null) {\n $stage = $this->importStages([Stage::TYPE_LEAD], $crmData['status']);\n }\n\n // If we have no way of importing this, just return null :(\n if ($stage === null) {\n return null;\n }\n\n $countryCode = null;\n if (empty($crmData['address']['countryCode'])) {\n $countryCode = $this->convertCountryNameToCode($crmData['address']['countryCode']);\n }\n\n $mobilePhone = $phone = $email = null;\n foreach (['mobile', 'phone', 'phone2', 'phone3', 'workPhone'] as $type) {\n if (empty($crmData[$type])) {\n continue;\n }\n\n if ($type === 'mobile') {\n $mobilePhone = parsePhoneNumber($countryCode, $crmData[$type]);\n } else {\n $phone = parsePhoneNumber($countryCode, $crmData[$type]);\n }\n\n break;\n }\n\n foreach (['email', 'email1', 'email2'] as $type) {\n if (empty($crmData[$type])) {\n continue;\n }\n\n $email = mb_strimwidth($crmData[$type], 0, 80);\n\n break;\n }\n\n $company = 'Unknown';\n if (empty($crmData['companyName']) === false) {\n $companyName = is_array($crmData['companyName']) ? $crmData['companyName'][0] : $crmData['companyName'];\n $company = mb_strimwidth($companyName, 0, 191);\n }\n\n $domain = null;\n if (empty($crmData['companyURL']) === false) {\n $domain = mb_strimwidth($crmData['companyURL'], 0, 191);\n $domain = StringUtil::resolveDomain($domain);\n }\n\n $profile = $this->config->profiles()->where('crm_provider_id', (string) $crmData['owner']['id'])->first();\n\n $data = [\n 'team_id' => $this->team->id,\n 'user_id' => $profile ? $profile->user_id : null,\n 'owner_id' => $crmData['owner']['id'],\n 'company' => $company,\n 'domain' => $domain,\n 'name' => mb_strimwidth($crmData['name'], 0, 191),\n 'title' => mb_strimwidth($crmData['occupation'], 0, 128),\n 'email' => mb_strimwidth($email, 0, 80),\n 'phone' => empty($phone['phone']) ? null : mb_strimwidth($phone['phone'], 0, 25),\n 'ext' => $phone['ext'] ?? null,\n 'mobile_phone' => empty($mobilePhone['phone']) ? null : mb_strimwidth($mobilePhone['phone'], 0, 25),\n 'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(\n crmConfiguration: $this->config,\n crmProviderId: $crmData['id'],\n modelType: Lead::class,\n fileName: $crmData['id'],\n avatarText: $crmData['name'] ?? 'Unknown'\n ),\n 'stage_id' => $stage->id,\n 'country_code' => $countryCode,\n 'remotely_created_at' => $this->filterOutOfBoundDates($crmData['dateAdded']),\n ];\n\n return $this->config->leads()->updateOrCreate(['crm_provider_id' => (string) $crmData['id']], $data);\n }\n\n /**\n * @throws ApplicationException\n */\n public function syncContacts(Carbon $since, ?Carbon $to = null): int\n {\n $syncCount = 0;\n $params = [\n 'sort' => '-dateLastModified',\n 'fields' => $this->getFieldsAsString(self::CONTACT_FIELDS),\n 'query' => 'isDeleted: false AND dateLastModified:[' . $since->format('YmdHis') . ' TO *]',\n ];\n\n try {\n $bhContacts = $this->client->get('search/ClientContact', $params);\n\n foreach ($bhContacts['data'] as $bhContact) {\n // Only sync if previously imported.\n if ($this->hasContact($bhContact['id'])) {\n $this->importContact($bhContact);\n $syncCount++;\n }\n }\n } catch (\\Exception $exception) {\n throw new ApplicationException('Failed to sync contacts', $exception->getCode(), $exception);\n }\n\n return $syncCount;\n }\n\n public function syncOpportunities(array $parameters, ?string $strategy = null): int\n {\n $strategyResolver = app(OpportunitySyncStrategyResolver::class, [\n 'client' => $this->client,\n ]);\n $strategies = $strategyResolver->getStrategies($this->config, $strategy);\n\n $syncCount = 0;\n $total = 0;\n $count = 0;\n $offset = 0;\n\n try {\n $bhOpportunities = [];\n foreach ($strategies as $syncStrategy) {\n $opportunitiesData = $syncStrategy->fetchOpportunities($parameters);\n $total = (int) ($opportunitiesData['total'] ?? 0);\n $count = (int) ($opportunitiesData['count'] ?? 0);\n $offset = (int) ($opportunitiesData['start'] ?? 0);\n $bhOpportunities[] = $opportunitiesData['data'] ?? [];\n }\n\n $bhOpportunities = array_merge(...$bhOpportunities);\n\n foreach ($bhOpportunities as $bhOpportunity) {\n $this->importOpportunity($bhOpportunity);\n $syncCount++;\n }\n } catch (\\Exception $exception) {\n $this->logger->info('Failed to sync opportunities', [\n 'params' => $parameters,\n 'exception' => $exception->getMessage(),\n ]);\n }\n\n // only log if there are one sync strategy\n if (count($strategies) === 1 && $total > $count + $offset) {\n $this->logger->info('[Bullhorn] Sync Opportunities - count warning', [\n 'team_id' => $this->team->getId(),\n 'total' => $total,\n 'count' => $count,\n 'offset' => $offset,\n ]);\n }\n\n return $syncCount;\n }\n\n /**\n * @throws ApplicationException\n */\n public function syncAccounts(Carbon $since, ?Carbon $to = null): int\n {\n $syncCount = 0;\n $params = [\n 'sort' => '-dateLastModified',\n 'fields' => $this->getFieldsAsString(self::ACCOUNT_FIELDS),\n 'query' => 'isDeleted: false AND dateLastModified:[' . $since->format('YmdHis') . ' TO *]',\n ];\n\n try {\n $bhAccounts = $this->client->get('search/ClientCorporation', $params);\n\n foreach ($bhAccounts['data'] as $bhAccount) {\n // Only sync if previously imported.\n if ($this->hasAccount($bhAccount['id'])) {\n $this->importAccount($bhAccount);\n $syncCount++;\n }\n }\n } catch (\\Exception $exception) {\n throw new ApplicationException('Failed to sync accounts', $exception->getCode(), $exception);\n }\n\n return $syncCount;\n }\n\n /**\n * @throws ApplicationException\n */\n public function syncLeads(Carbon $since, ?Carbon $to = null, ?string $crmProfileId = null): int\n {\n $syncCount = 0;\n $params = [\n 'sort' => '-dateLastModified',\n 'fields' => $this->getFieldsAsString(self::LEAD_FIELDS),\n 'query' => 'isDeleted: false AND dateLastModified:[' . $since->format('YmdHis') . ' TO *]',\n ];\n\n try {\n $bhLeads = $this->client->get('search/Candidate', $params);\n if ($bhLeads === null) {\n return $syncCount;\n }\n\n foreach ($bhLeads['data'] as $bhLead) {\n // Only sync if previously imported.\n if ($this->hasLead($bhLead['id'])) {\n $this->importLead($bhLead);\n $syncCount++;\n }\n }\n } catch (\\Exception $exception) {\n throw new ApplicationException('Failed to sync leads', $exception->getCode(), $exception);\n }\n\n return $syncCount;\n }\n\n\n public function syncContact(string $crmId): ?Contact\n {\n try {\n $data = $this->getRecord(FieldData::OBJECT_CONTACT, $crmId, self::CONTACT_FIELDS);\n } catch (HttpNotFoundException $exception) {\n return null;\n }\n\n return $this->importContact($data['data']);\n }\n\n public function syncOpportunity(string $crmId): ?Opportunity\n {\n $strategyResolver = app(OpportunitySyncStrategyResolver::class, [\n 'client' => $this->client,\n ]);\n $strategy = $strategyResolver->resolve(\n $this->config,\n OpportunitySyncStrategyResolver::SINGLE_SYNC_OPPORTUNITY_STRATEGY\n );\n\n try {\n $data = $strategy->fetchOpportunities(['crm_id' => $crmId]);\n } catch (HttpNotFoundException $exception) {\n $this->logger->info('[Bullhorn] Opportunity not found', [\n 'crm_id' => $crmId,\n 'reason' => $exception->getMessage(),\n ]);\n }\n\n return $this->importOpportunity($data['data'] ?? []);\n }\n\n public function syncAccount(string $crmId): ?Account\n {\n try {\n $data = $this->getRecord(FieldData::OBJECT_ACCOUNT, $crmId, self::ACCOUNT_FIELDS);\n } catch (HttpNotFoundException $exception) {\n return null;\n }\n\n return $this->importAccount($data['data']);\n }\n\n public function syncLead(string $crmId): ?Lead\n {\n try {\n $data = $this->getRecord(FieldData::OBJECT_LEAD, $crmId, self::LEAD_FIELDS);\n } catch (HttpNotFoundException $exception) {\n return null;\n }\n\n return $this->importLead($data['data']);\n }\n\n public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage\n {\n $missingStage = null;\n\n if ($types === null) {\n $types = [Stage::TYPE_LEAD, Stage::TYPE_OPPORTUNITY];\n }\n\n foreach ($types as $type) {\n $resource = $this->convertObjectTypeToResource($type);\n $entity = $this->client->get('meta/' . $resource, [\n 'fields' => 'status',\n 'meta' => 'full',\n ]);\n\n $missingStage = null;\n\n if (! is_array($entity['fields']) || count($entity['fields']) !== 1 || $entity['fields'][0]['name'] !== 'status') {\n throw new \\UnexpectedValueException('Invalid response from Bullhorn API');\n }\n\n // Upsert all stages for the team.\n foreach ($entity['fields'][0]['options'] as $i => $option) {\n $stage = $this->config->stages()->updateOrCreate([\n 'crm_provider_id' => $option['value'],\n 'type' => $type,\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($option['value'], 0, 50),\n 'label' => mb_strimwidth($option['label'], 0, 191),\n 'sequence' => $i,\n 'is_selectable' => 1,\n ]);\n\n if ($missingStageName && $missingStageName === $option['value']) {\n $missingStage = $stage;\n }\n }\n\n if ($missingStageName && $missingStage === null) {\n // If they requested a stage that still doesn't exist, it must be inactive so lazy create it.\n $missingStage = $this->config->stages()->create([\n 'team_id' => $this->team->id,\n 'crm_provider_id' => Uuid::uuid4(),\n 'name' => mb_strimwidth($missingStageName, 0, 50),\n 'label' => mb_strimwidth($missingStageName, 0, 191),\n 'type' => $type,\n 'sequence' => 0,\n 'is_selectable' => 0,\n ]);\n }\n }\n\n return $missingStage;\n }\n\n public function syncOrganization(): void\n {\n // There appears to be nothing we can sync/map to.\n }\n\n public function find(string $name, array $scopes): array\n {\n $count = $this->limit ?? 20;\n $offset = $this->offset ?? 0;\n\n $data = \\Cache::remember($this->team->id . $name . $count . $offset, 300, function () use ($name, $count) {\n $result = [];\n\n try {\n $objects = $this->client->get('find', [\n 'query' => $name,\n 'meta' => 'basic',\n 'countPerEntity' => $count,\n ]);\n } catch (\\GuzzleHttp\\Exception\\ServerException $exception) {\n throw new ServiceUnavailableException($exception->getMessage());\n }\n\n foreach ($objects['data'] as $object) {\n try {\n $prospectType = $this->convertResourceToObjectType($object['entityType']);\n } catch (\\UnexpectedValueException $exception) {\n // We don't support this, so skip it.\n continue;\n }\n\n $record = [\n 'crmId' => $object['entityId'],\n 'crmUrl' => $this->generateProviderUrl($object['entityId'], $object['entityType']),\n 'prospectType' => $prospectType,\n 'phoneNumbers' => [],\n ];\n\n // Unfortunately we need to go and query each entity itself to get the data we need.\n switch ($object['entityType']) {\n case self::ENTITY_ACCOUNT:\n $data = $this->getRecord(FieldData::OBJECT_ACCOUNT, $object['entityId'], self::ACCOUNT_FIELDS);\n $entity = $data['data'];\n\n $record['name'] = $entity['name'];\n $record['industry'] = $entity['industryList'];\n if (empty($entity['phone']) === false) {\n $record['phoneNumbers'][] = [\n 'number' => $entity['phone'],\n 'nationalFormat' => phone_national(null, $entity['phone']),\n 'type' => 'phone',\n ];\n }\n\n break;\n\n case self::ENTITY_CONTACT:\n $data = $this->getRecord(FieldData::OBJECT_CONTACT, $object['entityId'], self::CONTACT_FIELDS);\n $entity = $data['data'];\n\n $record['name'] = $entity['name'];\n $record['organization'] = $entity['clientCorporation'] ? $entity['clientCorporation']['name'] : null;\n $record['title'] = $entity['occupation'];\n\n foreach (['mobile', 'phone', 'phone2', 'phone3'] as $phone) {\n if (empty($entity[$phone]) === false) {\n $record['phoneNumbers'][] = [\n 'number' => $entity[$phone],\n 'nationalFormat' => phone_national(null, $entity[$phone]),\n 'type' => $phone === 'mobile' ? 'mobile' : 'phone',\n ];\n }\n }\n\n break;\n\n case self::ENTITY_LEAD:\n $bhData = $this->getRecord(FieldData::OBJECT_LEAD, $object['entityId'], self::LEAD_FIELDS);\n $entity = $bhData['data'];\n\n $record['name'] = $entity['name'];\n $record['organization'] = $entity['companyName'];\n $record['title'] = $entity['occupation'];\n\n foreach (['mobile', 'phone', 'phone2', 'phone3', 'workPhone'] as $phone) {\n if (empty($entity[$phone]) === false) {\n $record['phoneNumbers'][] = [\n 'number' => $entity[$phone],\n 'nationalFormat' => phone_national(null, $entity[$phone]),\n 'type' => $phone === 'mobile' ? 'mobile' : 'phone',\n ];\n }\n }\n\n break;\n\n default:\n // We are not interested in this entity type so skip it.\n continue 2;\n }\n\n $result[] = $record;\n }\n\n return $result;\n });\n\n return $data;\n }\n\n public function getClient(): BullhornClientInterface\n {\n return $this->client;\n }\n\n private function getFieldsAsString(array $fields)\n {\n return implode(', ', $fields);\n }\n\n public function findOpportunities(?string $crmAccountId, ?string $crmContactId, ?int $userId = null): array\n {\n $data = [];\n $ownerData = [];\n $ownerId = null;\n $contact = null;\n $account = null;\n\n if ($crmAccountId) {\n $account = $this->config->accounts()->where('crm_provider_id', $crmAccountId)->first();\n\n if ($account === null) {\n $account = $this->syncAccount($crmAccountId);\n }\n }\n\n if ($crmContactId) {\n $contact = $this->config->contacts()->where('crm_provider_id', $crmContactId)->first();\n\n if ($contact === null) {\n $contact = $this->syncContact($crmContactId);\n }\n }\n\n if ($contact || $account) {\n if ($contact && $account === null) {\n $account = $contact->account;\n }\n\n if ($account === null) {\n return [];\n }\n\n $where = 'isDeleted: false AND clientCorporation.id:' . (int) $account->crm_provider_id;\n\n $params = [\n 'sort' => '-dateLastModified',\n 'fields' => $this->getFieldsAsString(self::OPPORTUNITY_FIELDS),\n ];\n\n $onlyOpen = true;\n switch ($this->config->opportunity_assignment_rule) {\n case Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_UPDATED:\n $params['sort'] = '-dateLastModified';\n\n break;\n\n case Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_CREATED:\n $params['sort'] = '-dateAdded';\n\n break;\n\n case Configuration::OPP_ASSIGNMENT_ALL_OPEN_OLDEST_CREATED:\n $params['sort'] = '+dateAdded';\n\n break;\n\n case Configuration::OPP_ASSIGNMENT_ALL_RECENTLY_UPDATED:\n $params['sort'] = '-dateLastModified';\n $onlyOpen = false;\n }\n\n if ($onlyOpen) {\n $where .= ' AND isOpen: true';\n }\n\n $params['query'] = $where;\n\n $bhOpportunities = $this->client->get('search/JobOrder', $params);\n\n if ($userId) {\n $profileRepository = app(ProfileRepository::class);\n $profile = $profileRepository->findProfileByUserId($this->config, $userId);\n\n $ownerId = $profile instanceof Profile ? $profile->getCrmProviderId() : null;\n }\n\n foreach ($bhOpportunities['data'] as $bhOpportunity) {\n $stage = $this->config\n ->stages()\n ->where('crm_provider_id', $bhOpportunity['status'])\n ->first();\n\n if ($stage === null) {\n // Import it.\n $stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $bhOpportunity['status']);\n }\n\n $record = [\n 'crmId' => $bhOpportunity['id'],\n 'name' => $bhOpportunity['title'],\n 'value' => currency_format($bhOpportunity['salary'], $bhOpportunity['correlatedCustomText10'] ?? 'USD'),\n 'won' => strtolower($bhOpportunity['status']) === 'placed',\n 'closed' => $bhOpportunity['isOpen'] !== true,\n 'stage' => [\n 'id' => $stage->id_string,\n 'name' => $stage->name,\n ],\n 'recordType' => [],\n ];\n\n if ($ownerId && isset($bhOpportunity['owner']['id']) && $bhOpportunity['owner']['id'] === (int) $ownerId) {\n $ownerData[] = $record;\n }\n\n $data[] = $record;\n }\n }\n\n if (! empty($ownerData)) {\n return $ownerData;\n }\n\n return $data;\n }\n\n public function getTasks(string $objectType, string $objectId, ?string $opportunityId): array\n {\n $data = [];\n\n // For now it has not been agreed on how to implement this e.g. appointments or tasks?\n return [];\n\n if ($objectType === 'account' && $opportunityId === null) {\n // You cannot attach tasks to only accounts.\n return [];\n }\n\n // Query should default to any open task.\n $where = 'isDeleted: false AND isCompleted: false AND (' . $this->convertObjectTypeToResource($objectType) . 'UserId: ' . (int) $objectId;\n\n // If we are also going to check on a specific job order, set that up.\n if ($opportunityId) {\n $where .= ' OR JobOrder: ' . (int) $opportunityId;\n }\n\n $where .= ')';\n\n $params = [\n 'sort' => '-dateLastModified',\n 'fields' => $this->getFieldsAsString(self::TASK_FIELDS),\n 'query' => $where,\n ];\n\n $bhTasks = $this->client->get('search/Task', $params);\n\n foreach ($bhTasks['data'] as $bhTask) {\n $data[] = [\n 'crmId' => $bhTask['id'],\n 'subject' => $bhTask['subject'],\n 'due' => Carbon::createFromTimestampMs($bhTask['dateEnd'])->toIso8601String(),\n 'type' => null,\n ];\n }\n\n return $data;\n }\n\n /**\n * @url https://bullhorn.github.io/rest-api-docs/index.html#get-find\n *\n * @return null|array{\n * ?Lead,\n * ?Account,\n * ?Opportunity,\n * ?Contact,\n * ?Stage,\n * ?string\n *}\n */\n public function matchExactlyByEmail(string $email, ?int $userId = null): ?array\n {\n $bhObject = $this->client->get('find', [\n 'query' => $email,\n 'meta' => 'basic',\n 'countPerEntity' => 10,\n ]);\n\n $data = $this->convertCrmData($bhObject['data'], $userId);\n\n return ! empty(array_filter($data)) ? $data : null;\n }\n\n /**\n * @url https://bullhorn.github.io/rest-api-docs/index.html#get-find\n *\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n *}\n */\n public function matchByPhone(string $phone, ?string $rawPhoneNumber = null, ?int $userId = null): ?array\n {\n // Check if the user is internal.\n $teamMember = $this->team->users()->where('phone', $phone)->exists();\n\n // Skip the attendee if internal.\n if ($teamMember === false) {\n $bhObject = $this->client->get('find', [\n 'query' => $rawPhoneNumber ?? $phone,\n 'meta' => 'basic',\n 'countPerEntity' => 10,\n ]);\n\n if (empty($bhObject['data'])) {\n $nationalPhone = phone_national(null, $rawPhoneNumber ?? $phone);\n $formattedPhone = ltrim($nationalPhone, '+0');\n $bhObject = $this->client->get('find', [\n 'query' => $formattedPhone,\n 'meta' => 'basic',\n 'countPerEntity' => 10,\n ]);\n }\n\n $data = $this->convertCrmData($bhObject['data'], $userId);\n\n return ! empty(array_filter($data)) ? $data : null;\n }\n\n return null;\n }\n\n /**\n * @url https://bullhorn.github.io/rest-api-docs/index.html#get-find\n *\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n public function matchByName(string $name, ?int $userId = null): ?array\n {\n // Don't waste time searching for single character strings.\n if (\\strlen($name) <= 3) {\n return null;\n }\n\n $cacheKey = $this->getCacheKey($name, $userId);\n\n $result = \\Cache::remember($cacheKey, 60, function () use ($name, $userId) {\n $bhObject = $this->client->get('find', [\n 'query' => $name,\n 'meta' => 'basic',\n 'countPerEntity' => 10,\n ]);\n\n $data = $this->convertCrmData($bhObject['data'], $userId);\n\n return ! empty(array_filter($data)) ? $data : false;\n });\n\n return is_array($result) ? $result : null;\n }\n\n protected function getCacheKey(string $object, ?int $userId = null): ?string\n {\n $key = $this->team->getId() . $object;\n $keySuffix = $this->getOwnerKeySuffix($userId);\n\n return $key . $keySuffix;\n }\n\n private function getOwnerKeySuffix(?int $userId = null): string\n {\n return $userId === null ? '' : (string) $userId;\n }\n\n public function saveActivity(Activity $activity): Activity\n {\n\n if ($activity->hasCrmProviderId()) {\n return $activity;\n }\n\n switch ($activity->type) {\n case Activity::TYPE_CONFERENCE:\n case Activity::TYPE_SOFTPHONE:\n case Activity::TYPE_SOFTPHONE_INBOUND:\n case Activity::TYPE_SMS_INBOUND:\n case Activity::TYPE_SMS_OUTBOUND:\n $activity = $this->buildNotePayload($activity);\n\n break;\n }\n\n return $activity;\n }\n\n /**\n * @throws CrmException\n * @throws DomainException\n */\n private function buildNotePayload(Activity $activity): Activity\n {\n if ($activity->hasActivityType() === false) {\n throw new DomainException('[Bullhorn] No activity type assigned');\n }\n\n $action = $this->getCrmType($activity->getActivityType());\n if ($action === null) {\n throw new DomainException('[Bullhorn] No mapped CRM type for \"' . $activity->category->name . '\"');\n }\n\n if ($activity->lead_id === null && $activity->contact_id === null) {\n throw new DomainException('[Bullhorn] Cannot save note without candidate or DM');\n }\n\n $payload = [\n 'comments' => $this->generateActivityDescription($activity),\n 'action' => $action->value,\n 'commentingPerson' => [\n 'id' => (int) $this->profile->crm_provider_id,\n 'type' => 'CorporateUser',\n ],\n 'externalID' => $activity->id_string,\n 'minutesSpent' => $activity->duration,\n 'dateAdded' => $activity->hasActualEndTime() ? $activity->getActualEndTime()?->valueOf() : now()->valueOf(),\n ];\n\n if ($activity->lead_id) {\n $payload['personReference'] = [\n 'id' => (int) $activity->lead->crm_provider_id,\n 'type' => 'Candidate',\n ];\n }\n\n if ($activity->contact_id) {\n $payload['personReference'] = [\n 'id' => (int) $activity->contact->crm_provider_id,\n 'type' => 'ClientContact',\n ];\n }\n\n if ($activity->opportunity_id) {\n $payload['jobOrders'] = [\n 'replaceAll' => [\n (int) $activity->opportunity->crm_provider_id,\n ],\n ];\n }\n\n $bhActivity = $this->client->put('entity/Note', $payload);\n\n $activity->crm_provider_id = $bhActivity['changedEntityId'];\n $activity->save();\n\n return $activity;\n }\n\n private function generateActivityDescription(Activity $activity): string\n {\n $description = '';\n switch ($activity->type) {\n case Activity::TYPE_SOFTPHONE:\n case Activity::TYPE_SOFTPHONE_INBOUND:\n case Activity::TYPE_CONFERENCE:\n\n if ($activity->isTypeSoftPhone() && $activity->getProvider() !== Activity::PROVIDER_UPLOADER) {\n // Dialer calls have no native field for the subject.\n if ($activity->title) {\n $description .= sprintf(\n '<p><span style=\"font-size: 16px; line-height: 19.2px;\">%s</span></p>',\n $activity->title,\n );\n }\n\n // Conference meetings log duration into a distinct field, so log this in the main text.\n $description .= sprintf(\n '<p><span style=\"font-size: 13.3333px; line-height: 16px;\">Call Duration: %s</span></p>',\n $activity->duration_for_humans,\n );\n\n // Tell them which number was dialed.\n if ($activity->to) {\n $description .= sprintf(\n '<p><span style=\"font-size: 13.3333px; line-height: 16px;\">Dialed: %s</span></p>',\n $activity->to->phone_number,\n );\n }\n $description .= '<p></p>';\n }\n\n if ($activity->isTypeSoftphoneInbound() && $activity->getProvider() !== Activity::PROVIDER_UPLOADER) {\n // Dialer calls have no native field for the subject.\n if ($activity->title) {\n $description .= sprintf(\n '<p><span style=\"font-size: 16px; line-height: 19.2px;\">%s</span></p>',\n $activity->title,\n );\n }\n\n // Conference meetings log duration into a distinct field, so log this in the main text.\n $description .= sprintf(\n '<p><span style=\"font-size: 13.3333px; line-height: 16px;\">Call Duration: %s</span></p>',\n $activity->duration_for_humans,\n );\n $description .= sprintf(\n '<p><span style=\"font-size: 13.3333px; line-height: 16px;\">Caller: %s</span></p>',\n $activity->from->phone_number,\n );\n $description .= '<p></p>';\n }\n\n if ($activity->hasReasonCodeBotKicked()) {\n $description .= '<p>'\n . '<span style=\"font-size: 13.3333px; line-height: 16px;\">'\n . 'Notetaker removed from this meeting'\n . '</span>'\n . '</p>';\n // When we fix the state to be Activity::RECORDING_RECORDED as it should be this can change.\n } elseif ($activity->hasReasonCodeNotCompliant()) {\n $description .= '<p>'\n . '<span style=\"font-size: 13.3333px; line-height: 16px;\">'\n . 'Notetaker did not join due to recording consent not being provided by attendees'\n . '</span>'\n . '</p>';\n } elseif ($activity->canReviewActivity()) {\n $playbackUrl = $activity->user->team->partner->getPlaybackUrl($activity);\n $description .= sprintf(\n '<p>'\n . '<span style=\"font-size: 13.3333px; line-height: 16px;\">'\n . '<a href=\"%s\" target=\"_blank\">'\n . 'Review in Jiminny'\n . '</a>'\n . '️</span>'\n . '</p>',\n $playbackUrl,\n );\n }\n\n if ($activity->isTypeConference()) {\n $description .= '<p><strong>Attendees</strong></p><ul>';\n\n // Only include participants that joined.\n $participants = $activity->participants()->ghost(false)->whereNotNull('enter_time')->get();\n foreach ($participants as $participant) {\n $attendee = '<li>';\n if ($participant->name) {\n $attendee .= $participant->name;\n if ($participant->phone_number) {\n $attendee .= ' (' . $participant->phone_number . ')';\n } elseif ($participant->email) {\n $attendee .= ' (' . $participant->email . ')';\n }\n } else {\n $attendee .= $participant->phone_number;\n }\n\n if ($participant->is_ghost) {\n $attendee .= ' <strong>coach</coach>';\n }\n\n if ($participant->getTitle()) {\n $attendee .= ', ' . $participant->getTitle();\n }\n\n $description .= $attendee . '</li>';\n }\n $description .= '</ul>';\n }\n\n if (count($activity->notes) > 0) {\n $description .= '<p><strong>Notes</strong></p>';\n\n foreach ($activity->notes as $note) {\n $time = ($note->time > 3600) ? gmdate('H:i:s', $note->time) : gmdate('i:s', $note->time);\n $description .= '<p>' . $time . ' ' . $note->note . '</p>';\n }\n $description .= '</p>';\n }\n\n // Get all private messages.\n $messages = $activity->messages()\n ->where('is_private', 1)\n ->orderBy('created_at', 'asc');\n\n if ($messages->count() > 0) {\n $description .= '<p><strong>Coaching Chat</strong></p>';\n\n foreach ($messages->get() as $message) {\n $description .= '<p>' . $message->participant->name . ': ' . $message->message . '</p>';\n }\n $description .= '</p>';\n }\n\n // Get all public messages.\n $messages = $activity->messages()\n ->where('is_private', 0)\n ->orderBy('created_at', 'asc');\n\n if ($messages->count() > 0) {\n $description .= '<p><strong>Customer Chat</strong></p>';\n\n foreach ($messages->get() as $message) {\n $description .= '<p>' . $message->participant->name . ': ' . $message->message . '</p>';\n }\n $description .= '</p>';\n }\n\n if ($activity->summary) {\n $description .= '<p><strong>Summary</strong></p><p>' . $activity->summary . '</p><p></p>';\n }\n\n break;\n\n case Activity::TYPE_SMS_INBOUND:\n case Activity::TYPE_SMS_OUTBOUND:\n // Use Rich-text formatting.\n $description = sprintf(\n '<p><span style=\"font-size: 16px; line-height: 19.2px;\"><strong>%s</strong></span></p>',\n $activity->title,\n );\n $description .= sprintf(\n '<p><span style=\"font-size: 16px; line-height: 19.2px;\">%s</span></p>',\n $activity->description,\n );\n\n break;\n }\n\n return $description;\n }\n\n\n public function saveFollowupActivity(Activity $activity, array $fields): ?string\n {\n // Not yet required by current customers.\n return null;\n }\n\n public function updateStage($crmObject, Stage $stage): void\n {\n $objectId = $crmObject->crm_provider_id;\n\n $this->updateRecord($stage->type, $objectId, ['status' => $stage->crm_provider_id]);\n }\n\n public function syncProfiles(?User $userToSearch = null): ?Profile\n {\n $start = 0;\n $payload = [\n 'fields' => 'id,email',\n 'where' => 'isDeleted=false',\n 'count' => 500,\n ];\n\n $payload['start'] = $start;\n $profiles = $this->client->get('query/CorporateUser', $payload);\n\n foreach ($profiles['data'] as $profile) {\n if (empty($profile['email'])) {\n continue;\n }\n\n $user = $this->userRepository->findByTeamAndEmailAddress($this->team, $profile['email'], onlyActive: true);\n\n // Only import profiles for users we have locally.\n if ($user) {\n $profile = $user->crmProfile()->updateOrCreate([\n 'crm_configuration_id' => $this->config->id,\n 'crm_provider_id' => $profile['id'],\n ]);\n\n if ($userToSearch && $userToSearch->id === $user->id) {\n return $profile;\n }\n }\n }\n\n return null;\n }\n\n protected function convertCrmData(array $entities, ?int $userId = null): array\n {\n $lead = null;\n $contact = null;\n $opportunity = null;\n $account = null;\n $stage = null;\n $countryCode = null;\n\n $mappedEntities = [];\n foreach ($entities as $entity) {\n if (in_array($entity['entityType'], [self::ENTITY_LEAD, self::ENTITY_CONTACT, self::ENTITY_ACCOUNT])) {\n $mappedEntities[] = $entity;\n }\n }\n\n // If there is only one match, sync the data and return the object(s).\n if (count($mappedEntities) === 1) {\n $entity = $mappedEntities[0];\n\n if ($entity['entityType'] === self::ENTITY_LEAD) {\n $lead = $this->syncLead($entity['entityId']);\n\n // Lead might not be imported if the Stage is null for example.\n if ($lead) {\n $countryCode = $lead->country_code;\n $stage = $lead->stage;\n }\n } elseif (in_array($entity['entityType'], [self::ENTITY_CONTACT, self::ENTITY_ACCOUNT])) {\n if ($entity['entityType'] === self::ENTITY_CONTACT) {\n $contact = $this->syncContact($entity['entityId']);\n $account = $contact->account;\n } else {\n $account = $this->syncAccount($entity['entityId']);\n }\n\n if ($contact?->country_code) {\n $countryCode = $contact->country_code;\n } elseif ($account) {\n $countryCode = $account->country_code;\n }\n\n try {\n $bhOpportunities = $this->findOpportunities(\n $account?->crm_provider_id,\n $contact?->crm_provider_id,\n $userId,\n );\n\n // Take the first opportunity, which will be ordered as priority based on their settings.\n if (! empty($bhOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($bhOpportunities[0]['crmId']);\n $stage = $opportunity?->stage;\n }\n } catch (Exception) {\n // Nothing to see here.\n }\n }\n }\n\n return [\n $lead,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n }\n\n public function generateProviderUrl(string $providerId, string $objectType): ?string\n {\n // There doesn't appear to be a way to deeplink to anything on https://app.bullhornstaffing.com.\n return null;\n }\n\n public function updateRecord(string $objectType, string $objectId, array $data, array $headers = []): void\n {\n $this->client->post('entity/' . $this->convertObjectTypeToResource($objectType) . '/' . $objectId, $data);\n }\n\n public function getRecord(string $objectType, string $objectId, array $fields = ['id']): array\n {\n return $this->client->get(\n url: 'entity/' . $this->convertObjectTypeToResource($objectType) . '/' . $objectId,\n data: ['fields' => $this->getFieldsAsString($fields)],\n );\n }\n\n private function convertObjectTypeToResource(string $objectType)\n {\n switch ($objectType) {\n case FieldData::OBJECT_OPPORTUNITY:\n return self::ENTITY_OPPORTUNITY;\n\n case FieldData::OBJECT_CONTACT:\n return self::ENTITY_CONTACT;\n\n case FieldData::OBJECT_LEAD:\n return self::ENTITY_LEAD;\n\n case FieldData::OBJECT_ACCOUNT:\n return self::ENTITY_ACCOUNT;\n\n case FieldData::OBJECT_TASK:\n return self::ENTITY_ACTIVITY;\n\n default:\n throw new \\UnexpectedValueException('Unsupported object type \"' . $objectType . '\"');\n }\n }\n\n private function convertResourceToObjectType(string $resource): string\n {\n switch ($resource) {\n case self::ENTITY_OPPORTUNITY:\n return FieldData::OBJECT_OPPORTUNITY;\n\n case self::ENTITY_CONTACT:\n return FieldData::OBJECT_CONTACT;\n\n case self::ENTITY_LEAD:\n return FieldData::OBJECT_LEAD;\n\n case self::ENTITY_ACCOUNT:\n return FieldData::OBJECT_ACCOUNT;\n\n case self::ENTITY_ACTIVITY:\n return FieldData::OBJECT_TASK;\n\n default:\n throw new \\UnexpectedValueException('Unsupported resource \"' . $resource . '\"');\n }\n }\n\n public function fetchAndAssociateRelatedActivity(Activity $activity): ?Activity\n {\n // We need to know when it started to determine when to look for related activity.\n if ($activity->hasActualStartTime() === false) {\n return null;\n }\n\n try {\n $bhNote = $this->fetchRelatedActivity($activity);\n if (empty($bhNote)) {\n $this->logger->info('[Bullhorn] No related activity found', [\n 'activity' => $activity->getUuid(),\n ]);\n\n return null;\n }\n\n $activityType = $this->getPlaybookCategory($this->getPlaybook($activity->user), $bhNote['action']);\n $comments = $bhNote['comments'] . '<br />//<br />' . $this->generateActivityDescription($activity);\n // Now update the Note to contain our data.\n $payload = [\n 'comments' => $comments,\n 'externalID' => $activity->id_string,\n 'minutesSpent' => $activity->duration,\n ];\n\n $this->logger->info('[Bullhorn] updating related activity', [\n 'activity' => $activity->getUuid(),\n 'activityType' => $activityType->id ?? null,\n ]);\n\n $this->client->post('entity/Note/' . $bhNote['id'], $payload);\n\n $playbookCategoryId = null;\n if ($activity->getCategory() !== null) {\n $playbookCategoryId = $activity->getCategory()->getId();\n }\n\n // Update our activity to reflect the remote data.\n $activity->update([\n 'crm_provider_id' => $bhNote['id'],\n 'summary' => $bhNote['comments'],\n 'playbook_category_id' => $activityType->id ?? $playbookCategoryId,\n ]);\n\n return $activity;\n } catch (\\Exception $exception) {\n \\Sentry::captureException($exception);\n\n throw $exception;\n }\n }\n\n /**\n * Returns the most recently modified note within time range (if any).\n *\n * @return array A Note record from Bullhorn.\n */\n public function fetchRelatedActivity(Activity $activity): array\n {\n $from = $activity->hasScheduledStartTime()\n ? $activity->getScheduledStartTime()\n : $activity->getActualStartTime();\n $to = $activity->hasActualEndTime()\n ? $activity->getActualEndTime()?->addMinutes(15)\n : $from?->clone()->addMinutes(15);\n\n $filterStatements = [\n 'isDeleted: false',\n '*:* -externalID:[* TO *]', // externalID should be null e.g. we did not already relate it.\n sprintf('commentingPerson: %d', (int) $this->profile->crm_provider_id),\n ];\n\n /**\n * @XXX: Bullhorn does not specify the time zone in their docs. I've found a forum thread in which someone from\n * staff asserts it's UTC, however in my field test I've found it to be GMT-0300 (USA east coast).\n * To make the code immune to such glitches we will add 24h to start/end boundaries and will filter out\n * any excess items in code\n */\n $filterStatements[] = sprintf(\n 'dateLastModified:[%s TO %s]',\n $from?->clone()->subHours(24)->format('YmdHis'),\n $to->clone()->addHours(24)->format('YmdHis'),\n );\n\n if ($activity->lead_id) {\n $filterStatements[] = sprintf('candidateUserID: %d', (int) $activity->lead->crm_provider_id);\n }\n\n if ($activity->contact_id) {\n $filterStatements[] = sprintf('clientContactUserID: %d', (int) $activity->contact->crm_provider_id);\n }\n\n // if there is no contact or lead associated to the activity exit\n if ($activity->contact_id === null && $activity->lead_id === null) {\n return [];\n }\n\n $params = [\n 'fields' => $this->getFieldsAsString(self::TASK_FIELDS),\n 'query' => implode(' AND ', $filterStatements),\n 'sort' => '-dateLastModified',\n ];\n\n $bhNotes = $this->client->get('search/Note', $params);\n\n $startStamp = $from?->getTimestamp() * 1000;\n $endStamp = $to->getTimestamp() * 1000;\n\n $filteredNotes = array_values(\n array_filter(\n $bhNotes['data'],\n static fn (array $bhNote): bool\n => $bhNote['dateLastModified'] >= $startStamp && $startStamp <= $endStamp,\n ),\n );\n\n return ! empty($filteredNotes) ? array_shift($filteredNotes) : [];\n }\n\n /**\n * Select how to create the date out of the Bullhorn payload.\n * Do not allow dates beyond 9999-12-31 (max mysql DATE)\n */\n private function filterOutOfBoundDates(string $rawDateCandidate): ?Carbon\n {\n if (strlen($rawDateCandidate) > 12) {\n // A unix timestamp represented with more than 12 digits i\n // s definitely including milliseconds\n $dateFormatted = Carbon::createFromTimestampMs($rawDateCandidate);\n } else {\n $dateFormatted = Carbon::createFromTimestamp($rawDateCandidate);\n }\n\n $minDate = Carbon::createFromFormat('Y-m-d', '2000-01-01');\n if ($dateFormatted->lt($minDate)) {\n return null;\n }\n\n $maxDate = Carbon::createFromFormat('Y-m-d', '9999-12-31');\n if ($dateFormatted->gt($maxDate)) {\n $this->logger->warning('[Bullhorn] An out of bound date is provided', [\n 'date' => $dateFormatted,\n 'rawDate' => $rawDateCandidate,\n ]);\n\n return $maxDate;\n }\n\n return $dateFormatted;\n }\n\n public function verifyTaskExists(Activity $activity): bool\n {\n $crmProviderId = $activity->getCrmProviderId();\n $cacheKey = \"crm_task_exists:{$this->config->getId()}:$crmProviderId\";\n\n return Cache::remember($cacheKey, self::TASK_VERIFICATION_CACHE_TTL, function () use ($crmProviderId) {\n try {\n $note = $this->getRecord('Note', $crmProviderId, ['id', 'isDeleted']);\n\n return ! empty($note) && ($note['isDeleted'] ?? false) === false;\n } catch (HttpNotFoundException|HttpBadRequestException) {\n // Note not found in CRM - this is expected and permanent\n $this->logger->info('[Bullhorn] Note not found during verification', [\n 'note_id' => $crmProviderId,\n 'config_id' => $this->config->getId(),\n ]);\n\n return false;\n }\n // Let other exceptions (network errors, rate limits, etc.) bubble up for retry\n });\n }\n}","depth":4,"on_screen":true,"value":"<?php\n\nnamespace Jiminny\\Services\\Crm\\Bullhorn;\n\nuse Carbon\\Carbon;\nuse Exception;\nuse Illuminate\\Support\\Facades\\Cache;\nuse Illuminate\\Support\\Facades\\Log;\nuse Jiminny\\Contracts\\Services\\Crm\\MatchCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\FetchRelatedActivityInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\LayoutManagementInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\Provider\\BullhornInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityLookupInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityManipulationInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SettingsInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmMetadataInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\VerifyTaskExistsInterface;\nuse Jiminny\\Exceptions\\ApplicationException;\nuse Jiminny\\Exceptions\\CrmException;\nuse Jiminny\\Exceptions\\DomainException;\nuse Jiminny\\Exceptions\\HttpBadRequestException;\nuse Jiminny\\Exceptions\\HttpNotFoundException;\nuse Jiminny\\Exceptions\\ServiceUnavailableException;\nuse Jiminny\\Models\\Account;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Contact;\nuse Jiminny\\Models\\Crm\\Configuration;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Models\\Crm\\FieldData;\nuse Jiminny\\Models\\Crm\\Profile;\nuse Jiminny\\Models\\Lead;\nuse Jiminny\\Models\\Opportunity;\nuse Jiminny\\Models\\Playbook;\nuse Jiminny\\Models\\SocialAccount;\nuse Jiminny\\Models\\Stage;\nuse Jiminny\\Models\\User;\nuse Jiminny\\Repositories\\Crm\\ProfileRepository;\nuse Jiminny\\Repositories\\UserRepository;\nuse Jiminny\\Services\\Avatar\\ProspectPhotoPathService;\nuse Jiminny\\Services\\Crm\\BaseService;\nuse Jiminny\\Services\\Crm\\OpportunitySyncStrategyResolver;\nuse Jiminny\\Utils\\CurrencyFormatter;\nuse Jiminny\\Utils\\StringUtil;\nuse Ramsey\\Uuid\\Uuid;\n\nclass BullhornService extends BaseService implements\n BullhornInterface,\n SyncCrmEntitiesInterface,\n SyncCrmMetadataInterface,\n RemoteEntityManipulationInterface,\n FetchRelatedActivityInterface,\n LayoutManagementInterface,\n SettingsInterface,\n MatchCrmEntitiesInterface,\n RemoteEntityLookupInterface,\n VerifyTaskExistsInterface\n{\n private const int TASK_VERIFICATION_CACHE_TTL = 86400; // 1 day\n\n private const string ENTITY_LEAD = 'Candidate';\n private const string ENTITY_OPPORTUNITY = 'JobOrder';\n private const string ENTITY_ACCOUNT = 'ClientCorporation';\n private const string ENTITY_CONTACT = 'ClientContact';\n private const string ENTITY_ACTIVITY = 'Note';\n\n private const array OPPORTUNITY_FIELDS = [\n 'id',\n 'owner',\n 'status',\n 'title',\n 'salary',\n 'salaryUnit',\n 'payRate',\n 'isOpen',\n 'employmentType',\n 'dateClosed',\n 'startDate',\n 'dateLastModified',\n 'dateAdded',\n 'clientCorporation',\n 'correlatedCustomText10', // currency\n ];\n\n private const array TASK_FIELDS = [\n 'id',\n 'comments',\n 'dateAdded',\n 'dateLastModified',\n 'externalID',\n 'isDeleted',\n 'corporateUsers',\n 'clientContacts',\n 'commentingPerson',\n 'candidates',\n 'jobOrder',\n 'minutesSpent',\n 'action',\n ];\n\n private const array LEAD_FIELDS = [\n 'id',\n 'name',\n 'owner',\n 'occupation',\n 'companyName',\n 'companyURL',\n 'mobile',\n 'phone',\n 'phone2',\n 'phone3',\n 'workPhone',\n 'status',\n 'address',\n 'dateAdded',\n 'dateLastModified',\n ];\n\n private const array CONTACT_FIELDS = [\n 'id',\n 'name',\n 'occupation',\n 'mobile',\n 'phone',\n 'phone2',\n 'phone3',\n 'address',\n 'clientCorporation',\n 'dateAdded',\n 'owner',\n ];\n\n private const array ACCOUNT_FIELDS = [\n 'id',\n 'name',\n 'phone',\n 'industryList',\n 'address',\n 'companyURL',\n 'dateAdded',\n ];\n\n public function __construct(\n BullhornClientInterface $client,\n private readonly UserRepository $userRepository,\n private readonly ProspectPhotoPathService $prospectPhotoPathService\n ) {\n parent::__construct();\n\n $this->client = $client;\n }\n\n protected function getOAuthAccount(User $user): ?SocialAccount\n {\n // In this case, the Account Owner is always the connection for any API operations.\n $owner = $user->team->owner;\n\n return $owner->getSocialAccount(SocialAccount::PROVIDER_BULLHORN);\n }\n\n public function getDisplayName(): string\n {\n return 'Bullhorn';\n }\n\n public function createTranscriptNotes(Activity $activity): void\n {\n // Not done until a customer requests it.\n }\n\n public function syncProfileFields(): void\n {\n $fieldTypes = [\n 'lead',\n 'account',\n 'contact',\n 'opportunity',\n 'task',\n ];\n\n foreach ($fieldTypes as $fieldType) {\n try {\n $currentFields = $this->getFields($this->convertObjectTypeToResource($fieldType));\n\n $newFields = array_column($currentFields, 'name');\n $changedFields = array_merge(\n array_diff($this->profile->getFieldsAsArray($fieldType), $newFields),\n array_diff($newFields, $this->profile->getFieldsAsArray($fieldType))\n );\n\n if (! empty($changedFields)) {\n $fields = implode(',', $newFields);\n Log::debug(\n sprintf(\n '%s fields changed from %s to: %s',\n ucfirst($fieldType),\n $this->profile->{$fieldType . '_fields'},\n $fields,\n ),\n );\n\n $this->profile->{$fieldType . '_fields'} = $fields;\n }\n } catch (\\Exception $exception) {\n \\Log::error('No access to ' . $fieldType . ' object, skipping...');\n\n // XXX: should we log this fact somewhere?\n continue;\n }\n }\n\n $this->profile->save();\n }\n\n protected function getFieldTypes(): array\n {\n return [\n parent::OBJECT_TASK,\n //parent::OBJECT_ACCOUNT,\n //parent::OBJECT_CONTACT,\n //parent::OBJECT_OPPORTUNITY,\n //parent::OBJECT_LEAD,\n ];\n }\n\n protected function getFields(string $crmObject): array\n {\n $fields = [];\n foreach ($this->getObjectFields($crmObject) as $row) {\n $fields[] = ['label' => $row['label'] ?? $row['name'], 'name' => $row['name']];\n }\n\n return $fields;\n }\n\n private function getObjectFields(string $objectType): array\n {\n $entityType = $this->convertObjectTypeToResource($objectType);\n\n return Cache::remember(\n key: 'crm.profile#' . $this->profile->id . '.fieldType:' . $objectType,\n ttl: 60 * 60,\n callback: function () use ($entityType) {\n $entity = $this->client->get('meta/' . $entityType, [\n 'fields' => '*',\n ]);\n\n return $entity['fields'];\n },\n );\n }\n\n public function getDefaultFields(string $activityType): array\n {\n $fields = [];\n $defaultFields = FieldDefinitions::defaultFields();\n\n foreach ($defaultFields as $defaultField) {\n $fields[] = $this->config->fields()->firstOrCreate($defaultField);\n }\n\n return $fields;\n }\n\n public function getDefaultActivityField(string $activityType): Field\n {\n /** @var Field $activityField */\n $activityField = $this->config->fields()->where([\n 'crm_provider_id' => 'action',\n 'object_type' => $activityType,\n ])->first();\n\n return $activityField;\n }\n\n public function getDealInsightsFields(): array\n {\n return FieldDefinitions::dealInsightsFields();\n }\n\n public function getSupportedPlaybookTypes(): array\n {\n return [Playbook::ACTIVITY_TYPE_TASK];\n }\n\n protected function getDefaultFollowupLayoutFields(string $activityType): array\n {\n // We are not yet supporting follow-ups for this system.\n return [];\n }\n\n public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string\n {\n return $fieldValue;\n }\n\n public function syncField(Field $field): void\n {\n $resource = $this->convertObjectTypeToResource($field->object_type);\n $entity = $this->client->get('meta/' . $resource, [\n 'fields' => $field->crm_provider_id,\n 'meta' => 'full',\n ]);\n\n $bhField = $entity['fields'][0];\n\n $isMandatory = (empty($bhField['optional']) == false && $bhField['optional'] === false);\n $description = null;\n if (empty($bhField['description']) === false) {\n $description = mb_strimwidth($bhField['description'], 0, 191);\n }\n\n $label = 'Unknown';\n if (empty($bhField['label']) === false) {\n $label = mb_strimwidth($bhField['label'], 0, Field::LABEL_MAX_LENGTH);\n }\n\n $field->length = $bhField['maxLength'] ?? null;\n $field->is_mandatory = $isMandatory;\n $field->description = $description;\n $field->label = $label;\n $field->type = $this->convertFieldType(\n $bhField['dataType'] ?? null,\n $bhField['dataSpecialization'] ?? null,\n $bhField['inputType'] ?? null\n );\n\n $field->save();\n\n if (empty($bhField['options']) === false) {\n $values = [];\n\n // Import all values.\n foreach ($bhField['options'] as $i => $option) {\n $values[] = [\n 'value' => $option['value'],\n 'label' => mb_strimwidth($option['label'], 0, Field::LABEL_MAX_LENGTH),\n 'sequence' => $i,\n ];\n }\n\n // Take the existing set of field values.\n $fieldsToPurge = $field->values()->get()->pluck('value')->toArray();\n\n foreach ($values as $value) {\n // For all current values, upsert the field.\n $field->values()->updateOrCreate([\n 'value' => $value['value'],\n ], $value);\n\n // Remove this value from the ones we are going to purge.\n $key = array_search($value['value'], $fieldsToPurge, false);\n if ($key !== false) {\n unset($fieldsToPurge[$key]);\n }\n }\n\n // Delete the old values that are no longer used.\n $field->values()->whereIn('value', $fieldsToPurge)->delete();\n }\n }\n\n public function importPicklistValues(Field $field): array\n {\n // Taken care of in syncField.\n return [];\n }\n\n private function convertFieldType(?string $type, ?string $specialization, ?string $inputType): string\n {\n if ($type === null) {\n return Field::TYPE_UNSUPPORTED;\n }\n\n $type = strtolower($type);\n $specialization = strtolower($specialization);\n $inputType = strtolower($inputType);\n\n switch ($type) {\n case 'boolean':\n return Field::TYPE_BOOLEAN;\n\n case 'integer':\n switch ($specialization) {\n case 'integer':\n return Field::TYPE_NUMBER;\n default:\n return Field::TYPE_NUMBER;\n }\n\n // no break\n case 'bigdecimal':\n switch ($specialization) {\n case 'money':\n return Field::TYPE_CURRENCY;\n default:\n return Field::TYPE_NUMBER;\n }\n\n // no break\n case 'double':\n switch ($specialization) {\n case 'float':\n return Field::TYPE_NUMBER;\n default:\n return Field::TYPE_NUMBER;\n }\n\n // no break\n case 'timestamp':\n switch ($specialization) {\n case 'date':\n return Field::TYPE_DATE;\n case 'time':\n return Field::TYPE_TIME;\n case 'datetime':\n return Field::TYPE_DATETIME;\n default:\n return Field::TYPE_DATE;\n }\n\n // no break\n case 'string':\n switch ($inputType) {\n case 'textarea':\n return Field::TYPE_TEXTAREA;\n case 'select':\n return Field::TYPE_PICKLIST;\n default:\n return Field::TYPE_TEXT;\n }\n\n // no break\n default:\n // This is not actually a supported field.\n return Field::TYPE_UNSUPPORTED;\n }\n }\n\n private function importContact($crmData): Contact\n {\n $account = $mobilePhone = $phone = $email = null;\n if (empty($crmData['clientCorporation']) === false) {\n $account = $this->team\n ->accounts()\n ->where('crm_provider_id', $crmData['clientCorporation']['id'])\n ->first();\n\n if ($account === null) {\n $account = $this->syncAccount($crmData['clientCorporation']['id']);\n }\n }\n\n $countryCode = $this->convertCountryNameToCode($crmData['address']['countryCode']);\n\n foreach (['mobile', 'phone', 'phone2', 'phone3'] as $type) {\n if (empty($crmData[$type])) {\n continue;\n }\n\n if ($type === 'mobile') {\n $mobilePhone = parsePhoneNumber($countryCode, $crmData[$type]);\n } else {\n $phone = parsePhoneNumber($countryCode, $crmData[$type]);\n }\n\n break;\n }\n\n foreach (['email', 'email1', 'email2'] as $type) {\n if (empty($crmData[$type])) {\n continue;\n }\n\n $email = mb_strimwidth($crmData[$type], 0, 80);\n\n break;\n }\n\n $profile = $this->config->profiles()->where('crm_provider_id', (string) $crmData['owner']['id'])->first();\n\n $data = [\n 'team_id' => $this->team->id,\n 'account_id' => $account->id ?? null,\n 'user_id' => $profile ? $profile->user_id : null,\n 'owner_id' => $crmData['owner']['id'],\n 'name' => $crmData['name'],\n 'email' => $email,\n 'phone' => empty($phone['phone']) ? null : mb_strimwidth($phone['phone'], 0, 25),\n 'ext' => $phone['ext'] ?? null,\n 'mobile_phone' => empty($mobilePhone['phone']) ? null : mb_strimwidth($mobilePhone['phone'], 0, 25),\n 'title' => $crmData['occupation'],\n 'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(\n crmConfiguration: $this->config,\n crmProviderId: $crmData['id'],\n modelType: Contact::class,\n fileName: $crmData['id'],\n avatarText: $crmData['name'] ?? 'Unknown'\n ),\n 'remotely_created_at' => $this->filterOutOfBoundDates($crmData['dateAdded']),\n ];\n\n return $this->config->contacts()->updateOrCreate(['crm_provider_id' => (string) $crmData['id']], $data);\n }\n\n private function importOpportunity($crmData): ?Opportunity\n {\n if ($crmData['clientCorporation']['id']) {\n $account = $this->config->accounts()->where('crm_provider_id', (string) $crmData['clientCorporation']['id'])->first();\n\n if ($account === null) {\n $account = $this->syncAccount($crmData['clientCorporation']['id']);\n }\n } else {\n // If there is nothing to associate this with, don't import it.\n return null;\n }\n\n $profile = $this->config->profiles()->where('crm_provider_id', (string) $crmData['owner']['id'])->first();\n\n $stage = $this->config->stages()\n ->where('crm_provider_id', $crmData['status'])\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->first();\n\n if ($stage === null) {\n $stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $crmData['status']);\n }\n\n $data = [\n 'team_id' => $this->team->id,\n 'account_id' => $account->id,\n 'user_id' => $profile ? $profile->user_id : null,\n 'owner_id' => $crmData['owner']['id'],\n 'name' => mb_strimwidth($crmData['title'], 0, 128),\n 'value' => $crmData['salary'] ?? $crmData['payRate'],\n 'currency_code' => CurrencyFormatter::formatCode($crmData['correlatedCustomText10'] ?? 'USD'),\n 'close_date' => $this->filterOutOfBoundDates($crmData['startDate']),\n 'is_closed' => $crmData['isOpen'] !== true,\n 'is_won' => strtolower($crmData['status']) === 'placed',\n 'stage_id' => $stage->id,\n 'record_type_id' => null,\n 'remotely_created_at' => $this->filterOutOfBoundDates($crmData['dateAdded']),\n ];\n\n return $this->config->opportunities()->updateOrCreate(['crm_provider_id' => (string) $crmData['id']], $data);\n }\n\n private function importAccount($crmData): Account\n {\n $countryCode = $domain = null;\n if (empty($crmData['address']['countryCode'])) {\n $countryCode = $this->convertCountryNameToCode($crmData['address']['countryCode']);\n }\n\n $name = $crmData['name'] ?? 'Unknown';\n\n if ($crmData['companyURL'] !== null) {\n $domain = mb_strimwidth($crmData['companyURL'], 0, 191);\n $domain = StringUtil::resolveDomain($domain);\n }\n\n $data = [\n 'team_id' => $this->team->id,\n 'user_id' => null,\n 'owner_id' => null,\n 'name' => mb_strimwidth($name, 0, 191),\n 'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(\n crmConfiguration: $this->config,\n crmProviderId: $crmData['id'],\n modelType: Account::class,\n fileName: $crmData['id'],\n avatarText: $crmData['name'] ?? 'Unknown'\n ),\n 'country_code' => $countryCode,\n 'domain' => $domain,\n 'remotely_created_at' => $this->filterOutOfBoundDates($crmData['dateAdded']),\n ];\n\n return $this->config->accounts()->updateOrCreate(['crm_provider_id' => (string) $crmData['id']], $data);\n }\n\n private function importLead($crmData): ?Lead\n {\n // Get the current stage.\n $stage = $this->config->stages()->where('name', $crmData['status'])->where('type', Stage::TYPE_LEAD)->first();\n if ($stage === null) {\n $stage = $this->importStages([Stage::TYPE_LEAD], $crmData['status']);\n }\n\n // If we have no way of importing this, just return null :(\n if ($stage === null) {\n return null;\n }\n\n $countryCode = null;\n if (empty($crmData['address']['countryCode'])) {\n $countryCode = $this->convertCountryNameToCode($crmData['address']['countryCode']);\n }\n\n $mobilePhone = $phone = $email = null;\n foreach (['mobile', 'phone', 'phone2', 'phone3', 'workPhone'] as $type) {\n if (empty($crmData[$type])) {\n continue;\n }\n\n if ($type === 'mobile') {\n $mobilePhone = parsePhoneNumber($countryCode, $crmData[$type]);\n } else {\n $phone = parsePhoneNumber($countryCode, $crmData[$type]);\n }\n\n break;\n }\n\n foreach (['email', 'email1', 'email2'] as $type) {\n if (empty($crmData[$type])) {\n continue;\n }\n\n $email = mb_strimwidth($crmData[$type], 0, 80);\n\n break;\n }\n\n $company = 'Unknown';\n if (empty($crmData['companyName']) === false) {\n $companyName = is_array($crmData['companyName']) ? $crmData['companyName'][0] : $crmData['companyName'];\n $company = mb_strimwidth($companyName, 0, 191);\n }\n\n $domain = null;\n if (empty($crmData['companyURL']) === false) {\n $domain = mb_strimwidth($crmData['companyURL'], 0, 191);\n $domain = StringUtil::resolveDomain($domain);\n }\n\n $profile = $this->config->profiles()->where('crm_provider_id', (string) $crmData['owner']['id'])->first();\n\n $data = [\n 'team_id' => $this->team->id,\n 'user_id' => $profile ? $profile->user_id : null,\n 'owner_id' => $crmData['owner']['id'],\n 'company' => $company,\n 'domain' => $domain,\n 'name' => mb_strimwidth($crmData['name'], 0, 191),\n 'title' => mb_strimwidth($crmData['occupation'], 0, 128),\n 'email' => mb_strimwidth($email, 0, 80),\n 'phone' => empty($phone['phone']) ? null : mb_strimwidth($phone['phone'], 0, 25),\n 'ext' => $phone['ext'] ?? null,\n 'mobile_phone' => empty($mobilePhone['phone']) ? null : mb_strimwidth($mobilePhone['phone'], 0, 25),\n 'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(\n crmConfiguration: $this->config,\n crmProviderId: $crmData['id'],\n modelType: Lead::class,\n fileName: $crmData['id'],\n avatarText: $crmData['name'] ?? 'Unknown'\n ),\n 'stage_id' => $stage->id,\n 'country_code' => $countryCode,\n 'remotely_created_at' => $this->filterOutOfBoundDates($crmData['dateAdded']),\n ];\n\n return $this->config->leads()->updateOrCreate(['crm_provider_id' => (string) $crmData['id']], $data);\n }\n\n /**\n * @throws ApplicationException\n */\n public function syncContacts(Carbon $since, ?Carbon $to = null): int\n {\n $syncCount = 0;\n $params = [\n 'sort' => '-dateLastModified',\n 'fields' => $this->getFieldsAsString(self::CONTACT_FIELDS),\n 'query' => 'isDeleted: false AND dateLastModified:[' . $since->format('YmdHis') . ' TO *]',\n ];\n\n try {\n $bhContacts = $this->client->get('search/ClientContact', $params);\n\n foreach ($bhContacts['data'] as $bhContact) {\n // Only sync if previously imported.\n if ($this->hasContact($bhContact['id'])) {\n $this->importContact($bhContact);\n $syncCount++;\n }\n }\n } catch (\\Exception $exception) {\n throw new ApplicationException('Failed to sync contacts', $exception->getCode(), $exception);\n }\n\n return $syncCount;\n }\n\n public function syncOpportunities(array $parameters, ?string $strategy = null): int\n {\n $strategyResolver = app(OpportunitySyncStrategyResolver::class, [\n 'client' => $this->client,\n ]);\n $strategies = $strategyResolver->getStrategies($this->config, $strategy);\n\n $syncCount = 0;\n $total = 0;\n $count = 0;\n $offset = 0;\n\n try {\n $bhOpportunities = [];\n foreach ($strategies as $syncStrategy) {\n $opportunitiesData = $syncStrategy->fetchOpportunities($parameters);\n $total = (int) ($opportunitiesData['total'] ?? 0);\n $count = (int) ($opportunitiesData['count'] ?? 0);\n $offset = (int) ($opportunitiesData['start'] ?? 0);\n $bhOpportunities[] = $opportunitiesData['data'] ?? [];\n }\n\n $bhOpportunities = array_merge(...$bhOpportunities);\n\n foreach ($bhOpportunities as $bhOpportunity) {\n $this->importOpportunity($bhOpportunity);\n $syncCount++;\n }\n } catch (\\Exception $exception) {\n $this->logger->info('Failed to sync opportunities', [\n 'params' => $parameters,\n 'exception' => $exception->getMessage(),\n ]);\n }\n\n // only log if there are one sync strategy\n if (count($strategies) === 1 && $total > $count + $offset) {\n $this->logger->info('[Bullhorn] Sync Opportunities - count warning', [\n 'team_id' => $this->team->getId(),\n 'total' => $total,\n 'count' => $count,\n 'offset' => $offset,\n ]);\n }\n\n return $syncCount;\n }\n\n /**\n * @throws ApplicationException\n */\n public function syncAccounts(Carbon $since, ?Carbon $to = null): int\n {\n $syncCount = 0;\n $params = [\n 'sort' => '-dateLastModified',\n 'fields' => $this->getFieldsAsString(self::ACCOUNT_FIELDS),\n 'query' => 'isDeleted: false AND dateLastModified:[' . $since->format('YmdHis') . ' TO *]',\n ];\n\n try {\n $bhAccounts = $this->client->get('search/ClientCorporation', $params);\n\n foreach ($bhAccounts['data'] as $bhAccount) {\n // Only sync if previously imported.\n if ($this->hasAccount($bhAccount['id'])) {\n $this->importAccount($bhAccount);\n $syncCount++;\n }\n }\n } catch (\\Exception $exception) {\n throw new ApplicationException('Failed to sync accounts', $exception->getCode(), $exception);\n }\n\n return $syncCount;\n }\n\n /**\n * @throws ApplicationException\n */\n public function syncLeads(Carbon $since, ?Carbon $to = null, ?string $crmProfileId = null): int\n {\n $syncCount = 0;\n $params = [\n 'sort' => '-dateLastModified',\n 'fields' => $this->getFieldsAsString(self::LEAD_FIELDS),\n 'query' => 'isDeleted: false AND dateLastModified:[' . $since->format('YmdHis') . ' TO *]',\n ];\n\n try {\n $bhLeads = $this->client->get('search/Candidate', $params);\n if ($bhLeads === null) {\n return $syncCount;\n }\n\n foreach ($bhLeads['data'] as $bhLead) {\n // Only sync if previously imported.\n if ($this->hasLead($bhLead['id'])) {\n $this->importLead($bhLead);\n $syncCount++;\n }\n }\n } catch (\\Exception $exception) {\n throw new ApplicationException('Failed to sync leads', $exception->getCode(), $exception);\n }\n\n return $syncCount;\n }\n\n\n public function syncContact(string $crmId): ?Contact\n {\n try {\n $data = $this->getRecord(FieldData::OBJECT_CONTACT, $crmId, self::CONTACT_FIELDS);\n } catch (HttpNotFoundException $exception) {\n return null;\n }\n\n return $this->importContact($data['data']);\n }\n\n public function syncOpportunity(string $crmId): ?Opportunity\n {\n $strategyResolver = app(OpportunitySyncStrategyResolver::class, [\n 'client' => $this->client,\n ]);\n $strategy = $strategyResolver->resolve(\n $this->config,\n OpportunitySyncStrategyResolver::SINGLE_SYNC_OPPORTUNITY_STRATEGY\n );\n\n try {\n $data = $strategy->fetchOpportunities(['crm_id' => $crmId]);\n } catch (HttpNotFoundException $exception) {\n $this->logger->info('[Bullhorn] Opportunity not found', [\n 'crm_id' => $crmId,\n 'reason' => $exception->getMessage(),\n ]);\n }\n\n return $this->importOpportunity($data['data'] ?? []);\n }\n\n public function syncAccount(string $crmId): ?Account\n {\n try {\n $data = $this->getRecord(FieldData::OBJECT_ACCOUNT, $crmId, self::ACCOUNT_FIELDS);\n } catch (HttpNotFoundException $exception) {\n return null;\n }\n\n return $this->importAccount($data['data']);\n }\n\n public function syncLead(string $crmId): ?Lead\n {\n try {\n $data = $this->getRecord(FieldData::OBJECT_LEAD, $crmId, self::LEAD_FIELDS);\n } catch (HttpNotFoundException $exception) {\n return null;\n }\n\n return $this->importLead($data['data']);\n }\n\n public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage\n {\n $missingStage = null;\n\n if ($types === null) {\n $types = [Stage::TYPE_LEAD, Stage::TYPE_OPPORTUNITY];\n }\n\n foreach ($types as $type) {\n $resource = $this->convertObjectTypeToResource($type);\n $entity = $this->client->get('meta/' . $resource, [\n 'fields' => 'status',\n 'meta' => 'full',\n ]);\n\n $missingStage = null;\n\n if (! is_array($entity['fields']) || count($entity['fields']) !== 1 || $entity['fields'][0]['name'] !== 'status') {\n throw new \\UnexpectedValueException('Invalid response from Bullhorn API');\n }\n\n // Upsert all stages for the team.\n foreach ($entity['fields'][0]['options'] as $i => $option) {\n $stage = $this->config->stages()->updateOrCreate([\n 'crm_provider_id' => $option['value'],\n 'type' => $type,\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($option['value'], 0, 50),\n 'label' => mb_strimwidth($option['label'], 0, 191),\n 'sequence' => $i,\n 'is_selectable' => 1,\n ]);\n\n if ($missingStageName && $missingStageName === $option['value']) {\n $missingStage = $stage;\n }\n }\n\n if ($missingStageName && $missingStage === null) {\n // If they requested a stage that still doesn't exist, it must be inactive so lazy create it.\n $missingStage = $this->config->stages()->create([\n 'team_id' => $this->team->id,\n 'crm_provider_id' => Uuid::uuid4(),\n 'name' => mb_strimwidth($missingStageName, 0, 50),\n 'label' => mb_strimwidth($missingStageName, 0, 191),\n 'type' => $type,\n 'sequence' => 0,\n 'is_selectable' => 0,\n ]);\n }\n }\n\n return $missingStage;\n }\n\n public function syncOrganization(): void\n {\n // There appears to be nothing we can sync/map to.\n }\n\n public function find(string $name, array $scopes): array\n {\n $count = $this->limit ?? 20;\n $offset = $this->offset ?? 0;\n\n $data = \\Cache::remember($this->team->id . $name . $count . $offset, 300, function () use ($name, $count) {\n $result = [];\n\n try {\n $objects = $this->client->get('find', [\n 'query' => $name,\n 'meta' => 'basic',\n 'countPerEntity' => $count,\n ]);\n } catch (\\GuzzleHttp\\Exception\\ServerException $exception) {\n throw new ServiceUnavailableException($exception->getMessage());\n }\n\n foreach ($objects['data'] as $object) {\n try {\n $prospectType = $this->convertResourceToObjectType($object['entityType']);\n } catch (\\UnexpectedValueException $exception) {\n // We don't support this, so skip it.\n continue;\n }\n\n $record = [\n 'crmId' => $object['entityId'],\n 'crmUrl' => $this->generateProviderUrl($object['entityId'], $object['entityType']),\n 'prospectType' => $prospectType,\n 'phoneNumbers' => [],\n ];\n\n // Unfortunately we need to go and query each entity itself to get the data we need.\n switch ($object['entityType']) {\n case self::ENTITY_ACCOUNT:\n $data = $this->getRecord(FieldData::OBJECT_ACCOUNT, $object['entityId'], self::ACCOUNT_FIELDS);\n $entity = $data['data'];\n\n $record['name'] = $entity['name'];\n $record['industry'] = $entity['industryList'];\n if (empty($entity['phone']) === false) {\n $record['phoneNumbers'][] = [\n 'number' => $entity['phone'],\n 'nationalFormat' => phone_national(null, $entity['phone']),\n 'type' => 'phone',\n ];\n }\n\n break;\n\n case self::ENTITY_CONTACT:\n $data = $this->getRecord(FieldData::OBJECT_CONTACT, $object['entityId'], self::CONTACT_FIELDS);\n $entity = $data['data'];\n\n $record['name'] = $entity['name'];\n $record['organization'] = $entity['clientCorporation'] ? $entity['clientCorporation']['name'] : null;\n $record['title'] = $entity['occupation'];\n\n foreach (['mobile', 'phone', 'phone2', 'phone3'] as $phone) {\n if (empty($entity[$phone]) === false) {\n $record['phoneNumbers'][] = [\n 'number' => $entity[$phone],\n 'nationalFormat' => phone_national(null, $entity[$phone]),\n 'type' => $phone === 'mobile' ? 'mobile' : 'phone',\n ];\n }\n }\n\n break;\n\n case self::ENTITY_LEAD:\n $bhData = $this->getRecord(FieldData::OBJECT_LEAD, $object['entityId'], self::LEAD_FIELDS);\n $entity = $bhData['data'];\n\n $record['name'] = $entity['name'];\n $record['organization'] = $entity['companyName'];\n $record['title'] = $entity['occupation'];\n\n foreach (['mobile', 'phone', 'phone2', 'phone3', 'workPhone'] as $phone) {\n if (empty($entity[$phone]) === false) {\n $record['phoneNumbers'][] = [\n 'number' => $entity[$phone],\n 'nationalFormat' => phone_national(null, $entity[$phone]),\n 'type' => $phone === 'mobile' ? 'mobile' : 'phone',\n ];\n }\n }\n\n break;\n\n default:\n // We are not interested in this entity type so skip it.\n continue 2;\n }\n\n $result[] = $record;\n }\n\n return $result;\n });\n\n return $data;\n }\n\n public function getClient(): BullhornClientInterface\n {\n return $this->client;\n }\n\n private function getFieldsAsString(array $fields)\n {\n return implode(', ', $fields);\n }\n\n public function findOpportunities(?string $crmAccountId, ?string $crmContactId, ?int $userId = null): array\n {\n $data = [];\n $ownerData = [];\n $ownerId = null;\n $contact = null;\n $account = null;\n\n if ($crmAccountId) {\n $account = $this->config->accounts()->where('crm_provider_id', $crmAccountId)->first();\n\n if ($account === null) {\n $account = $this->syncAccount($crmAccountId);\n }\n }\n\n if ($crmContactId) {\n $contact = $this->config->contacts()->where('crm_provider_id', $crmContactId)->first();\n\n if ($contact === null) {\n $contact = $this->syncContact($crmContactId);\n }\n }\n\n if ($contact || $account) {\n if ($contact && $account === null) {\n $account = $contact->account;\n }\n\n if ($account === null) {\n return [];\n }\n\n $where = 'isDeleted: false AND clientCorporation.id:' . (int) $account->crm_provider_id;\n\n $params = [\n 'sort' => '-dateLastModified',\n 'fields' => $this->getFieldsAsString(self::OPPORTUNITY_FIELDS),\n ];\n\n $onlyOpen = true;\n switch ($this->config->opportunity_assignment_rule) {\n case Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_UPDATED:\n $params['sort'] = '-dateLastModified';\n\n break;\n\n case Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_CREATED:\n $params['sort'] = '-dateAdded';\n\n break;\n\n case Configuration::OPP_ASSIGNMENT_ALL_OPEN_OLDEST_CREATED:\n $params['sort'] = '+dateAdded';\n\n break;\n\n case Configuration::OPP_ASSIGNMENT_ALL_RECENTLY_UPDATED:\n $params['sort'] = '-dateLastModified';\n $onlyOpen = false;\n }\n\n if ($onlyOpen) {\n $where .= ' AND isOpen: true';\n }\n\n $params['query'] = $where;\n\n $bhOpportunities = $this->client->get('search/JobOrder', $params);\n\n if ($userId) {\n $profileRepository = app(ProfileRepository::class);\n $profile = $profileRepository->findProfileByUserId($this->config, $userId);\n\n $ownerId = $profile instanceof Profile ? $profile->getCrmProviderId() : null;\n }\n\n foreach ($bhOpportunities['data'] as $bhOpportunity) {\n $stage = $this->config\n ->stages()\n ->where('crm_provider_id', $bhOpportunity['status'])\n ->first();\n\n if ($stage === null) {\n // Import it.\n $stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $bhOpportunity['status']);\n }\n\n $record = [\n 'crmId' => $bhOpportunity['id'],\n 'name' => $bhOpportunity['title'],\n 'value' => currency_format($bhOpportunity['salary'], $bhOpportunity['correlatedCustomText10'] ?? 'USD'),\n 'won' => strtolower($bhOpportunity['status']) === 'placed',\n 'closed' => $bhOpportunity['isOpen'] !== true,\n 'stage' => [\n 'id' => $stage->id_string,\n 'name' => $stage->name,\n ],\n 'recordType' => [],\n ];\n\n if ($ownerId && isset($bhOpportunity['owner']['id']) && $bhOpportunity['owner']['id'] === (int) $ownerId) {\n $ownerData[] = $record;\n }\n\n $data[] = $record;\n }\n }\n\n if (! empty($ownerData)) {\n return $ownerData;\n }\n\n return $data;\n }\n\n public function getTasks(string $objectType, string $objectId, ?string $opportunityId): array\n {\n $data = [];\n\n // For now it has not been agreed on how to implement this e.g. appointments or tasks?\n return [];\n\n if ($objectType === 'account' && $opportunityId === null) {\n // You cannot attach tasks to only accounts.\n return [];\n }\n\n // Query should default to any open task.\n $where = 'isDeleted: false AND isCompleted: false AND (' . $this->convertObjectTypeToResource($objectType) . 'UserId: ' . (int) $objectId;\n\n // If we are also going to check on a specific job order, set that up.\n if ($opportunityId) {\n $where .= ' OR JobOrder: ' . (int) $opportunityId;\n }\n\n $where .= ')';\n\n $params = [\n 'sort' => '-dateLastModified',\n 'fields' => $this->getFieldsAsString(self::TASK_FIELDS),\n 'query' => $where,\n ];\n\n $bhTasks = $this->client->get('search/Task', $params);\n\n foreach ($bhTasks['data'] as $bhTask) {\n $data[] = [\n 'crmId' => $bhTask['id'],\n 'subject' => $bhTask['subject'],\n 'due' => Carbon::createFromTimestampMs($bhTask['dateEnd'])->toIso8601String(),\n 'type' => null,\n ];\n }\n\n return $data;\n }\n\n /**\n * @url https://bullhorn.github.io/rest-api-docs/index.html#get-find\n *\n * @return null|array{\n * ?Lead,\n * ?Account,\n * ?Opportunity,\n * ?Contact,\n * ?Stage,\n * ?string\n *}\n */\n public function matchExactlyByEmail(string $email, ?int $userId = null): ?array\n {\n $bhObject = $this->client->get('find', [\n 'query' => $email,\n 'meta' => 'basic',\n 'countPerEntity' => 10,\n ]);\n\n $data = $this->convertCrmData($bhObject['data'], $userId);\n\n return ! empty(array_filter($data)) ? $data : null;\n }\n\n /**\n * @url https://bullhorn.github.io/rest-api-docs/index.html#get-find\n *\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n *}\n */\n public function matchByPhone(string $phone, ?string $rawPhoneNumber = null, ?int $userId = null): ?array\n {\n // Check if the user is internal.\n $teamMember = $this->team->users()->where('phone', $phone)->exists();\n\n // Skip the attendee if internal.\n if ($teamMember === false) {\n $bhObject = $this->client->get('find', [\n 'query' => $rawPhoneNumber ?? $phone,\n 'meta' => 'basic',\n 'countPerEntity' => 10,\n ]);\n\n if (empty($bhObject['data'])) {\n $nationalPhone = phone_national(null, $rawPhoneNumber ?? $phone);\n $formattedPhone = ltrim($nationalPhone, '+0');\n $bhObject = $this->client->get('find', [\n 'query' => $formattedPhone,\n 'meta' => 'basic',\n 'countPerEntity' => 10,\n ]);\n }\n\n $data = $this->convertCrmData($bhObject['data'], $userId);\n\n return ! empty(array_filter($data)) ? $data : null;\n }\n\n return null;\n }\n\n /**\n * @url https://bullhorn.github.io/rest-api-docs/index.html#get-find\n *\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n public function matchByName(string $name, ?int $userId = null): ?array\n {\n // Don't waste time searching for single character strings.\n if (\\strlen($name) <= 3) {\n return null;\n }\n\n $cacheKey = $this->getCacheKey($name, $userId);\n\n $result = \\Cache::remember($cacheKey, 60, function () use ($name, $userId) {\n $bhObject = $this->client->get('find', [\n 'query' => $name,\n 'meta' => 'basic',\n 'countPerEntity' => 10,\n ]);\n\n $data = $this->convertCrmData($bhObject['data'], $userId);\n\n return ! empty(array_filter($data)) ? $data : false;\n });\n\n return is_array($result) ? $result : null;\n }\n\n protected function getCacheKey(string $object, ?int $userId = null): ?string\n {\n $key = $this->team->getId() . $object;\n $keySuffix = $this->getOwnerKeySuffix($userId);\n\n return $key . $keySuffix;\n }\n\n private function getOwnerKeySuffix(?int $userId = null): string\n {\n return $userId === null ? '' : (string) $userId;\n }\n\n public function saveActivity(Activity $activity): Activity\n {\n\n if ($activity->hasCrmProviderId()) {\n return $activity;\n }\n\n switch ($activity->type) {\n case Activity::TYPE_CONFERENCE:\n case Activity::TYPE_SOFTPHONE:\n case Activity::TYPE_SOFTPHONE_INBOUND:\n case Activity::TYPE_SMS_INBOUND:\n case Activity::TYPE_SMS_OUTBOUND:\n $activity = $this->buildNotePayload($activity);\n\n break;\n }\n\n return $activity;\n }\n\n /**\n * @throws CrmException\n * @throws DomainException\n */\n private function buildNotePayload(Activity $activity): Activity\n {\n if ($activity->hasActivityType() === false) {\n throw new DomainException('[Bullhorn] No activity type assigned');\n }\n\n $action = $this->getCrmType($activity->getActivityType());\n if ($action === null) {\n throw new DomainException('[Bullhorn] No mapped CRM type for \"' . $activity->category->name . '\"');\n }\n\n if ($activity->lead_id === null && $activity->contact_id === null) {\n throw new DomainException('[Bullhorn] Cannot save note without candidate or DM');\n }\n\n $payload = [\n 'comments' => $this->generateActivityDescription($activity),\n 'action' => $action->value,\n 'commentingPerson' => [\n 'id' => (int) $this->profile->crm_provider_id,\n 'type' => 'CorporateUser',\n ],\n 'externalID' => $activity->id_string,\n 'minutesSpent' => $activity->duration,\n 'dateAdded' => $activity->hasActualEndTime() ? $activity->getActualEndTime()?->valueOf() : now()->valueOf(),\n ];\n\n if ($activity->lead_id) {\n $payload['personReference'] = [\n 'id' => (int) $activity->lead->crm_provider_id,\n 'type' => 'Candidate',\n ];\n }\n\n if ($activity->contact_id) {\n $payload['personReference'] = [\n 'id' => (int) $activity->contact->crm_provider_id,\n 'type' => 'ClientContact',\n ];\n }\n\n if ($activity->opportunity_id) {\n $payload['jobOrders'] = [\n 'replaceAll' => [\n (int) $activity->opportunity->crm_provider_id,\n ],\n ];\n }\n\n $bhActivity = $this->client->put('entity/Note', $payload);\n\n $activity->crm_provider_id = $bhActivity['changedEntityId'];\n $activity->save();\n\n return $activity;\n }\n\n private function generateActivityDescription(Activity $activity): string\n {\n $description = '';\n switch ($activity->type) {\n case Activity::TYPE_SOFTPHONE:\n case Activity::TYPE_SOFTPHONE_INBOUND:\n case Activity::TYPE_CONFERENCE:\n\n if ($activity->isTypeSoftPhone() && $activity->getProvider() !== Activity::PROVIDER_UPLOADER) {\n // Dialer calls have no native field for the subject.\n if ($activity->title) {\n $description .= sprintf(\n '<p><span style=\"font-size: 16px; line-height: 19.2px;\">%s</span></p>',\n $activity->title,\n );\n }\n\n // Conference meetings log duration into a distinct field, so log this in the main text.\n $description .= sprintf(\n '<p><span style=\"font-size: 13.3333px; line-height: 16px;\">Call Duration: %s</span></p>',\n $activity->duration_for_humans,\n );\n\n // Tell them which number was dialed.\n if ($activity->to) {\n $description .= sprintf(\n '<p><span style=\"font-size: 13.3333px; line-height: 16px;\">Dialed: %s</span></p>',\n $activity->to->phone_number,\n );\n }\n $description .= '<p></p>';\n }\n\n if ($activity->isTypeSoftphoneInbound() && $activity->getProvider() !== Activity::PROVIDER_UPLOADER) {\n // Dialer calls have no native field for the subject.\n if ($activity->title) {\n $description .= sprintf(\n '<p><span style=\"font-size: 16px; line-height: 19.2px;\">%s</span></p>',\n $activity->title,\n );\n }\n\n // Conference meetings log duration into a distinct field, so log this in the main text.\n $description .= sprintf(\n '<p><span style=\"font-size: 13.3333px; line-height: 16px;\">Call Duration: %s</span></p>',\n $activity->duration_for_humans,\n );\n $description .= sprintf(\n '<p><span style=\"font-size: 13.3333px; line-height: 16px;\">Caller: %s</span></p>',\n $activity->from->phone_number,\n );\n $description .= '<p></p>';\n }\n\n if ($activity->hasReasonCodeBotKicked()) {\n $description .= '<p>'\n . '<span style=\"font-size: 13.3333px; line-height: 16px;\">'\n . 'Notetaker removed from this meeting'\n . '</span>'\n . '</p>';\n // When we fix the state to be Activity::RECORDING_RECORDED as it should be this can change.\n } elseif ($activity->hasReasonCodeNotCompliant()) {\n $description .= '<p>'\n . '<span style=\"font-size: 13.3333px; line-height: 16px;\">'\n . 'Notetaker did not join due to recording consent not being provided by attendees'\n . '</span>'\n . '</p>';\n } elseif ($activity->canReviewActivity()) {\n $playbackUrl = $activity->user->team->partner->getPlaybackUrl($activity);\n $description .= sprintf(\n '<p>'\n . '<span style=\"font-size: 13.3333px; line-height: 16px;\">'\n . '<a href=\"%s\" target=\"_blank\">'\n . 'Review in Jiminny'\n . '</a>'\n . '️</span>'\n . '</p>',\n $playbackUrl,\n );\n }\n\n if ($activity->isTypeConference()) {\n $description .= '<p><strong>Attendees</strong></p><ul>';\n\n // Only include participants that joined.\n $participants = $activity->participants()->ghost(false)->whereNotNull('enter_time')->get();\n foreach ($participants as $participant) {\n $attendee = '<li>';\n if ($participant->name) {\n $attendee .= $participant->name;\n if ($participant->phone_number) {\n $attendee .= ' (' . $participant->phone_number . ')';\n } elseif ($participant->email) {\n $attendee .= ' (' . $participant->email . ')';\n }\n } else {\n $attendee .= $participant->phone_number;\n }\n\n if ($participant->is_ghost) {\n $attendee .= ' <strong>coach</coach>';\n }\n\n if ($participant->getTitle()) {\n $attendee .= ', ' . $participant->getTitle();\n }\n\n $description .= $attendee . '</li>';\n }\n $description .= '</ul>';\n }\n\n if (count($activity->notes) > 0) {\n $description .= '<p><strong>Notes</strong></p>';\n\n foreach ($activity->notes as $note) {\n $time = ($note->time > 3600) ? gmdate('H:i:s', $note->time) : gmdate('i:s', $note->time);\n $description .= '<p>' . $time . ' ' . $note->note . '</p>';\n }\n $description .= '</p>';\n }\n\n // Get all private messages.\n $messages = $activity->messages()\n ->where('is_private', 1)\n ->orderBy('created_at', 'asc');\n\n if ($messages->count() > 0) {\n $description .= '<p><strong>Coaching Chat</strong></p>';\n\n foreach ($messages->get() as $message) {\n $description .= '<p>' . $message->participant->name . ': ' . $message->message . '</p>';\n }\n $description .= '</p>';\n }\n\n // Get all public messages.\n $messages = $activity->messages()\n ->where('is_private', 0)\n ->orderBy('created_at', 'asc');\n\n if ($messages->count() > 0) {\n $description .= '<p><strong>Customer Chat</strong></p>';\n\n foreach ($messages->get() as $message) {\n $description .= '<p>' . $message->participant->name . ': ' . $message->message . '</p>';\n }\n $description .= '</p>';\n }\n\n if ($activity->summary) {\n $description .= '<p><strong>Summary</strong></p><p>' . $activity->summary . '</p><p></p>';\n }\n\n break;\n\n case Activity::TYPE_SMS_INBOUND:\n case Activity::TYPE_SMS_OUTBOUND:\n // Use Rich-text formatting.\n $description = sprintf(\n '<p><span style=\"font-size: 16px; line-height: 19.2px;\"><strong>%s</strong></span></p>',\n $activity->title,\n );\n $description .= sprintf(\n '<p><span style=\"font-size: 16px; line-height: 19.2px;\">%s</span></p>',\n $activity->description,\n );\n\n break;\n }\n\n return $description;\n }\n\n\n public function saveFollowupActivity(Activity $activity, array $fields): ?string\n {\n // Not yet required by current customers.\n return null;\n }\n\n public function updateStage($crmObject, Stage $stage): void\n {\n $objectId = $crmObject->crm_provider_id;\n\n $this->updateRecord($stage->type, $objectId, ['status' => $stage->crm_provider_id]);\n }\n\n public function syncProfiles(?User $userToSearch = null): ?Profile\n {\n $start = 0;\n $payload = [\n 'fields' => 'id,email',\n 'where' => 'isDeleted=false',\n 'count' => 500,\n ];\n\n $payload['start'] = $start;\n $profiles = $this->client->get('query/CorporateUser', $payload);\n\n foreach ($profiles['data'] as $profile) {\n if (empty($profile['email'])) {\n continue;\n }\n\n $user = $this->userRepository->findByTeamAndEmailAddress($this->team, $profile['email'], onlyActive: true);\n\n // Only import profiles for users we have locally.\n if ($user) {\n $profile = $user->crmProfile()->updateOrCreate([\n 'crm_configuration_id' => $this->config->id,\n 'crm_provider_id' => $profile['id'],\n ]);\n\n if ($userToSearch && $userToSearch->id === $user->id) {\n return $profile;\n }\n }\n }\n\n return null;\n }\n\n protected function convertCrmData(array $entities, ?int $userId = null): array\n {\n $lead = null;\n $contact = null;\n $opportunity = null;\n $account = null;\n $stage = null;\n $countryCode = null;\n\n $mappedEntities = [];\n foreach ($entities as $entity) {\n if (in_array($entity['entityType'], [self::ENTITY_LEAD, self::ENTITY_CONTACT, self::ENTITY_ACCOUNT])) {\n $mappedEntities[] = $entity;\n }\n }\n\n // If there is only one match, sync the data and return the object(s).\n if (count($mappedEntities) === 1) {\n $entity = $mappedEntities[0];\n\n if ($entity['entityType'] === self::ENTITY_LEAD) {\n $lead = $this->syncLead($entity['entityId']);\n\n // Lead might not be imported if the Stage is null for example.\n if ($lead) {\n $countryCode = $lead->country_code;\n $stage = $lead->stage;\n }\n } elseif (in_array($entity['entityType'], [self::ENTITY_CONTACT, self::ENTITY_ACCOUNT])) {\n if ($entity['entityType'] === self::ENTITY_CONTACT) {\n $contact = $this->syncContact($entity['entityId']);\n $account = $contact->account;\n } else {\n $account = $this->syncAccount($entity['entityId']);\n }\n\n if ($contact?->country_code) {\n $countryCode = $contact->country_code;\n } elseif ($account) {\n $countryCode = $account->country_code;\n }\n\n try {\n $bhOpportunities = $this->findOpportunities(\n $account?->crm_provider_id,\n $contact?->crm_provider_id,\n $userId,\n );\n\n // Take the first opportunity, which will be ordered as priority based on their settings.\n if (! empty($bhOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($bhOpportunities[0]['crmId']);\n $stage = $opportunity?->stage;\n }\n } catch (Exception) {\n // Nothing to see here.\n }\n }\n }\n\n return [\n $lead,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n }\n\n public function generateProviderUrl(string $providerId, string $objectType): ?string\n {\n // There doesn't appear to be a way to deeplink to anything on https://app.bullhornstaffing.com.\n return null;\n }\n\n public function updateRecord(string $objectType, string $objectId, array $data, array $headers = []): void\n {\n $this->client->post('entity/' . $this->convertObjectTypeToResource($objectType) . '/' . $objectId, $data);\n }\n\n public function getRecord(string $objectType, string $objectId, array $fields = ['id']): array\n {\n return $this->client->get(\n url: 'entity/' . $this->convertObjectTypeToResource($objectType) . '/' . $objectId,\n data: ['fields' => $this->getFieldsAsString($fields)],\n );\n }\n\n private function convertObjectTypeToResource(string $objectType)\n {\n switch ($objectType) {\n case FieldData::OBJECT_OPPORTUNITY:\n return self::ENTITY_OPPORTUNITY;\n\n case FieldData::OBJECT_CONTACT:\n return self::ENTITY_CONTACT;\n\n case FieldData::OBJECT_LEAD:\n return self::ENTITY_LEAD;\n\n case FieldData::OBJECT_ACCOUNT:\n return self::ENTITY_ACCOUNT;\n\n case FieldData::OBJECT_TASK:\n return self::ENTITY_ACTIVITY;\n\n default:\n throw new \\UnexpectedValueException('Unsupported object type \"' . $objectType . '\"');\n }\n }\n\n private function convertResourceToObjectType(string $resource): string\n {\n switch ($resource) {\n case self::ENTITY_OPPORTUNITY:\n return FieldData::OBJECT_OPPORTUNITY;\n\n case self::ENTITY_CONTACT:\n return FieldData::OBJECT_CONTACT;\n\n case self::ENTITY_LEAD:\n return FieldData::OBJECT_LEAD;\n\n case self::ENTITY_ACCOUNT:\n return FieldData::OBJECT_ACCOUNT;\n\n case self::ENTITY_ACTIVITY:\n return FieldData::OBJECT_TASK;\n\n default:\n throw new \\UnexpectedValueException('Unsupported resource \"' . $resource . '\"');\n }\n }\n\n public function fetchAndAssociateRelatedActivity(Activity $activity): ?Activity\n {\n // We need to know when it started to determine when to look for related activity.\n if ($activity->hasActualStartTime() === false) {\n return null;\n }\n\n try {\n $bhNote = $this->fetchRelatedActivity($activity);\n if (empty($bhNote)) {\n $this->logger->info('[Bullhorn] No related activity found', [\n 'activity' => $activity->getUuid(),\n ]);\n\n return null;\n }\n\n $activityType = $this->getPlaybookCategory($this->getPlaybook($activity->user), $bhNote['action']);\n $comments = $bhNote['comments'] . '<br />//<br />' . $this->generateActivityDescription($activity);\n // Now update the Note to contain our data.\n $payload = [\n 'comments' => $comments,\n 'externalID' => $activity->id_string,\n 'minutesSpent' => $activity->duration,\n ];\n\n $this->logger->info('[Bullhorn] updating related activity', [\n 'activity' => $activity->getUuid(),\n 'activityType' => $activityType->id ?? null,\n ]);\n\n $this->client->post('entity/Note/' . $bhNote['id'], $payload);\n\n $playbookCategoryId = null;\n if ($activity->getCategory() !== null) {\n $playbookCategoryId = $activity->getCategory()->getId();\n }\n\n // Update our activity to reflect the remote data.\n $activity->update([\n 'crm_provider_id' => $bhNote['id'],\n 'summary' => $bhNote['comments'],\n 'playbook_category_id' => $activityType->id ?? $playbookCategoryId,\n ]);\n\n return $activity;\n } catch (\\Exception $exception) {\n \\Sentry::captureException($exception);\n\n throw $exception;\n }\n }\n\n /**\n * Returns the most recently modified note within time range (if any).\n *\n * @return array A Note record from Bullhorn.\n */\n public function fetchRelatedActivity(Activity $activity): array\n {\n $from = $activity->hasScheduledStartTime()\n ? $activity->getScheduledStartTime()\n : $activity->getActualStartTime();\n $to = $activity->hasActualEndTime()\n ? $activity->getActualEndTime()?->addMinutes(15)\n : $from?->clone()->addMinutes(15);\n\n $filterStatements = [\n 'isDeleted: false',\n '*:* -externalID:[* TO *]', // externalID should be null e.g. we did not already relate it.\n sprintf('commentingPerson: %d', (int) $this->profile->crm_provider_id),\n ];\n\n /**\n * @XXX: Bullhorn does not specify the time zone in their docs. I've found a forum thread in which someone from\n * staff asserts it's UTC, however in my field test I've found it to be GMT-0300 (USA east coast).\n * To make the code immune to such glitches we will add 24h to start/end boundaries and will filter out\n * any excess items in code\n */\n $filterStatements[] = sprintf(\n 'dateLastModified:[%s TO %s]',\n $from?->clone()->subHours(24)->format('YmdHis'),\n $to->clone()->addHours(24)->format('YmdHis'),\n );\n\n if ($activity->lead_id) {\n $filterStatements[] = sprintf('candidateUserID: %d', (int) $activity->lead->crm_provider_id);\n }\n\n if ($activity->contact_id) {\n $filterStatements[] = sprintf('clientContactUserID: %d', (int) $activity->contact->crm_provider_id);\n }\n\n // if there is no contact or lead associated to the activity exit\n if ($activity->contact_id === null && $activity->lead_id === null) {\n return [];\n }\n\n $params = [\n 'fields' => $this->getFieldsAsString(self::TASK_FIELDS),\n 'query' => implode(' AND ', $filterStatements),\n 'sort' => '-dateLastModified',\n ];\n\n $bhNotes = $this->client->get('search/Note', $params);\n\n $startStamp = $from?->getTimestamp() * 1000;\n $endStamp = $to->getTimestamp() * 1000;\n\n $filteredNotes = array_values(\n array_filter(\n $bhNotes['data'],\n static fn (array $bhNote): bool\n => $bhNote['dateLastModified'] >= $startStamp && $startStamp <= $endStamp,\n ),\n );\n\n return ! empty($filteredNotes) ? array_shift($filteredNotes) : [];\n }\n\n /**\n * Select how to create the date out of the Bullhorn payload.\n * Do not allow dates beyond 9999-12-31 (max mysql DATE)\n */\n private function filterOutOfBoundDates(string $rawDateCandidate): ?Carbon\n {\n if (strlen($rawDateCandidate) > 12) {\n // A unix timestamp represented with more than 12 digits i\n // s definitely including milliseconds\n $dateFormatted = Carbon::createFromTimestampMs($rawDateCandidate);\n } else {\n $dateFormatted = Carbon::createFromTimestamp($rawDateCandidate);\n }\n\n $minDate = Carbon::createFromFormat('Y-m-d', '2000-01-01');\n if ($dateFormatted->lt($minDate)) {\n return null;\n }\n\n $maxDate = Carbon::createFromFormat('Y-m-d', '9999-12-31');\n if ($dateFormatted->gt($maxDate)) {\n $this->logger->warning('[Bullhorn] An out of bound date is provided', [\n 'date' => $dateFormatted,\n 'rawDate' => $rawDateCandidate,\n ]);\n\n return $maxDate;\n }\n\n return $dateFormatted;\n }\n\n public function verifyTaskExists(Activity $activity): bool\n {\n $crmProviderId = $activity->getCrmProviderId();\n $cacheKey = \"crm_task_exists:{$this->config->getId()}:$crmProviderId\";\n\n return Cache::remember($cacheKey, self::TASK_VERIFICATION_CACHE_TTL, function () use ($crmProviderId) {\n try {\n $note = $this->getRecord('Note', $crmProviderId, ['id', 'isDeleted']);\n\n return ! empty($note) && ($note['isDeleted'] ?? false) === false;\n } catch (HttpNotFoundException|HttpBadRequestException) {\n // Note not found in CRM - this is expected and permanent\n $this->logger->info('[Bullhorn] Note not found during verification', [\n 'note_id' => $crmProviderId,\n 'config_id' => $this->config->getId(),\n ]);\n\n return false;\n }\n // Let other exceptions (network errors, rate limits, etc.) bubble up for retry\n });\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":true,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Execute","depth":4,"bounds":{"left":0.85486114,"top":0.31,"width":0.018055556,"height":0.026666667},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Explain Plan","depth":4,"bounds":{"left":0.87291664,"top":0.31,"width":0.018055556,"height":0.026666667},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Browse Query History","depth":4,"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"View Parameters","depth":4,"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Open Query Execution Settings…","depth":4,"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"In-Editor Results","depth":4,"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Tx: Auto","depth":4,"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Cancel Running Statements","depth":4,"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Playground","depth":4,"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"jiminny","depth":4,"bounds":{"left":0.9138889,"top":0.31,"width":0.059027776,"height":0.026666667},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.088194445,"height":0.027777778},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"8","depth":4,"bounds":{"left":0.8645833,"top":0.34444445,"width":0.016666668,"height":0.02111111},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"1","depth":4,"bounds":{"left":0.8854167,"top":0.34444445,"width":0.015277778,"height":0.02111111},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"3","depth":4,"bounds":{"left":0.9048611,"top":0.34444445,"width":0.016666668,"height":0.02111111},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"4","depth":4,"bounds":{"left":0.92569447,"top":0.34444445,"width":0.016666668,"height":0.02111111},"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"bounds":{"left":0.9458333,"top":0.3422222,"width":0.015277778,"height":0.025555555},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"bounds":{"left":0.9611111,"top":0.3422222,"width":0.014583333,"height":0.025555555},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"SELECT\n# DISTINCT\nopp.id as opp_id, opp.uuid, opp.name,\n# COUNT(dr.id) AS deal_risk_count,\n\n# dr.id,\n# cfd.value,\nusr.name AS owner_name,\nopp.value, opp.currency_code, opp.close_date, opp.remotely_created_at, opp.is_closed, opp.is_won,\nusr.uuid as owner_uuid,\nusr.photo_path as owner_photo,\nusr.id AS owner_id,\njt.name as owner_job,\nopp.stage_id, opp.stage_updated_at,\nacc.name AS acc_name, opp.stage_updated_at, acc.crm_provider_id AS acc_provider_id, opp.crm_provider_id AS opp_provider_id,\nrt.business_process_id AS pipeline_id\nFROM opportunities opp\nLEFT JOIN record_types rt ON opp.record_type_id = rt.id\nLEFT JOIN users usr ON opp.user_id = usr.id\nLEFT JOIN accounts acc ON opp.account_id = acc.id\nLEFT JOIN job_titles jt ON usr.job_title_id = jt.id\n\n# LEFT JOIN group_deal_risk_types gdrt ON gdrt.group_id = usr.group_id\n# LEFT JOIN deal_risks dr ON dr.opportunity_id = opp.id AND dr.group_deal_risk_type_id = gdrt.id\n\nLEFT JOIN crm_field_data cfd ON (cfd.object_id = opp.id and cfd.crm_field_id = 66814)\n\nWHERE opp.user_id IS NOT NULL\nAND opp.deleted_at IS NULL\nAND opp.is_closed = 1\nAND opp.is_won = 0\nAND opp.close_date >= '2024-01-01 00:00:00'\nAND opp.close_date <= '2024-12-31 23:59:59'\nAND usr.team_id = 1\n\n# and opp.id = 4823179\n\nGROUP BY opp.id, cfd.value\nORDER BY\n# cfd.value\n CAST(cfd.value AS UNSIGNED)\n# owner_name\n# (SELECT COUNT(dr.id)\n# FROM deal_risks dr\n# JOIN group_deal_risk_types gdrt ON dr.group_deal_risk_type_id = gdrt.id\n# WHERE dr.opportunity_id = opp.id\n# AND dr.is_active = 1\n# AND gdrt.is_enabled = 1)\nDESC\nLIMIT 25\n# OFFSET 0\n;\n\nselect * from crm_fields where crm_configuration_id = 1 and object_type = 'opportunity';\nselect * from crm_layout_entities where crm_layout_id = 1493;\n\nSELECT * FROM opportunities WHERE id = 4584045;\nSELECT * FROM crm_field_data WHERE object_id = 4584045;\n\nSELECT * FROM opportunities where team_id = 1 and crm_provider_id = '0061K00000lfb9IQAQ'\n\nSELECT\n opp.id as opportunity_id,\n opp.name,\n COUNT(dr.id) as risk_count\nFROM opportunities opp\nLEFT JOIN deal_risks dr ON dr.opportunity_id = opp.id\nWHERE opp.id IN (\n 6069574,8900938,9507638,9524799,9660490,9662824,9705675,9749758,4158460,4391812,6450439,4448422,5118945,5590675,4584045,7228149,9002408,9165534,9446720,9641778,9665149,9703344,9709280,5747948,4158491,6182565,6263970,5798120,5111315,4536978,6062352,6548383,6072095,6548225,4480986,5011422,6548381,8760540,8917554,9509986,9514392,9569009,9569011,9578490,9713604,5784397,7276612,7288405,8994421,9118219,9608148,9818911,4510535,6479693,5958049,6271674,6550448,4158331,4158483,6126571,6171615,6540943,4897466,5190896,5796182,5932762,8572433,8723698,8892697,9711001,9789264,4549188,6100831,6170064,6260799,6263653,6449936,6530871,6538978,7777651,8059269,8319918,8787049,8901150,9263153,9453207,9514738,9696700,9791062,5752018,6421452,7439134,7878923,9354763,9369285,9514396,9582506,9889949,9890216,4094311,4158495,4158496,6098128,5585661,3872564,6442149,5891604,6164746,6199593,6583474,6519684,9018490,9809006,4496897,5041324,5829430,6198319,6253504,6555763,7242914,7931055,8024125,8797814,8058559,8673347,8892695,8994420,9616219,9714970,9722004,9809439,9818918,6523177,8134147,9002915,9711422,9892713,9901719,9954210,9978435,5800810,6243518,6416114,6222251,6411974,6512456,5791953,6545606,9914780,5805540,6238986,6463838,6547680,9767049,9809437,9810885,9890855,6673493,9902036,4335521,6379871,6503799,6546077,8018765,9907556,9958433,9905855,9916179,9946741,9957877,5563344,6271838,6450815,7641128,7762567,7780592,8684810,8685786,8685787,8685788\n)\nGROUP BY opp.id;\n\nSELECT COUNT(dr.id)\n FROM deal_risks dr\n JOIN group_deal_risk_types gdrt ON dr.group_deal_risk_type_id = gdrt.id\n WHERE dr.opportunity_id = 5563344\n# AND gdrt.group_id = usr.group_id\n\n\n\nEXPLAIN SELECT\n opp.id as opp_id, opp.uuid, opp.name,\n\ncfd.value,\ncfv.sequence,\ncfv.value,\n# MAX(cfd.value) AS max_cfd_value,\nusr.name AS owner_name,\nopp.value, opp.currency_code, opp.close_date, opp.remotely_created_at, opp.is_closed, opp.is_won,\nusr.uuid as owner_uuid,\nusr.photo_path as owner_photo,\nusr.id AS owner_id,\njt.name as owner_job,\nopp.stage_id, opp.stage_updated_at,\nacc.name AS acc_name, opp.stage_updated_at, acc.crm_provider_id AS acc_provider_id, opp.crm_provider_id AS opp_provider_id,\nrt.business_process_id AS pipeline_id\nFROM opportunities opp\n\nLEFT JOIN record_types rt ON opp.record_type_id = rt.id\nLEFT JOIN users usr ON opp.user_id = usr.id\nLEFT JOIN accounts acc ON opp.account_id = acc.id\nLEFT JOIN job_titles jt ON usr.job_title_id = jt.id\n\nLEFT JOIN crm_field_data cfd ON cfd.id = (SELECT sub_cfd.id\n FROM crm_field_data sub_cfd\n WHERE sub_cfd.object_id = opp.id\n AND sub_cfd.crm_field_id = 66810\n ORDER BY sub_cfd.updated_at DESC\n LIMIT 1)\n\nLEFT JOIN crm_field_values cfv ON cfv.crm_field_id = 66810 AND cfv.value = cfd.value\n\nWHERE opp.user_id IS NOT NULL\nAND opp.deleted_at IS NULL\nAND opp.is_closed = 0\n# AND opp.is_closed = 1\n# AND opp.is_won = 0\nAND opp.close_date >= '2024-01-01 00:00:00'\nAND opp.close_date <= '2024-12-31 23:59:59'\nAND usr.team_id = 1\n\n# and opp.id = 4823179\n\n# GROUP BY opp.id\nORDER BY\n cfv.sequence DESC,\n# opp.name\n# owner_name\n cfd.value\n# CAST(cfd.value AS UNSIGNED)\n# CAST(MAX(cfd.value) AS UNSIGNED)\n# (SELECT COUNT(dr.id)\n# FROM deal_risks dr\n# JOIN group_deal_risk_types gdrt ON dr.group_deal_risk_type_id = gdrt.id\n# WHERE dr.opportunity_id = opp.id\n# AND dr.is_active = 1 AND gdrt.is_enabled = 1)\nDESC\n# LIMIT 75, 25\n;\n\n\n\nSELECT * FROM crm_field_values WHERE crm_field_id = 66810;\n\nSELECT f.id, f.crm_provider_id AS field_name, f.label, fd.object_id AS dealId, fd.value, fd.updated_at, fd.id, fd.created_at\nFROM crm_fields f\nINNER JOIN crm_field_data fd ON fd.crm_field_id = f.id\n WHERE (f.crm_configuration_id = 1)\n AND (f.object_type = 'opportunity')\n# AND (fd.object_id IN (4158331,4158483,4158495,4158496,6069574,6263970,6171615,6540943,6545606,5829430,6198319,6263653,6548381,7242914,8797814,7228149,7439134,7878923,8134147,9002915,9118219,9165534,9354763,9369285,9446720))\n AND (fd.object_id IN (4158460))\n AND (f.crm_provider_id IN ('ForecastCategoryName'))\n ORDER BY fd.object_id ASC, fd.updated_at DESC;\n\nselect * from crm_layouts where crm_configuration_id = 1;\nselect cf.* from crm_fields cf\njoin crm_layout_entities cle on cf.id = cle.crm_field_id\nwhere crm_layout_id = 1493;\n\nSELECT * FROM opportunities WHERE id = 4158460;\nSELECT * FROM crm_field_data WHERE object_id = 4158460;\n\nselect * from users where team_id = 1;\nSELECT * FROM users WHERE id = 7160;\nselect * from role_user where user_id = 3248;\n\nselect * from crm_field_values where crm_field_id = 66824;\n\nSELECT * FROM users WHERE id = 23470;\nselect * from crm_configurations;\n\n# ******************************************\nselect * from teams where id = 1038; # 23521, 966\nselect * from users where team_id = 1038;\nselect * from crm_configurations where id = 966;\nSELECT * FROM activities WHERE uuid_to_bin('e8dc7c34-31f6-4430-bd81-2d9d8f00dd07') = uuid; # 54965762\nSELECT * FROM activities WHERE uuid_to_bin('4b5727c0-69d1-428f-b8d9-b649055166e2') = uuid; # 54965764\nSELECT * FROM participants WHERE activity_id = 54965764;\n\nSELECT id, uuid, title, recording_state FROM activities WHERE uuid_to_bin('355f105d-5606-4379-b0c5-d91daf59f4ce') = uuid; # 54964075\nSELECT * FROM participants WHERE activity_id = 54964075;\nselect f.id, slug, title, tf.team_id from team_features tf JOIN features f on tf.feature_id = f.id\n where tf.team_id = 1038;\n\n\n# ****************************************** PD *************************\nselect * from teams where id = 1029;\nSELECT * FROM crm_configurations WHERE id = 957;\nSELECT * FROM activities WHERE uuid_to_bin('581a33cb-4343-44bd-ac0c-4a5cee71c5ec') = uuid; # 54963423\nSELECT * FROM activities WHERE uuid_to_bin('07238011-25fa-418e-838b-fb21e82b9ea2') = uuid; # 54963433\n\nSELECT * FROM activities WHERE uuid_to_bin('8b6bb2f7-e7a7-4aec-8806-fbfea7093ccd') = uuid; # 54963421\n\n# ****************************************** Close *************************\nselect * from teams where id = 1031;\nSELECT * FROM crm_configurations WHERE id = 959;\nSELECT * FROM activities WHERE uuid_to_bin('59c517e0-96c9-4dad-bfeb-b7bef72f8725') = uuid; # 54963517\n\n\nSELECT * FROM activities WHERE uuid_to_bin('07238011-25fa-418e-838b-fb21e82b9ea2') = uuid; # 54963433\n\nSELECT * FROM activities WHERE uuid_to_bin('d68311e3-ac34-49bd-bf04-88323a7e5352') = uuid; # 54966009\n\n# ****************************************** SF *************************\nSELECT * FROM activities WHERE uuid_to_bin('2bd4cfa3-4eb5-4c77-84a7-9ed46a21c988') = uuid;\n\nselect * from roles;\nselect * from permissions;\nselect * from permission_role where permission_id = 136;\n\nselect * from migrations order by id desc;\n\nselect * from teams where id IN (1, 1037);\nselect * from crm_layouts where crm_configuration_id = 1;\nSELECT * FROM crm_layout_entities WHERE crm_layout_id = 1493;\nSELECT * FROM crm_fields WHERE id IN (1652,1661,66799,66814,66821,66836,66843,66846,66864,84752,182306,323580,380378);\n\nselect * from features;\nselect * from team_features where feature_id = 33;\nselect * from opportunities;\n\nselect * from teams;\nSELECT\n CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,\n u.email,\n sa.*,\n t.owner_id FROM social_accounts sa\nJOIN users u on u.id = sa.sociable_id\nJOIN teams t on t.id = u.team_id\nWHERE u.team_id = 1052 and sa.provider = 'hubspot';\n\nSELECT * FROM accounts where id = 11512582;\nselect * from activities where account_id = 11512582;","depth":4,"on_screen":true,"value":"SELECT\n# DISTINCT\nopp.id as opp_id, opp.uuid, opp.name,\n# COUNT(dr.id) AS deal_risk_count,\n\n# dr.id,\n# cfd.value,\nusr.name AS owner_name,\nopp.value, opp.currency_code, opp.close_date, opp.remotely_created_at, opp.is_closed, opp.is_won,\nusr.uuid as owner_uuid,\nusr.photo_path as owner_photo,\nusr.id AS owner_id,\njt.name as owner_job,\nopp.stage_id, opp.stage_updated_at,\nacc.name AS acc_name, opp.stage_updated_at, acc.crm_provider_id AS acc_provider_id, opp.crm_provider_id AS opp_provider_id,\nrt.business_process_id AS pipeline_id\nFROM opportunities opp\nLEFT JOIN record_types rt ON opp.record_type_id = rt.id\nLEFT JOIN users usr ON opp.user_id = usr.id\nLEFT JOIN accounts acc ON opp.account_id = acc.id\nLEFT JOIN job_titles jt ON usr.job_title_id = jt.id\n\n# LEFT JOIN group_deal_risk_types gdrt ON gdrt.group_id = usr.group_id\n# LEFT JOIN deal_risks dr ON dr.opportunity_id = opp.id AND dr.group_deal_risk_type_id = gdrt.id\n\nLEFT JOIN crm_field_data cfd ON (cfd.object_id = opp.id and cfd.crm_field_id = 66814)\n\nWHERE opp.user_id IS NOT NULL\nAND opp.deleted_at IS NULL\nAND opp.is_closed = 1\nAND opp.is_won = 0\nAND opp.close_date >= '2024-01-01 00:00:00'\nAND opp.close_date <= '2024-12-31 23:59:59'\nAND usr.team_id = 1\n\n# and opp.id = 4823179\n\nGROUP BY opp.id, cfd.value\nORDER BY\n# cfd.value\n CAST(cfd.value AS UNSIGNED)\n# owner_name\n# (SELECT COUNT(dr.id)\n# FROM deal_risks dr\n# JOIN group_deal_risk_types gdrt ON dr.group_deal_risk_type_id = gdrt.id\n# WHERE dr.opportunity_id = opp.id\n# AND dr.is_active = 1\n# AND gdrt.is_enabled = 1)\nDESC\nLIMIT 25\n# OFFSET 0\n;\n\nselect * from crm_fields where crm_configuration_id = 1 and object_type = 'opportunity';\nselect * from crm_layout_entities where crm_layout_id = 1493;\n\nSELECT * FROM opportunities WHERE id = 4584045;\nSELECT * FROM crm_field_data WHERE object_id = 4584045;\n\nSELECT * FROM opportunities where team_id = 1 and crm_provider_id = '0061K00000lfb9IQAQ'\n\nSELECT\n opp.id as opportunity_id,\n opp.name,\n COUNT(dr.id) as risk_count\nFROM opportunities opp\nLEFT JOIN deal_risks dr ON dr.opportunity_id = opp.id\nWHERE opp.id IN (\n 6069574,8900938,9507638,9524799,9660490,9662824,9705675,9749758,4158460,4391812,6450439,4448422,5118945,5590675,4584045,7228149,9002408,9165534,9446720,9641778,9665149,9703344,9709280,5747948,4158491,6182565,6263970,5798120,5111315,4536978,6062352,6548383,6072095,6548225,4480986,5011422,6548381,8760540,8917554,9509986,9514392,9569009,9569011,9578490,9713604,5784397,7276612,7288405,8994421,9118219,9608148,9818911,4510535,6479693,5958049,6271674,6550448,4158331,4158483,6126571,6171615,6540943,4897466,5190896,5796182,5932762,8572433,8723698,8892697,9711001,9789264,4549188,6100831,6170064,6260799,6263653,6449936,6530871,6538978,7777651,8059269,8319918,8787049,8901150,9263153,9453207,9514738,9696700,9791062,5752018,6421452,7439134,7878923,9354763,9369285,9514396,9582506,9889949,9890216,4094311,4158495,4158496,6098128,5585661,3872564,6442149,5891604,6164746,6199593,6583474,6519684,9018490,9809006,4496897,5041324,5829430,6198319,6253504,6555763,7242914,7931055,8024125,8797814,8058559,8673347,8892695,8994420,9616219,9714970,9722004,9809439,9818918,6523177,8134147,9002915,9711422,9892713,9901719,9954210,9978435,5800810,6243518,6416114,6222251,6411974,6512456,5791953,6545606,9914780,5805540,6238986,6463838,6547680,9767049,9809437,9810885,9890855,6673493,9902036,4335521,6379871,6503799,6546077,8018765,9907556,9958433,9905855,9916179,9946741,9957877,5563344,6271838,6450815,7641128,7762567,7780592,8684810,8685786,8685787,8685788\n)\nGROUP BY opp.id;\n\nSELECT COUNT(dr.id)\n FROM deal_risks dr\n JOIN group_deal_risk_types gdrt ON dr.group_deal_risk_type_id = gdrt.id\n WHERE dr.opportunity_id = 5563344\n# AND gdrt.group_id = usr.group_id\n\n\n\nEXPLAIN SELECT\n opp.id as opp_id, opp.uuid, opp.name,\n\ncfd.value,\ncfv.sequence,\ncfv.value,\n# MAX(cfd.value) AS max_cfd_value,\nusr.name AS owner_name,\nopp.value, opp.currency_code, opp.close_date, opp.remotely_created_at, opp.is_closed, opp.is_won,\nusr.uuid as owner_uuid,\nusr.photo_path as owner_photo,\nusr.id AS owner_id,\njt.name as owner_job,\nopp.stage_id, opp.stage_updated_at,\nacc.name AS acc_name, opp.stage_updated_at, acc.crm_provider_id AS acc_provider_id, opp.crm_provider_id AS opp_provider_id,\nrt.business_process_id AS pipeline_id\nFROM opportunities opp\n\nLEFT JOIN record_types rt ON opp.record_type_id = rt.id\nLEFT JOIN users usr ON opp.user_id = usr.id\nLEFT JOIN accounts acc ON opp.account_id = acc.id\nLEFT JOIN job_titles jt ON usr.job_title_id = jt.id\n\nLEFT JOIN crm_field_data cfd ON cfd.id = (SELECT sub_cfd.id\n FROM crm_field_data sub_cfd\n WHERE sub_cfd.object_id = opp.id\n AND sub_cfd.crm_field_id = 66810\n ORDER BY sub_cfd.updated_at DESC\n LIMIT 1)\n\nLEFT JOIN crm_field_values cfv ON cfv.crm_field_id = 66810 AND cfv.value = cfd.value\n\nWHERE opp.user_id IS NOT NULL\nAND opp.deleted_at IS NULL\nAND opp.is_closed = 0\n# AND opp.is_closed = 1\n# AND opp.is_won = 0\nAND opp.close_date >= '2024-01-01 00:00:00'\nAND opp.close_date <= '2024-12-31 23:59:59'\nAND usr.team_id = 1\n\n# and opp.id = 4823179\n\n# GROUP BY opp.id\nORDER BY\n cfv.sequence DESC,\n# opp.name\n# owner_name\n cfd.value\n# CAST(cfd.value AS UNSIGNED)\n# CAST(MAX(cfd.value) AS UNSIGNED)\n# (SELECT COUNT(dr.id)\n# FROM deal_risks dr\n# JOIN group_deal_risk_types gdrt ON dr.group_deal_risk_type_id = gdrt.id\n# WHERE dr.opportunity_id = opp.id\n# AND dr.is_active = 1 AND gdrt.is_enabled = 1)\nDESC\n# LIMIT 75, 25\n;\n\n\n\nSELECT * FROM crm_field_values WHERE crm_field_id = 66810;\n\nSELECT f.id, f.crm_provider_id AS field_name, f.label, fd.object_id AS dealId, fd.value, fd.updated_at, fd.id, fd.created_at\nFROM crm_fields f\nINNER JOIN crm_field_data fd ON fd.crm_field_id = f.id\n WHERE (f.crm_configuration_id = 1)\n AND (f.object_type = 'opportunity')\n# AND (fd.object_id IN (4158331,4158483,4158495,4158496,6069574,6263970,6171615,6540943,6545606,5829430,6198319,6263653,6548381,7242914,8797814,7228149,7439134,7878923,8134147,9002915,9118219,9165534,9354763,9369285,9446720))\n AND (fd.object_id IN (4158460))\n AND (f.crm_provider_id IN ('ForecastCategoryName'))\n ORDER BY fd.object_id ASC, fd.updated_at DESC;\n\nselect * from crm_layouts where crm_configuration_id = 1;\nselect cf.* from crm_fields cf\njoin crm_layout_entities cle on cf.id = cle.crm_field_id\nwhere crm_layout_id = 1493;\n\nSELECT * FROM opportunities WHERE id = 4158460;\nSELECT * FROM crm_field_data WHERE object_id = 4158460;\n\nselect * from users where team_id = 1;\nSELECT * FROM users WHERE id = 7160;\nselect * from role_user where user_id = 3248;\n\nselect * from crm_field_values where crm_field_id = 66824;\n\nSELECT * FROM users WHERE id = 23470;\nselect * from crm_configurations;\n\n# ******************************************\nselect * from teams where id = 1038; # 23521, 966\nselect * from users where team_id = 1038;\nselect * from crm_configurations where id = 966;\nSELECT * FROM activities WHERE uuid_to_bin('e8dc7c34-31f6-4430-bd81-2d9d8f00dd07') = uuid; # 54965762\nSELECT * FROM activities WHERE uuid_to_bin('4b5727c0-69d1-428f-b8d9-b649055166e2') = uuid; # 54965764\nSELECT * FROM participants WHERE activity_id = 54965764;\n\nSELECT id, uuid, title, recording_state FROM activities WHERE uuid_to_bin('355f105d-5606-4379-b0c5-d91daf59f4ce') = uuid; # 54964075\nSELECT * FROM participants WHERE activity_id = 54964075;\nselect f.id, slug, title, tf.team_id from team_features tf JOIN features f on tf.feature_id = f.id\n where tf.team_id = 1038;\n\n\n# ****************************************** PD *************************\nselect * from teams where id = 1029;\nSELECT * FROM crm_configurations WHERE id = 957;\nSELECT * FROM activities WHERE uuid_to_bin('581a33cb-4343-44bd-ac0c-4a5cee71c5ec') = uuid; # 54963423\nSELECT * FROM activities WHERE uuid_to_bin('07238011-25fa-418e-838b-fb21e82b9ea2') = uuid; # 54963433\n\nSELECT * FROM activities WHERE uuid_to_bin('8b6bb2f7-e7a7-4aec-8806-fbfea7093ccd') = uuid; # 54963421\n\n# ****************************************** Close *************************\nselect * from teams where id = 1031;\nSELECT * FROM crm_configurations WHERE id = 959;\nSELECT * FROM activities WHERE uuid_to_bin('59c517e0-96c9-4dad-bfeb-b7bef72f8725') = uuid; # 54963517\n\n\nSELECT * FROM activities WHERE uuid_to_bin('07238011-25fa-418e-838b-fb21e82b9ea2') = uuid; # 54963433\n\nSELECT * FROM activities WHERE uuid_to_bin('d68311e3-ac34-49bd-bf04-88323a7e5352') = uuid; # 54966009\n\n# ****************************************** SF *************************\nSELECT * FROM activities WHERE uuid_to_bin('2bd4cfa3-4eb5-4c77-84a7-9ed46a21c988') = uuid;\n\nselect * from roles;\nselect * from permissions;\nselect * from permission_role where permission_id = 136;\n\nselect * from migrations order by id desc;\n\nselect * from teams where id IN (1, 1037);\nselect * from crm_layouts where crm_configuration_id = 1;\nSELECT * FROM crm_layout_entities WHERE crm_layout_id = 1493;\nSELECT * FROM crm_fields WHERE id IN (1652,1661,66799,66814,66821,66836,66843,66846,66864,84752,182306,323580,380378);\n\nselect * from features;\nselect * from team_features where feature_id = 33;\nselect * from opportunities;\n\nselect * from teams;\nSELECT\n CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,\n u.email,\n sa.*,\n t.owner_id FROM social_accounts sa\nJOIN users u on u.id = sa.sociable_id\nJOIN teams t on t.id = u.team_id\nWHERE u.team_id = 1052 and sa.provider = 'hubspot';\n\nSELECT * FROM accounts where id = 11512582;\nselect * from activities where account_id = 11512582;","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Project","depth":3,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Project","depth":3,"bounds":{"left":0.025,"top":0.06666667,"width":0.050694443,"height":0.034444444},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"New File or Directory…","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Expand Selected","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Collapse All","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Options","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
2798564629323822362
|
-3411827625184589122
|
click
|
accessibility
|
NULL
|
Project: faVsco.js, menu
#12066 on JY-20725-handle Project: faVsco.js, menu
#12066 on JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
HandleHubspotRateLimitTest
Run 'HandleHubspotRateLimitTest'
Debug 'HandleHubspotRateLimitTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
2
13
3
Previous Highlighted Error
Next Highlighted Error
<?php
namespace Jiminny\Services\Crm\Bullhorn;
use Carbon\Carbon;
use Exception;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
use Jiminny\Contracts\Services\Crm\MatchCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\FetchRelatedActivityInterface;
use Jiminny\Contracts\Services\Crm\LayoutManagementInterface;
use Jiminny\Contracts\Services\Crm\Provider\BullhornInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityLookupInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityManipulationInterface;
use Jiminny\Contracts\Services\Crm\SettingsInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmMetadataInterface;
use Jiminny\Contracts\Services\Crm\VerifyTaskExistsInterface;
use Jiminny\Exceptions\ApplicationException;
use Jiminny\Exceptions\CrmException;
use Jiminny\Exceptions\DomainException;
use Jiminny\Exceptions\HttpBadRequestException;
use Jiminny\Exceptions\HttpNotFoundException;
use Jiminny\Exceptions\ServiceUnavailableException;
use Jiminny\Models\Account;
use Jiminny\Models\Activity;
use Jiminny\Models\Contact;
use Jiminny\Models\Crm\Configuration;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\FieldData;
use Jiminny\Models\Crm\Profile;
use Jiminny\Models\Lead;
use Jiminny\Models\Opportunity;
use Jiminny\Models\Playbook;
use Jiminny\Models\SocialAccount;
use Jiminny\Models\Stage;
use Jiminny\Models\User;
use Jiminny\Repositories\Crm\ProfileRepository;
use Jiminny\Repositories\UserRepository;
use Jiminny\Services\Avatar\ProspectPhotoPathService;
use Jiminny\Services\Crm\BaseService;
use Jiminny\Services\Crm\OpportunitySyncStrategyResolver;
use Jiminny\Utils\CurrencyFormatter;
use Jiminny\Utils\StringUtil;
use Ramsey\Uuid\Uuid;
class BullhornService extends BaseService implements
BullhornInterface,
SyncCrmEntitiesInterface,
SyncCrmMetadataInterface,
RemoteEntityManipulationInterface,
FetchRelatedActivityInterface,
LayoutManagementInterface,
SettingsInterface,
MatchCrmEntitiesInterface,
RemoteEntityLookupInterface,
VerifyTaskExistsInterface
{
private const int TASK_VERIFICATION_CACHE_TTL = 86400; // 1 day
private const string ENTITY_LEAD = 'Candidate';
private const string ENTITY_OPPORTUNITY = 'JobOrder';
private const string ENTITY_ACCOUNT = 'ClientCorporation';
private const string ENTITY_CONTACT = 'ClientContact';
private const string ENTITY_ACTIVITY = 'Note';
private const array OPPORTUNITY_FIELDS = [
'id',
'owner',
'status',
'title',
'salary',
'salaryUnit',
'payRate',
'isOpen',
'employmentType',
'dateClosed',
'startDate',
'dateLastModified',
'dateAdded',
'clientCorporation',
'correlatedCustomText10', // currency
];
private const array TASK_FIELDS = [
'id',
'comments',
'dateAdded',
'dateLastModified',
'externalID',
'isDeleted',
'corporateUsers',
'clientContacts',
'commentingPerson',
'candidates',
'jobOrder',
'minutesSpent',
'action',
];
private const array LEAD_FIELDS = [
'id',
'name',
'owner',
'occupation',
'companyName',
'companyURL',
'mobile',
'phone',
'phone2',
'phone3',
'workPhone',
'status',
'address',
'dateAdded',
'dateLastModified',
];
private const array CONTACT_FIELDS = [
'id',
'name',
'occupation',
'mobile',
'phone',
'phone2',
'phone3',
'address',
'clientCorporation',
'dateAdded',
'owner',
];
private const array ACCOUNT_FIELDS = [
'id',
'name',
'phone',
'industryList',
'address',
'companyURL',
'dateAdded',
];
public function __construct(
BullhornClientInterface $client,
private readonly UserRepository $userRepository,
private readonly ProspectPhotoPathService $prospectPhotoPathService
) {
parent::__construct();
$this->client = $client;
}
protected function getOAuthAccount(User $user): ?SocialAccount
{
// In this case, the Account Owner is always the connection for any API operations.
$owner = $user->team->owner;
return $owner->getSocialAccount(SocialAccount::PROVIDER_BULLHORN);
}
public function getDisplayName(): string
{
return 'Bullhorn';
}
public function createTranscriptNotes(Activity $activity): void
{
// Not done until a customer requests it.
}
public function syncProfileFields(): void
{
$fieldTypes = [
'lead',
'account',
'contact',
'opportunity',
'task',
];
foreach ($fieldTypes as $fieldType) {
try {
$currentFields = $this->getFields($this->convertObjectTypeToResource($fieldType));
$newFields = array_column($currentFields, 'name');
$changedFields = array_merge(
array_diff($this->profile->getFieldsAsArray($fieldType), $newFields),
array_diff($newFields, $this->profile->getFieldsAsArray($fieldType))
);
if (! empty($changedFields)) {
$fields = implode(',', $newFields);
Log::debug(
sprintf(
'%s fields changed from %s to: %s',
ucfirst($fieldType),
$this->profile->{$fieldType . '_fields'},
$fields,
),
);
$this->profile->{$fieldType . '_fields'} = $fields;
}
} catch (\Exception $exception) {
\Log::error('No access to ' . $fieldType . ' object, skipping...');
// XXX: should we log this fact somewhere?
continue;
}
}
$this->profile->save();
}
protected function getFieldTypes(): array
{
return [
parent::OBJECT_TASK,
//parent::OBJECT_ACCOUNT,
//parent::OBJECT_CONTACT,
//parent::OBJECT_OPPORTUNITY,
//parent::OBJECT_LEAD,
];
}
protected function getFields(string $crmObject): array
{
$fields = [];
foreach ($this->getObjectFields($crmObject) as $row) {
$fields[] = ['label' => $row['label'] ?? $row['name'], 'name' => $row['name']];
}
return $fields;
}
private function getObjectFields(string $objectType): array
{
$entityType = $this->convertObjectTypeToResource($objectType);
return Cache::remember(
key: 'crm.profile#' . $this->profile->id . '.fieldType:' . $objectType,
ttl: 60 * 60,
callback: function () use ($entityType) {
$entity = $this->client->get('meta/' . $entityType, [
'fields' => '*',
]);
return $entity['fields'];
},
);
}
public function getDefaultFields(string $activityType): array
{
$fields = [];
$defaultFields = FieldDefinitions::defaultFields();
foreach ($defaultFields as $defaultField) {
$fields[] = $this->config->fields()->firstOrCreate($defaultField);
}
return $fields;
}
public function getDefaultActivityField(string $activityType): Field
{
/** @var Field $activityField */
$activityField = $this->config->fields()->where([
'crm_provider_id' => 'action',
'object_type' => $activityType,
])->first();
return $activityField;
}
public function getDealInsightsFields(): array
{
return FieldDefinitions::dealInsightsFields();
}
public function getSupportedPlaybookTypes(): array
{
return [Playbook::ACTIVITY_TYPE_TASK];
}
protected function getDefaultFollowupLayoutFields(string $activityType): array
{
// We are not yet supporting follow-ups for this system.
return [];
}
public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string
{
return $fieldValue;
}
public function syncField(Field $field): void
{
$resource = $this->convertObjectTypeToResource($field->object_type);
$entity = $this->client->get('meta/' . $resource, [
'fields' => $field->crm_provider_id,
'meta' => 'full',
]);
$bhField = $entity['fields'][0];
$isMandatory = (empty($bhField['optional']) == false && $bhField['optional'] === false);
$description = null;
if (empty($bhField['description']) === false) {
$description = mb_strimwidth($bhField['description'], 0, 191);
}
$label = 'Unknown';
if (empty($bhField['label']) === false) {
$label = mb_strimwidth($bhField['label'], 0, Field::LABEL_MAX_LENGTH);
}
$field->length = $bhField['maxLength'] ?? null;
$field->is_mandatory = $isMandatory;
$field->description = $description;
$field->label = $label;
$field->type = $this->convertFieldType(
$bhField['dataType'] ?? null,
$bhField['dataSpecialization'] ?? null,
$bhField['inputType'] ?? null
);
$field->save();
if (empty($bhField['options']) === false) {
$values = [];
// Import all values.
foreach ($bhField['options'] as $i => $option) {
$values[] = [
'value' => $option['value'],
'label' => mb_strimwidth($option['label'], 0, Field::LABEL_MAX_LENGTH),
'sequence' => $i,
];
}
// Take the existing set of field values.
$fieldsToPurge = $field->values()->get()->pluck('value')->toArray();
foreach ($values as $value) {
// For all current values, upsert the field.
$field->values()->updateOrCreate([
'value' => $value['value'],
], $value);
// Remove this value from the ones we are going to purge.
$key = array_search($value['value'], $fieldsToPurge, false);
if ($key !== false) {
unset($fieldsToPurge[$key]);
}
}
// Delete the old values that are no longer used.
$field->values()->whereIn('value', $fieldsToPurge)->delete();
}
}
public function importPicklistValues(Field $field): array
{
// Taken care of in syncField.
return [];
}
private function convertFieldType(?string $type, ?string $specialization, ?string $inputType): string
{
if ($type === null) {
return Field::TYPE_UNSUPPORTED;
}
$type = strtolower($type);
$specialization = strtolower($specialization);
$inputType = strtolower($inputType);
switch ($type) {
case 'boolean':
return Field::TYPE_BOOLEAN;
case 'integer':
switch ($specialization) {
case 'integer':
return Field::TYPE_NUMBER;
default:
return Field::TYPE_NUMBER;
}
// no break
case 'bigdecimal':
switch ($specialization) {
case 'money':
return Field::TYPE_CURRENCY;
default:
return Field::TYPE_NUMBER;
}
// no break
case 'double':
switch ($specialization) {
case 'float':
return Field::TYPE_NUMBER;
default:
return Field::TYPE_NUMBER;
}
// no break
case 'timestamp':
switch ($specialization) {
case 'date':
return Field::TYPE_DATE;
case 'time':
return Field::TYPE_TIME;
case 'datetime':
return Field::TYPE_DATETIME;
default:
return Field::TYPE_DATE;
}
// no break
case 'string':
switch ($inputType) {
case 'textarea':
return Field::TYPE_TEXTAREA;
case 'select':
return Field::TYPE_PICKLIST;
default:
return Field::TYPE_TEXT;
}
// no break
default:
// This is not actually a supported field.
return Field::TYPE_UNSUPPORTED;
}
}
private function importContact($crmData): Contact
{
$account = $mobilePhone = $phone = $email = null;
if (empty($crmData['clientCorporation']) === false) {
$account = $this->team
->accounts()
->where('crm_provider_id', $crmData['clientCorporation']['id'])
->first();
if ($account === null) {
$account = $this->syncAccount($crmData['clientCorporation']['id']);
}
}
$countryCode = $this->convertCountryNameToCode($crmData['address']['countryCode']);
foreach (['mobile', 'phone', 'phone2', 'phone3'] as $type) {
if (empty($crmData[$type])) {
continue;
}
if ($type === 'mobile') {
$mobilePhone = parsePhoneNumber($countryCode, $crmData[$type]);
} else {
$phone = parsePhoneNumber($countryCode, $crmData[$type]);
}
break;
}
foreach (['email', 'email1', 'email2'] as $type) {
if (empty($crmData[$type])) {
continue;
}
$email = mb_strimwidth($crmData[$type], 0, 80);
break;
}
$profile = $this->config->profiles()->where('crm_provider_id', (string) $crmData['owner']['id'])->first();
$data = [
'team_id' => $this->team->id,
'account_id' => $account->id ?? null,
'user_id' => $profile ? $profile->user_id : null,
'owner_id' => $crmData['owner']['id'],
'name' => $crmData['name'],
'email' => $email,
'phone' => empty($phone['phone']) ? null : mb_strimwidth($phone['phone'], 0, 25),
'ext' => $phone['ext'] ?? null,
'mobile_phone' => empty($mobilePhone['phone']) ? null : mb_strimwidth($mobilePhone['phone'], 0, 25),
'title' => $crmData['occupation'],
'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(
crmConfiguration: $this->config,
crmProviderId: $crmData['id'],
modelType: Contact::class,
fileName: $crmData['id'],
avatarText: $crmData['name'] ?? 'Unknown'
),
'remotely_created_at' => $this->filterOutOfBoundDates($crmData['dateAdded']),
];
return $this->config->contacts()->updateOrCreate(['crm_provider_id' => (string) $crmData['id']], $data);
}
private function importOpportunity($crmData): ?Opportunity
{
if ($crmData['clientCorporation']['id']) {
$account = $this->config->accounts()->where('crm_provider_id', (string) $crmData['clientCorporation']['id'])->first();
if ($account === null) {
$account = $this->syncAccount($crmData['clientCorporation']['id']);
}
} else {
// If there is nothing to associate this with, don't import it.
return null;
}
$profile = $this->config->profiles()->where('crm_provider_id', (string) $crmData['owner']['id'])->first();
$stage = $this->config->stages()
->where('crm_provider_id', $crmData['status'])
->where('type', Stage::TYPE_OPPORTUNITY)
->first();
if ($stage === null) {
$stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $crmData['status']);
}
$data = [
'team_id' => $this->team->id,
'account_id' => $account->id,
'user_id' => $profile ? $profile->user_id : null,
'owner_id' => $crmData['owner']['id'],
'name' => mb_strimwidth($crmData['title'], 0, 128),
'value' => $crmData['salary'] ?? $crmData['payRate'],
'currency_code' => CurrencyFormatter::formatCode($crmData['correlatedCustomText10'] ?? 'USD'),
'close_date' => $this->filterOutOfBoundDates($crmData['startDate']),
'is_closed' => $crmData['isOpen'] !== true,
'is_won' => strtolower($crmData['status']) === 'placed',
'stage_id' => $stage->id,
'record_type_id' => null,
'remotely_created_at' => $this->filterOutOfBoundDates($crmData['dateAdded']),
];
return $this->config->opportunities()->updateOrCreate(['crm_provider_id' => (string) $crmData['id']], $data);
}
private function importAccount($crmData): Account
{
$countryCode = $domain = null;
if (empty($crmData['address']['countryCode'])) {
$countryCode = $this->convertCountryNameToCode($crmData['address']['countryCode']);
}
$name = $crmData['name'] ?? 'Unknown';
if ($crmData['companyURL'] !== null) {
$domain = mb_strimwidth($crmData['companyURL'], 0, 191);
$domain = StringUtil::resolveDomain($domain);
}
$data = [
'team_id' => $this->team->id,
'user_id' => null,
'owner_id' => null,
'name' => mb_strimwidth($name, 0, 191),
'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(
crmConfiguration: $this->config,
crmProviderId: $crmData['id'],
modelType: Account::class,
fileName: $crmData['id'],
avatarText: $crmData['name'] ?? 'Unknown'
),
'country_code' => $countryCode,
'domain' => $domain,
'remotely_created_at' => $this->filterOutOfBoundDates($crmData['dateAdded']),
];
return $this->config->accounts()->updateOrCreate(['crm_provider_id' => (string) $crmData['id']], $data);
}
private function importLead($crmData): ?Lead
{
// Get the current stage.
$stage = $this->config->stages()->where('name', $crmData['status'])->where('type', Stage::TYPE_LEAD)->first();
if ($stage === null) {
$stage = $this->importStages([Stage::TYPE_LEAD], $crmData['status']);
}
// If we have no way of importing this, just return null :(
if ($stage === null) {
return null;
}
$countryCode = null;
if (empty($crmData['address']['countryCode'])) {
$countryCode = $this->convertCountryNameToCode($crmData['address']['countryCode']);
}
$mobilePhone = $phone = $email = null;
foreach (['mobile', 'phone', 'phone2', 'phone3', 'workPhone'] as $type) {
if (empty($crmData[$type])) {
continue;
}
if ($type === 'mobile') {
$mobilePhone = parsePhoneNumber($countryCode, $crmData[$type]);
} else {
$phone = parsePhoneNumber($countryCode, $crmData[$type]);
}
break;
}
foreach (['email', 'email1', 'email2'] as $type) {
if (empty($crmData[$type])) {
continue;
}
$email = mb_strimwidth($crmData[$type], 0, 80);
break;
}
$company = 'Unknown';
if (empty($crmData['companyName']) === false) {
$companyName = is_array($crmData['companyName']) ? $crmData['companyName'][0] : $crmData['companyName'];
$company = mb_strimwidth($companyName, 0, 191);
}
$domain = null;
if (empty($crmData['companyURL']) === false) {
$domain = mb_strimwidth($crmData['companyURL'], 0, 191);
$domain = StringUtil::resolveDomain($domain);
}
$profile = $this->config->profiles()->where('crm_provider_id', (string) $crmData['owner']['id'])->first();
$data = [
'team_id' => $this->team->id,
'user_id' => $profile ? $profile->user_id : null,
'owner_id' => $crmData['owner']['id'],
'company' => $company,
'domain' => $domain,
'name' => mb_strimwidth($crmData['name'], 0, 191),
'title' => mb_strimwidth($crmData['occupation'], 0, 128),
'email' => mb_strimwidth($email, 0, 80),
'phone' => empty($phone['phone']) ? null : mb_strimwidth($phone['phone'], 0, 25),
'ext' => $phone['ext'] ?? null,
'mobile_phone' => empty($mobilePhone['phone']) ? null : mb_strimwidth($mobilePhone['phone'], 0, 25),
'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(
crmConfiguration: $this->config,
crmProviderId: $crmData['id'],
modelType: Lead::class,
fileName: $crmData['id'],
avatarText: $crmData['name'] ?? 'Unknown'
),
'stage_id' => $stage->id,
'country_code' => $countryCode,
'remotely_created_at' => $this->filterOutOfBoundDates($crmData['dateAdded']),
];
return $this->config->leads()->updateOrCreate(['crm_provider_id' => (string) $crmData['id']], $data);
}
/**
* @throws ApplicationException
*/
public function syncContacts(Carbon $since, ?Carbon $to = null): int
{
$syncCount = 0;
$params = [
'sort' => '-dateLastModified',
'fields' => $this->getFieldsAsString(self::CONTACT_FIELDS),
'query' => 'isDeleted: false AND dateLastModified:[' . $since->format('YmdHis') . ' TO *]',
];
try {
$bhContacts = $this->client->get('search/ClientContact', $params);
foreach ($bhContacts['data'] as $bhContact) {
// Only sync if previously imported.
if ($this->hasContact($bhContact['id'])) {
$this->importContact($bhContact);
$syncCount++;
}
}
} catch (\Exception $exception) {
throw new ApplicationException('Failed to sync contacts', $exception->getCode(), $exception);
}
return $syncCount;
}
public function syncOpportunities(array $parameters, ?string $strategy = null): int
{
$strategyResolver = app(OpportunitySyncStrategyResolver::class, [
'client' => $this->client,
]);
$strategies = $strategyResolver->getStrategies($this->config, $strategy);
$syncCount = 0;
$total = 0;
$count = 0;
$offset = 0;
try {
$bhOpportunities = [];
foreach ($strategies as $syncStrategy) {
$opportunitiesData = $syncStrategy->fetchOpportunities($parameters);
$total = (int) ($opportunitiesData['total'] ?? 0);
$count = (int) ($opportunitiesData['count'] ?? 0);
$offset = (int) ($opportunitiesData['start'] ?? 0);
$bhOpportunities[] = $opportunitiesData['data'] ?? [];
}
$bhOpportunities = array_merge(...$bhOpportunities);
foreach ($bhOpportunities as $bhOpportunity) {
$this->importOpportunity($bhOpportunity);
$syncCount++;
}
} catch (\Exception $exception) {
$this->logger->info('Failed to sync opportunities', [
'params' => $parameters,
'exception' => $exception->getMessage(),
]);
}
// only log if there are one sync strategy
if (count($strategies) === 1 && $total > $count + $offset) {
$this->logger->info('[Bullhorn] Sync Opportunities - count warning', [
'team_id' => $this->team->getId(),
'total' => $total,
'count' => $count,
'offset' => $offset,
]);
}
return $syncCount;
}
/**
* @throws ApplicationException
*/
public function syncAccounts(Carbon $since, ?Carbon $to = null): int
{
$syncCount = 0;
$params = [
'sort' => '-dateLastModified',
'fields' => $this->getFieldsAsString(self::ACCOUNT_FIELDS),
'query' => 'isDeleted: false AND dateLastModified:[' . $since->format('YmdHis') . ' TO *]',
];
try {
$bhAccounts = $this->client->get('search/ClientCorporation', $params);
foreach ($bhAccounts['data'] as $bhAccount) {
// Only sync if previously imported.
if ($this->hasAccount($bhAccount['id'])) {
$this->importAccount($bhAccount);
$syncCount++;
}
}
} catch (\Exception $exception) {
throw new ApplicationException('Failed to sync accounts', $exception->getCode(), $exception);
}
return $syncCount;
}
/**
* @throws ApplicationException
*/
public function syncLeads(Carbon $since, ?Carbon $to = null, ?string $crmProfileId = null): int
{
$syncCount = 0;
$params = [
'sort' => '-dateLastModified',
'fields' => $this->getFieldsAsString(self::LEAD_FIELDS),
'query' => 'isDeleted: false AND dateLastModified:[' . $since->format('YmdHis') . ' TO *]',
];
try {
$bhLeads = $this->client->get('search/Candidate', $params);
if ($bhLeads === null) {
return $syncCount;
}
foreach ($bhLeads['data'] as $bhLead) {
// Only sync if previously imported.
if ($this->hasLead($bhLead['id'])) {
$this->importLead($bhLead);
$syncCount++;
}
}
} catch (\Exception $exception) {
throw new ApplicationException('Failed to sync leads', $exception->getCode(), $exception);
}
return $syncCount;
}
public function syncContact(string $crmId): ?Contact
{
try {
$data = $this->getRecord(FieldData::OBJECT_CONTACT, $crmId, self::CONTACT_FIELDS);
} catch (HttpNotFoundException $exception) {
return null;
}
return $this->importContact($data['data']);
}
public function syncOpportunity(string $crmId): ?Opportunity
{
$strategyResolver = app(OpportunitySyncStrategyResolver::class, [
'client' => $this->client,
]);
$strategy = $strategyResolver->resolve(
$this->config,
OpportunitySyncStrategyResolver::SINGLE_SYNC_OPPORTUNITY_STRATEGY
);
try {
$data = $strategy->fetchOpportunities(['crm_id' => $crmId]);
} catch (HttpNotFoundException $exception) {
$this->logger->info('[Bullhorn] Opportunity not found', [
'crm_id' => $crmId,
'reason' => $exception->getMessage(),
]);
}
return $this->importOpportunity($data['data'] ?? []);
}
public function syncAccount(string $crmId): ?Account
{
try {
$data = $this->getRecord(FieldData::OBJECT_ACCOUNT, $crmId, self::ACCOUNT_FIELDS);
} catch (HttpNotFoundException $exception) {
return null;
}
return $this->importAccount($data['data']);
}
public function syncLead(string $crmId): ?Lead
{
try {
$data = $this->getRecord(FieldData::OBJECT_LEAD, $crmId, self::LEAD_FIELDS);
} catch (HttpNotFoundException $exception) {
return null;
}
return $this->importLead($data['data']);
}
public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage
{
$missingStage = null;
if ($types === null) {
$types = [Stage::TYPE_LEAD, Stage::TYPE_OPPORTUNITY];
}
foreach ($types as $type) {
$resource = $this->convertObjectTypeToResource($type);
$entity = $this->client->get('meta/' . $resource, [
'fields' => 'status',
'meta' => 'full',
]);
$missingStage = null;
if (! is_array($entity['fields']) || count($entity['fields']) !== 1 || $entity['fields'][0]['name'] !== 'status') {
throw new \UnexpectedValueException('Invalid response from Bullhorn API');
}
// Upsert all stages for the team.
foreach ($entity['fields'][0]['options'] as $i => $option) {
$stage = $this->config->stages()->updateOrCreate([
'crm_provider_id' => $option['value'],
'type' => $type,
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($option['value'], 0, 50),
'label' => mb_strimwidth($option['label'], 0, 191),
'sequence' => $i,
'is_selectable' => 1,
]);
if ($missingStageName && $missingStageName === $option['value']) {
$missingStage = $stage;
}
}
if ($missingStageName && $missingStage === null) {
// If they requested a stage that still doesn't exist, it must be inactive so lazy create it.
$missingStage = $this->config->stages()->create([
'team_id' => $this->team->id,
'crm_provider_id' => Uuid::uuid4(),
'name' => mb_strimwidth($missingStageName, 0, 50),
'label' => mb_strimwidth($missingStageName, 0, 191),
'type' => $type,
'sequence' => 0,
'is_selectable' => 0,
]);
}
}
return $missingStage;
}
public function syncOrganization(): void
{
// There appears to be nothing we can sync/map to.
}
public function find(string $name, array $scopes): array
{
$count = $this->limit ?? 20;
$offset = $this->offset ?? 0;
$data = \Cache::remember($this->team->id . $name . $count . $offset, 300, function () use ($name, $count) {
$result = [];
try {
$objects = $this->client->get('find', [
'query' => $name,
'meta' => 'basic',
'countPerEntity' => $count,
]);
} catch (\GuzzleHttp\Exception\ServerException $exception) {
throw new ServiceUnavailableException($exception->getMessage());
}
foreach ($objects['data'] as $object) {
try {
$prospectType = $this->convertResourceToObjectType($object['entityType']);
} catch (\UnexpectedValueException $exception) {
// We don't support this, so skip it.
continue;
}
$record = [
'crmId' => $object['entityId'],
'crmUrl' => $this->generateProviderUrl($object['entityId'], $object['entityType']),
'prospectType' => $prospectType,
'phoneNumbers' => [],
];
// Unfortunately we need to go and query each entity itself to get the data we need.
switch ($object['entityType']) {
case self::ENTITY_ACCOUNT:
$data = $this->getRecord(FieldData::OBJECT_ACCOUNT, $object['entityId'], self::ACCOUNT_FIELDS);
$entity = $data['data'];
$record['name'] = $entity['name'];
$record['industry'] = $entity['industryList'];
if (empty($entity['phone']) === false) {
$record['phoneNumbers'][] = [
'number' => $entity['phone'],
'nationalFormat' => phone_national(null, $entity['phone']),
'type' => 'phone',
];
}
break;
case self::ENTITY_CONTACT:
$data = $this->getRecord(FieldData::OBJECT_CONTACT, $object['entityId'], self::CONTACT_FIELDS);
$entity = $data['data'];
$record['name'] = $entity['name'];
$record['organization'] = $entity['clientCorporation'] ? $entity['clientCorporation']['name'] : null;
$record['title'] = $entity['occupation'];
foreach (['mobile', 'phone', 'phone2', 'phone3'] as $phone) {
if (empty($entity[$phone]) === false) {
$record['phoneNumbers'][] = [
'number' => $entity[$phone],
'nationalFormat' => phone_national(null, $entity[$phone]),
'type' => $phone === 'mobile' ? 'mobile' : 'phone',
];
}
}
break;
case self::ENTITY_LEAD:
$bhData = $this->getRecord(FieldData::OBJECT_LEAD, $object['entityId'], self::LEAD_FIELDS);
$entity = $bhData['data'];
$record['name'] = $entity['name'];
$record['organization'] = $entity['companyName'];
$record['title'] = $entity['occupation'];
foreach (['mobile', 'phone', 'phone2', 'phone3', 'workPhone'] as $phone) {
if (empty($entity[$phone]) === false) {
$record['phoneNumbers'][] = [
'number' => $entity[$phone],
'nationalFormat' => phone_national(null, $entity[$phone]),
'type' => $phone === 'mobile' ? 'mobile' : 'phone',
];
}
}
break;
default:
// We are not interested in this entity type so skip it.
continue 2;
}
$result[] = $record;
}
return $result;
});
return $data;
}
public function getClient(): BullhornClientInterface
{
return $this->client;
}
private function getFieldsAsString(array $fields)
{
return implode(', ', $fields);
}
public function findOpportunities(?string $crmAccountId, ?string $crmContactId, ?int $userId = null): array
{
$data = [];
$ownerData = [];
$ownerId = null;
$contact = null;
$account = null;
if ($crmAccountId) {
$account = $this->config->accounts()->where('crm_provider_id', $crmAccountId)->first();
if ($account === null) {
$account = $this->syncAccount($crmAccountId);
}
}
if ($crmContactId) {
$contact = $this->config->contacts()->where('crm_provider_id', $crmContactId)->first();
if ($contact === null) {
$contact = $this->syncContact($crmContactId);
}
}
if ($contact || $account) {
if ($contact && $account === null) {
$account = $contact->account;
}
if ($account === null) {
return [];
}
$where = 'isDeleted: false AND clientCorporation.id:' . (int) $account->crm_provider_id;
$params = [
'sort' => '-dateLastModified',
'fields' => $this->getFieldsAsString(self::OPPORTUNITY_FIELDS),
];
$onlyOpen = true;
switch ($this->config->opportunity_assignment_rule) {
case Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_UPDATED:
$params['sort'] = '-dateLastModified';
break;
case Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_CREATED:
$params['sort'] = '-dateAdded';
break;
case Configuration::OPP_ASSIGNMENT_ALL_OPEN_OLDEST_CREATED:
$params['sort'] = '+dateAdded';
break;
case Configuration::OPP_ASSIGNMENT_ALL_RECENTLY_UPDATED:
$params['sort'] = '-dateLastModified';
$onlyOpen = false;
}
if ($onlyOpen) {
$where .= ' AND isOpen: true';
}
$params['query'] = $where;
$bhOpportunities = $this->client->get('search/JobOrder', $params);
if ($userId) {
$profileRepository = app(ProfileRepository::class);
$profile = $profileRepository->findProfileByUserId($this->config, $userId);
$ownerId = $profile instanceof Profile ? $profile->getCrmProviderId() : null;
}
foreach ($bhOpportunities['data'] as $bhOpportunity) {
$stage = $this->config
->stages()
->where('crm_provider_id', $bhOpportunity['status'])
->first();
if ($stage === null) {
// Import it.
$stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $bhOpportunity['status']);
}
$record = [
'crmId' => $bhOpportunity['id'],
'name' => $bhOpportunity['title'],
'value' => currency_format($bhOpportunity['salary'], $bhOpportunity['correlatedCustomText10'] ?? 'USD'),
'won' => strtolower($bhOpportunity['status']) === 'placed',
'closed' => $bhOpportunity['isOpen'] !== true,
'stage' => [
'id' => $stage->id_string,
'name' => $stage->name,
],
'recordType' => [],
];
if ($ownerId && isset($bhOpportunity['owner']['id']) && $bhOpportunity['owner']['id'] === (int) $ownerId) {
$ownerData[] = $record;
}
$data[] = $record;
}
}
if (! empty($ownerData)) {
return $ownerData;
}
return $data;
}
public function getTasks(string $objectType, string $objectId, ?string $opportunityId): array
{
$data = [];
// For now it has not been agreed on how to implement this e.g. appointments or tasks?
return [];
if ($objectType === 'account' && $opportunityId === null) {
// You cannot attach tasks to only accounts.
return [];
}
// Query should default to any open task.
$where = 'isDeleted: false AND isCompleted: false AND (' . $this->convertObjectTypeToResource($objectType) . 'UserId: ' . (int) $objectId;
// If we are also going to check on a specific job order, set that up.
if ($opportunityId) {
$where .= ' OR JobOrder: ' . (int) $opportunityId;
}
$where .= ')';
$params = [
'sort' => '-dateLastModified',
'fields' => $this->getFieldsAsString(self::TASK_FIELDS),
'query' => $where,
];
$bhTasks = $this->client->get('search/Task', $params);
foreach ($bhTasks['data'] as $bhTask) {
$data[] = [
'crmId' => $bhTask['id'],
'subject' => $bhTask['subject'],
'due' => Carbon::createFromTimestampMs($bhTask['dateEnd'])->toIso8601String(),
'type' => null,
];
}
return $data;
}
/**
* @url [URL_WITH_CREDENTIALS] [URL_WITH_CREDENTIALS] [URL_WITH_CREDENTIALS] CrmException
* @throws DomainException
*/
private function buildNotePayload(Activity $activity): Activity
{
if ($activity->hasActivityType() === false) {
throw new DomainException('[Bullhorn] No activity type assigned');
}
$action = $this->getCrmType($activity->getActivityType());
if ($action === null) {
throw new DomainException('[Bullhorn] No mapped CRM type for "' . $activity->category->name . '"');
}
if ($activity->lead_id === null && $activity->contact_id === null) {
throw new DomainException('[Bullhorn] Cannot save note without candidate or DM');
}
$payload = [
'comments' => $this->generateActivityDescription($activity),
'action' => $action->value,
'commentingPerson' => [
'id' => (int) $this->profile->crm_provider_id,
'type' => 'CorporateUser',
],
'externalID' => $activity->id_string,
'minutesSpent' => $activity->duration,
'dateAdded' => $activity->hasActualEndTime() ? $activity->getActualEndTime()?->valueOf() : now()->valueOf(),
];
if ($activity->lead_id) {
$payload['personReference'] = [
'id' => (int) $activity->lead->crm_provider_id,
'type' => 'Candidate',
];
}
if ($activity->contact_id) {
$payload['personReference'] = [
'id' => (int) $activity->contact->crm_provider_id,
'type' => 'ClientContact',
];
}
if ($activity->opportunity_id) {
$payload['jobOrders'] = [
'replaceAll' => [
(int) $activity->opportunity->crm_provider_id,
],
];
}
$bhActivity = $this->client->put('entity/Note', $payload);
$activity->crm_provider_id = $bhActivity['changedEntityId'];
$activity->save();
return $activity;
}
private function generateActivityDescription(Activity $activity): string
{
$description = '';
switch ($activity->type) {
case Activity::TYPE_SOFTPHONE:
case Activity::TYPE_SOFTPHONE_INBOUND:
case Activity::TYPE_CONFERENCE:
if ($activity->isTypeSoftPhone() && $activity->getProvider() !== Activity::PROVIDER_UPLOADER) {
// Dialer calls have no native field for the subject.
if ($activity->title) {
$description .= sprintf(
'<p><span style="font-size: 16px; line-height: 19.2px;">%s</span></p>',
$activity->title,
);
}
// Conference meetings log duration into a distinct field, so log this in the main text.
$description .= sprintf(
'<p><span style="font-size: 13.3333px; line-height: 16px;">Call Duration: %s</span></p>',
$activity->duration_for_humans,
);
// Tell them which number was dialed.
if ($activity->to) {
$description .= sprintf(
'<p><span style="font-size: 13.3333px; line-height: 16px;">Dialed: %s</span></p>',
$activity->to->phone_number,
);
}
$description .= '<p></p>';
}
if ($activity->isTypeSoftphoneInbound() && $activity->getProvider() !== Activity::PROVIDER_UPLOADER) {
...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
45387
|
1626
|
19
|
2026-05-14T14:33:55.707307+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-14/1778 /Users/lukas/.screenpipe/data/data/2026-05-14/1778769235707_m2.jpg...
|
PhpStorm
|
faVsco.js – BullhornService.php
|
1
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
ActivityFilesLaterJiminny... ~@ jiminny-x-integrat ActivityFilesLaterJiminny... ~@ jiminny-x-integrati& platform-inner-team© Channels# ai-chapter# alertsi backend# bugscontusion-cllnia# curiosity_lab# engineering# general# jiminny-bg# platform-tickets# product_launches# random# releases# sofia-office# support# thank-yous# the people of iimi..• Direct messages8. A...• A3 62P. Galya Dimitrova Mvasil VasilevA. Stefka Stoyanova%: Todor Stamatovf. Mario GeorgievP. Nikolay Ivanov2o James Graham "2. Stoyan Tanev. Steliyan Georgiev. Petko Kashinski*. Lukas Kovali...a: Apps® ToastS lira Gloud6 Huddle with Aneliya Angelova& R. Aneliya Angelova •• Messagest Add canvasur FilesAneliya Angelova 2:30 PMЛукаш, само при Сейлсфорс и Хубспот се синкваха активити типовете, когато се направи плейбук,нали?пои останалите скМі тоябва оъчно ла се въвелат.Lukas Kovalik 2:47 PMтряова да се за всички, някьде не се ли попьлвапри зохо май беше hardcoded но май и там си връщаха две категорииAneliya Angelova @ 4:00 PMЛукаш имаш ли време да се чуем за тестването на https://jiminny.atlassian.net/browse/JY-20725X Preview insackt[HubSpot] Optimise CRM rematctC OpenReady for QA- MediumAA Aneliya AngelovaAs of today at 4:00 PM RetreshOpen in Jira+ SummariseLukas Kovalik 4:02 PMzean-iurwiniareelYou joined the huddle LIVE 4:06 PMAneliva Angelova is here toolAneliya Angelova 4:44 PM11512582Lukas Kovalik O 5:16 PMcurl --location "httns:ani.hubani.com/crm/v3/obiects/contacts/search--header 'Content-Type: application/json'\--header "Authorization: BearerC|Kvma?iMx7OINOM8kOFwrAowAcAkUAhIR24?A05D?xiVrIYMILuP6SA01Kwcmhr=n.v2 hytorDniMvDalYck-CMalovolNOM9kOFwrAUAcrkcaws-ThwRARIRAOF-ATFSAOFRAO-ROIEBAQUBEggBAQEBAYICFIґ7Au8O2Nwal9YW2CbCCxYvwngmSgNldTFSAFoAYABoAHAAeAA'\--data "{ "limit", 13'Message Aneliva Angelova+ AaIđ 0Pm~ (Q SearchDate ModifiedYesterdav at 13.3Yesterday at 13:37Yesterday at 13.30Yesterday at 13:35resterday at 15.34Yesterdav at 13:34Yesterday at 13:33Yesterday at 13.32Yesterday at 13:32resterday at 15.3Yesterdav at 13:30Yesterday at 13:30Yesterday at 13:29Yesterday at 13:29Yesterday at 13:28resterday at 13-21Yesterdav at 13:27Yesterday at 13:26Yesterday at 13:26Yesterday at 13:25Yesterday at 13.24Yesterdav at 13:22Yesterday at 13:22resterady al 15.clYesterday at 13:20Yesterday at 13.19Yecterdav at 12:10Yesterday at 13:18Yesterday at 13.10Yesterdav at 13:17Yesterday at 13:17Yesterday at 13:16Yacterdav at 12:15Yesterday at 13.14Yecterdav at 12:14Yesterday at 13:13Yesterday at 13:12Yesterday at 13:12Yesterday at 13.11Yecterdav at 12:10Yesterday at 13:10Yesterdav at 13:0962 K:MPEG-4 movie71 KBMPEG-4 movie23 KBMPEG-4 movieMPEG-4 movie13 KE16 KB17 KBMPE0"4 movie29 K:6 KBMPEG-4 movie12 KBMP2G-4 movieMPEG-4 movie7 KBI8 KB37 KBMPEG"4 movie10 KВIMPEG-4 movie7 KBMPEG-4 movie8 KB9 KB1MPEG-4 movie8 KBMPEG-4 movie72 KB14 KBMPEG-4 movie13 KEMPEG-4 movie9 KB MPEG-4 movie18 KB12 KB|MPEG-4 movie10 KBMPEG-4 movie16 KB6KBMPEG-4 movie6KEMPEG-4 movie12 KBMPEG-4 movie23 KBMPEG-4 movie8 K:MPEG-4 movie6 KBMPEG-4 movie11 KBMPEG-4 movie11 KPMPEG-4 movie20 KB34 KB10 K:MPEG-4 movie7KBMPEG-4 movie5 KB11 KBMPEG-4 movieD6KRMDEG-A movia111 KBMPEG-4 movie102 KBMPEG-4 movie88 KBMPEG-A movie59 KB MPEG-4 movie98 KB97 KBGAKPMPEG-4 movieMPEG-4 movie44 KB93 KBMPEG-4 movie78 KPMDEG-A movid50 KBMPEG-4 movie58 KB17 KBMPEG-4 movieAl Notes: OffLeave• 0FavouritesE jiminny© Recents* ApplicationsiCloudiCloud Drive228 Sync folderLocationsQ DXP4800PLUS-B5FA@ Network|• CRMI• Orange• Red• Yellow• Green• Purple•) All lags..lohlDownloadsNameLoom.pkgAlfred copv.alfredoreterencesB KeychronAssist-1.0.2 (1).dmgA Keychron Assist-1.0.2.dmgmazanoke-images-yWJo.zio• Photos-3-001.zipD Transcript.pdf→mage U.loge1 Orioninstaller.dmaimage.jpg- image (2).1pc• ПО-22221726037035-004-001_ORGES.pdf• %D0%9F%D0%9E-22221726037035-004-001_archive.zipПO-22221726037035-004-001_archive (1).zip• repon 4).XmAltred copy2.altredoreterences05012026_0000000026574472_ SWIFT_OB70501260015890.pdf27022026_0000000026574472_SWIFT_OB72702260049200.pdf= 03042026 [CREDIT_CARD] SWIFT [CREDIT_CARD].001B reporti) xm=pdt.odipdf-1.pdfD pdf-2.pdf-pdf-5.pd1= ndf-1 ndipdf-3.pdfKoválik Family Tree.gedbitwarden export 20251031122528.isonKoválik Family Tree.zip*macOS Storage_Cleanup.mdal favicon icofirst_aid_notes_complete.docxrenortl2).esvconfig.ymlIteration run Search HS.postman_collection.jsonm licence bettertouchtoalMariusHosting Config.json1ooks-891a6503-bbb7-4b2b-9c3.csv•Alfredmazanoke-images-YWJ6ана Ковалик.jpg•искане даниел Ковалик..pa• Фактура Март Даниел Ковалик.jрс• Фактура Април Даниел Ковалик.jpg• Dhotac 2.001Q SearchKind00,4 MDinstdlle..dckage55.9 MBAlfred...ferences10,1 MBDisk Image10,1 MBDisk ImageIL MBLiP archive6,6 MBZIP archive2,5 MB PDF Document2,5 MBJreo lmage2.2 MBDisk Image2,2 MB PDF Document2 MBJPEG image1,9 MBJPEG imaqe192 KBPDF Document140 KbZIP archive148 KВZIP archivel148 KBZIP archive122 KBXML document111 KBAlfred...ferences94 KBPDF Document92 KBPDr DocumentK:91 KB91 KB30 KB29 KBPDF DocumentXML documentPDF Document28 KBPDF Document28 KR28 KB27 KB14 KB11 KB6 KB6KBPDE DocumentDocumentJSONICSV DocumentZIP archiveMarkdo…..ument5KRWindo...n image4 KB3 KBword ..cumentCSV Document2 KBYAML document1 KBcSV Document928 byteshttlicence183 bytesZero butesJSONAlfred.. ferencesZero bytesFolde1,9 MB1,8 MB17 MB1,7 MBColdorJPEG imageJPEG ImageIPEG imadeJPEG imageColdo1 of 58 selected, 8.48 GB availabld• Inu 14 May 1/•33.02Date AddedIs Mdl ZUzo dl 19:4530 Jan 2026 at 12:3617 Mar 2026 at 20:2717 Mar 2026 at 20:26Z3 Aor 2020 al 13:0229 Jan 2026 at 15:2019 Dec 2025 at 10:1619 Dec 2025 at 12:238 Aor 2026 at 20:3519 Dec 2025 at 10:2919 Dec 2025 at 12:1819 Dec 2025 at 12:4026 Mar 2026 at 11:2410 May 2026 at 13:5326 Mar 2026 at 11:2426 Mar 2026 at 11:2426 Mar 2026 at 11:2310 May 2026 at 14:3730 Jan 2026 at 12:3713 Feb 2026 at 11:54Z3 Apr 2026 at 13.0823 Aor 2026 at 13:0810 May 2026 at 13:5410 May 2026 at 14:3710 May 2026 at 13:4910 May 2026 at 13:5010 May 2026 at 13:5110 Mav 2026 at 12:51110 May 2026 at 13:5019 Dec 2025 at 11:3431 Oct 2025 at 12:2525 Nov 2025 at 17:596 Mar 2026 at 11.2224 Anr 2026 at 16:5220 Oct 2025 at 11:0218 Mar 2026 at 15:29• Mav 2026 at 11:09129 Oct 2025 at 19:329 May 2026 at 10:0418 Mar 2026 at 11:5510 May 2026 at 13:5712 Jun 2025 at 19:0430 Jan 2026 at 12:3630 Jan 2026 at 12:3616 Oct 2025 aт 16:0123 Apr 2026 at 13:0223 Apr 2026 at 13:0223 Apr 2026 at 13:0222 Anr 2026 at 12:0223 Apr 2026 at 13:0229 Jan 2026 at 15:20...
|
NULL
|
-6348758399068430483
|
NULL
|
click
|
ocr
|
NULL
|
ActivityFilesLaterJiminny... ~@ jiminny-x-integrat ActivityFilesLaterJiminny... ~@ jiminny-x-integrati& platform-inner-team© Channels# ai-chapter# alertsi backend# bugscontusion-cllnia# curiosity_lab# engineering# general# jiminny-bg# platform-tickets# product_launches# random# releases# sofia-office# support# thank-yous# the people of iimi..• Direct messages8. A...• A3 62P. Galya Dimitrova Mvasil VasilevA. Stefka Stoyanova%: Todor Stamatovf. Mario GeorgievP. Nikolay Ivanov2o James Graham "2. Stoyan Tanev. Steliyan Georgiev. Petko Kashinski*. Lukas Kovali...a: Apps® ToastS lira Gloud6 Huddle with Aneliya Angelova& R. Aneliya Angelova •• Messagest Add canvasur FilesAneliya Angelova 2:30 PMЛукаш, само при Сейлсфорс и Хубспот се синкваха активити типовете, когато се направи плейбук,нали?пои останалите скМі тоябва оъчно ла се въвелат.Lukas Kovalik 2:47 PMтряова да се за всички, някьде не се ли попьлвапри зохо май беше hardcoded но май и там си връщаха две категорииAneliya Angelova @ 4:00 PMЛукаш имаш ли време да се чуем за тестването на https://jiminny.atlassian.net/browse/JY-20725X Preview insackt[HubSpot] Optimise CRM rematctC OpenReady for QA- MediumAA Aneliya AngelovaAs of today at 4:00 PM RetreshOpen in Jira+ SummariseLukas Kovalik 4:02 PMzean-iurwiniareelYou joined the huddle LIVE 4:06 PMAneliva Angelova is here toolAneliya Angelova 4:44 PM11512582Lukas Kovalik O 5:16 PMcurl --location "httns:ani.hubani.com/crm/v3/obiects/contacts/search--header 'Content-Type: application/json'\--header "Authorization: BearerC|Kvma?iMx7OINOM8kOFwrAowAcAkUAhIR24?A05D?xiVrIYMILuP6SA01Kwcmhr=n.v2 hytorDniMvDalYck-CMalovolNOM9kOFwrAUAcrkcaws-ThwRARIRAOF-ATFSAOFRAO-ROIEBAQUBEggBAQEBAYICFIґ7Au8O2Nwal9YW2CbCCxYvwngmSgNldTFSAFoAYABoAHAAeAA'\--data "{ "limit", 13'Message Aneliva Angelova+ AaIđ 0Pm~ (Q SearchDate ModifiedYesterdav at 13.3Yesterday at 13:37Yesterday at 13.30Yesterday at 13:35resterday at 15.34Yesterdav at 13:34Yesterday at 13:33Yesterday at 13.32Yesterday at 13:32resterday at 15.3Yesterdav at 13:30Yesterday at 13:30Yesterday at 13:29Yesterday at 13:29Yesterday at 13:28resterday at 13-21Yesterdav at 13:27Yesterday at 13:26Yesterday at 13:26Yesterday at 13:25Yesterday at 13.24Yesterdav at 13:22Yesterday at 13:22resterady al 15.clYesterday at 13:20Yesterday at 13.19Yecterdav at 12:10Yesterday at 13:18Yesterday at 13.10Yesterdav at 13:17Yesterday at 13:17Yesterday at 13:16Yacterdav at 12:15Yesterday at 13.14Yecterdav at 12:14Yesterday at 13:13Yesterday at 13:12Yesterday at 13:12Yesterday at 13.11Yecterdav at 12:10Yesterday at 13:10Yesterdav at 13:0962 K:MPEG-4 movie71 KBMPEG-4 movie23 KBMPEG-4 movieMPEG-4 movie13 KE16 KB17 KBMPE0"4 movie29 K:6 KBMPEG-4 movie12 KBMP2G-4 movieMPEG-4 movie7 KBI8 KB37 KBMPEG"4 movie10 KВIMPEG-4 movie7 KBMPEG-4 movie8 KB9 KB1MPEG-4 movie8 KBMPEG-4 movie72 KB14 KBMPEG-4 movie13 KEMPEG-4 movie9 KB MPEG-4 movie18 KB12 KB|MPEG-4 movie10 KBMPEG-4 movie16 KB6KBMPEG-4 movie6KEMPEG-4 movie12 KBMPEG-4 movie23 KBMPEG-4 movie8 K:MPEG-4 movie6 KBMPEG-4 movie11 KBMPEG-4 movie11 KPMPEG-4 movie20 KB34 KB10 K:MPEG-4 movie7KBMPEG-4 movie5 KB11 KBMPEG-4 movieD6KRMDEG-A movia111 KBMPEG-4 movie102 KBMPEG-4 movie88 KBMPEG-A movie59 KB MPEG-4 movie98 KB97 KBGAKPMPEG-4 movieMPEG-4 movie44 KB93 KBMPEG-4 movie78 KPMDEG-A movid50 KBMPEG-4 movie58 KB17 KBMPEG-4 movieAl Notes: OffLeave• 0FavouritesE jiminny© Recents* ApplicationsiCloudiCloud Drive228 Sync folderLocationsQ DXP4800PLUS-B5FA@ Network|• CRMI• Orange• Red• Yellow• Green• Purple•) All lags..lohlDownloadsNameLoom.pkgAlfred copv.alfredoreterencesB KeychronAssist-1.0.2 (1).dmgA Keychron Assist-1.0.2.dmgmazanoke-images-yWJo.zio• Photos-3-001.zipD Transcript.pdf→mage U.loge1 Orioninstaller.dmaimage.jpg- image (2).1pc• ПО-22221726037035-004-001_ORGES.pdf• %D0%9F%D0%9E-22221726037035-004-001_archive.zipПO-22221726037035-004-001_archive (1).zip• repon 4).XmAltred copy2.altredoreterences05012026_0000000026574472_ SWIFT_OB70501260015890.pdf27022026_0000000026574472_SWIFT_OB72702260049200.pdf= 03042026 [CREDIT_CARD] SWIFT [CREDIT_CARD].001B reporti) xm=pdt.odipdf-1.pdfD pdf-2.pdf-pdf-5.pd1= ndf-1 ndipdf-3.pdfKoválik Family Tree.gedbitwarden export 20251031122528.isonKoválik Family Tree.zip*macOS Storage_Cleanup.mdal favicon icofirst_aid_notes_complete.docxrenortl2).esvconfig.ymlIteration run Search HS.postman_collection.jsonm licence bettertouchtoalMariusHosting Config.json1ooks-891a6503-bbb7-4b2b-9c3.csv•Alfredmazanoke-images-YWJ6ана Ковалик.jpg•искане даниел Ковалик..pa• Фактура Март Даниел Ковалик.jрс• Фактура Април Даниел Ковалик.jpg• Dhotac 2.001Q SearchKind00,4 MDinstdlle..dckage55.9 MBAlfred...ferences10,1 MBDisk Image10,1 MBDisk ImageIL MBLiP archive6,6 MBZIP archive2,5 MB PDF Document2,5 MBJreo lmage2.2 MBDisk Image2,2 MB PDF Document2 MBJPEG image1,9 MBJPEG imaqe192 KBPDF Document140 KbZIP archive148 KВZIP archivel148 KBZIP archive122 KBXML document111 KBAlfred...ferences94 KBPDF Document92 KBPDr DocumentK:91 KB91 KB30 KB29 KBPDF DocumentXML documentPDF Document28 KBPDF Document28 KR28 KB27 KB14 KB11 KB6 KB6KBPDE DocumentDocumentJSONICSV DocumentZIP archiveMarkdo…..ument5KRWindo...n image4 KB3 KBword ..cumentCSV Document2 KBYAML document1 KBcSV Document928 byteshttlicence183 bytesZero butesJSONAlfred.. ferencesZero bytesFolde1,9 MB1,8 MB17 MB1,7 MBColdorJPEG imageJPEG ImageIPEG imadeJPEG imageColdo1 of 58 selected, 8.48 GB availabld• Inu 14 May 1/•33.02Date AddedIs Mdl ZUzo dl 19:4530 Jan 2026 at 12:3617 Mar 2026 at 20:2717 Mar 2026 at 20:26Z3 Aor 2020 al 13:0229 Jan 2026 at 15:2019 Dec 2025 at 10:1619 Dec 2025 at 12:238 Aor 2026 at 20:3519 Dec 2025 at 10:2919 Dec 2025 at 12:1819 Dec 2025 at 12:4026 Mar 2026 at 11:2410 May 2026 at 13:5326 Mar 2026 at 11:2426 Mar 2026 at 11:2426 Mar 2026 at 11:2310 May 2026 at 14:3730 Jan 2026 at 12:3713 Feb 2026 at 11:54Z3 Apr 2026 at 13.0823 Aor 2026 at 13:0810 May 2026 at 13:5410 May 2026 at 14:3710 May 2026 at 13:4910 May 2026 at 13:5010 May 2026 at 13:5110 Mav 2026 at 12:51110 May 2026 at 13:5019 Dec 2025 at 11:3431 Oct 2025 at 12:2525 Nov 2025 at 17:596 Mar 2026 at 11.2224 Anr 2026 at 16:5220 Oct 2025 at 11:0218 Mar 2026 at 15:29• Mav 2026 at 11:09129 Oct 2025 at 19:329 May 2026 at 10:0418 Mar 2026 at 11:5510 May 2026 at 13:5712 Jun 2025 at 19:0430 Jan 2026 at 12:3630 Jan 2026 at 12:3616 Oct 2025 aт 16:0123 Apr 2026 at 13:0223 Apr 2026 at 13:0223 Apr 2026 at 13:0222 Anr 2026 at 12:0223 Apr 2026 at 13:0229 Jan 2026 at 15:20...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
45386
|
1625
|
34
|
2026-05-14T14:33:55.707319+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-14/1778 /Users/lukas/.screenpipe/data/data/2026-05-14/1778769235707_m1.jpg...
|
PhpStorm
|
faVsco.js – BullhornService.php
|
1
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
PhpStormFileEditViewNavigateCodeLaravelRefactorRun PhpStormFileEditViewNavigateCodeLaravelRefactorRunToolsGitWindowHelp•FV faVsco.jsv#12066 on JY-20725-handle-HS-search-rate-limit k vlhl100% CHandleHubspotRateLimitTest v8• Thu 14 May 17:33:55* :QProject v© UserAutomatedReportsController.php:=custom.logAutomatedReportGenerated.phpTrackAutomatedReportGeneratedEvent.php© BullhornLoginHandler.phpPlanhatService.phpAutomatedReportResult.phpSendReportJob.phpDeleteCrmEntityTrait.phpDeleteAccountJob.php=laravel.log© BullhornLoginHandlerInterfa© ImportActivityTypes.phpT WriteCrmTrait.php© DecorateActivity.php© Salesforce/Service.phpT LogActivityTrait.phpSF [jiminny@localhost]© BullhornService.php© BullhornSessionDecorator.plPipedrive/Service.phpClose/Service.phpCopper/Service.phpBullhornService.php X© PlainTextDecorateActivity.phpA HS_local [jiminny@localho:W© BullhornSessionManager.ph|© ActivityPlaybookTrait.phpCrmHelperRepository.php© AccountController.phpT IntegrationAppTrait.php= .env.staging4 console [QAI PROD] X18® BullhornSessionManagerinte© FieldDefinitions.phpE.envDetachActivityObject.phpRematchActivityOnCrmObjectDetach.php© MatchActivityCrmData.php© Client.php4 console [PROD]v @ Close> OpportunitySyncStrategy> O Processor> D ProspectSearchStrategy> O Translator© HubspotPaginationService.php© HandleHubspotRateLimit.phpclass BullhornService extends BaseService implementspublic function saveActivity(Activity $activity): Activityreturn sactivity:}A console (EU]© Client.php© CloseException.php© FieldDefinitions.php© FieldValueConverter.php© Service.php A8 A73 X1 X10 ^© StandardFieldMetadata.php/*** Othrows CrmException* Othrows DomainException*/1 usage.id = 33;v Copper1337private function puildNotePayload(Activity $activity): Activity> J Apі|1338> D Exception1339> D OpportunitySyncStrategy1340if (Sactivity->hasActivityType() === false) {throw new DomainException( message: '[Bullhorn] No activity type assigned');> D ProspectSearchStrategy1341> @ Traits1342© FieldDefinitions.php1343© FieldValueConverter.php1344Saction = Sthis->getCrmType(Sactivity->getActivityType());if ($action === null) {© Service.php1345> @ CrmObjects1346D DecorateActivity1347© BaseDecorateActivity.php1348DGo jiminny084143 ×4 л215•m_layout_id =2162,1661,66799,66217218219220221=222223:224225226= 227228229230throw new DomainException( message:'[Bullhorn] No mapped CRM type for "' . $activity->category->na2311 232233v234id THEN '(ownhubspot':-= 11512582;© PlainTextDecorateActivity.pl1349if ($activity->lead_id === null && $activity->contact_id === null) {throw new DomainException( message:'[Bullhorn] Cannot save note without candidate or DM');C Dummy1350}-v D Helpers1351T ActivityPlaybookTrait.php1352© Arraylterator.php1353$payload = ['comments'=> $this->generateActivityDescription(Sactivity),# CannantionCtstaTrait nhnWorkspace associated with branch 'JY-20725-handle-HS-search-rate-limit' has been restored // Rollback // Configure... (today 16:17)W Windsurf Teams1337:22UTF-8Ca 4 spaces...
|
NULL
|
-2345985353863117101
|
NULL
|
click
|
ocr
|
NULL
|
PhpStormFileEditViewNavigateCodeLaravelRefactorRun PhpStormFileEditViewNavigateCodeLaravelRefactorRunToolsGitWindowHelp•FV faVsco.jsv#12066 on JY-20725-handle-HS-search-rate-limit k vlhl100% CHandleHubspotRateLimitTest v8• Thu 14 May 17:33:55* :QProject v© UserAutomatedReportsController.php:=custom.logAutomatedReportGenerated.phpTrackAutomatedReportGeneratedEvent.php© BullhornLoginHandler.phpPlanhatService.phpAutomatedReportResult.phpSendReportJob.phpDeleteCrmEntityTrait.phpDeleteAccountJob.php=laravel.log© BullhornLoginHandlerInterfa© ImportActivityTypes.phpT WriteCrmTrait.php© DecorateActivity.php© Salesforce/Service.phpT LogActivityTrait.phpSF [jiminny@localhost]© BullhornService.php© BullhornSessionDecorator.plPipedrive/Service.phpClose/Service.phpCopper/Service.phpBullhornService.php X© PlainTextDecorateActivity.phpA HS_local [jiminny@localho:W© BullhornSessionManager.ph|© ActivityPlaybookTrait.phpCrmHelperRepository.php© AccountController.phpT IntegrationAppTrait.php= .env.staging4 console [QAI PROD] X18® BullhornSessionManagerinte© FieldDefinitions.phpE.envDetachActivityObject.phpRematchActivityOnCrmObjectDetach.php© MatchActivityCrmData.php© Client.php4 console [PROD]v @ Close> OpportunitySyncStrategy> O Processor> D ProspectSearchStrategy> O Translator© HubspotPaginationService.php© HandleHubspotRateLimit.phpclass BullhornService extends BaseService implementspublic function saveActivity(Activity $activity): Activityreturn sactivity:}A console (EU]© Client.php© CloseException.php© FieldDefinitions.php© FieldValueConverter.php© Service.php A8 A73 X1 X10 ^© StandardFieldMetadata.php/*** Othrows CrmException* Othrows DomainException*/1 usage.id = 33;v Copper1337private function puildNotePayload(Activity $activity): Activity> J Apі|1338> D Exception1339> D OpportunitySyncStrategy1340if (Sactivity->hasActivityType() === false) {throw new DomainException( message: '[Bullhorn] No activity type assigned');> D ProspectSearchStrategy1341> @ Traits1342© FieldDefinitions.php1343© FieldValueConverter.php1344Saction = Sthis->getCrmType(Sactivity->getActivityType());if ($action === null) {© Service.php1345> @ CrmObjects1346D DecorateActivity1347© BaseDecorateActivity.php1348DGo jiminny084143 ×4 л215•m_layout_id =2162,1661,66799,66217218219220221=222223:224225226= 227228229230throw new DomainException( message:'[Bullhorn] No mapped CRM type for "' . $activity->category->na2311 232233v234id THEN '(ownhubspot':-= 11512582;© PlainTextDecorateActivity.pl1349if ($activity->lead_id === null && $activity->contact_id === null) {throw new DomainException( message:'[Bullhorn] Cannot save note without candidate or DM');C Dummy1350}-v D Helpers1351T ActivityPlaybookTrait.php1352© Arraylterator.php1353$payload = ['comments'=> $this->generateActivityDescription(Sactivity),# CannantionCtstaTrait nhnWorkspace associated with branch 'JY-20725-handle-HS-search-rate-limit' has been restored // Rollback // Configure... (today 16:17)W Windsurf Teams1337:22UTF-8Ca 4 spaces...
|
45385
|
NULL
|
NULL
|
NULL
|
|
45388
|
1625
|
35
|
2026-05-14T14:34:00.118762+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-14/1778 /Users/lukas/.screenpipe/data/data/2026-05-14/1778769240118_m1.jpg...
|
PhpStorm
|
faVsco.js – BullhornService.php
|
1
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
#12066 on JY-20725-handle Project: faVsco.js, menu
#12066 on JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
HandleHubspotRateLimitTest
Run 'HandleHubspotRateLimitTest'
Debug 'HandleHubspotRateLimitTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
8
73
3
11
Previous Highlighted Error
Next Highlighted Error
<?php
namespace Jiminny\Services\Crm\Bullhorn;
use Carbon\Carbon;
use Exception;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
use Jiminny\Contracts\Services\Crm\MatchCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\FetchRelatedActivityInterface;
use Jiminny\Contracts\Services\Crm\LayoutManagementInterface;
use Jiminny\Contracts\Services\Crm\Provider\BullhornInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityLookupInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityManipulationInterface;
use Jiminny\Contracts\Services\Crm\SettingsInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmMetadataInterface;
use Jiminny\Contracts\Services\Crm\VerifyTaskExistsInterface;
use Jiminny\Exceptions\ApplicationException;
use Jiminny\Exceptions\CrmException;
use Jiminny\Exceptions\DomainException;
use Jiminny\Exceptions\HttpBadRequestException;
use Jiminny\Exceptions\HttpNotFoundException;
use Jiminny\Exceptions\ServiceUnavailableException;
use Jiminny\Models\Account;
use Jiminny\Models\Activity;
use Jiminny\Models\Contact;
use Jiminny\Models\Crm\Configuration;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\FieldData;
use Jiminny\Models\Crm\Profile;
use Jiminny\Models\Lead;
use Jiminny\Models\Opportunity;
use Jiminny\Models\Playbook;
use Jiminny\Models\SocialAccount;
use Jiminny\Models\Stage;
use Jiminny\Models\User;
use Jiminny\Repositories\Crm\ProfileRepository;
use Jiminny\Repositories\UserRepository;
use Jiminny\Services\Avatar\ProspectPhotoPathService;
use Jiminny\Services\Crm\BaseService;
use Jiminny\Services\Crm\OpportunitySyncStrategyResolver;
use Jiminny\Utils\CurrencyFormatter;
use Jiminny\Utils\StringUtil;
use Ramsey\Uuid\Uuid;
class BullhornService extends BaseService implements
BullhornInterface,
SyncCrmEntitiesInterface,
SyncCrmMetadataInterface,
RemoteEntityManipulationInterface,
FetchRelatedActivityInterface,
LayoutManagementInterface,
SettingsInterface,
MatchCrmEntitiesInterface,
RemoteEntityLookupInterface,
VerifyTaskExistsInterface
{
private const int TASK_VERIFICATION_CACHE_TTL = 86400; // 1 day
private const string ENTITY_LEAD = 'Candidate';
private const string ENTITY_OPPORTUNITY = 'JobOrder';
private const string ENTITY_ACCOUNT = 'ClientCorporation';
private const string ENTITY_CONTACT = 'ClientContact';
private const string ENTITY_ACTIVITY = 'Note';
private const array OPPORTUNITY_FIELDS = [
'id',
'owner',
'status',
'title',
'salary',
'salaryUnit',
'payRate',
'isOpen',
'employmentType',
'dateClosed',
'startDate',
'dateLastModified',
'dateAdded',
'clientCorporation',
'correlatedCustomText10', // currency
];
private const array TASK_FIELDS = [
'id',
'comments',
'dateAdded',
'dateLastModified',
'externalID',
'isDeleted',
'corporateUsers',
'clientContacts',
'commentingPerson',
'candidates',
'jobOrder',
'minutesSpent',
'action',
];
private const array LEAD_FIELDS = [
'id',
'name',
'owner',
'occupation',
'companyName',
'companyURL',
'mobile',
'phone',
'phone2',
'phone3',
'workPhone',
'status',
'address',
'dateAdded',
'dateLastModified',
];
private const array CONTACT_FIELDS = [
'id',
'name',
'occupation',
'mobile',
'phone',
'phone2',
'phone3',
'address',
'clientCorporation',
'dateAdded',
'owner',
];
private const array ACCOUNT_FIELDS = [
'id',
'name',
'phone',
'industryList',
'address',
'companyURL',
'dateAdded',
];
public function __construct(
BullhornClientInterface $client,
private readonly UserRepository $userRepository,
private readonly ProspectPhotoPathService $prospectPhotoPathService
) {
parent::__construct();
$this->client = $client;
}
protected function getOAuthAccount(User $user): ?SocialAccount
{
// In this case, the Account Owner is always the connection for any API operations.
$owner = $user->team->owner;
return $owner->getSocialAccount(SocialAccount::PROVIDER_BULLHORN);
}
public function getDisplayName(): string
{
return 'Bullhorn';
}
public function createTranscriptNotes(Activity $activity): void
{
// Not done until a customer requests it.
}
public function syncProfileFields(): void
{
$fieldTypes = [
'lead',
'account',
'contact',
'opportunity',
'task',
];
foreach ($fieldTypes as $fieldType) {
try {
$currentFields = $this->getFields($this->convertObjectTypeToResource($fieldType));
$newFields = array_column($currentFields, 'name');
$changedFields = array_merge(
array_diff($this->profile->getFieldsAsArray($fieldType), $newFields),
array_diff($newFields, $this->profile->getFieldsAsArray($fieldType))
);
if (! empty($changedFields)) {
$fields = implode(',', $newFields);
Log::debug(
sprintf(
'%s fields changed from %s to: %s',
ucfirst($fieldType),
$this->profile->{$fieldType . '_fields'},
$fields,
),
);
$this->profile->{$fieldType . '_fields'} = $fields;
}
} catch (\Exception $exception) {
\Log::error('No access to ' . $fieldType . ' object, skipping...');
// XXX: should we log this fact somewhere?
continue;
}
}
$this->profile->save();
}
protected function getFieldTypes(): array
{
return [
parent::OBJECT_TASK,
//parent::OBJECT_ACCOUNT,
//parent::OBJECT_CONTACT,
//parent::OBJECT_OPPORTUNITY,
//parent::OBJECT_LEAD,
];
}
protected function getFields(string $crmObject): array
{
$fields = [];
foreach ($this->getObjectFields($crmObject) as $row) {
$fields[] = ['label' => $row['label'] ?? $row['name'], 'name' => $row['name']];
}
return $fields;
}
private function getObjectFields(string $objectType): array
{
$entityType = $this->convertObjectTypeToResource($objectType);
return Cache::remember(
key: 'crm.profile#' . $this->profile->id . '.fieldType:' . $objectType,
ttl: 60 * 60,
callback: function () use ($entityType) {
$entity = $this->client->get('meta/' . $entityType, [
'fields' => '*',
]);
return $entity['fields'];
},
);
}
public function getDefaultFields(string $activityType): array
{
$fields = [];
$defaultFields = FieldDefinitions::defaultFields();
foreach ($defaultFields as $defaultField) {
$fields[] = $this->config->fields()->firstOrCreate($defaultField);
}
return $fields;
}
public function getDefaultActivityField(string $activityType): Field
{
/** @var Field $activityField */
$activityField = $this->config->fields()->where([
'crm_provider_id' => 'action',
'object_type' => $activityType,
])->first();
return $activityField;
}
public function getDealInsightsFields(): array
{
return FieldDefinitions::dealInsightsFields();
}
public function getSupportedPlaybookTypes(): array
{
return [Playbook::ACTIVITY_TYPE_TASK];
}
protected function getDefaultFollowupLayoutFields(string $activityType): array
{
// We are not yet supporting follow-ups for this system.
return [];
}
public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string
{
return $fieldValue;
}
public function syncField(Field $field): void
{
$resource = $this->convertObjectTypeToResource($field->object_type);
$entity = $this->client->get('meta/' . $resource, [
'fields' => $field->crm_provider_id,
'meta' => 'full',
]);
$bhField = $entity['fields'][0];
$isMandatory = (empty($bhField['optional']) == false && $bhField['optional'] === false);
$description = null;
if (empty($bhField['description']) === false) {
$description = mb_strimwidth($bhField['description'], 0, 191);
}
$label = 'Unknown';
if (empty($bhField['label']) === false) {
$label = mb_strimwidth($bhField['label'], 0, Field::LABEL_MAX_LENGTH);
}
$field->length = $bhField['maxLength'] ?? null;
$field->is_mandatory = $isMandatory;
$field->description = $description;
$field->label = $label;
$field->type = $this->convertFieldType(
$bhField['dataType'] ?? null,
$bhField['dataSpecialization'] ?? null,
$bhField['inputType'] ?? null
);
$field->save();
if (empty($bhField['options']) === false) {
$values = [];
// Import all values.
foreach ($bhField['options'] as $i => $option) {
$values[] = [
'value' => $option['value'],
'label' => mb_strimwidth($option['label'], 0, Field::LABEL_MAX_LENGTH),
'sequence' => $i,
];
}
// Take the existing set of field values.
$fieldsToPurge = $field->values()->get()->pluck('value')->toArray();
foreach ($values as $value) {
// For all current values, upsert the field.
$field->values()->updateOrCreate([
'value' => $value['value'],
], $value);
// Remove this value from the ones we are going to purge.
$key = array_search($value['value'], $fieldsToPurge, false);
if ($key !== false) {
unset($fieldsToPurge[$key]);
}
}
// Delete the old values that are no longer used.
$field->values()->whereIn('value', $fieldsToPurge)->delete();
}
}
public function importPicklistValues(Field $field): array
{
// Taken care of in syncField.
return [];
}
private function convertFieldType(?string $type, ?string $specialization, ?string $inputType): string
{
if ($type === null) {
return Field::TYPE_UNSUPPORTED;
}
$type = strtolower($type);
$specialization = strtolower($specialization);
$inputType = strtolower($inputType);
switch ($type) {
case 'boolean':
return Field::TYPE_BOOLEAN;
case 'integer':
switch ($specialization) {
case 'integer':
return Field::TYPE_NUMBER;
default:
return Field::TYPE_NUMBER;
}
// no break
case 'bigdecimal':
switch ($specialization) {
case 'money':
return Field::TYPE_CURRENCY;
default:
return Field::TYPE_NUMBER;
}
// no break
case 'double':
switch ($specialization) {
case 'float':
return Field::TYPE_NUMBER;
default:
return Field::TYPE_NUMBER;
}
// no break
case 'timestamp':
switch ($specialization) {
case 'date':
return Field::TYPE_DATE;
case 'time':
return Field::TYPE_TIME;
case 'datetime':
return Field::TYPE_DATETIME;
default:
return Field::TYPE_DATE;
}
// no break
case 'string':
switch ($inputType) {
case 'textarea':
return Field::TYPE_TEXTAREA;
case 'select':
return Field::TYPE_PICKLIST;
default:
return Field::TYPE_TEXT;
}
// no break
default:
// This is not actually a supported field.
return Field::TYPE_UNSUPPORTED;
}
}
private function importContact($crmData): Contact
{
$account = $mobilePhone = $phone = $email = null;
if (empty($crmData['clientCorporation']) === false) {
$account = $this->team
->accounts()
->where('crm_provider_id', $crmData['clientCorporation']['id'])
->first();
if ($account === null) {
$account = $this->syncAccount($crmData['clientCorporation']['id']);
}
}
$countryCode = $this->convertCountryNameToCode($crmData['address']['countryCode']);
foreach (['mobile', 'phone', 'phone2', 'phone3'] as $type) {
if (empty($crmData[$type])) {
continue;
}
if ($type === 'mobile') {
$mobilePhone = parsePhoneNumber($countryCode, $crmData[$type]);
} else {
$phone = parsePhoneNumber($countryCode, $crmData[$type]);
}
break;
}
foreach (['email', 'email1', 'email2'] as $type) {
if (empty($crmData[$type])) {
continue;
}
$email = mb_strimwidth($crmData[$type], 0, 80);
break;
}
$profile = $this->config->profiles()->where('crm_provider_id', (string) $crmData['owner']['id'])->first();
$data = [
'team_id' => $this->team->id,
'account_id' => $account->id ?? null,
'user_id' => $profile ? $profile->user_id : null,
'owner_id' => $crmData['owner']['id'],
'name' => $crmData['name'],
'email' => $email,
'phone' => empty($phone['phone']) ? null : mb_strimwidth($phone['phone'], 0, 25),
'ext' => $phone['ext'] ?? null,
'mobile_phone' => empty($mobilePhone['phone']) ? null : mb_strimwidth($mobilePhone['phone'], 0, 25),
'title' => $crmData['occupation'],
'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(
crmConfiguration: $this->config,
crmProviderId: $crmData['id'],
modelType: Contact::class,
fileName: $crmData['id'],
avatarText: $crmData['name'] ?? 'Unknown'
),
'remotely_created_at' => $this->filterOutOfBoundDates($crmData['dateAdded']),
];
return $this->config->contacts()->updateOrCreate(['crm_provider_id' => (string) $crmData['id']], $data);
}
private function importOpportunity($crmData): ?Opportunity
{
if ($crmData['clientCorporation']['id']) {
$account = $this->config->accounts()->where('crm_provider_id', (string) $crmData['clientCorporation']['id'])->first();
if ($account === null) {
$account = $this->syncAccount($crmData['clientCorporation']['id']);
}
} else {
// If there is nothing to associate this with, don't import it.
return null;
}
$profile = $this->config->profiles()->where('crm_provider_id', (string) $crmData['owner']['id'])->first();
$stage = $this->config->stages()
->where('crm_provider_id', $crmData['status'])
->where('type', Stage::TYPE_OPPORTUNITY)
->first();
if ($stage === null) {
$stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $crmData['status']);
}
$data = [
'team_id' => $this->team->id,
'account_id' => $account->id,
'user_id' => $profile ? $profile->user_id : null,
'owner_id' => $crmData['owner']['id'],
'name' => mb_strimwidth($crmData['title'], 0, 128),
'value' => $crmData['salary'] ?? $crmData['payRate'],
'currency_code' => CurrencyFormatter::formatCode($crmData['correlatedCustomText10'] ?? 'USD'),
'close_date' => $this->filterOutOfBoundDates($crmData['startDate']),
'is_closed' => $crmData['isOpen'] !== true,
'is_won' => strtolower($crmData['status']) === 'placed',
'stage_id' => $stage->id,
'record_type_id' => null,
'remotely_created_at' => $this->filterOutOfBoundDates($crmData['dateAdded']),
];
return $this->config->opportunities()->updateOrCreate(['crm_provider_id' => (string) $crmData['id']], $data);
}
private function importAccount($crmData): Account
{
$countryCode = $domain = null;
if (empty($crmData['address']['countryCode'])) {
$countryCode = $this->convertCountryNameToCode($crmData['address']['countryCode']);
}
$name = $crmData['name'] ?? 'Unknown';
if ($crmData['companyURL'] !== null) {
$domain = mb_strimwidth($crmData['companyURL'], 0, 191);
$domain = StringUtil::resolveDomain($domain);
}
$data = [
'team_id' => $this->team->id,
'user_id' => null,
'owner_id' => null,
'name' => mb_strimwidth($name, 0, 191),
'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(
crmConfiguration: $this->config,
crmProviderId: $crmData['id'],
modelType: Account::class,
fileName: $crmData['id'],
avatarText: $crmData['name'] ?? 'Unknown'
),
'country_code' => $countryCode,
'domain' => $domain,
'remotely_created_at' => $this->filterOutOfBoundDates($crmData['dateAdded']),
];
return $this->config->accounts()->updateOrCreate(['crm_provider_id' => (string) $crmData['id']], $data);
}
private function importLead($crmData): ?Lead
{
// Get the current stage.
$stage = $this->config->stages()->where('name', $crmData['status'])->where('type', Stage::TYPE_LEAD)->first();
if ($stage === null) {
$stage = $this->importStages([Stage::TYPE_LEAD], $crmData['status']);
}
// If we have no way of importing this, just return null :(
if ($stage === null) {
return null;
}
$countryCode = null;
if (empty($crmData['address']['countryCode'])) {
$countryCode = $this->convertCountryNameToCode($crmData['address']['countryCode']);
}
$mobilePhone = $phone = $email = null;
foreach (['mobile', 'phone', 'phone2', 'phone3', 'workPhone'] as $type) {
if (empty($crmData[$type])) {
continue;
}
if ($type === 'mobile') {
$mobilePhone = parsePhoneNumber($countryCode, $crmData[$type]);
} else {
$phone = parsePhoneNumber($countryCode, $crmData[$type]);
}
break;
}
foreach (['email', 'email1', 'email2'] as $type) {
if (empty($crmData[$type])) {
continue;
}
$email = mb_strimwidth($crmData[$type], 0, 80);
break;
}
$company = 'Unknown';
if (empty($crmData['companyName']) === false) {
$companyName = is_array($crmData['companyName']) ? $crmData['companyName'][0] : $crmData['companyName'];
$company = mb_strimwidth($companyName, 0, 191);
}
$domain = null;
if (empty($crmData['companyURL']) === false) {
$domain = mb_strimwidth($crmData['companyURL'], 0, 191);
$domain = StringUtil::resolveDomain($domain);
}
$profile = $this->config->profiles()->where('crm_provider_id', (string) $crmData['owner']['id'])->first();
$data = [
'team_id' => $this->team->id,
'user_id' => $profile ? $profile->user_id : null,
'owner_id' => $crmData['owner']['id'],
'company' => $company,
'domain' => $domain,
'name' => mb_strimwidth($crmData['name'], 0, 191),
'title' => mb_strimwidth($crmData['occupation'], 0, 128),
'email' => mb_strimwidth($email, 0, 80),
'phone' => empty($phone['phone']) ? null : mb_strimwidth($phone['phone'], 0, 25),
'ext' => $phone['ext'] ?? null,
'mobile_phone' => empty($mobilePhone['phone']) ? null : mb_strimwidth($mobilePhone['phone'], 0, 25),
'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(
crmConfiguration: $this->config,
crmProviderId: $crmData['id'],
modelType: Lead::class,
fileName: $crmData['id'],
avatarText: $crmData['name'] ?? 'Unknown'
),
'stage_id' => $stage->id,
'country_code' => $countryCode,
'remotely_created_at' => $this->filterOutOfBoundDates($crmData['dateAdded']),
];
return $this->config->leads()->updateOrCreate(['crm_provider_id' => (string) $crmData['id']], $data);
}
/**
* @throws ApplicationException
*/
public function syncContacts(Carbon $since, ?Carbon $to = null): int
{
$syncCount = 0;
$params = [
'sort' => '-dateLastModified',
'fields' => $this->getFieldsAsString(self::CONTACT_FIELDS),
'query' => 'isDeleted: false AND dateLastModified:[' . $since->format('YmdHis') . ' TO *]',
];
try {
$bhContacts = $this->client->get('search/ClientContact', $params);
foreach ($bhContacts['data'] as $bhContact) {
// Only sync if previously imported.
if ($this->hasContact($bhContact['id'])) {
$this->importContact($bhContact);
$syncCount++;
}
}
} catch (\Exception $exception) {
throw new ApplicationException('Failed to sync contacts', $exception->getCode(), $exception);
}
return $syncCount;
}
public function syncOpportunities(array $parameters, ?string $strategy = null): int
{
$strategyResolver = app(OpportunitySyncStrategyResolver::class, [
'client' => $this->client,
]);
$strategies = $strategyResolver->getStrategies($this->config, $strategy);
$syncCount = 0;
$total = 0;
$count = 0;
$offset = 0;
try {
$bhOpportunities = [];
foreach ($strategies as $syncStrategy) {
$opportunitiesData = $syncStrategy->fetchOpportunities($parameters);
$total = (int) ($opportunitiesData['total'] ?? 0);
$count = (int) ($opportunitiesData['count'] ?? 0);
$offset = (int) ($opportunitiesData['start'] ?? 0);
$bhOpportunities[] = $opportunitiesData['data'] ?? [];
}
$bhOpportunities = array_merge(...$bhOpportunities);
foreach ($bhOpportunities as $bhOpportunity) {
$this->importOpportunity($bhOpportunity);
$syncCount++;
}
} catch (\Exception $exception) {
$this->logger->info('Failed to sync opportunities', [
'params' => $parameters,
'exception' => $exception->getMessage(),
]);
}
// only log if there are one sync strategy
if (count($strategies) === 1 && $total > $count + $offset) {
$this->logger->info('[Bullhorn] Sync Opportunities - count warning', [
'team_id' => $this->team->getId(),
'total' => $total,
'count' => $count,
'offset' => $offset,
]);
}
return $syncCount;
}
/**
* @throws ApplicationException
*/
public function syncAccounts(Carbon $since, ?Carbon $to = null): int
{
$syncCount = 0;
$params = [
'sort' => '-dateLastModified',
'fields' => $this->getFieldsAsString(self::ACCOUNT_FIELDS),
'query' => 'isDeleted: false AND dateLastModified:[' . $since->format('YmdHis') . ' TO *]',
];
try {
$bhAccounts = $this->client->get('search/ClientCorporation', $params);
foreach ($bhAccounts['data'] as $bhAccount) {
// Only sync if previously imported.
if ($this->hasAccount($bhAccount['id'])) {
$this->importAccount($bhAccount);
$syncCount++;
}
}
} catch (\Exception $exception) {
throw new ApplicationException('Failed to sync accounts', $exception->getCode(), $exception);
}
return $syncCount;
}
/**
* @throws ApplicationException
*/
public function syncLeads(Carbon $since, ?Carbon $to = null, ?string $crmProfileId = null): int
{
$syncCount = 0;
$params = [
'sort' => '-dateLastModified',
'fields' => $this->getFieldsAsString(self::LEAD_FIELDS),
'query' => 'isDeleted: false AND dateLastModified:[' . $since->format('YmdHis') . ' TO *]',
];
try {
$bhLeads = $this->client->get('search/Candidate', $params);
if ($bhLeads === null) {
return $syncCount;
}
foreach ($bhLeads['data'] as $bhLead) {
// Only sync if previously imported.
if ($this->hasLead($bhLead['id'])) {
$this->importLead($bhLead);
$syncCount++;
}
}
} catch (\Exception $exception) {
throw new ApplicationException('Failed to sync leads', $exception->getCode(), $exception);
}
return $syncCount;
}
public function syncContact(string $crmId): ?Contact
{
try {
$data = $this->getRecord(FieldData::OBJECT_CONTACT, $crmId, self::CONTACT_FIELDS);
} catch (HttpNotFoundException $exception) {
return null;
}
return $this->importContact($data['data']);
}
public function syncOpportunity(string $crmId): ?Opportunity
{
$strategyResolver = app(OpportunitySyncStrategyResolver::class, [
'client' => $this->client,
]);
$strategy = $strategyResolver->resolve(
$this->config,
OpportunitySyncStrategyResolver::SINGLE_SYNC_OPPORTUNITY_STRATEGY
);
try {
$data = $strategy->fetchOpportunities(['crm_id' => $crmId]);
} catch (HttpNotFoundException $exception) {
$this->logger->info('[Bullhorn] Opportunity not found', [
'crm_id' => $crmId,
'reason' => $exception->getMessage(),
]);
}
return $this->importOpportunity($data['data'] ?? []);
}
public function syncAccount(string $crmId): ?Account
{
try {
$data = $this->getRecord(FieldData::OBJECT_ACCOUNT, $crmId, self::ACCOUNT_FIELDS);
} catch (HttpNotFoundException $exception) {
return null;
}
return $this->importAccount($data['data']);
}
public function syncLead(string $crmId): ?Lead
{
try {
$data = $this->getRecord(FieldData::OBJECT_LEAD, $crmId, self::LEAD_FIELDS);
} catch (HttpNotFoundException $exception) {
return null;
}
return $this->importLead($data['data']);
}
public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage
{
$missingStage = null;
if ($types === null) {
$types = [Stage::TYPE_LEAD, Stage::TYPE_OPPORTUNITY];
}
foreach ($types as $type) {
$resource = $this->convertObjectTypeToResource($type);
$entity = $this->client->get('meta/' . $resource, [
'fields' => 'status',
'meta' => 'full',
]);
$missingStage = null;
if (! is_array($entity['fields']) || count($entity['fields']) !== 1 || $entity['fields'][0]['name'] !== 'status') {
throw new \UnexpectedValueException('Invalid response from Bullhorn API');
}
// Upsert all stages for the team.
foreach ($entity['fields'][0]['options'] as $i => $option) {
$stage = $this->config->stages()->updateOrCreate([
'crm_provider_id' => $option['value'],
'type' => $type,
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($option['value'], 0, 50),
'label' => mb_strimwidth($option['label'], 0, 191),
'sequence' => $i,
'is_selectable' => 1,
]);
if ($missingStageName && $missingStageName === $option['value']) {
$missingStage = $stage;
}
}
if ($missingStageName && $missingStage === null) {
// If they requested a stage that still doesn't exist, it must be inactive so lazy create it.
$missingStage = $this->config->stages()->create([
'team_id' => $this->team->id,
'crm_provider_id' => Uuid::uuid4(),
'name' => mb_strimwidth($missingStageName, 0, 50),
'label' => mb_strimwidth($missingStageName, 0, 191),
'type' => $type,
'sequence' => 0,
'is_selectable' => 0,
]);
}
}
return $missingStage;
}
public function syncOrganization(): void
{
// There appears to be nothing we can sync/map to.
}
public function find(string $name, array $scopes): array
{
$count = $this->limit ?? 20;
$offset = $this->offset ?? 0;
$data = \Cache::remember($this->team->id . $name . $count . $offset, 300, function () use ($name, $count) {
$result = [];
try {
$objects = $this->client->get('find', [
'query' => $name,
'meta' => 'basic',
'countPerEntity' => $count,
]);
} catch (\GuzzleHttp\Exception\ServerException $exception) {
throw new ServiceUnavailableException($exception->getMessage());
}
foreach ($objects['data'] as $object) {
try {
$prospectType = $this->convertResourceToObjectType($object['entityType']);
} catch (\UnexpectedValueException $exception) {
// We don't support this, so skip it.
continue;
}
$record = [
'crmId' => $object['entityId'],
'crmUrl' => $this->generateProviderUrl($object['entityId'], $object['entityType']),
'prospectType' => $prospectType,
'phoneNumbers' => [],
];
// Unfortunately we need to go and query each entity itself to get the data we need.
switch ($object['entityType']) {
case self::ENTITY_ACCOUNT:
$data = $this->getRecord(FieldData::OBJECT_ACCOUNT, $object['entityId'], self::ACCOUNT_FIELDS);
$entity = $data['data'];
$record['name'] = $entity['name'];
$record['industry'] = $entity['industryList'];
if (empty($entity['phone']) === false) {
$record['phoneNumbers'][] = [
'number' => $entity['phone'],
'nationalFormat' => phone_national(null, $entity['phone']),
'type' => 'phone',
];
}
break;
case self::ENTITY_CONTACT:
$data = $this->getRecord(FieldData::OBJECT_CONTACT, $object['entityId'], self::CONTACT_FIELDS);
$entity = $data['data'];
$record['name'] = $entity['name'];
$record['organization'] = $entity['clientCorporation'] ? $entity['clientCorporation']['name'] : null;
$record['title'] = $entity['occupation'];
foreach (['mobile', 'phone', 'phone2', 'phone3'] as $phone) {
if (empty($entity[$phone]) === false) {
$record['phoneNumbers'][] = [
'number' => $entity[$phone],
'nationalFormat' => phone_national(null, $entity[$phone]),
'type' => $phone === 'mobile' ? 'mobile' : 'phone',
];
}
}
break;
case self::ENTITY_LEAD:
$bhData = $this->getRecord(FieldData::OBJECT_LEAD, $object['entityId'], self::LEAD_FIELDS);
$entity = $bhData['data'];
$record['name'] = $entity['name'];
$record['organization'] = $entity['companyName'];
$record['title'] = $entity['occupation'];
foreach (['mobile', 'phone', 'phone2', 'phone3', 'workPhone'] as $phone) {
if (empty($entity[$phone]) === false) {
$record['phoneNumbers'][] = [
'number' => $entity[$phone],
'nationalFormat' => phone_national(null, $entity[$phone]),
'type' => $phone === 'mobile' ? 'mobile' : 'phone',
];
}
}
break;
default:
// We are not interested in this entity type so skip it.
continue 2;
}
$result[] = $record;
}
return $result;
});
return $data;
}
public function getClient(): BullhornClientInterface
{
return $this->client;
}
private function getFieldsAsString(array $fields)
{
return implode(', ', $fields);
}
public function findOpportunities(?string $crmAccountId, ?string $crmContactId, ?int $userId = null): array
{
$data = [];
$ownerData = [];
$ownerId = null;
$contact = null;
$account = null;
if ($crmAccountId) {
$account = $this->config->accounts()->where('crm_provider_id', $crmAccountId)->first();
if ($account === null) {
$account = $this->syncAccount($crmAccountId);
}
}
if ($crmContactId) {
$contact = $this->config->contacts()->where('crm_provider_id', $crmContactId)->first();
if ($contact === null) {
$contact = $this->syncContact($crmContactId);
}
}
if ($contact || $account) {
if ($contact && $account === null) {
$account = $contact->account;
}
if ($account === null) {
return [];
}
$where = 'isDeleted: false AND clientCorporation.id:' . (int) $account->crm_provider_id;
$params = [
'sort' => '-dateLastModified',
'fields' => $this->getFieldsAsString(self::OPPORTUNITY_FIELDS),
];
$onlyOpen = true;
switch ($this->config->opportunity_assignment_rule) {
case Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_UPDATED:
$params['sort'] = '-dateLastModified';
break;
case Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_CREATED:
$params['sort'] = '-dateAdded';
break;
case Configuration::OPP_ASSIGNMENT_ALL_OPEN_OLDEST_CREATED:
$params['sort'] = '+dateAdded';
break;
case Configuration::OPP_ASSIGNMENT_ALL_RECENTLY_UPDATED:
$params['sort'] = '-dateLastModified';
$onlyOpen = false;
}
if ($onlyOpen) {
$where .= ' AND isOpen: true';
}
$params['query'] = $where;
$bhOpportunities = $this->client->get('search/JobOrder', $params);
if ($userId) {
$profileRepository = app(ProfileRepository::class);
$profile = $profileRepository->findProfileByUserId($this->config, $userId);
$ownerId = $profile instanceof Profile ? $profile->getCrmProviderId() : null;
}
foreach ($bhOpportunities['data'] as $bhOpportunity) {
$stage = $this->config
->stages()
->where('crm_provider_id', $bhOpportunity['status'])
->first();
if ($stage === null) {
// Import it.
$stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $bhOpportunity['status']);
}
$record = [
'crmId' => $bhOpportunity['id'],
'name' => $bhOpportunity['title'],
'value' => currency_format($bhOpportunity['salary'], $bhOpportunity['correlatedCustomText10'] ?? 'USD'),
'won' => strtolower($bhOpportunity['status']) === 'placed',
'closed' => $bhOpportunity['isOpen'] !== true,
'stage' => [
'id' => $stage->id_string,
'name' => $stage->name,
],
'recordType' => [],
];
if ($ownerId && isset($bhOpportunity['owner']['id']) && $bhOpportunity['owner']['id'] === (int) $ownerId) {
$ownerData[] = $record;
}
$data[] = $record;
}
}
if (! empty($ownerData)) {
return $ownerData;
}
return $data;
}
public function getTasks(string $objectType, string $objectId, ?string $opportunityId): array
{
$data = [];
// For now it has not been agreed on how to implement this e.g. appointments or tasks?
return [];
if ($objectType === 'account' && $opportunityId === null) {
// You cannot attach tasks to only accounts.
return [];
}
// Query should default to any open task.
$where = 'isDeleted: false AND isCompleted: false AND (' . $this->convertObjectTypeToResource($objectType) . 'UserId: ' . (int) $objectId;
// If we are also going to check on a specific job order, set that up.
if ($opportunityId) {
$where .= ' OR JobOrder: ' . (int) $opportunityId;
}
$where .= ')';
$params = [
'sort' => '-dateLastModified',
'fields' => $this->getFieldsAsString(self::TASK_FIELDS),
'query' => $where,
];
$bhTasks = $this->client->get('search/Task', $params);
foreach ($bhTasks['data'] as $bhTask) {
$data[] = [
'crmId' => $bhTask['id'],
'subject' => $bhTask['subject'],
'due' => Carbon::createFromTimestampMs($bhTask['dateEnd'])->toIso8601String(),
'type' => null,
];
}
return $data;
}
/**
* @url [URL_WITH_CREDENTIALS] [URL_WITH_CREDENTIALS] [URL_WITH_CREDENTIALS] CrmException
* @throws DomainException
*/
private function buildNotePayload(Activity $activity): Activity
{
if ($activity->hasActivityType() === false) {
throw new DomainException('[Bullhorn] No activity type assigned');
}
$action = $this->getCrmType($activity->getActivityType());
if ($action === null) {
throw new DomainException('[Bullhorn] No mapped CRM type for "' . $activity->category->name . '"');
}
if ($activity->lead_id === null && $activity->contact_id === null) {
throw new DomainException('[Bullhorn] Cannot save note without candidate or DM');
}
$payload = [
'comments' => $this->generateActivityDescription($activity),
'action' => $action->value,
'commentingPerson' => [
'id' => (int) $this->profile->crm_provider_id,
'type' => 'CorporateUser',
],
'externalID' => $activity->id_string,
'minutesSpent' => $activity->duration,
'dateAdded' => $activity->hasActualEndTime() ? $activity->getActualEndTime()?->valueOf() : now()->valueOf(),
];
if ($activity->lead_id) {
$payload['personReference'] = [
'id' => (int) $activity->lead->crm_provider_id,
'type' => 'Candidate',
];
}
if ($activity->contact_id) {
$payload['personReference'] = [
'id' => (int) $activity->contact->crm_provider_id,
'type' => 'ClientContact',
];
}
if ($activity->opportunity_id) {
$payload['jobOrders'] = [
'replaceAll' => [
(int) $activity->opportunity->crm_provider_id,
],
];
}
$bhActivity = $this->client->put('entity/Note', $payload);
$activity->crm_provider_id = $bhActivity['changedEntityId'];
$activity->save();
return $activity;
}
private function generateActivityDescription(Activity $activity): string
{
$description = '';
switch ($activity->type) {
case Activity::TYPE_SOFTPHONE:
case Activity::TYPE_SOFTPHONE_INBOUND:
case Activity::TYPE_CONFERENCE:
if ($activity->isTypeSoftPhone() && $activity->getProvider() !== Activity::PROVIDER_UPLOADER) {
// Dialer calls have no native field for the subject.
if ($activity->title) {
$description .= sprintf(
'<p><span style="font-size: 16px; line-height: 19.2px;">%s</span></p>',
$activity->title,
);
}
// Conference meetings log duration into a distinct field, so log this in the main text.
$description .= sprintf(
'<p><span style="font-size: 13.3333px; line-height: 16px;">Call Duration: %s</span></p>',
$activity->duration_for_humans,
);
// Tell them which number was dialed.
if ($activity->to) {
$description .= sprintf(
'<p><span style="font-size: 13.3333px; line-height: 16px;">Dialed: %s</span></p>',
$activity->to->phone_number,
);
}
$description .= '<p></p>';
}
if ($activity->isTypeSoftphoneInbound() && $activity->getProvider() !== Activity::PROVIDER_UPLOADER) {
...
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"bounds":{"left":0.054166667,"top":0.027777778,"width":0.08055556,"height":0.035555556},"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"#12066 on JY-20725-handle-HS-search-rate-limit, menu","depth":5,"bounds":{"left":0.13472222,"top":0.027777778,"width":0.25555557,"height":0.035555556},"on_screen":true,"help_text":"Pull request #12066 exists for current branch JY-20725-handle-HS-search-rate-limit, but local branch is out of sync with remote","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"bounds":{"left":0.6326389,"top":0.027777778,"width":0.023611112,"height":0.035555556},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"HandleHubspotRateLimitTest","depth":6,"bounds":{"left":0.6645833,"top":0.027777778,"width":0.15902779,"height":0.035555556},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'HandleHubspotRateLimitTest'","depth":6,"bounds":{"left":0.82361114,"top":0.027777778,"width":0.023611112,"height":0.035555556},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'HandleHubspotRateLimitTest'","depth":6,"bounds":{"left":0.8472222,"top":0.027777778,"width":0.023611112,"height":0.035555556},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"bounds":{"left":0.87083334,"top":0.027777778,"width":0.023611112,"height":0.035555556},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"bounds":{"left":0.9291667,"top":0.027777778,"width":0.023611112,"height":0.035555556},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"bounds":{"left":0.9527778,"top":0.027777778,"width":0.023611112,"height":0.035555556},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"IDE and Project Settings","depth":5,"bounds":{"left":0.9763889,"top":0.027777778,"width":0.023611112,"height":0.035555556},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.088194445,"height":0.027777778},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"8","depth":4,"bounds":{"left":0.7326389,"top":0.3122222,"width":0.016666668,"height":0.02111111},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"73","depth":4,"bounds":{"left":0.7534722,"top":0.3122222,"width":0.020833334,"height":0.02111111},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"3","depth":4,"bounds":{"left":0.77847224,"top":0.3122222,"width":0.016666668,"height":0.02111111},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"11","depth":4,"bounds":{"left":0.79930556,"top":0.3122222,"width":0.01875,"height":0.02111111},"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"bounds":{"left":0.8215278,"top":0.31,"width":0.015277778,"height":0.025555555},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"bounds":{"left":0.8368056,"top":0.31,"width":0.014583333,"height":0.025555555},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\nnamespace Jiminny\\Services\\Crm\\Bullhorn;\n\nuse Carbon\\Carbon;\nuse Exception;\nuse Illuminate\\Support\\Facades\\Cache;\nuse Illuminate\\Support\\Facades\\Log;\nuse Jiminny\\Contracts\\Services\\Crm\\MatchCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\FetchRelatedActivityInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\LayoutManagementInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\Provider\\BullhornInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityLookupInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityManipulationInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SettingsInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmMetadataInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\VerifyTaskExistsInterface;\nuse Jiminny\\Exceptions\\ApplicationException;\nuse Jiminny\\Exceptions\\CrmException;\nuse Jiminny\\Exceptions\\DomainException;\nuse Jiminny\\Exceptions\\HttpBadRequestException;\nuse Jiminny\\Exceptions\\HttpNotFoundException;\nuse Jiminny\\Exceptions\\ServiceUnavailableException;\nuse Jiminny\\Models\\Account;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Contact;\nuse Jiminny\\Models\\Crm\\Configuration;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Models\\Crm\\FieldData;\nuse Jiminny\\Models\\Crm\\Profile;\nuse Jiminny\\Models\\Lead;\nuse Jiminny\\Models\\Opportunity;\nuse Jiminny\\Models\\Playbook;\nuse Jiminny\\Models\\SocialAccount;\nuse Jiminny\\Models\\Stage;\nuse Jiminny\\Models\\User;\nuse Jiminny\\Repositories\\Crm\\ProfileRepository;\nuse Jiminny\\Repositories\\UserRepository;\nuse Jiminny\\Services\\Avatar\\ProspectPhotoPathService;\nuse Jiminny\\Services\\Crm\\BaseService;\nuse Jiminny\\Services\\Crm\\OpportunitySyncStrategyResolver;\nuse Jiminny\\Utils\\CurrencyFormatter;\nuse Jiminny\\Utils\\StringUtil;\nuse Ramsey\\Uuid\\Uuid;\n\nclass BullhornService extends BaseService implements\n BullhornInterface,\n SyncCrmEntitiesInterface,\n SyncCrmMetadataInterface,\n RemoteEntityManipulationInterface,\n FetchRelatedActivityInterface,\n LayoutManagementInterface,\n SettingsInterface,\n MatchCrmEntitiesInterface,\n RemoteEntityLookupInterface,\n VerifyTaskExistsInterface\n{\n private const int TASK_VERIFICATION_CACHE_TTL = 86400; // 1 day\n\n private const string ENTITY_LEAD = 'Candidate';\n private const string ENTITY_OPPORTUNITY = 'JobOrder';\n private const string ENTITY_ACCOUNT = 'ClientCorporation';\n private const string ENTITY_CONTACT = 'ClientContact';\n private const string ENTITY_ACTIVITY = 'Note';\n\n private const array OPPORTUNITY_FIELDS = [\n 'id',\n 'owner',\n 'status',\n 'title',\n 'salary',\n 'salaryUnit',\n 'payRate',\n 'isOpen',\n 'employmentType',\n 'dateClosed',\n 'startDate',\n 'dateLastModified',\n 'dateAdded',\n 'clientCorporation',\n 'correlatedCustomText10', // currency\n ];\n\n private const array TASK_FIELDS = [\n 'id',\n 'comments',\n 'dateAdded',\n 'dateLastModified',\n 'externalID',\n 'isDeleted',\n 'corporateUsers',\n 'clientContacts',\n 'commentingPerson',\n 'candidates',\n 'jobOrder',\n 'minutesSpent',\n 'action',\n ];\n\n private const array LEAD_FIELDS = [\n 'id',\n 'name',\n 'owner',\n 'occupation',\n 'companyName',\n 'companyURL',\n 'mobile',\n 'phone',\n 'phone2',\n 'phone3',\n 'workPhone',\n 'status',\n 'address',\n 'dateAdded',\n 'dateLastModified',\n ];\n\n private const array CONTACT_FIELDS = [\n 'id',\n 'name',\n 'occupation',\n 'mobile',\n 'phone',\n 'phone2',\n 'phone3',\n 'address',\n 'clientCorporation',\n 'dateAdded',\n 'owner',\n ];\n\n private const array ACCOUNT_FIELDS = [\n 'id',\n 'name',\n 'phone',\n 'industryList',\n 'address',\n 'companyURL',\n 'dateAdded',\n ];\n\n public function __construct(\n BullhornClientInterface $client,\n private readonly UserRepository $userRepository,\n private readonly ProspectPhotoPathService $prospectPhotoPathService\n ) {\n parent::__construct();\n\n $this->client = $client;\n }\n\n protected function getOAuthAccount(User $user): ?SocialAccount\n {\n // In this case, the Account Owner is always the connection for any API operations.\n $owner = $user->team->owner;\n\n return $owner->getSocialAccount(SocialAccount::PROVIDER_BULLHORN);\n }\n\n public function getDisplayName(): string\n {\n return 'Bullhorn';\n }\n\n public function createTranscriptNotes(Activity $activity): void\n {\n // Not done until a customer requests it.\n }\n\n public function syncProfileFields(): void\n {\n $fieldTypes = [\n 'lead',\n 'account',\n 'contact',\n 'opportunity',\n 'task',\n ];\n\n foreach ($fieldTypes as $fieldType) {\n try {\n $currentFields = $this->getFields($this->convertObjectTypeToResource($fieldType));\n\n $newFields = array_column($currentFields, 'name');\n $changedFields = array_merge(\n array_diff($this->profile->getFieldsAsArray($fieldType), $newFields),\n array_diff($newFields, $this->profile->getFieldsAsArray($fieldType))\n );\n\n if (! empty($changedFields)) {\n $fields = implode(',', $newFields);\n Log::debug(\n sprintf(\n '%s fields changed from %s to: %s',\n ucfirst($fieldType),\n $this->profile->{$fieldType . '_fields'},\n $fields,\n ),\n );\n\n $this->profile->{$fieldType . '_fields'} = $fields;\n }\n } catch (\\Exception $exception) {\n \\Log::error('No access to ' . $fieldType . ' object, skipping...');\n\n // XXX: should we log this fact somewhere?\n continue;\n }\n }\n\n $this->profile->save();\n }\n\n protected function getFieldTypes(): array\n {\n return [\n parent::OBJECT_TASK,\n //parent::OBJECT_ACCOUNT,\n //parent::OBJECT_CONTACT,\n //parent::OBJECT_OPPORTUNITY,\n //parent::OBJECT_LEAD,\n ];\n }\n\n protected function getFields(string $crmObject): array\n {\n $fields = [];\n foreach ($this->getObjectFields($crmObject) as $row) {\n $fields[] = ['label' => $row['label'] ?? $row['name'], 'name' => $row['name']];\n }\n\n return $fields;\n }\n\n private function getObjectFields(string $objectType): array\n {\n $entityType = $this->convertObjectTypeToResource($objectType);\n\n return Cache::remember(\n key: 'crm.profile#' . $this->profile->id . '.fieldType:' . $objectType,\n ttl: 60 * 60,\n callback: function () use ($entityType) {\n $entity = $this->client->get('meta/' . $entityType, [\n 'fields' => '*',\n ]);\n\n return $entity['fields'];\n },\n );\n }\n\n public function getDefaultFields(string $activityType): array\n {\n $fields = [];\n $defaultFields = FieldDefinitions::defaultFields();\n\n foreach ($defaultFields as $defaultField) {\n $fields[] = $this->config->fields()->firstOrCreate($defaultField);\n }\n\n return $fields;\n }\n\n public function getDefaultActivityField(string $activityType): Field\n {\n /** @var Field $activityField */\n $activityField = $this->config->fields()->where([\n 'crm_provider_id' => 'action',\n 'object_type' => $activityType,\n ])->first();\n\n return $activityField;\n }\n\n public function getDealInsightsFields(): array\n {\n return FieldDefinitions::dealInsightsFields();\n }\n\n public function getSupportedPlaybookTypes(): array\n {\n return [Playbook::ACTIVITY_TYPE_TASK];\n }\n\n protected function getDefaultFollowupLayoutFields(string $activityType): array\n {\n // We are not yet supporting follow-ups for this system.\n return [];\n }\n\n public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string\n {\n return $fieldValue;\n }\n\n public function syncField(Field $field): void\n {\n $resource = $this->convertObjectTypeToResource($field->object_type);\n $entity = $this->client->get('meta/' . $resource, [\n 'fields' => $field->crm_provider_id,\n 'meta' => 'full',\n ]);\n\n $bhField = $entity['fields'][0];\n\n $isMandatory = (empty($bhField['optional']) == false && $bhField['optional'] === false);\n $description = null;\n if (empty($bhField['description']) === false) {\n $description = mb_strimwidth($bhField['description'], 0, 191);\n }\n\n $label = 'Unknown';\n if (empty($bhField['label']) === false) {\n $label = mb_strimwidth($bhField['label'], 0, Field::LABEL_MAX_LENGTH);\n }\n\n $field->length = $bhField['maxLength'] ?? null;\n $field->is_mandatory = $isMandatory;\n $field->description = $description;\n $field->label = $label;\n $field->type = $this->convertFieldType(\n $bhField['dataType'] ?? null,\n $bhField['dataSpecialization'] ?? null,\n $bhField['inputType'] ?? null\n );\n\n $field->save();\n\n if (empty($bhField['options']) === false) {\n $values = [];\n\n // Import all values.\n foreach ($bhField['options'] as $i => $option) {\n $values[] = [\n 'value' => $option['value'],\n 'label' => mb_strimwidth($option['label'], 0, Field::LABEL_MAX_LENGTH),\n 'sequence' => $i,\n ];\n }\n\n // Take the existing set of field values.\n $fieldsToPurge = $field->values()->get()->pluck('value')->toArray();\n\n foreach ($values as $value) {\n // For all current values, upsert the field.\n $field->values()->updateOrCreate([\n 'value' => $value['value'],\n ], $value);\n\n // Remove this value from the ones we are going to purge.\n $key = array_search($value['value'], $fieldsToPurge, false);\n if ($key !== false) {\n unset($fieldsToPurge[$key]);\n }\n }\n\n // Delete the old values that are no longer used.\n $field->values()->whereIn('value', $fieldsToPurge)->delete();\n }\n }\n\n public function importPicklistValues(Field $field): array\n {\n // Taken care of in syncField.\n return [];\n }\n\n private function convertFieldType(?string $type, ?string $specialization, ?string $inputType): string\n {\n if ($type === null) {\n return Field::TYPE_UNSUPPORTED;\n }\n\n $type = strtolower($type);\n $specialization = strtolower($specialization);\n $inputType = strtolower($inputType);\n\n switch ($type) {\n case 'boolean':\n return Field::TYPE_BOOLEAN;\n\n case 'integer':\n switch ($specialization) {\n case 'integer':\n return Field::TYPE_NUMBER;\n default:\n return Field::TYPE_NUMBER;\n }\n\n // no break\n case 'bigdecimal':\n switch ($specialization) {\n case 'money':\n return Field::TYPE_CURRENCY;\n default:\n return Field::TYPE_NUMBER;\n }\n\n // no break\n case 'double':\n switch ($specialization) {\n case 'float':\n return Field::TYPE_NUMBER;\n default:\n return Field::TYPE_NUMBER;\n }\n\n // no break\n case 'timestamp':\n switch ($specialization) {\n case 'date':\n return Field::TYPE_DATE;\n case 'time':\n return Field::TYPE_TIME;\n case 'datetime':\n return Field::TYPE_DATETIME;\n default:\n return Field::TYPE_DATE;\n }\n\n // no break\n case 'string':\n switch ($inputType) {\n case 'textarea':\n return Field::TYPE_TEXTAREA;\n case 'select':\n return Field::TYPE_PICKLIST;\n default:\n return Field::TYPE_TEXT;\n }\n\n // no break\n default:\n // This is not actually a supported field.\n return Field::TYPE_UNSUPPORTED;\n }\n }\n\n private function importContact($crmData): Contact\n {\n $account = $mobilePhone = $phone = $email = null;\n if (empty($crmData['clientCorporation']) === false) {\n $account = $this->team\n ->accounts()\n ->where('crm_provider_id', $crmData['clientCorporation']['id'])\n ->first();\n\n if ($account === null) {\n $account = $this->syncAccount($crmData['clientCorporation']['id']);\n }\n }\n\n $countryCode = $this->convertCountryNameToCode($crmData['address']['countryCode']);\n\n foreach (['mobile', 'phone', 'phone2', 'phone3'] as $type) {\n if (empty($crmData[$type])) {\n continue;\n }\n\n if ($type === 'mobile') {\n $mobilePhone = parsePhoneNumber($countryCode, $crmData[$type]);\n } else {\n $phone = parsePhoneNumber($countryCode, $crmData[$type]);\n }\n\n break;\n }\n\n foreach (['email', 'email1', 'email2'] as $type) {\n if (empty($crmData[$type])) {\n continue;\n }\n\n $email = mb_strimwidth($crmData[$type], 0, 80);\n\n break;\n }\n\n $profile = $this->config->profiles()->where('crm_provider_id', (string) $crmData['owner']['id'])->first();\n\n $data = [\n 'team_id' => $this->team->id,\n 'account_id' => $account->id ?? null,\n 'user_id' => $profile ? $profile->user_id : null,\n 'owner_id' => $crmData['owner']['id'],\n 'name' => $crmData['name'],\n 'email' => $email,\n 'phone' => empty($phone['phone']) ? null : mb_strimwidth($phone['phone'], 0, 25),\n 'ext' => $phone['ext'] ?? null,\n 'mobile_phone' => empty($mobilePhone['phone']) ? null : mb_strimwidth($mobilePhone['phone'], 0, 25),\n 'title' => $crmData['occupation'],\n 'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(\n crmConfiguration: $this->config,\n crmProviderId: $crmData['id'],\n modelType: Contact::class,\n fileName: $crmData['id'],\n avatarText: $crmData['name'] ?? 'Unknown'\n ),\n 'remotely_created_at' => $this->filterOutOfBoundDates($crmData['dateAdded']),\n ];\n\n return $this->config->contacts()->updateOrCreate(['crm_provider_id' => (string) $crmData['id']], $data);\n }\n\n private function importOpportunity($crmData): ?Opportunity\n {\n if ($crmData['clientCorporation']['id']) {\n $account = $this->config->accounts()->where('crm_provider_id', (string) $crmData['clientCorporation']['id'])->first();\n\n if ($account === null) {\n $account = $this->syncAccount($crmData['clientCorporation']['id']);\n }\n } else {\n // If there is nothing to associate this with, don't import it.\n return null;\n }\n\n $profile = $this->config->profiles()->where('crm_provider_id', (string) $crmData['owner']['id'])->first();\n\n $stage = $this->config->stages()\n ->where('crm_provider_id', $crmData['status'])\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->first();\n\n if ($stage === null) {\n $stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $crmData['status']);\n }\n\n $data = [\n 'team_id' => $this->team->id,\n 'account_id' => $account->id,\n 'user_id' => $profile ? $profile->user_id : null,\n 'owner_id' => $crmData['owner']['id'],\n 'name' => mb_strimwidth($crmData['title'], 0, 128),\n 'value' => $crmData['salary'] ?? $crmData['payRate'],\n 'currency_code' => CurrencyFormatter::formatCode($crmData['correlatedCustomText10'] ?? 'USD'),\n 'close_date' => $this->filterOutOfBoundDates($crmData['startDate']),\n 'is_closed' => $crmData['isOpen'] !== true,\n 'is_won' => strtolower($crmData['status']) === 'placed',\n 'stage_id' => $stage->id,\n 'record_type_id' => null,\n 'remotely_created_at' => $this->filterOutOfBoundDates($crmData['dateAdded']),\n ];\n\n return $this->config->opportunities()->updateOrCreate(['crm_provider_id' => (string) $crmData['id']], $data);\n }\n\n private function importAccount($crmData): Account\n {\n $countryCode = $domain = null;\n if (empty($crmData['address']['countryCode'])) {\n $countryCode = $this->convertCountryNameToCode($crmData['address']['countryCode']);\n }\n\n $name = $crmData['name'] ?? 'Unknown';\n\n if ($crmData['companyURL'] !== null) {\n $domain = mb_strimwidth($crmData['companyURL'], 0, 191);\n $domain = StringUtil::resolveDomain($domain);\n }\n\n $data = [\n 'team_id' => $this->team->id,\n 'user_id' => null,\n 'owner_id' => null,\n 'name' => mb_strimwidth($name, 0, 191),\n 'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(\n crmConfiguration: $this->config,\n crmProviderId: $crmData['id'],\n modelType: Account::class,\n fileName: $crmData['id'],\n avatarText: $crmData['name'] ?? 'Unknown'\n ),\n 'country_code' => $countryCode,\n 'domain' => $domain,\n 'remotely_created_at' => $this->filterOutOfBoundDates($crmData['dateAdded']),\n ];\n\n return $this->config->accounts()->updateOrCreate(['crm_provider_id' => (string) $crmData['id']], $data);\n }\n\n private function importLead($crmData): ?Lead\n {\n // Get the current stage.\n $stage = $this->config->stages()->where('name', $crmData['status'])->where('type', Stage::TYPE_LEAD)->first();\n if ($stage === null) {\n $stage = $this->importStages([Stage::TYPE_LEAD], $crmData['status']);\n }\n\n // If we have no way of importing this, just return null :(\n if ($stage === null) {\n return null;\n }\n\n $countryCode = null;\n if (empty($crmData['address']['countryCode'])) {\n $countryCode = $this->convertCountryNameToCode($crmData['address']['countryCode']);\n }\n\n $mobilePhone = $phone = $email = null;\n foreach (['mobile', 'phone', 'phone2', 'phone3', 'workPhone'] as $type) {\n if (empty($crmData[$type])) {\n continue;\n }\n\n if ($type === 'mobile') {\n $mobilePhone = parsePhoneNumber($countryCode, $crmData[$type]);\n } else {\n $phone = parsePhoneNumber($countryCode, $crmData[$type]);\n }\n\n break;\n }\n\n foreach (['email', 'email1', 'email2'] as $type) {\n if (empty($crmData[$type])) {\n continue;\n }\n\n $email = mb_strimwidth($crmData[$type], 0, 80);\n\n break;\n }\n\n $company = 'Unknown';\n if (empty($crmData['companyName']) === false) {\n $companyName = is_array($crmData['companyName']) ? $crmData['companyName'][0] : $crmData['companyName'];\n $company = mb_strimwidth($companyName, 0, 191);\n }\n\n $domain = null;\n if (empty($crmData['companyURL']) === false) {\n $domain = mb_strimwidth($crmData['companyURL'], 0, 191);\n $domain = StringUtil::resolveDomain($domain);\n }\n\n $profile = $this->config->profiles()->where('crm_provider_id', (string) $crmData['owner']['id'])->first();\n\n $data = [\n 'team_id' => $this->team->id,\n 'user_id' => $profile ? $profile->user_id : null,\n 'owner_id' => $crmData['owner']['id'],\n 'company' => $company,\n 'domain' => $domain,\n 'name' => mb_strimwidth($crmData['name'], 0, 191),\n 'title' => mb_strimwidth($crmData['occupation'], 0, 128),\n 'email' => mb_strimwidth($email, 0, 80),\n 'phone' => empty($phone['phone']) ? null : mb_strimwidth($phone['phone'], 0, 25),\n 'ext' => $phone['ext'] ?? null,\n 'mobile_phone' => empty($mobilePhone['phone']) ? null : mb_strimwidth($mobilePhone['phone'], 0, 25),\n 'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(\n crmConfiguration: $this->config,\n crmProviderId: $crmData['id'],\n modelType: Lead::class,\n fileName: $crmData['id'],\n avatarText: $crmData['name'] ?? 'Unknown'\n ),\n 'stage_id' => $stage->id,\n 'country_code' => $countryCode,\n 'remotely_created_at' => $this->filterOutOfBoundDates($crmData['dateAdded']),\n ];\n\n return $this->config->leads()->updateOrCreate(['crm_provider_id' => (string) $crmData['id']], $data);\n }\n\n /**\n * @throws ApplicationException\n */\n public function syncContacts(Carbon $since, ?Carbon $to = null): int\n {\n $syncCount = 0;\n $params = [\n 'sort' => '-dateLastModified',\n 'fields' => $this->getFieldsAsString(self::CONTACT_FIELDS),\n 'query' => 'isDeleted: false AND dateLastModified:[' . $since->format('YmdHis') . ' TO *]',\n ];\n\n try {\n $bhContacts = $this->client->get('search/ClientContact', $params);\n\n foreach ($bhContacts['data'] as $bhContact) {\n // Only sync if previously imported.\n if ($this->hasContact($bhContact['id'])) {\n $this->importContact($bhContact);\n $syncCount++;\n }\n }\n } catch (\\Exception $exception) {\n throw new ApplicationException('Failed to sync contacts', $exception->getCode(), $exception);\n }\n\n return $syncCount;\n }\n\n public function syncOpportunities(array $parameters, ?string $strategy = null): int\n {\n $strategyResolver = app(OpportunitySyncStrategyResolver::class, [\n 'client' => $this->client,\n ]);\n $strategies = $strategyResolver->getStrategies($this->config, $strategy);\n\n $syncCount = 0;\n $total = 0;\n $count = 0;\n $offset = 0;\n\n try {\n $bhOpportunities = [];\n foreach ($strategies as $syncStrategy) {\n $opportunitiesData = $syncStrategy->fetchOpportunities($parameters);\n $total = (int) ($opportunitiesData['total'] ?? 0);\n $count = (int) ($opportunitiesData['count'] ?? 0);\n $offset = (int) ($opportunitiesData['start'] ?? 0);\n $bhOpportunities[] = $opportunitiesData['data'] ?? [];\n }\n\n $bhOpportunities = array_merge(...$bhOpportunities);\n\n foreach ($bhOpportunities as $bhOpportunity) {\n $this->importOpportunity($bhOpportunity);\n $syncCount++;\n }\n } catch (\\Exception $exception) {\n $this->logger->info('Failed to sync opportunities', [\n 'params' => $parameters,\n 'exception' => $exception->getMessage(),\n ]);\n }\n\n // only log if there are one sync strategy\n if (count($strategies) === 1 && $total > $count + $offset) {\n $this->logger->info('[Bullhorn] Sync Opportunities - count warning', [\n 'team_id' => $this->team->getId(),\n 'total' => $total,\n 'count' => $count,\n 'offset' => $offset,\n ]);\n }\n\n return $syncCount;\n }\n\n /**\n * @throws ApplicationException\n */\n public function syncAccounts(Carbon $since, ?Carbon $to = null): int\n {\n $syncCount = 0;\n $params = [\n 'sort' => '-dateLastModified',\n 'fields' => $this->getFieldsAsString(self::ACCOUNT_FIELDS),\n 'query' => 'isDeleted: false AND dateLastModified:[' . $since->format('YmdHis') . ' TO *]',\n ];\n\n try {\n $bhAccounts = $this->client->get('search/ClientCorporation', $params);\n\n foreach ($bhAccounts['data'] as $bhAccount) {\n // Only sync if previously imported.\n if ($this->hasAccount($bhAccount['id'])) {\n $this->importAccount($bhAccount);\n $syncCount++;\n }\n }\n } catch (\\Exception $exception) {\n throw new ApplicationException('Failed to sync accounts', $exception->getCode(), $exception);\n }\n\n return $syncCount;\n }\n\n /**\n * @throws ApplicationException\n */\n public function syncLeads(Carbon $since, ?Carbon $to = null, ?string $crmProfileId = null): int\n {\n $syncCount = 0;\n $params = [\n 'sort' => '-dateLastModified',\n 'fields' => $this->getFieldsAsString(self::LEAD_FIELDS),\n 'query' => 'isDeleted: false AND dateLastModified:[' . $since->format('YmdHis') . ' TO *]',\n ];\n\n try {\n $bhLeads = $this->client->get('search/Candidate', $params);\n if ($bhLeads === null) {\n return $syncCount;\n }\n\n foreach ($bhLeads['data'] as $bhLead) {\n // Only sync if previously imported.\n if ($this->hasLead($bhLead['id'])) {\n $this->importLead($bhLead);\n $syncCount++;\n }\n }\n } catch (\\Exception $exception) {\n throw new ApplicationException('Failed to sync leads', $exception->getCode(), $exception);\n }\n\n return $syncCount;\n }\n\n\n public function syncContact(string $crmId): ?Contact\n {\n try {\n $data = $this->getRecord(FieldData::OBJECT_CONTACT, $crmId, self::CONTACT_FIELDS);\n } catch (HttpNotFoundException $exception) {\n return null;\n }\n\n return $this->importContact($data['data']);\n }\n\n public function syncOpportunity(string $crmId): ?Opportunity\n {\n $strategyResolver = app(OpportunitySyncStrategyResolver::class, [\n 'client' => $this->client,\n ]);\n $strategy = $strategyResolver->resolve(\n $this->config,\n OpportunitySyncStrategyResolver::SINGLE_SYNC_OPPORTUNITY_STRATEGY\n );\n\n try {\n $data = $strategy->fetchOpportunities(['crm_id' => $crmId]);\n } catch (HttpNotFoundException $exception) {\n $this->logger->info('[Bullhorn] Opportunity not found', [\n 'crm_id' => $crmId,\n 'reason' => $exception->getMessage(),\n ]);\n }\n\n return $this->importOpportunity($data['data'] ?? []);\n }\n\n public function syncAccount(string $crmId): ?Account\n {\n try {\n $data = $this->getRecord(FieldData::OBJECT_ACCOUNT, $crmId, self::ACCOUNT_FIELDS);\n } catch (HttpNotFoundException $exception) {\n return null;\n }\n\n return $this->importAccount($data['data']);\n }\n\n public function syncLead(string $crmId): ?Lead\n {\n try {\n $data = $this->getRecord(FieldData::OBJECT_LEAD, $crmId, self::LEAD_FIELDS);\n } catch (HttpNotFoundException $exception) {\n return null;\n }\n\n return $this->importLead($data['data']);\n }\n\n public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage\n {\n $missingStage = null;\n\n if ($types === null) {\n $types = [Stage::TYPE_LEAD, Stage::TYPE_OPPORTUNITY];\n }\n\n foreach ($types as $type) {\n $resource = $this->convertObjectTypeToResource($type);\n $entity = $this->client->get('meta/' . $resource, [\n 'fields' => 'status',\n 'meta' => 'full',\n ]);\n\n $missingStage = null;\n\n if (! is_array($entity['fields']) || count($entity['fields']) !== 1 || $entity['fields'][0]['name'] !== 'status') {\n throw new \\UnexpectedValueException('Invalid response from Bullhorn API');\n }\n\n // Upsert all stages for the team.\n foreach ($entity['fields'][0]['options'] as $i => $option) {\n $stage = $this->config->stages()->updateOrCreate([\n 'crm_provider_id' => $option['value'],\n 'type' => $type,\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($option['value'], 0, 50),\n 'label' => mb_strimwidth($option['label'], 0, 191),\n 'sequence' => $i,\n 'is_selectable' => 1,\n ]);\n\n if ($missingStageName && $missingStageName === $option['value']) {\n $missingStage = $stage;\n }\n }\n\n if ($missingStageName && $missingStage === null) {\n // If they requested a stage that still doesn't exist, it must be inactive so lazy create it.\n $missingStage = $this->config->stages()->create([\n 'team_id' => $this->team->id,\n 'crm_provider_id' => Uuid::uuid4(),\n 'name' => mb_strimwidth($missingStageName, 0, 50),\n 'label' => mb_strimwidth($missingStageName, 0, 191),\n 'type' => $type,\n 'sequence' => 0,\n 'is_selectable' => 0,\n ]);\n }\n }\n\n return $missingStage;\n }\n\n public function syncOrganization(): void\n {\n // There appears to be nothing we can sync/map to.\n }\n\n public function find(string $name, array $scopes): array\n {\n $count = $this->limit ?? 20;\n $offset = $this->offset ?? 0;\n\n $data = \\Cache::remember($this->team->id . $name . $count . $offset, 300, function () use ($name, $count) {\n $result = [];\n\n try {\n $objects = $this->client->get('find', [\n 'query' => $name,\n 'meta' => 'basic',\n 'countPerEntity' => $count,\n ]);\n } catch (\\GuzzleHttp\\Exception\\ServerException $exception) {\n throw new ServiceUnavailableException($exception->getMessage());\n }\n\n foreach ($objects['data'] as $object) {\n try {\n $prospectType = $this->convertResourceToObjectType($object['entityType']);\n } catch (\\UnexpectedValueException $exception) {\n // We don't support this, so skip it.\n continue;\n }\n\n $record = [\n 'crmId' => $object['entityId'],\n 'crmUrl' => $this->generateProviderUrl($object['entityId'], $object['entityType']),\n 'prospectType' => $prospectType,\n 'phoneNumbers' => [],\n ];\n\n // Unfortunately we need to go and query each entity itself to get the data we need.\n switch ($object['entityType']) {\n case self::ENTITY_ACCOUNT:\n $data = $this->getRecord(FieldData::OBJECT_ACCOUNT, $object['entityId'], self::ACCOUNT_FIELDS);\n $entity = $data['data'];\n\n $record['name'] = $entity['name'];\n $record['industry'] = $entity['industryList'];\n if (empty($entity['phone']) === false) {\n $record['phoneNumbers'][] = [\n 'number' => $entity['phone'],\n 'nationalFormat' => phone_national(null, $entity['phone']),\n 'type' => 'phone',\n ];\n }\n\n break;\n\n case self::ENTITY_CONTACT:\n $data = $this->getRecord(FieldData::OBJECT_CONTACT, $object['entityId'], self::CONTACT_FIELDS);\n $entity = $data['data'];\n\n $record['name'] = $entity['name'];\n $record['organization'] = $entity['clientCorporation'] ? $entity['clientCorporation']['name'] : null;\n $record['title'] = $entity['occupation'];\n\n foreach (['mobile', 'phone', 'phone2', 'phone3'] as $phone) {\n if (empty($entity[$phone]) === false) {\n $record['phoneNumbers'][] = [\n 'number' => $entity[$phone],\n 'nationalFormat' => phone_national(null, $entity[$phone]),\n 'type' => $phone === 'mobile' ? 'mobile' : 'phone',\n ];\n }\n }\n\n break;\n\n case self::ENTITY_LEAD:\n $bhData = $this->getRecord(FieldData::OBJECT_LEAD, $object['entityId'], self::LEAD_FIELDS);\n $entity = $bhData['data'];\n\n $record['name'] = $entity['name'];\n $record['organization'] = $entity['companyName'];\n $record['title'] = $entity['occupation'];\n\n foreach (['mobile', 'phone', 'phone2', 'phone3', 'workPhone'] as $phone) {\n if (empty($entity[$phone]) === false) {\n $record['phoneNumbers'][] = [\n 'number' => $entity[$phone],\n 'nationalFormat' => phone_national(null, $entity[$phone]),\n 'type' => $phone === 'mobile' ? 'mobile' : 'phone',\n ];\n }\n }\n\n break;\n\n default:\n // We are not interested in this entity type so skip it.\n continue 2;\n }\n\n $result[] = $record;\n }\n\n return $result;\n });\n\n return $data;\n }\n\n public function getClient(): BullhornClientInterface\n {\n return $this->client;\n }\n\n private function getFieldsAsString(array $fields)\n {\n return implode(', ', $fields);\n }\n\n public function findOpportunities(?string $crmAccountId, ?string $crmContactId, ?int $userId = null): array\n {\n $data = [];\n $ownerData = [];\n $ownerId = null;\n $contact = null;\n $account = null;\n\n if ($crmAccountId) {\n $account = $this->config->accounts()->where('crm_provider_id', $crmAccountId)->first();\n\n if ($account === null) {\n $account = $this->syncAccount($crmAccountId);\n }\n }\n\n if ($crmContactId) {\n $contact = $this->config->contacts()->where('crm_provider_id', $crmContactId)->first();\n\n if ($contact === null) {\n $contact = $this->syncContact($crmContactId);\n }\n }\n\n if ($contact || $account) {\n if ($contact && $account === null) {\n $account = $contact->account;\n }\n\n if ($account === null) {\n return [];\n }\n\n $where = 'isDeleted: false AND clientCorporation.id:' . (int) $account->crm_provider_id;\n\n $params = [\n 'sort' => '-dateLastModified',\n 'fields' => $this->getFieldsAsString(self::OPPORTUNITY_FIELDS),\n ];\n\n $onlyOpen = true;\n switch ($this->config->opportunity_assignment_rule) {\n case Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_UPDATED:\n $params['sort'] = '-dateLastModified';\n\n break;\n\n case Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_CREATED:\n $params['sort'] = '-dateAdded';\n\n break;\n\n case Configuration::OPP_ASSIGNMENT_ALL_OPEN_OLDEST_CREATED:\n $params['sort'] = '+dateAdded';\n\n break;\n\n case Configuration::OPP_ASSIGNMENT_ALL_RECENTLY_UPDATED:\n $params['sort'] = '-dateLastModified';\n $onlyOpen = false;\n }\n\n if ($onlyOpen) {\n $where .= ' AND isOpen: true';\n }\n\n $params['query'] = $where;\n\n $bhOpportunities = $this->client->get('search/JobOrder', $params);\n\n if ($userId) {\n $profileRepository = app(ProfileRepository::class);\n $profile = $profileRepository->findProfileByUserId($this->config, $userId);\n\n $ownerId = $profile instanceof Profile ? $profile->getCrmProviderId() : null;\n }\n\n foreach ($bhOpportunities['data'] as $bhOpportunity) {\n $stage = $this->config\n ->stages()\n ->where('crm_provider_id', $bhOpportunity['status'])\n ->first();\n\n if ($stage === null) {\n // Import it.\n $stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $bhOpportunity['status']);\n }\n\n $record = [\n 'crmId' => $bhOpportunity['id'],\n 'name' => $bhOpportunity['title'],\n 'value' => currency_format($bhOpportunity['salary'], $bhOpportunity['correlatedCustomText10'] ?? 'USD'),\n 'won' => strtolower($bhOpportunity['status']) === 'placed',\n 'closed' => $bhOpportunity['isOpen'] !== true,\n 'stage' => [\n 'id' => $stage->id_string,\n 'name' => $stage->name,\n ],\n 'recordType' => [],\n ];\n\n if ($ownerId && isset($bhOpportunity['owner']['id']) && $bhOpportunity['owner']['id'] === (int) $ownerId) {\n $ownerData[] = $record;\n }\n\n $data[] = $record;\n }\n }\n\n if (! empty($ownerData)) {\n return $ownerData;\n }\n\n return $data;\n }\n\n public function getTasks(string $objectType, string $objectId, ?string $opportunityId): array\n {\n $data = [];\n\n // For now it has not been agreed on how to implement this e.g. appointments or tasks?\n return [];\n\n if ($objectType === 'account' && $opportunityId === null) {\n // You cannot attach tasks to only accounts.\n return [];\n }\n\n // Query should default to any open task.\n $where = 'isDeleted: false AND isCompleted: false AND (' . $this->convertObjectTypeToResource($objectType) . 'UserId: ' . (int) $objectId;\n\n // If we are also going to check on a specific job order, set that up.\n if ($opportunityId) {\n $where .= ' OR JobOrder: ' . (int) $opportunityId;\n }\n\n $where .= ')';\n\n $params = [\n 'sort' => '-dateLastModified',\n 'fields' => $this->getFieldsAsString(self::TASK_FIELDS),\n 'query' => $where,\n ];\n\n $bhTasks = $this->client->get('search/Task', $params);\n\n foreach ($bhTasks['data'] as $bhTask) {\n $data[] = [\n 'crmId' => $bhTask['id'],\n 'subject' => $bhTask['subject'],\n 'due' => Carbon::createFromTimestampMs($bhTask['dateEnd'])->toIso8601String(),\n 'type' => null,\n ];\n }\n\n return $data;\n }\n\n /**\n * @url https://bullhorn.github.io/rest-api-docs/index.html#get-find\n *\n * @return null|array{\n * ?Lead,\n * ?Account,\n * ?Opportunity,\n * ?Contact,\n * ?Stage,\n * ?string\n *}\n */\n public function matchExactlyByEmail(string $email, ?int $userId = null): ?array\n {\n $bhObject = $this->client->get('find', [\n 'query' => $email,\n 'meta' => 'basic',\n 'countPerEntity' => 10,\n ]);\n\n $data = $this->convertCrmData($bhObject['data'], $userId);\n\n return ! empty(array_filter($data)) ? $data : null;\n }\n\n /**\n * @url https://bullhorn.github.io/rest-api-docs/index.html#get-find\n *\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n *}\n */\n public function matchByPhone(string $phone, ?string $rawPhoneNumber = null, ?int $userId = null): ?array\n {\n // Check if the user is internal.\n $teamMember = $this->team->users()->where('phone', $phone)->exists();\n\n // Skip the attendee if internal.\n if ($teamMember === false) {\n $bhObject = $this->client->get('find', [\n 'query' => $rawPhoneNumber ?? $phone,\n 'meta' => 'basic',\n 'countPerEntity' => 10,\n ]);\n\n if (empty($bhObject['data'])) {\n $nationalPhone = phone_national(null, $rawPhoneNumber ?? $phone);\n $formattedPhone = ltrim($nationalPhone, '+0');\n $bhObject = $this->client->get('find', [\n 'query' => $formattedPhone,\n 'meta' => 'basic',\n 'countPerEntity' => 10,\n ]);\n }\n\n $data = $this->convertCrmData($bhObject['data'], $userId);\n\n return ! empty(array_filter($data)) ? $data : null;\n }\n\n return null;\n }\n\n /**\n * @url https://bullhorn.github.io/rest-api-docs/index.html#get-find\n *\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n public function matchByName(string $name, ?int $userId = null): ?array\n {\n // Don't waste time searching for single character strings.\n if (\\strlen($name) <= 3) {\n return null;\n }\n\n $cacheKey = $this->getCacheKey($name, $userId);\n\n $result = \\Cache::remember($cacheKey, 60, function () use ($name, $userId) {\n $bhObject = $this->client->get('find', [\n 'query' => $name,\n 'meta' => 'basic',\n 'countPerEntity' => 10,\n ]);\n\n $data = $this->convertCrmData($bhObject['data'], $userId);\n\n return ! empty(array_filter($data)) ? $data : false;\n });\n\n return is_array($result) ? $result : null;\n }\n\n protected function getCacheKey(string $object, ?int $userId = null): ?string\n {\n $key = $this->team->getId() . $object;\n $keySuffix = $this->getOwnerKeySuffix($userId);\n\n return $key . $keySuffix;\n }\n\n private function getOwnerKeySuffix(?int $userId = null): string\n {\n return $userId === null ? '' : (string) $userId;\n }\n\n public function saveActivity(Activity $activity): Activity\n {\n\n if ($activity->hasCrmProviderId()) {\n return $activity;\n }\n\n switch ($activity->type) {\n case Activity::TYPE_CONFERENCE:\n case Activity::TYPE_SOFTPHONE:\n case Activity::TYPE_SOFTPHONE_INBOUND:\n case Activity::TYPE_SMS_INBOUND:\n case Activity::TYPE_SMS_OUTBOUND:\n $activity = $this->buildNotePayload($activity);\n\n break;\n }\n\n return $activity;\n }\n\n /**\n * @throws CrmException\n * @throws DomainException\n */\n private function buildNotePayload(Activity $activity): Activity\n {\n if ($activity->hasActivityType() === false) {\n throw new DomainException('[Bullhorn] No activity type assigned');\n }\n\n $action = $this->getCrmType($activity->getActivityType());\n if ($action === null) {\n throw new DomainException('[Bullhorn] No mapped CRM type for \"' . $activity->category->name . '\"');\n }\n\n if ($activity->lead_id === null && $activity->contact_id === null) {\n throw new DomainException('[Bullhorn] Cannot save note without candidate or DM');\n }\n\n $payload = [\n 'comments' => $this->generateActivityDescription($activity),\n 'action' => $action->value,\n 'commentingPerson' => [\n 'id' => (int) $this->profile->crm_provider_id,\n 'type' => 'CorporateUser',\n ],\n 'externalID' => $activity->id_string,\n 'minutesSpent' => $activity->duration,\n 'dateAdded' => $activity->hasActualEndTime() ? $activity->getActualEndTime()?->valueOf() : now()->valueOf(),\n ];\n\n if ($activity->lead_id) {\n $payload['personReference'] = [\n 'id' => (int) $activity->lead->crm_provider_id,\n 'type' => 'Candidate',\n ];\n }\n\n if ($activity->contact_id) {\n $payload['personReference'] = [\n 'id' => (int) $activity->contact->crm_provider_id,\n 'type' => 'ClientContact',\n ];\n }\n\n if ($activity->opportunity_id) {\n $payload['jobOrders'] = [\n 'replaceAll' => [\n (int) $activity->opportunity->crm_provider_id,\n ],\n ];\n }\n\n $bhActivity = $this->client->put('entity/Note', $payload);\n\n $activity->crm_provider_id = $bhActivity['changedEntityId'];\n $activity->save();\n\n return $activity;\n }\n\n private function generateActivityDescription(Activity $activity): string\n {\n $description = '';\n switch ($activity->type) {\n case Activity::TYPE_SOFTPHONE:\n case Activity::TYPE_SOFTPHONE_INBOUND:\n case Activity::TYPE_CONFERENCE:\n\n if ($activity->isTypeSoftPhone() && $activity->getProvider() !== Activity::PROVIDER_UPLOADER) {\n // Dialer calls have no native field for the subject.\n if ($activity->title) {\n $description .= sprintf(\n '<p><span style=\"font-size: 16px; line-height: 19.2px;\">%s</span></p>',\n $activity->title,\n );\n }\n\n // Conference meetings log duration into a distinct field, so log this in the main text.\n $description .= sprintf(\n '<p><span style=\"font-size: 13.3333px; line-height: 16px;\">Call Duration: %s</span></p>',\n $activity->duration_for_humans,\n );\n\n // Tell them which number was dialed.\n if ($activity->to) {\n $description .= sprintf(\n '<p><span style=\"font-size: 13.3333px; line-height: 16px;\">Dialed: %s</span></p>',\n $activity->to->phone_number,\n );\n }\n $description .= '<p></p>';\n }\n\n if ($activity->isTypeSoftphoneInbound() && $activity->getProvider() !== Activity::PROVIDER_UPLOADER) {\n // Dialer calls have no native field for the subject.\n if ($activity->title) {\n $description .= sprintf(\n '<p><span style=\"font-size: 16px; line-height: 19.2px;\">%s</span></p>',\n $activity->title,\n );\n }\n\n // Conference meetings log duration into a distinct field, so log this in the main text.\n $description .= sprintf(\n '<p><span style=\"font-size: 13.3333px; line-height: 16px;\">Call Duration: %s</span></p>',\n $activity->duration_for_humans,\n );\n $description .= sprintf(\n '<p><span style=\"font-size: 13.3333px; line-height: 16px;\">Caller: %s</span></p>',\n $activity->from->phone_number,\n );\n $description .= '<p></p>';\n }\n\n if ($activity->hasReasonCodeBotKicked()) {\n $description .= '<p>'\n . '<span style=\"font-size: 13.3333px; line-height: 16px;\">'\n . 'Notetaker removed from this meeting'\n . '</span>'\n . '</p>';\n // When we fix the state to be Activity::RECORDING_RECORDED as it should be this can change.\n } elseif ($activity->hasReasonCodeNotCompliant()) {\n $description .= '<p>'\n . '<span style=\"font-size: 13.3333px; line-height: 16px;\">'\n . 'Notetaker did not join due to recording consent not being provided by attendees'\n . '</span>'\n . '</p>';\n } elseif ($activity->canReviewActivity()) {\n $playbackUrl = $activity->user->team->partner->getPlaybackUrl($activity);\n $description .= sprintf(\n '<p>'\n . '<span style=\"font-size: 13.3333px; line-height: 16px;\">'\n . '<a href=\"%s\" target=\"_blank\">'\n . 'Review in Jiminny'\n . '</a>'\n . '️</span>'\n . '</p>',\n $playbackUrl,\n );\n }\n\n if ($activity->isTypeConference()) {\n $description .= '<p><strong>Attendees</strong></p><ul>';\n\n // Only include participants that joined.\n $participants = $activity->participants()->ghost(false)->whereNotNull('enter_time')->get();\n foreach ($participants as $participant) {\n $attendee = '<li>';\n if ($participant->name) {\n $attendee .= $participant->name;\n if ($participant->phone_number) {\n $attendee .= ' (' . $participant->phone_number . ')';\n } elseif ($participant->email) {\n $attendee .= ' (' . $participant->email . ')';\n }\n } else {\n $attendee .= $participant->phone_number;\n }\n\n if ($participant->is_ghost) {\n $attendee .= ' <strong>coach</coach>';\n }\n\n if ($participant->getTitle()) {\n $attendee .= ', ' . $participant->getTitle();\n }\n\n $description .= $attendee . '</li>';\n }\n $description .= '</ul>';\n }\n\n if (count($activity->notes) > 0) {\n $description .= '<p><strong>Notes</strong></p>';\n\n foreach ($activity->notes as $note) {\n $time = ($note->time > 3600) ? gmdate('H:i:s', $note->time) : gmdate('i:s', $note->time);\n $description .= '<p>' . $time . ' ' . $note->note . '</p>';\n }\n $description .= '</p>';\n }\n\n // Get all private messages.\n $messages = $activity->messages()\n ->where('is_private', 1)\n ->orderBy('created_at', 'asc');\n\n if ($messages->count() > 0) {\n $description .= '<p><strong>Coaching Chat</strong></p>';\n\n foreach ($messages->get() as $message) {\n $description .= '<p>' . $message->participant->name . ': ' . $message->message . '</p>';\n }\n $description .= '</p>';\n }\n\n // Get all public messages.\n $messages = $activity->messages()\n ->where('is_private', 0)\n ->orderBy('created_at', 'asc');\n\n if ($messages->count() > 0) {\n $description .= '<p><strong>Customer Chat</strong></p>';\n\n foreach ($messages->get() as $message) {\n $description .= '<p>' . $message->participant->name . ': ' . $message->message . '</p>';\n }\n $description .= '</p>';\n }\n\n if ($activity->summary) {\n $description .= '<p><strong>Summary</strong></p><p>' . $activity->summary . '</p><p></p>';\n }\n\n break;\n\n case Activity::TYPE_SMS_INBOUND:\n case Activity::TYPE_SMS_OUTBOUND:\n // Use Rich-text formatting.\n $description = sprintf(\n '<p><span style=\"font-size: 16px; line-height: 19.2px;\"><strong>%s</strong></span></p>',\n $activity->title,\n );\n $description .= sprintf(\n '<p><span style=\"font-size: 16px; line-height: 19.2px;\">%s</span></p>',\n $activity->description,\n );\n\n break;\n }\n\n return $description;\n }\n\n\n public function saveFollowupActivity(Activity $activity, array $fields): ?string\n {\n // Not yet required by current customers.\n return null;\n }\n\n public function updateStage($crmObject, Stage $stage): void\n {\n $objectId = $crmObject->crm_provider_id;\n\n $this->updateRecord($stage->type, $objectId, ['status' => $stage->crm_provider_id]);\n }\n\n public function syncProfiles(?User $userToSearch = null): ?Profile\n {\n $start = 0;\n $payload = [\n 'fields' => 'id,email',\n 'where' => 'isDeleted=false',\n 'count' => 500,\n ];\n\n $payload['start'] = $start;\n $profiles = $this->client->get('query/CorporateUser', $payload);\n\n foreach ($profiles['data'] as $profile) {\n if (empty($profile['email'])) {\n continue;\n }\n\n $user = $this->userRepository->findByTeamAndEmailAddress($this->team, $profile['email'], onlyActive: true);\n\n // Only import profiles for users we have locally.\n if ($user) {\n $profile = $user->crmProfile()->updateOrCreate([\n 'crm_configuration_id' => $this->config->id,\n 'crm_provider_id' => $profile['id'],\n ]);\n\n if ($userToSearch && $userToSearch->id === $user->id) {\n return $profile;\n }\n }\n }\n\n return null;\n }\n\n protected function convertCrmData(array $entities, ?int $userId = null): array\n {\n $lead = null;\n $contact = null;\n $opportunity = null;\n $account = null;\n $stage = null;\n $countryCode = null;\n\n $mappedEntities = [];\n foreach ($entities as $entity) {\n if (in_array($entity['entityType'], [self::ENTITY_LEAD, self::ENTITY_CONTACT, self::ENTITY_ACCOUNT])) {\n $mappedEntities[] = $entity;\n }\n }\n\n // If there is only one match, sync the data and return the object(s).\n if (count($mappedEntities) === 1) {\n $entity = $mappedEntities[0];\n\n if ($entity['entityType'] === self::ENTITY_LEAD) {\n $lead = $this->syncLead($entity['entityId']);\n\n // Lead might not be imported if the Stage is null for example.\n if ($lead) {\n $countryCode = $lead->country_code;\n $stage = $lead->stage;\n }\n } elseif (in_array($entity['entityType'], [self::ENTITY_CONTACT, self::ENTITY_ACCOUNT])) {\n if ($entity['entityType'] === self::ENTITY_CONTACT) {\n $contact = $this->syncContact($entity['entityId']);\n $account = $contact->account;\n } else {\n $account = $this->syncAccount($entity['entityId']);\n }\n\n if ($contact?->country_code) {\n $countryCode = $contact->country_code;\n } elseif ($account) {\n $countryCode = $account->country_code;\n }\n\n try {\n $bhOpportunities = $this->findOpportunities(\n $account?->crm_provider_id,\n $contact?->crm_provider_id,\n $userId,\n );\n\n // Take the first opportunity, which will be ordered as priority based on their settings.\n if (! empty($bhOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($bhOpportunities[0]['crmId']);\n $stage = $opportunity?->stage;\n }\n } catch (Exception) {\n // Nothing to see here.\n }\n }\n }\n\n return [\n $lead,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n }\n\n public function generateProviderUrl(string $providerId, string $objectType): ?string\n {\n // There doesn't appear to be a way to deeplink to anything on https://app.bullhornstaffing.com.\n return null;\n }\n\n public function updateRecord(string $objectType, string $objectId, array $data, array $headers = []): void\n {\n $this->client->post('entity/' . $this->convertObjectTypeToResource($objectType) . '/' . $objectId, $data);\n }\n\n public function getRecord(string $objectType, string $objectId, array $fields = ['id']): array\n {\n return $this->client->get(\n url: 'entity/' . $this->convertObjectTypeToResource($objectType) . '/' . $objectId,\n data: ['fields' => $this->getFieldsAsString($fields)],\n );\n }\n\n private function convertObjectTypeToResource(string $objectType)\n {\n switch ($objectType) {\n case FieldData::OBJECT_OPPORTUNITY:\n return self::ENTITY_OPPORTUNITY;\n\n case FieldData::OBJECT_CONTACT:\n return self::ENTITY_CONTACT;\n\n case FieldData::OBJECT_LEAD:\n return self::ENTITY_LEAD;\n\n case FieldData::OBJECT_ACCOUNT:\n return self::ENTITY_ACCOUNT;\n\n case FieldData::OBJECT_TASK:\n return self::ENTITY_ACTIVITY;\n\n default:\n throw new \\UnexpectedValueException('Unsupported object type \"' . $objectType . '\"');\n }\n }\n\n private function convertResourceToObjectType(string $resource): string\n {\n switch ($resource) {\n case self::ENTITY_OPPORTUNITY:\n return FieldData::OBJECT_OPPORTUNITY;\n\n case self::ENTITY_CONTACT:\n return FieldData::OBJECT_CONTACT;\n\n case self::ENTITY_LEAD:\n return FieldData::OBJECT_LEAD;\n\n case self::ENTITY_ACCOUNT:\n return FieldData::OBJECT_ACCOUNT;\n\n case self::ENTITY_ACTIVITY:\n return FieldData::OBJECT_TASK;\n\n default:\n throw new \\UnexpectedValueException('Unsupported resource \"' . $resource . '\"');\n }\n }\n\n public function fetchAndAssociateRelatedActivity(Activity $activity): ?Activity\n {\n // We need to know when it started to determine when to look for related activity.\n if ($activity->hasActualStartTime() === false) {\n return null;\n }\n\n try {\n $bhNote = $this->fetchRelatedActivity($activity);\n if (empty($bhNote)) {\n $this->logger->info('[Bullhorn] No related activity found', [\n 'activity' => $activity->getUuid(),\n ]);\n\n return null;\n }\n\n $activityType = $this->getPlaybookCategory($this->getPlaybook($activity->user), $bhNote['action']);\n $comments = $bhNote['comments'] . '<br />//<br />' . $this->generateActivityDescription($activity);\n // Now update the Note to contain our data.\n $payload = [\n 'comments' => $comments,\n 'externalID' => $activity->id_string,\n 'minutesSpent' => $activity->duration,\n ];\n\n $this->logger->info('[Bullhorn] updating related activity', [\n 'activity' => $activity->getUuid(),\n 'activityType' => $activityType->id ?? null,\n ]);\n\n $this->client->post('entity/Note/' . $bhNote['id'], $payload);\n\n $playbookCategoryId = null;\n if ($activity->getCategory() !== null) {\n $playbookCategoryId = $activity->getCategory()->getId();\n }\n\n // Update our activity to reflect the remote data.\n $activity->update([\n 'crm_provider_id' => $bhNote['id'],\n 'summary' => $bhNote['comments'],\n 'playbook_category_id' => $activityType->id ?? $playbookCategoryId,\n ]);\n\n return $activity;\n } catch (\\Exception $exception) {\n \\Sentry::captureException($exception);\n\n throw $exception;\n }\n }\n\n /**\n * Returns the most recently modified note within time range (if any).\n *\n * @return array A Note record from Bullhorn.\n */\n public function fetchRelatedActivity(Activity $activity): array\n {\n $from = $activity->hasScheduledStartTime()\n ? $activity->getScheduledStartTime()\n : $activity->getActualStartTime();\n $to = $activity->hasActualEndTime()\n ? $activity->getActualEndTime()?->addMinutes(15)\n : $from?->clone()->addMinutes(15);\n\n $filterStatements = [\n 'isDeleted: false',\n '*:* -externalID:[* TO *]', // externalID should be null e.g. we did not already relate it.\n sprintf('commentingPerson: %d', (int) $this->profile->crm_provider_id),\n ];\n\n /**\n * @XXX: Bullhorn does not specify the time zone in their docs. I've found a forum thread in which someone from\n * staff asserts it's UTC, however in my field test I've found it to be GMT-0300 (USA east coast).\n * To make the code immune to such glitches we will add 24h to start/end boundaries and will filter out\n * any excess items in code\n */\n $filterStatements[] = sprintf(\n 'dateLastModified:[%s TO %s]',\n $from?->clone()->subHours(24)->format('YmdHis'),\n $to->clone()->addHours(24)->format('YmdHis'),\n );\n\n if ($activity->lead_id) {\n $filterStatements[] = sprintf('candidateUserID: %d', (int) $activity->lead->crm_provider_id);\n }\n\n if ($activity->contact_id) {\n $filterStatements[] = sprintf('clientContactUserID: %d', (int) $activity->contact->crm_provider_id);\n }\n\n // if there is no contact or lead associated to the activity exit\n if ($activity->contact_id === null && $activity->lead_id === null) {\n return [];\n }\n\n $params = [\n 'fields' => $this->getFieldsAsString(self::TASK_FIELDS),\n 'query' => implode(' AND ', $filterStatements),\n 'sort' => '-dateLastModified',\n ];\n\n $bhNotes = $this->client->get('search/Note', $params);\n\n $startStamp = $from?->getTimestamp() * 1000;\n $endStamp = $to->getTimestamp() * 1000;\n\n $filteredNotes = array_values(\n array_filter(\n $bhNotes['data'],\n static fn (array $bhNote): bool\n => $bhNote['dateLastModified'] >= $startStamp && $startStamp <= $endStamp,\n ),\n );\n\n return ! empty($filteredNotes) ? array_shift($filteredNotes) : [];\n }\n\n /**\n * Select how to create the date out of the Bullhorn payload.\n * Do not allow dates beyond 9999-12-31 (max mysql DATE)\n */\n private function filterOutOfBoundDates(string $rawDateCandidate): ?Carbon\n {\n if (strlen($rawDateCandidate) > 12) {\n // A unix timestamp represented with more than 12 digits i\n // s definitely including milliseconds\n $dateFormatted = Carbon::createFromTimestampMs($rawDateCandidate);\n } else {\n $dateFormatted = Carbon::createFromTimestamp($rawDateCandidate);\n }\n\n $minDate = Carbon::createFromFormat('Y-m-d', '2000-01-01');\n if ($dateFormatted->lt($minDate)) {\n return null;\n }\n\n $maxDate = Carbon::createFromFormat('Y-m-d', '9999-12-31');\n if ($dateFormatted->gt($maxDate)) {\n $this->logger->warning('[Bullhorn] An out of bound date is provided', [\n 'date' => $dateFormatted,\n 'rawDate' => $rawDateCandidate,\n ]);\n\n return $maxDate;\n }\n\n return $dateFormatted;\n }\n\n public function verifyTaskExists(Activity $activity): bool\n {\n $crmProviderId = $activity->getCrmProviderId();\n $cacheKey = \"crm_task_exists:{$this->config->getId()}:$crmProviderId\";\n\n return Cache::remember($cacheKey, self::TASK_VERIFICATION_CACHE_TTL, function () use ($crmProviderId) {\n try {\n $note = $this->getRecord('Note', $crmProviderId, ['id', 'isDeleted']);\n\n return ! empty($note) && ($note['isDeleted'] ?? false) === false;\n } catch (HttpNotFoundException|HttpBadRequestException) {\n // Note not found in CRM - this is expected and permanent\n $this->logger->info('[Bullhorn] Note not found during verification', [\n 'note_id' => $crmProviderId,\n 'config_id' => $this->config->getId(),\n ]);\n\n return false;\n }\n // Let other exceptions (network errors, rate limits, etc.) bubble up for retry\n });\n }\n}","depth":4,"on_screen":true,"value":"<?php\n\nnamespace Jiminny\\Services\\Crm\\Bullhorn;\n\nuse Carbon\\Carbon;\nuse Exception;\nuse Illuminate\\Support\\Facades\\Cache;\nuse Illuminate\\Support\\Facades\\Log;\nuse Jiminny\\Contracts\\Services\\Crm\\MatchCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\FetchRelatedActivityInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\LayoutManagementInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\Provider\\BullhornInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityLookupInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityManipulationInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SettingsInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmMetadataInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\VerifyTaskExistsInterface;\nuse Jiminny\\Exceptions\\ApplicationException;\nuse Jiminny\\Exceptions\\CrmException;\nuse Jiminny\\Exceptions\\DomainException;\nuse Jiminny\\Exceptions\\HttpBadRequestException;\nuse Jiminny\\Exceptions\\HttpNotFoundException;\nuse Jiminny\\Exceptions\\ServiceUnavailableException;\nuse Jiminny\\Models\\Account;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Contact;\nuse Jiminny\\Models\\Crm\\Configuration;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Models\\Crm\\FieldData;\nuse Jiminny\\Models\\Crm\\Profile;\nuse Jiminny\\Models\\Lead;\nuse Jiminny\\Models\\Opportunity;\nuse Jiminny\\Models\\Playbook;\nuse Jiminny\\Models\\SocialAccount;\nuse Jiminny\\Models\\Stage;\nuse Jiminny\\Models\\User;\nuse Jiminny\\Repositories\\Crm\\ProfileRepository;\nuse Jiminny\\Repositories\\UserRepository;\nuse Jiminny\\Services\\Avatar\\ProspectPhotoPathService;\nuse Jiminny\\Services\\Crm\\BaseService;\nuse Jiminny\\Services\\Crm\\OpportunitySyncStrategyResolver;\nuse Jiminny\\Utils\\CurrencyFormatter;\nuse Jiminny\\Utils\\StringUtil;\nuse Ramsey\\Uuid\\Uuid;\n\nclass BullhornService extends BaseService implements\n BullhornInterface,\n SyncCrmEntitiesInterface,\n SyncCrmMetadataInterface,\n RemoteEntityManipulationInterface,\n FetchRelatedActivityInterface,\n LayoutManagementInterface,\n SettingsInterface,\n MatchCrmEntitiesInterface,\n RemoteEntityLookupInterface,\n VerifyTaskExistsInterface\n{\n private const int TASK_VERIFICATION_CACHE_TTL = 86400; // 1 day\n\n private const string ENTITY_LEAD = 'Candidate';\n private const string ENTITY_OPPORTUNITY = 'JobOrder';\n private const string ENTITY_ACCOUNT = 'ClientCorporation';\n private const string ENTITY_CONTACT = 'ClientContact';\n private const string ENTITY_ACTIVITY = 'Note';\n\n private const array OPPORTUNITY_FIELDS = [\n 'id',\n 'owner',\n 'status',\n 'title',\n 'salary',\n 'salaryUnit',\n 'payRate',\n 'isOpen',\n 'employmentType',\n 'dateClosed',\n 'startDate',\n 'dateLastModified',\n 'dateAdded',\n 'clientCorporation',\n 'correlatedCustomText10', // currency\n ];\n\n private const array TASK_FIELDS = [\n 'id',\n 'comments',\n 'dateAdded',\n 'dateLastModified',\n 'externalID',\n 'isDeleted',\n 'corporateUsers',\n 'clientContacts',\n 'commentingPerson',\n 'candidates',\n 'jobOrder',\n 'minutesSpent',\n 'action',\n ];\n\n private const array LEAD_FIELDS = [\n 'id',\n 'name',\n 'owner',\n 'occupation',\n 'companyName',\n 'companyURL',\n 'mobile',\n 'phone',\n 'phone2',\n 'phone3',\n 'workPhone',\n 'status',\n 'address',\n 'dateAdded',\n 'dateLastModified',\n ];\n\n private const array CONTACT_FIELDS = [\n 'id',\n 'name',\n 'occupation',\n 'mobile',\n 'phone',\n 'phone2',\n 'phone3',\n 'address',\n 'clientCorporation',\n 'dateAdded',\n 'owner',\n ];\n\n private const array ACCOUNT_FIELDS = [\n 'id',\n 'name',\n 'phone',\n 'industryList',\n 'address',\n 'companyURL',\n 'dateAdded',\n ];\n\n public function __construct(\n BullhornClientInterface $client,\n private readonly UserRepository $userRepository,\n private readonly ProspectPhotoPathService $prospectPhotoPathService\n ) {\n parent::__construct();\n\n $this->client = $client;\n }\n\n protected function getOAuthAccount(User $user): ?SocialAccount\n {\n // In this case, the Account Owner is always the connection for any API operations.\n $owner = $user->team->owner;\n\n return $owner->getSocialAccount(SocialAccount::PROVIDER_BULLHORN);\n }\n\n public function getDisplayName(): string\n {\n return 'Bullhorn';\n }\n\n public function createTranscriptNotes(Activity $activity): void\n {\n // Not done until a customer requests it.\n }\n\n public function syncProfileFields(): void\n {\n $fieldTypes = [\n 'lead',\n 'account',\n 'contact',\n 'opportunity',\n 'task',\n ];\n\n foreach ($fieldTypes as $fieldType) {\n try {\n $currentFields = $this->getFields($this->convertObjectTypeToResource($fieldType));\n\n $newFields = array_column($currentFields, 'name');\n $changedFields = array_merge(\n array_diff($this->profile->getFieldsAsArray($fieldType), $newFields),\n array_diff($newFields, $this->profile->getFieldsAsArray($fieldType))\n );\n\n if (! empty($changedFields)) {\n $fields = implode(',', $newFields);\n Log::debug(\n sprintf(\n '%s fields changed from %s to: %s',\n ucfirst($fieldType),\n $this->profile->{$fieldType . '_fields'},\n $fields,\n ),\n );\n\n $this->profile->{$fieldType . '_fields'} = $fields;\n }\n } catch (\\Exception $exception) {\n \\Log::error('No access to ' . $fieldType . ' object, skipping...');\n\n // XXX: should we log this fact somewhere?\n continue;\n }\n }\n\n $this->profile->save();\n }\n\n protected function getFieldTypes(): array\n {\n return [\n parent::OBJECT_TASK,\n //parent::OBJECT_ACCOUNT,\n //parent::OBJECT_CONTACT,\n //parent::OBJECT_OPPORTUNITY,\n //parent::OBJECT_LEAD,\n ];\n }\n\n protected function getFields(string $crmObject): array\n {\n $fields = [];\n foreach ($this->getObjectFields($crmObject) as $row) {\n $fields[] = ['label' => $row['label'] ?? $row['name'], 'name' => $row['name']];\n }\n\n return $fields;\n }\n\n private function getObjectFields(string $objectType): array\n {\n $entityType = $this->convertObjectTypeToResource($objectType);\n\n return Cache::remember(\n key: 'crm.profile#' . $this->profile->id . '.fieldType:' . $objectType,\n ttl: 60 * 60,\n callback: function () use ($entityType) {\n $entity = $this->client->get('meta/' . $entityType, [\n 'fields' => '*',\n ]);\n\n return $entity['fields'];\n },\n );\n }\n\n public function getDefaultFields(string $activityType): array\n {\n $fields = [];\n $defaultFields = FieldDefinitions::defaultFields();\n\n foreach ($defaultFields as $defaultField) {\n $fields[] = $this->config->fields()->firstOrCreate($defaultField);\n }\n\n return $fields;\n }\n\n public function getDefaultActivityField(string $activityType): Field\n {\n /** @var Field $activityField */\n $activityField = $this->config->fields()->where([\n 'crm_provider_id' => 'action',\n 'object_type' => $activityType,\n ])->first();\n\n return $activityField;\n }\n\n public function getDealInsightsFields(): array\n {\n return FieldDefinitions::dealInsightsFields();\n }\n\n public function getSupportedPlaybookTypes(): array\n {\n return [Playbook::ACTIVITY_TYPE_TASK];\n }\n\n protected function getDefaultFollowupLayoutFields(string $activityType): array\n {\n // We are not yet supporting follow-ups for this system.\n return [];\n }\n\n public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string\n {\n return $fieldValue;\n }\n\n public function syncField(Field $field): void\n {\n $resource = $this->convertObjectTypeToResource($field->object_type);\n $entity = $this->client->get('meta/' . $resource, [\n 'fields' => $field->crm_provider_id,\n 'meta' => 'full',\n ]);\n\n $bhField = $entity['fields'][0];\n\n $isMandatory = (empty($bhField['optional']) == false && $bhField['optional'] === false);\n $description = null;\n if (empty($bhField['description']) === false) {\n $description = mb_strimwidth($bhField['description'], 0, 191);\n }\n\n $label = 'Unknown';\n if (empty($bhField['label']) === false) {\n $label = mb_strimwidth($bhField['label'], 0, Field::LABEL_MAX_LENGTH);\n }\n\n $field->length = $bhField['maxLength'] ?? null;\n $field->is_mandatory = $isMandatory;\n $field->description = $description;\n $field->label = $label;\n $field->type = $this->convertFieldType(\n $bhField['dataType'] ?? null,\n $bhField['dataSpecialization'] ?? null,\n $bhField['inputType'] ?? null\n );\n\n $field->save();\n\n if (empty($bhField['options']) === false) {\n $values = [];\n\n // Import all values.\n foreach ($bhField['options'] as $i => $option) {\n $values[] = [\n 'value' => $option['value'],\n 'label' => mb_strimwidth($option['label'], 0, Field::LABEL_MAX_LENGTH),\n 'sequence' => $i,\n ];\n }\n\n // Take the existing set of field values.\n $fieldsToPurge = $field->values()->get()->pluck('value')->toArray();\n\n foreach ($values as $value) {\n // For all current values, upsert the field.\n $field->values()->updateOrCreate([\n 'value' => $value['value'],\n ], $value);\n\n // Remove this value from the ones we are going to purge.\n $key = array_search($value['value'], $fieldsToPurge, false);\n if ($key !== false) {\n unset($fieldsToPurge[$key]);\n }\n }\n\n // Delete the old values that are no longer used.\n $field->values()->whereIn('value', $fieldsToPurge)->delete();\n }\n }\n\n public function importPicklistValues(Field $field): array\n {\n // Taken care of in syncField.\n return [];\n }\n\n private function convertFieldType(?string $type, ?string $specialization, ?string $inputType): string\n {\n if ($type === null) {\n return Field::TYPE_UNSUPPORTED;\n }\n\n $type = strtolower($type);\n $specialization = strtolower($specialization);\n $inputType = strtolower($inputType);\n\n switch ($type) {\n case 'boolean':\n return Field::TYPE_BOOLEAN;\n\n case 'integer':\n switch ($specialization) {\n case 'integer':\n return Field::TYPE_NUMBER;\n default:\n return Field::TYPE_NUMBER;\n }\n\n // no break\n case 'bigdecimal':\n switch ($specialization) {\n case 'money':\n return Field::TYPE_CURRENCY;\n default:\n return Field::TYPE_NUMBER;\n }\n\n // no break\n case 'double':\n switch ($specialization) {\n case 'float':\n return Field::TYPE_NUMBER;\n default:\n return Field::TYPE_NUMBER;\n }\n\n // no break\n case 'timestamp':\n switch ($specialization) {\n case 'date':\n return Field::TYPE_DATE;\n case 'time':\n return Field::TYPE_TIME;\n case 'datetime':\n return Field::TYPE_DATETIME;\n default:\n return Field::TYPE_DATE;\n }\n\n // no break\n case 'string':\n switch ($inputType) {\n case 'textarea':\n return Field::TYPE_TEXTAREA;\n case 'select':\n return Field::TYPE_PICKLIST;\n default:\n return Field::TYPE_TEXT;\n }\n\n // no break\n default:\n // This is not actually a supported field.\n return Field::TYPE_UNSUPPORTED;\n }\n }\n\n private function importContact($crmData): Contact\n {\n $account = $mobilePhone = $phone = $email = null;\n if (empty($crmData['clientCorporation']) === false) {\n $account = $this->team\n ->accounts()\n ->where('crm_provider_id', $crmData['clientCorporation']['id'])\n ->first();\n\n if ($account === null) {\n $account = $this->syncAccount($crmData['clientCorporation']['id']);\n }\n }\n\n $countryCode = $this->convertCountryNameToCode($crmData['address']['countryCode']);\n\n foreach (['mobile', 'phone', 'phone2', 'phone3'] as $type) {\n if (empty($crmData[$type])) {\n continue;\n }\n\n if ($type === 'mobile') {\n $mobilePhone = parsePhoneNumber($countryCode, $crmData[$type]);\n } else {\n $phone = parsePhoneNumber($countryCode, $crmData[$type]);\n }\n\n break;\n }\n\n foreach (['email', 'email1', 'email2'] as $type) {\n if (empty($crmData[$type])) {\n continue;\n }\n\n $email = mb_strimwidth($crmData[$type], 0, 80);\n\n break;\n }\n\n $profile = $this->config->profiles()->where('crm_provider_id', (string) $crmData['owner']['id'])->first();\n\n $data = [\n 'team_id' => $this->team->id,\n 'account_id' => $account->id ?? null,\n 'user_id' => $profile ? $profile->user_id : null,\n 'owner_id' => $crmData['owner']['id'],\n 'name' => $crmData['name'],\n 'email' => $email,\n 'phone' => empty($phone['phone']) ? null : mb_strimwidth($phone['phone'], 0, 25),\n 'ext' => $phone['ext'] ?? null,\n 'mobile_phone' => empty($mobilePhone['phone']) ? null : mb_strimwidth($mobilePhone['phone'], 0, 25),\n 'title' => $crmData['occupation'],\n 'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(\n crmConfiguration: $this->config,\n crmProviderId: $crmData['id'],\n modelType: Contact::class,\n fileName: $crmData['id'],\n avatarText: $crmData['name'] ?? 'Unknown'\n ),\n 'remotely_created_at' => $this->filterOutOfBoundDates($crmData['dateAdded']),\n ];\n\n return $this->config->contacts()->updateOrCreate(['crm_provider_id' => (string) $crmData['id']], $data);\n }\n\n private function importOpportunity($crmData): ?Opportunity\n {\n if ($crmData['clientCorporation']['id']) {\n $account = $this->config->accounts()->where('crm_provider_id', (string) $crmData['clientCorporation']['id'])->first();\n\n if ($account === null) {\n $account = $this->syncAccount($crmData['clientCorporation']['id']);\n }\n } else {\n // If there is nothing to associate this with, don't import it.\n return null;\n }\n\n $profile = $this->config->profiles()->where('crm_provider_id', (string) $crmData['owner']['id'])->first();\n\n $stage = $this->config->stages()\n ->where('crm_provider_id', $crmData['status'])\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->first();\n\n if ($stage === null) {\n $stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $crmData['status']);\n }\n\n $data = [\n 'team_id' => $this->team->id,\n 'account_id' => $account->id,\n 'user_id' => $profile ? $profile->user_id : null,\n 'owner_id' => $crmData['owner']['id'],\n 'name' => mb_strimwidth($crmData['title'], 0, 128),\n 'value' => $crmData['salary'] ?? $crmData['payRate'],\n 'currency_code' => CurrencyFormatter::formatCode($crmData['correlatedCustomText10'] ?? 'USD'),\n 'close_date' => $this->filterOutOfBoundDates($crmData['startDate']),\n 'is_closed' => $crmData['isOpen'] !== true,\n 'is_won' => strtolower($crmData['status']) === 'placed',\n 'stage_id' => $stage->id,\n 'record_type_id' => null,\n 'remotely_created_at' => $this->filterOutOfBoundDates($crmData['dateAdded']),\n ];\n\n return $this->config->opportunities()->updateOrCreate(['crm_provider_id' => (string) $crmData['id']], $data);\n }\n\n private function importAccount($crmData): Account\n {\n $countryCode = $domain = null;\n if (empty($crmData['address']['countryCode'])) {\n $countryCode = $this->convertCountryNameToCode($crmData['address']['countryCode']);\n }\n\n $name = $crmData['name'] ?? 'Unknown';\n\n if ($crmData['companyURL'] !== null) {\n $domain = mb_strimwidth($crmData['companyURL'], 0, 191);\n $domain = StringUtil::resolveDomain($domain);\n }\n\n $data = [\n 'team_id' => $this->team->id,\n 'user_id' => null,\n 'owner_id' => null,\n 'name' => mb_strimwidth($name, 0, 191),\n 'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(\n crmConfiguration: $this->config,\n crmProviderId: $crmData['id'],\n modelType: Account::class,\n fileName: $crmData['id'],\n avatarText: $crmData['name'] ?? 'Unknown'\n ),\n 'country_code' => $countryCode,\n 'domain' => $domain,\n 'remotely_created_at' => $this->filterOutOfBoundDates($crmData['dateAdded']),\n ];\n\n return $this->config->accounts()->updateOrCreate(['crm_provider_id' => (string) $crmData['id']], $data);\n }\n\n private function importLead($crmData): ?Lead\n {\n // Get the current stage.\n $stage = $this->config->stages()->where('name', $crmData['status'])->where('type', Stage::TYPE_LEAD)->first();\n if ($stage === null) {\n $stage = $this->importStages([Stage::TYPE_LEAD], $crmData['status']);\n }\n\n // If we have no way of importing this, just return null :(\n if ($stage === null) {\n return null;\n }\n\n $countryCode = null;\n if (empty($crmData['address']['countryCode'])) {\n $countryCode = $this->convertCountryNameToCode($crmData['address']['countryCode']);\n }\n\n $mobilePhone = $phone = $email = null;\n foreach (['mobile', 'phone', 'phone2', 'phone3', 'workPhone'] as $type) {\n if (empty($crmData[$type])) {\n continue;\n }\n\n if ($type === 'mobile') {\n $mobilePhone = parsePhoneNumber($countryCode, $crmData[$type]);\n } else {\n $phone = parsePhoneNumber($countryCode, $crmData[$type]);\n }\n\n break;\n }\n\n foreach (['email', 'email1', 'email2'] as $type) {\n if (empty($crmData[$type])) {\n continue;\n }\n\n $email = mb_strimwidth($crmData[$type], 0, 80);\n\n break;\n }\n\n $company = 'Unknown';\n if (empty($crmData['companyName']) === false) {\n $companyName = is_array($crmData['companyName']) ? $crmData['companyName'][0] : $crmData['companyName'];\n $company = mb_strimwidth($companyName, 0, 191);\n }\n\n $domain = null;\n if (empty($crmData['companyURL']) === false) {\n $domain = mb_strimwidth($crmData['companyURL'], 0, 191);\n $domain = StringUtil::resolveDomain($domain);\n }\n\n $profile = $this->config->profiles()->where('crm_provider_id', (string) $crmData['owner']['id'])->first();\n\n $data = [\n 'team_id' => $this->team->id,\n 'user_id' => $profile ? $profile->user_id : null,\n 'owner_id' => $crmData['owner']['id'],\n 'company' => $company,\n 'domain' => $domain,\n 'name' => mb_strimwidth($crmData['name'], 0, 191),\n 'title' => mb_strimwidth($crmData['occupation'], 0, 128),\n 'email' => mb_strimwidth($email, 0, 80),\n 'phone' => empty($phone['phone']) ? null : mb_strimwidth($phone['phone'], 0, 25),\n 'ext' => $phone['ext'] ?? null,\n 'mobile_phone' => empty($mobilePhone['phone']) ? null : mb_strimwidth($mobilePhone['phone'], 0, 25),\n 'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(\n crmConfiguration: $this->config,\n crmProviderId: $crmData['id'],\n modelType: Lead::class,\n fileName: $crmData['id'],\n avatarText: $crmData['name'] ?? 'Unknown'\n ),\n 'stage_id' => $stage->id,\n 'country_code' => $countryCode,\n 'remotely_created_at' => $this->filterOutOfBoundDates($crmData['dateAdded']),\n ];\n\n return $this->config->leads()->updateOrCreate(['crm_provider_id' => (string) $crmData['id']], $data);\n }\n\n /**\n * @throws ApplicationException\n */\n public function syncContacts(Carbon $since, ?Carbon $to = null): int\n {\n $syncCount = 0;\n $params = [\n 'sort' => '-dateLastModified',\n 'fields' => $this->getFieldsAsString(self::CONTACT_FIELDS),\n 'query' => 'isDeleted: false AND dateLastModified:[' . $since->format('YmdHis') . ' TO *]',\n ];\n\n try {\n $bhContacts = $this->client->get('search/ClientContact', $params);\n\n foreach ($bhContacts['data'] as $bhContact) {\n // Only sync if previously imported.\n if ($this->hasContact($bhContact['id'])) {\n $this->importContact($bhContact);\n $syncCount++;\n }\n }\n } catch (\\Exception $exception) {\n throw new ApplicationException('Failed to sync contacts', $exception->getCode(), $exception);\n }\n\n return $syncCount;\n }\n\n public function syncOpportunities(array $parameters, ?string $strategy = null): int\n {\n $strategyResolver = app(OpportunitySyncStrategyResolver::class, [\n 'client' => $this->client,\n ]);\n $strategies = $strategyResolver->getStrategies($this->config, $strategy);\n\n $syncCount = 0;\n $total = 0;\n $count = 0;\n $offset = 0;\n\n try {\n $bhOpportunities = [];\n foreach ($strategies as $syncStrategy) {\n $opportunitiesData = $syncStrategy->fetchOpportunities($parameters);\n $total = (int) ($opportunitiesData['total'] ?? 0);\n $count = (int) ($opportunitiesData['count'] ?? 0);\n $offset = (int) ($opportunitiesData['start'] ?? 0);\n $bhOpportunities[] = $opportunitiesData['data'] ?? [];\n }\n\n $bhOpportunities = array_merge(...$bhOpportunities);\n\n foreach ($bhOpportunities as $bhOpportunity) {\n $this->importOpportunity($bhOpportunity);\n $syncCount++;\n }\n } catch (\\Exception $exception) {\n $this->logger->info('Failed to sync opportunities', [\n 'params' => $parameters,\n 'exception' => $exception->getMessage(),\n ]);\n }\n\n // only log if there are one sync strategy\n if (count($strategies) === 1 && $total > $count + $offset) {\n $this->logger->info('[Bullhorn] Sync Opportunities - count warning', [\n 'team_id' => $this->team->getId(),\n 'total' => $total,\n 'count' => $count,\n 'offset' => $offset,\n ]);\n }\n\n return $syncCount;\n }\n\n /**\n * @throws ApplicationException\n */\n public function syncAccounts(Carbon $since, ?Carbon $to = null): int\n {\n $syncCount = 0;\n $params = [\n 'sort' => '-dateLastModified',\n 'fields' => $this->getFieldsAsString(self::ACCOUNT_FIELDS),\n 'query' => 'isDeleted: false AND dateLastModified:[' . $since->format('YmdHis') . ' TO *]',\n ];\n\n try {\n $bhAccounts = $this->client->get('search/ClientCorporation', $params);\n\n foreach ($bhAccounts['data'] as $bhAccount) {\n // Only sync if previously imported.\n if ($this->hasAccount($bhAccount['id'])) {\n $this->importAccount($bhAccount);\n $syncCount++;\n }\n }\n } catch (\\Exception $exception) {\n throw new ApplicationException('Failed to sync accounts', $exception->getCode(), $exception);\n }\n\n return $syncCount;\n }\n\n /**\n * @throws ApplicationException\n */\n public function syncLeads(Carbon $since, ?Carbon $to = null, ?string $crmProfileId = null): int\n {\n $syncCount = 0;\n $params = [\n 'sort' => '-dateLastModified',\n 'fields' => $this->getFieldsAsString(self::LEAD_FIELDS),\n 'query' => 'isDeleted: false AND dateLastModified:[' . $since->format('YmdHis') . ' TO *]',\n ];\n\n try {\n $bhLeads = $this->client->get('search/Candidate', $params);\n if ($bhLeads === null) {\n return $syncCount;\n }\n\n foreach ($bhLeads['data'] as $bhLead) {\n // Only sync if previously imported.\n if ($this->hasLead($bhLead['id'])) {\n $this->importLead($bhLead);\n $syncCount++;\n }\n }\n } catch (\\Exception $exception) {\n throw new ApplicationException('Failed to sync leads', $exception->getCode(), $exception);\n }\n\n return $syncCount;\n }\n\n\n public function syncContact(string $crmId): ?Contact\n {\n try {\n $data = $this->getRecord(FieldData::OBJECT_CONTACT, $crmId, self::CONTACT_FIELDS);\n } catch (HttpNotFoundException $exception) {\n return null;\n }\n\n return $this->importContact($data['data']);\n }\n\n public function syncOpportunity(string $crmId): ?Opportunity\n {\n $strategyResolver = app(OpportunitySyncStrategyResolver::class, [\n 'client' => $this->client,\n ]);\n $strategy = $strategyResolver->resolve(\n $this->config,\n OpportunitySyncStrategyResolver::SINGLE_SYNC_OPPORTUNITY_STRATEGY\n );\n\n try {\n $data = $strategy->fetchOpportunities(['crm_id' => $crmId]);\n } catch (HttpNotFoundException $exception) {\n $this->logger->info('[Bullhorn] Opportunity not found', [\n 'crm_id' => $crmId,\n 'reason' => $exception->getMessage(),\n ]);\n }\n\n return $this->importOpportunity($data['data'] ?? []);\n }\n\n public function syncAccount(string $crmId): ?Account\n {\n try {\n $data = $this->getRecord(FieldData::OBJECT_ACCOUNT, $crmId, self::ACCOUNT_FIELDS);\n } catch (HttpNotFoundException $exception) {\n return null;\n }\n\n return $this->importAccount($data['data']);\n }\n\n public function syncLead(string $crmId): ?Lead\n {\n try {\n $data = $this->getRecord(FieldData::OBJECT_LEAD, $crmId, self::LEAD_FIELDS);\n } catch (HttpNotFoundException $exception) {\n return null;\n }\n\n return $this->importLead($data['data']);\n }\n\n public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage\n {\n $missingStage = null;\n\n if ($types === null) {\n $types = [Stage::TYPE_LEAD, Stage::TYPE_OPPORTUNITY];\n }\n\n foreach ($types as $type) {\n $resource = $this->convertObjectTypeToResource($type);\n $entity = $this->client->get('meta/' . $resource, [\n 'fields' => 'status',\n 'meta' => 'full',\n ]);\n\n $missingStage = null;\n\n if (! is_array($entity['fields']) || count($entity['fields']) !== 1 || $entity['fields'][0]['name'] !== 'status') {\n throw new \\UnexpectedValueException('Invalid response from Bullhorn API');\n }\n\n // Upsert all stages for the team.\n foreach ($entity['fields'][0]['options'] as $i => $option) {\n $stage = $this->config->stages()->updateOrCreate([\n 'crm_provider_id' => $option['value'],\n 'type' => $type,\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($option['value'], 0, 50),\n 'label' => mb_strimwidth($option['label'], 0, 191),\n 'sequence' => $i,\n 'is_selectable' => 1,\n ]);\n\n if ($missingStageName && $missingStageName === $option['value']) {\n $missingStage = $stage;\n }\n }\n\n if ($missingStageName && $missingStage === null) {\n // If they requested a stage that still doesn't exist, it must be inactive so lazy create it.\n $missingStage = $this->config->stages()->create([\n 'team_id' => $this->team->id,\n 'crm_provider_id' => Uuid::uuid4(),\n 'name' => mb_strimwidth($missingStageName, 0, 50),\n 'label' => mb_strimwidth($missingStageName, 0, 191),\n 'type' => $type,\n 'sequence' => 0,\n 'is_selectable' => 0,\n ]);\n }\n }\n\n return $missingStage;\n }\n\n public function syncOrganization(): void\n {\n // There appears to be nothing we can sync/map to.\n }\n\n public function find(string $name, array $scopes): array\n {\n $count = $this->limit ?? 20;\n $offset = $this->offset ?? 0;\n\n $data = \\Cache::remember($this->team->id . $name . $count . $offset, 300, function () use ($name, $count) {\n $result = [];\n\n try {\n $objects = $this->client->get('find', [\n 'query' => $name,\n 'meta' => 'basic',\n 'countPerEntity' => $count,\n ]);\n } catch (\\GuzzleHttp\\Exception\\ServerException $exception) {\n throw new ServiceUnavailableException($exception->getMessage());\n }\n\n foreach ($objects['data'] as $object) {\n try {\n $prospectType = $this->convertResourceToObjectType($object['entityType']);\n } catch (\\UnexpectedValueException $exception) {\n // We don't support this, so skip it.\n continue;\n }\n\n $record = [\n 'crmId' => $object['entityId'],\n 'crmUrl' => $this->generateProviderUrl($object['entityId'], $object['entityType']),\n 'prospectType' => $prospectType,\n 'phoneNumbers' => [],\n ];\n\n // Unfortunately we need to go and query each entity itself to get the data we need.\n switch ($object['entityType']) {\n case self::ENTITY_ACCOUNT:\n $data = $this->getRecord(FieldData::OBJECT_ACCOUNT, $object['entityId'], self::ACCOUNT_FIELDS);\n $entity = $data['data'];\n\n $record['name'] = $entity['name'];\n $record['industry'] = $entity['industryList'];\n if (empty($entity['phone']) === false) {\n $record['phoneNumbers'][] = [\n 'number' => $entity['phone'],\n 'nationalFormat' => phone_national(null, $entity['phone']),\n 'type' => 'phone',\n ];\n }\n\n break;\n\n case self::ENTITY_CONTACT:\n $data = $this->getRecord(FieldData::OBJECT_CONTACT, $object['entityId'], self::CONTACT_FIELDS);\n $entity = $data['data'];\n\n $record['name'] = $entity['name'];\n $record['organization'] = $entity['clientCorporation'] ? $entity['clientCorporation']['name'] : null;\n $record['title'] = $entity['occupation'];\n\n foreach (['mobile', 'phone', 'phone2', 'phone3'] as $phone) {\n if (empty($entity[$phone]) === false) {\n $record['phoneNumbers'][] = [\n 'number' => $entity[$phone],\n 'nationalFormat' => phone_national(null, $entity[$phone]),\n 'type' => $phone === 'mobile' ? 'mobile' : 'phone',\n ];\n }\n }\n\n break;\n\n case self::ENTITY_LEAD:\n $bhData = $this->getRecord(FieldData::OBJECT_LEAD, $object['entityId'], self::LEAD_FIELDS);\n $entity = $bhData['data'];\n\n $record['name'] = $entity['name'];\n $record['organization'] = $entity['companyName'];\n $record['title'] = $entity['occupation'];\n\n foreach (['mobile', 'phone', 'phone2', 'phone3', 'workPhone'] as $phone) {\n if (empty($entity[$phone]) === false) {\n $record['phoneNumbers'][] = [\n 'number' => $entity[$phone],\n 'nationalFormat' => phone_national(null, $entity[$phone]),\n 'type' => $phone === 'mobile' ? 'mobile' : 'phone',\n ];\n }\n }\n\n break;\n\n default:\n // We are not interested in this entity type so skip it.\n continue 2;\n }\n\n $result[] = $record;\n }\n\n return $result;\n });\n\n return $data;\n }\n\n public function getClient(): BullhornClientInterface\n {\n return $this->client;\n }\n\n private function getFieldsAsString(array $fields)\n {\n return implode(', ', $fields);\n }\n\n public function findOpportunities(?string $crmAccountId, ?string $crmContactId, ?int $userId = null): array\n {\n $data = [];\n $ownerData = [];\n $ownerId = null;\n $contact = null;\n $account = null;\n\n if ($crmAccountId) {\n $account = $this->config->accounts()->where('crm_provider_id', $crmAccountId)->first();\n\n if ($account === null) {\n $account = $this->syncAccount($crmAccountId);\n }\n }\n\n if ($crmContactId) {\n $contact = $this->config->contacts()->where('crm_provider_id', $crmContactId)->first();\n\n if ($contact === null) {\n $contact = $this->syncContact($crmContactId);\n }\n }\n\n if ($contact || $account) {\n if ($contact && $account === null) {\n $account = $contact->account;\n }\n\n if ($account === null) {\n return [];\n }\n\n $where = 'isDeleted: false AND clientCorporation.id:' . (int) $account->crm_provider_id;\n\n $params = [\n 'sort' => '-dateLastModified',\n 'fields' => $this->getFieldsAsString(self::OPPORTUNITY_FIELDS),\n ];\n\n $onlyOpen = true;\n switch ($this->config->opportunity_assignment_rule) {\n case Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_UPDATED:\n $params['sort'] = '-dateLastModified';\n\n break;\n\n case Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_CREATED:\n $params['sort'] = '-dateAdded';\n\n break;\n\n case Configuration::OPP_ASSIGNMENT_ALL_OPEN_OLDEST_CREATED:\n $params['sort'] = '+dateAdded';\n\n break;\n\n case Configuration::OPP_ASSIGNMENT_ALL_RECENTLY_UPDATED:\n $params['sort'] = '-dateLastModified';\n $onlyOpen = false;\n }\n\n if ($onlyOpen) {\n $where .= ' AND isOpen: true';\n }\n\n $params['query'] = $where;\n\n $bhOpportunities = $this->client->get('search/JobOrder', $params);\n\n if ($userId) {\n $profileRepository = app(ProfileRepository::class);\n $profile = $profileRepository->findProfileByUserId($this->config, $userId);\n\n $ownerId = $profile instanceof Profile ? $profile->getCrmProviderId() : null;\n }\n\n foreach ($bhOpportunities['data'] as $bhOpportunity) {\n $stage = $this->config\n ->stages()\n ->where('crm_provider_id', $bhOpportunity['status'])\n ->first();\n\n if ($stage === null) {\n // Import it.\n $stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $bhOpportunity['status']);\n }\n\n $record = [\n 'crmId' => $bhOpportunity['id'],\n 'name' => $bhOpportunity['title'],\n 'value' => currency_format($bhOpportunity['salary'], $bhOpportunity['correlatedCustomText10'] ?? 'USD'),\n 'won' => strtolower($bhOpportunity['status']) === 'placed',\n 'closed' => $bhOpportunity['isOpen'] !== true,\n 'stage' => [\n 'id' => $stage->id_string,\n 'name' => $stage->name,\n ],\n 'recordType' => [],\n ];\n\n if ($ownerId && isset($bhOpportunity['owner']['id']) && $bhOpportunity['owner']['id'] === (int) $ownerId) {\n $ownerData[] = $record;\n }\n\n $data[] = $record;\n }\n }\n\n if (! empty($ownerData)) {\n return $ownerData;\n }\n\n return $data;\n }\n\n public function getTasks(string $objectType, string $objectId, ?string $opportunityId): array\n {\n $data = [];\n\n // For now it has not been agreed on how to implement this e.g. appointments or tasks?\n return [];\n\n if ($objectType === 'account' && $opportunityId === null) {\n // You cannot attach tasks to only accounts.\n return [];\n }\n\n // Query should default to any open task.\n $where = 'isDeleted: false AND isCompleted: false AND (' . $this->convertObjectTypeToResource($objectType) . 'UserId: ' . (int) $objectId;\n\n // If we are also going to check on a specific job order, set that up.\n if ($opportunityId) {\n $where .= ' OR JobOrder: ' . (int) $opportunityId;\n }\n\n $where .= ')';\n\n $params = [\n 'sort' => '-dateLastModified',\n 'fields' => $this->getFieldsAsString(self::TASK_FIELDS),\n 'query' => $where,\n ];\n\n $bhTasks = $this->client->get('search/Task', $params);\n\n foreach ($bhTasks['data'] as $bhTask) {\n $data[] = [\n 'crmId' => $bhTask['id'],\n 'subject' => $bhTask['subject'],\n 'due' => Carbon::createFromTimestampMs($bhTask['dateEnd'])->toIso8601String(),\n 'type' => null,\n ];\n }\n\n return $data;\n }\n\n /**\n * @url https://bullhorn.github.io/rest-api-docs/index.html#get-find\n *\n * @return null|array{\n * ?Lead,\n * ?Account,\n * ?Opportunity,\n * ?Contact,\n * ?Stage,\n * ?string\n *}\n */\n public function matchExactlyByEmail(string $email, ?int $userId = null): ?array\n {\n $bhObject = $this->client->get('find', [\n 'query' => $email,\n 'meta' => 'basic',\n 'countPerEntity' => 10,\n ]);\n\n $data = $this->convertCrmData($bhObject['data'], $userId);\n\n return ! empty(array_filter($data)) ? $data : null;\n }\n\n /**\n * @url https://bullhorn.github.io/rest-api-docs/index.html#get-find\n *\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n *}\n */\n public function matchByPhone(string $phone, ?string $rawPhoneNumber = null, ?int $userId = null): ?array\n {\n // Check if the user is internal.\n $teamMember = $this->team->users()->where('phone', $phone)->exists();\n\n // Skip the attendee if internal.\n if ($teamMember === false) {\n $bhObject = $this->client->get('find', [\n 'query' => $rawPhoneNumber ?? $phone,\n 'meta' => 'basic',\n 'countPerEntity' => 10,\n ]);\n\n if (empty($bhObject['data'])) {\n $nationalPhone = phone_national(null, $rawPhoneNumber ?? $phone);\n $formattedPhone = ltrim($nationalPhone, '+0');\n $bhObject = $this->client->get('find', [\n 'query' => $formattedPhone,\n 'meta' => 'basic',\n 'countPerEntity' => 10,\n ]);\n }\n\n $data = $this->convertCrmData($bhObject['data'], $userId);\n\n return ! empty(array_filter($data)) ? $data : null;\n }\n\n return null;\n }\n\n /**\n * @url https://bullhorn.github.io/rest-api-docs/index.html#get-find\n *\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n public function matchByName(string $name, ?int $userId = null): ?array\n {\n // Don't waste time searching for single character strings.\n if (\\strlen($name) <= 3) {\n return null;\n }\n\n $cacheKey = $this->getCacheKey($name, $userId);\n\n $result = \\Cache::remember($cacheKey, 60, function () use ($name, $userId) {\n $bhObject = $this->client->get('find', [\n 'query' => $name,\n 'meta' => 'basic',\n 'countPerEntity' => 10,\n ]);\n\n $data = $this->convertCrmData($bhObject['data'], $userId);\n\n return ! empty(array_filter($data)) ? $data : false;\n });\n\n return is_array($result) ? $result : null;\n }\n\n protected function getCacheKey(string $object, ?int $userId = null): ?string\n {\n $key = $this->team->getId() . $object;\n $keySuffix = $this->getOwnerKeySuffix($userId);\n\n return $key . $keySuffix;\n }\n\n private function getOwnerKeySuffix(?int $userId = null): string\n {\n return $userId === null ? '' : (string) $userId;\n }\n\n public function saveActivity(Activity $activity): Activity\n {\n\n if ($activity->hasCrmProviderId()) {\n return $activity;\n }\n\n switch ($activity->type) {\n case Activity::TYPE_CONFERENCE:\n case Activity::TYPE_SOFTPHONE:\n case Activity::TYPE_SOFTPHONE_INBOUND:\n case Activity::TYPE_SMS_INBOUND:\n case Activity::TYPE_SMS_OUTBOUND:\n $activity = $this->buildNotePayload($activity);\n\n break;\n }\n\n return $activity;\n }\n\n /**\n * @throws CrmException\n * @throws DomainException\n */\n private function buildNotePayload(Activity $activity): Activity\n {\n if ($activity->hasActivityType() === false) {\n throw new DomainException('[Bullhorn] No activity type assigned');\n }\n\n $action = $this->getCrmType($activity->getActivityType());\n if ($action === null) {\n throw new DomainException('[Bullhorn] No mapped CRM type for \"' . $activity->category->name . '\"');\n }\n\n if ($activity->lead_id === null && $activity->contact_id === null) {\n throw new DomainException('[Bullhorn] Cannot save note without candidate or DM');\n }\n\n $payload = [\n 'comments' => $this->generateActivityDescription($activity),\n 'action' => $action->value,\n 'commentingPerson' => [\n 'id' => (int) $this->profile->crm_provider_id,\n 'type' => 'CorporateUser',\n ],\n 'externalID' => $activity->id_string,\n 'minutesSpent' => $activity->duration,\n 'dateAdded' => $activity->hasActualEndTime() ? $activity->getActualEndTime()?->valueOf() : now()->valueOf(),\n ];\n\n if ($activity->lead_id) {\n $payload['personReference'] = [\n 'id' => (int) $activity->lead->crm_provider_id,\n 'type' => 'Candidate',\n ];\n }\n\n if ($activity->contact_id) {\n $payload['personReference'] = [\n 'id' => (int) $activity->contact->crm_provider_id,\n 'type' => 'ClientContact',\n ];\n }\n\n if ($activity->opportunity_id) {\n $payload['jobOrders'] = [\n 'replaceAll' => [\n (int) $activity->opportunity->crm_provider_id,\n ],\n ];\n }\n\n $bhActivity = $this->client->put('entity/Note', $payload);\n\n $activity->crm_provider_id = $bhActivity['changedEntityId'];\n $activity->save();\n\n return $activity;\n }\n\n private function generateActivityDescription(Activity $activity): string\n {\n $description = '';\n switch ($activity->type) {\n case Activity::TYPE_SOFTPHONE:\n case Activity::TYPE_SOFTPHONE_INBOUND:\n case Activity::TYPE_CONFERENCE:\n\n if ($activity->isTypeSoftPhone() && $activity->getProvider() !== Activity::PROVIDER_UPLOADER) {\n // Dialer calls have no native field for the subject.\n if ($activity->title) {\n $description .= sprintf(\n '<p><span style=\"font-size: 16px; line-height: 19.2px;\">%s</span></p>',\n $activity->title,\n );\n }\n\n // Conference meetings log duration into a distinct field, so log this in the main text.\n $description .= sprintf(\n '<p><span style=\"font-size: 13.3333px; line-height: 16px;\">Call Duration: %s</span></p>',\n $activity->duration_for_humans,\n );\n\n // Tell them which number was dialed.\n if ($activity->to) {\n $description .= sprintf(\n '<p><span style=\"font-size: 13.3333px; line-height: 16px;\">Dialed: %s</span></p>',\n $activity->to->phone_number,\n );\n }\n $description .= '<p></p>';\n }\n\n if ($activity->isTypeSoftphoneInbound() && $activity->getProvider() !== Activity::PROVIDER_UPLOADER) {\n // Dialer calls have no native field for the subject.\n if ($activity->title) {\n $description .= sprintf(\n '<p><span style=\"font-size: 16px; line-height: 19.2px;\">%s</span></p>',\n $activity->title,\n );\n }\n\n // Conference meetings log duration into a distinct field, so log this in the main text.\n $description .= sprintf(\n '<p><span style=\"font-size: 13.3333px; line-height: 16px;\">Call Duration: %s</span></p>',\n $activity->duration_for_humans,\n );\n $description .= sprintf(\n '<p><span style=\"font-size: 13.3333px; line-height: 16px;\">Caller: %s</span></p>',\n $activity->from->phone_number,\n );\n $description .= '<p></p>';\n }\n\n if ($activity->hasReasonCodeBotKicked()) {\n $description .= '<p>'\n . '<span style=\"font-size: 13.3333px; line-height: 16px;\">'\n . 'Notetaker removed from this meeting'\n . '</span>'\n . '</p>';\n // When we fix the state to be Activity::RECORDING_RECORDED as it should be this can change.\n } elseif ($activity->hasReasonCodeNotCompliant()) {\n $description .= '<p>'\n . '<span style=\"font-size: 13.3333px; line-height: 16px;\">'\n . 'Notetaker did not join due to recording consent not being provided by attendees'\n . '</span>'\n . '</p>';\n } elseif ($activity->canReviewActivity()) {\n $playbackUrl = $activity->user->team->partner->getPlaybackUrl($activity);\n $description .= sprintf(\n '<p>'\n . '<span style=\"font-size: 13.3333px; line-height: 16px;\">'\n . '<a href=\"%s\" target=\"_blank\">'\n . 'Review in Jiminny'\n . '</a>'\n . '️</span>'\n . '</p>',\n $playbackUrl,\n );\n }\n\n if ($activity->isTypeConference()) {\n $description .= '<p><strong>Attendees</strong></p><ul>';\n\n // Only include participants that joined.\n $participants = $activity->participants()->ghost(false)->whereNotNull('enter_time')->get();\n foreach ($participants as $participant) {\n $attendee = '<li>';\n if ($participant->name) {\n $attendee .= $participant->name;\n if ($participant->phone_number) {\n $attendee .= ' (' . $participant->phone_number . ')';\n } elseif ($participant->email) {\n $attendee .= ' (' . $participant->email . ')';\n }\n } else {\n $attendee .= $participant->phone_number;\n }\n\n if ($participant->is_ghost) {\n $attendee .= ' <strong>coach</coach>';\n }\n\n if ($participant->getTitle()) {\n $attendee .= ', ' . $participant->getTitle();\n }\n\n $description .= $attendee . '</li>';\n }\n $description .= '</ul>';\n }\n\n if (count($activity->notes) > 0) {\n $description .= '<p><strong>Notes</strong></p>';\n\n foreach ($activity->notes as $note) {\n $time = ($note->time > 3600) ? gmdate('H:i:s', $note->time) : gmdate('i:s', $note->time);\n $description .= '<p>' . $time . ' ' . $note->note . '</p>';\n }\n $description .= '</p>';\n }\n\n // Get all private messages.\n $messages = $activity->messages()\n ->where('is_private', 1)\n ->orderBy('created_at', 'asc');\n\n if ($messages->count() > 0) {\n $description .= '<p><strong>Coaching Chat</strong></p>';\n\n foreach ($messages->get() as $message) {\n $description .= '<p>' . $message->participant->name . ': ' . $message->message . '</p>';\n }\n $description .= '</p>';\n }\n\n // Get all public messages.\n $messages = $activity->messages()\n ->where('is_private', 0)\n ->orderBy('created_at', 'asc');\n\n if ($messages->count() > 0) {\n $description .= '<p><strong>Customer Chat</strong></p>';\n\n foreach ($messages->get() as $message) {\n $description .= '<p>' . $message->participant->name . ': ' . $message->message . '</p>';\n }\n $description .= '</p>';\n }\n\n if ($activity->summary) {\n $description .= '<p><strong>Summary</strong></p><p>' . $activity->summary . '</p><p></p>';\n }\n\n break;\n\n case Activity::TYPE_SMS_INBOUND:\n case Activity::TYPE_SMS_OUTBOUND:\n // Use Rich-text formatting.\n $description = sprintf(\n '<p><span style=\"font-size: 16px; line-height: 19.2px;\"><strong>%s</strong></span></p>',\n $activity->title,\n );\n $description .= sprintf(\n '<p><span style=\"font-size: 16px; line-height: 19.2px;\">%s</span></p>',\n $activity->description,\n );\n\n break;\n }\n\n return $description;\n }\n\n\n public function saveFollowupActivity(Activity $activity, array $fields): ?string\n {\n // Not yet required by current customers.\n return null;\n }\n\n public function updateStage($crmObject, Stage $stage): void\n {\n $objectId = $crmObject->crm_provider_id;\n\n $this->updateRecord($stage->type, $objectId, ['status' => $stage->crm_provider_id]);\n }\n\n public function syncProfiles(?User $userToSearch = null): ?Profile\n {\n $start = 0;\n $payload = [\n 'fields' => 'id,email',\n 'where' => 'isDeleted=false',\n 'count' => 500,\n ];\n\n $payload['start'] = $start;\n $profiles = $this->client->get('query/CorporateUser', $payload);\n\n foreach ($profiles['data'] as $profile) {\n if (empty($profile['email'])) {\n continue;\n }\n\n $user = $this->userRepository->findByTeamAndEmailAddress($this->team, $profile['email'], onlyActive: true);\n\n // Only import profiles for users we have locally.\n if ($user) {\n $profile = $user->crmProfile()->updateOrCreate([\n 'crm_configuration_id' => $this->config->id,\n 'crm_provider_id' => $profile['id'],\n ]);\n\n if ($userToSearch && $userToSearch->id === $user->id) {\n return $profile;\n }\n }\n }\n\n return null;\n }\n\n protected function convertCrmData(array $entities, ?int $userId = null): array\n {\n $lead = null;\n $contact = null;\n $opportunity = null;\n $account = null;\n $stage = null;\n $countryCode = null;\n\n $mappedEntities = [];\n foreach ($entities as $entity) {\n if (in_array($entity['entityType'], [self::ENTITY_LEAD, self::ENTITY_CONTACT, self::ENTITY_ACCOUNT])) {\n $mappedEntities[] = $entity;\n }\n }\n\n // If there is only one match, sync the data and return the object(s).\n if (count($mappedEntities) === 1) {\n $entity = $mappedEntities[0];\n\n if ($entity['entityType'] === self::ENTITY_LEAD) {\n $lead = $this->syncLead($entity['entityId']);\n\n // Lead might not be imported if the Stage is null for example.\n if ($lead) {\n $countryCode = $lead->country_code;\n $stage = $lead->stage;\n }\n } elseif (in_array($entity['entityType'], [self::ENTITY_CONTACT, self::ENTITY_ACCOUNT])) {\n if ($entity['entityType'] === self::ENTITY_CONTACT) {\n $contact = $this->syncContact($entity['entityId']);\n $account = $contact->account;\n } else {\n $account = $this->syncAccount($entity['entityId']);\n }\n\n if ($contact?->country_code) {\n $countryCode = $contact->country_code;\n } elseif ($account) {\n $countryCode = $account->country_code;\n }\n\n try {\n $bhOpportunities = $this->findOpportunities(\n $account?->crm_provider_id,\n $contact?->crm_provider_id,\n $userId,\n );\n\n // Take the first opportunity, which will be ordered as priority based on their settings.\n if (! empty($bhOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($bhOpportunities[0]['crmId']);\n $stage = $opportunity?->stage;\n }\n } catch (Exception) {\n // Nothing to see here.\n }\n }\n }\n\n return [\n $lead,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n }\n\n public function generateProviderUrl(string $providerId, string $objectType): ?string\n {\n // There doesn't appear to be a way to deeplink to anything on https://app.bullhornstaffing.com.\n return null;\n }\n\n public function updateRecord(string $objectType, string $objectId, array $data, array $headers = []): void\n {\n $this->client->post('entity/' . $this->convertObjectTypeToResource($objectType) . '/' . $objectId, $data);\n }\n\n public function getRecord(string $objectType, string $objectId, array $fields = ['id']): array\n {\n return $this->client->get(\n url: 'entity/' . $this->convertObjectTypeToResource($objectType) . '/' . $objectId,\n data: ['fields' => $this->getFieldsAsString($fields)],\n );\n }\n\n private function convertObjectTypeToResource(string $objectType)\n {\n switch ($objectType) {\n case FieldData::OBJECT_OPPORTUNITY:\n return self::ENTITY_OPPORTUNITY;\n\n case FieldData::OBJECT_CONTACT:\n return self::ENTITY_CONTACT;\n\n case FieldData::OBJECT_LEAD:\n return self::ENTITY_LEAD;\n\n case FieldData::OBJECT_ACCOUNT:\n return self::ENTITY_ACCOUNT;\n\n case FieldData::OBJECT_TASK:\n return self::ENTITY_ACTIVITY;\n\n default:\n throw new \\UnexpectedValueException('Unsupported object type \"' . $objectType . '\"');\n }\n }\n\n private function convertResourceToObjectType(string $resource): string\n {\n switch ($resource) {\n case self::ENTITY_OPPORTUNITY:\n return FieldData::OBJECT_OPPORTUNITY;\n\n case self::ENTITY_CONTACT:\n return FieldData::OBJECT_CONTACT;\n\n case self::ENTITY_LEAD:\n return FieldData::OBJECT_LEAD;\n\n case self::ENTITY_ACCOUNT:\n return FieldData::OBJECT_ACCOUNT;\n\n case self::ENTITY_ACTIVITY:\n return FieldData::OBJECT_TASK;\n\n default:\n throw new \\UnexpectedValueException('Unsupported resource \"' . $resource . '\"');\n }\n }\n\n public function fetchAndAssociateRelatedActivity(Activity $activity): ?Activity\n {\n // We need to know when it started to determine when to look for related activity.\n if ($activity->hasActualStartTime() === false) {\n return null;\n }\n\n try {\n $bhNote = $this->fetchRelatedActivity($activity);\n if (empty($bhNote)) {\n $this->logger->info('[Bullhorn] No related activity found', [\n 'activity' => $activity->getUuid(),\n ]);\n\n return null;\n }\n\n $activityType = $this->getPlaybookCategory($this->getPlaybook($activity->user), $bhNote['action']);\n $comments = $bhNote['comments'] . '<br />//<br />' . $this->generateActivityDescription($activity);\n // Now update the Note to contain our data.\n $payload = [\n 'comments' => $comments,\n 'externalID' => $activity->id_string,\n 'minutesSpent' => $activity->duration,\n ];\n\n $this->logger->info('[Bullhorn] updating related activity', [\n 'activity' => $activity->getUuid(),\n 'activityType' => $activityType->id ?? null,\n ]);\n\n $this->client->post('entity/Note/' . $bhNote['id'], $payload);\n\n $playbookCategoryId = null;\n if ($activity->getCategory() !== null) {\n $playbookCategoryId = $activity->getCategory()->getId();\n }\n\n // Update our activity to reflect the remote data.\n $activity->update([\n 'crm_provider_id' => $bhNote['id'],\n 'summary' => $bhNote['comments'],\n 'playbook_category_id' => $activityType->id ?? $playbookCategoryId,\n ]);\n\n return $activity;\n } catch (\\Exception $exception) {\n \\Sentry::captureException($exception);\n\n throw $exception;\n }\n }\n\n /**\n * Returns the most recently modified note within time range (if any).\n *\n * @return array A Note record from Bullhorn.\n */\n public function fetchRelatedActivity(Activity $activity): array\n {\n $from = $activity->hasScheduledStartTime()\n ? $activity->getScheduledStartTime()\n : $activity->getActualStartTime();\n $to = $activity->hasActualEndTime()\n ? $activity->getActualEndTime()?->addMinutes(15)\n : $from?->clone()->addMinutes(15);\n\n $filterStatements = [\n 'isDeleted: false',\n '*:* -externalID:[* TO *]', // externalID should be null e.g. we did not already relate it.\n sprintf('commentingPerson: %d', (int) $this->profile->crm_provider_id),\n ];\n\n /**\n * @XXX: Bullhorn does not specify the time zone in their docs. I've found a forum thread in which someone from\n * staff asserts it's UTC, however in my field test I've found it to be GMT-0300 (USA east coast).\n * To make the code immune to such glitches we will add 24h to start/end boundaries and will filter out\n * any excess items in code\n */\n $filterStatements[] = sprintf(\n 'dateLastModified:[%s TO %s]',\n $from?->clone()->subHours(24)->format('YmdHis'),\n $to->clone()->addHours(24)->format('YmdHis'),\n );\n\n if ($activity->lead_id) {\n $filterStatements[] = sprintf('candidateUserID: %d', (int) $activity->lead->crm_provider_id);\n }\n\n if ($activity->contact_id) {\n $filterStatements[] = sprintf('clientContactUserID: %d', (int) $activity->contact->crm_provider_id);\n }\n\n // if there is no contact or lead associated to the activity exit\n if ($activity->contact_id === null && $activity->lead_id === null) {\n return [];\n }\n\n $params = [\n 'fields' => $this->getFieldsAsString(self::TASK_FIELDS),\n 'query' => implode(' AND ', $filterStatements),\n 'sort' => '-dateLastModified',\n ];\n\n $bhNotes = $this->client->get('search/Note', $params);\n\n $startStamp = $from?->getTimestamp() * 1000;\n $endStamp = $to->getTimestamp() * 1000;\n\n $filteredNotes = array_values(\n array_filter(\n $bhNotes['data'],\n static fn (array $bhNote): bool\n => $bhNote['dateLastModified'] >= $startStamp && $startStamp <= $endStamp,\n ),\n );\n\n return ! empty($filteredNotes) ? array_shift($filteredNotes) : [];\n }\n\n /**\n * Select how to create the date out of the Bullhorn payload.\n * Do not allow dates beyond 9999-12-31 (max mysql DATE)\n */\n private function filterOutOfBoundDates(string $rawDateCandidate): ?Carbon\n {\n if (strlen($rawDateCandidate) > 12) {\n // A unix timestamp represented with more than 12 digits i\n // s definitely including milliseconds\n $dateFormatted = Carbon::createFromTimestampMs($rawDateCandidate);\n } else {\n $dateFormatted = Carbon::createFromTimestamp($rawDateCandidate);\n }\n\n $minDate = Carbon::createFromFormat('Y-m-d', '2000-01-01');\n if ($dateFormatted->lt($minDate)) {\n return null;\n }\n\n $maxDate = Carbon::createFromFormat('Y-m-d', '9999-12-31');\n if ($dateFormatted->gt($maxDate)) {\n $this->logger->warning('[Bullhorn] An out of bound date is provided', [\n 'date' => $dateFormatted,\n 'rawDate' => $rawDateCandidate,\n ]);\n\n return $maxDate;\n }\n\n return $dateFormatted;\n }\n\n public function verifyTaskExists(Activity $activity): bool\n {\n $crmProviderId = $activity->getCrmProviderId();\n $cacheKey = \"crm_task_exists:{$this->config->getId()}:$crmProviderId\";\n\n return Cache::remember($cacheKey, self::TASK_VERIFICATION_CACHE_TTL, function () use ($crmProviderId) {\n try {\n $note = $this->getRecord('Note', $crmProviderId, ['id', 'isDeleted']);\n\n return ! empty($note) && ($note['isDeleted'] ?? false) === false;\n } catch (HttpNotFoundException|HttpBadRequestException) {\n // Note not found in CRM - this is expected and permanent\n $this->logger->info('[Bullhorn] Note not found during verification', [\n 'note_id' => $crmProviderId,\n 'config_id' => $this->config->getId(),\n ]);\n\n return false;\n }\n // Let other exceptions (network errors, rate limits, etc.) bubble up for retry\n });\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":true,"is_selected":false,"is_expanded":false}]...
|
-3734033779188788614
|
-3409575825370887490
|
click
|
accessibility
|
NULL
|
Project: faVsco.js, menu
#12066 on JY-20725-handle Project: faVsco.js, menu
#12066 on JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
HandleHubspotRateLimitTest
Run 'HandleHubspotRateLimitTest'
Debug 'HandleHubspotRateLimitTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
8
73
3
11
Previous Highlighted Error
Next Highlighted Error
<?php
namespace Jiminny\Services\Crm\Bullhorn;
use Carbon\Carbon;
use Exception;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
use Jiminny\Contracts\Services\Crm\MatchCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\FetchRelatedActivityInterface;
use Jiminny\Contracts\Services\Crm\LayoutManagementInterface;
use Jiminny\Contracts\Services\Crm\Provider\BullhornInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityLookupInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityManipulationInterface;
use Jiminny\Contracts\Services\Crm\SettingsInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmMetadataInterface;
use Jiminny\Contracts\Services\Crm\VerifyTaskExistsInterface;
use Jiminny\Exceptions\ApplicationException;
use Jiminny\Exceptions\CrmException;
use Jiminny\Exceptions\DomainException;
use Jiminny\Exceptions\HttpBadRequestException;
use Jiminny\Exceptions\HttpNotFoundException;
use Jiminny\Exceptions\ServiceUnavailableException;
use Jiminny\Models\Account;
use Jiminny\Models\Activity;
use Jiminny\Models\Contact;
use Jiminny\Models\Crm\Configuration;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\FieldData;
use Jiminny\Models\Crm\Profile;
use Jiminny\Models\Lead;
use Jiminny\Models\Opportunity;
use Jiminny\Models\Playbook;
use Jiminny\Models\SocialAccount;
use Jiminny\Models\Stage;
use Jiminny\Models\User;
use Jiminny\Repositories\Crm\ProfileRepository;
use Jiminny\Repositories\UserRepository;
use Jiminny\Services\Avatar\ProspectPhotoPathService;
use Jiminny\Services\Crm\BaseService;
use Jiminny\Services\Crm\OpportunitySyncStrategyResolver;
use Jiminny\Utils\CurrencyFormatter;
use Jiminny\Utils\StringUtil;
use Ramsey\Uuid\Uuid;
class BullhornService extends BaseService implements
BullhornInterface,
SyncCrmEntitiesInterface,
SyncCrmMetadataInterface,
RemoteEntityManipulationInterface,
FetchRelatedActivityInterface,
LayoutManagementInterface,
SettingsInterface,
MatchCrmEntitiesInterface,
RemoteEntityLookupInterface,
VerifyTaskExistsInterface
{
private const int TASK_VERIFICATION_CACHE_TTL = 86400; // 1 day
private const string ENTITY_LEAD = 'Candidate';
private const string ENTITY_OPPORTUNITY = 'JobOrder';
private const string ENTITY_ACCOUNT = 'ClientCorporation';
private const string ENTITY_CONTACT = 'ClientContact';
private const string ENTITY_ACTIVITY = 'Note';
private const array OPPORTUNITY_FIELDS = [
'id',
'owner',
'status',
'title',
'salary',
'salaryUnit',
'payRate',
'isOpen',
'employmentType',
'dateClosed',
'startDate',
'dateLastModified',
'dateAdded',
'clientCorporation',
'correlatedCustomText10', // currency
];
private const array TASK_FIELDS = [
'id',
'comments',
'dateAdded',
'dateLastModified',
'externalID',
'isDeleted',
'corporateUsers',
'clientContacts',
'commentingPerson',
'candidates',
'jobOrder',
'minutesSpent',
'action',
];
private const array LEAD_FIELDS = [
'id',
'name',
'owner',
'occupation',
'companyName',
'companyURL',
'mobile',
'phone',
'phone2',
'phone3',
'workPhone',
'status',
'address',
'dateAdded',
'dateLastModified',
];
private const array CONTACT_FIELDS = [
'id',
'name',
'occupation',
'mobile',
'phone',
'phone2',
'phone3',
'address',
'clientCorporation',
'dateAdded',
'owner',
];
private const array ACCOUNT_FIELDS = [
'id',
'name',
'phone',
'industryList',
'address',
'companyURL',
'dateAdded',
];
public function __construct(
BullhornClientInterface $client,
private readonly UserRepository $userRepository,
private readonly ProspectPhotoPathService $prospectPhotoPathService
) {
parent::__construct();
$this->client = $client;
}
protected function getOAuthAccount(User $user): ?SocialAccount
{
// In this case, the Account Owner is always the connection for any API operations.
$owner = $user->team->owner;
return $owner->getSocialAccount(SocialAccount::PROVIDER_BULLHORN);
}
public function getDisplayName(): string
{
return 'Bullhorn';
}
public function createTranscriptNotes(Activity $activity): void
{
// Not done until a customer requests it.
}
public function syncProfileFields(): void
{
$fieldTypes = [
'lead',
'account',
'contact',
'opportunity',
'task',
];
foreach ($fieldTypes as $fieldType) {
try {
$currentFields = $this->getFields($this->convertObjectTypeToResource($fieldType));
$newFields = array_column($currentFields, 'name');
$changedFields = array_merge(
array_diff($this->profile->getFieldsAsArray($fieldType), $newFields),
array_diff($newFields, $this->profile->getFieldsAsArray($fieldType))
);
if (! empty($changedFields)) {
$fields = implode(',', $newFields);
Log::debug(
sprintf(
'%s fields changed from %s to: %s',
ucfirst($fieldType),
$this->profile->{$fieldType . '_fields'},
$fields,
),
);
$this->profile->{$fieldType . '_fields'} = $fields;
}
} catch (\Exception $exception) {
\Log::error('No access to ' . $fieldType . ' object, skipping...');
// XXX: should we log this fact somewhere?
continue;
}
}
$this->profile->save();
}
protected function getFieldTypes(): array
{
return [
parent::OBJECT_TASK,
//parent::OBJECT_ACCOUNT,
//parent::OBJECT_CONTACT,
//parent::OBJECT_OPPORTUNITY,
//parent::OBJECT_LEAD,
];
}
protected function getFields(string $crmObject): array
{
$fields = [];
foreach ($this->getObjectFields($crmObject) as $row) {
$fields[] = ['label' => $row['label'] ?? $row['name'], 'name' => $row['name']];
}
return $fields;
}
private function getObjectFields(string $objectType): array
{
$entityType = $this->convertObjectTypeToResource($objectType);
return Cache::remember(
key: 'crm.profile#' . $this->profile->id . '.fieldType:' . $objectType,
ttl: 60 * 60,
callback: function () use ($entityType) {
$entity = $this->client->get('meta/' . $entityType, [
'fields' => '*',
]);
return $entity['fields'];
},
);
}
public function getDefaultFields(string $activityType): array
{
$fields = [];
$defaultFields = FieldDefinitions::defaultFields();
foreach ($defaultFields as $defaultField) {
$fields[] = $this->config->fields()->firstOrCreate($defaultField);
}
return $fields;
}
public function getDefaultActivityField(string $activityType): Field
{
/** @var Field $activityField */
$activityField = $this->config->fields()->where([
'crm_provider_id' => 'action',
'object_type' => $activityType,
])->first();
return $activityField;
}
public function getDealInsightsFields(): array
{
return FieldDefinitions::dealInsightsFields();
}
public function getSupportedPlaybookTypes(): array
{
return [Playbook::ACTIVITY_TYPE_TASK];
}
protected function getDefaultFollowupLayoutFields(string $activityType): array
{
// We are not yet supporting follow-ups for this system.
return [];
}
public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string
{
return $fieldValue;
}
public function syncField(Field $field): void
{
$resource = $this->convertObjectTypeToResource($field->object_type);
$entity = $this->client->get('meta/' . $resource, [
'fields' => $field->crm_provider_id,
'meta' => 'full',
]);
$bhField = $entity['fields'][0];
$isMandatory = (empty($bhField['optional']) == false && $bhField['optional'] === false);
$description = null;
if (empty($bhField['description']) === false) {
$description = mb_strimwidth($bhField['description'], 0, 191);
}
$label = 'Unknown';
if (empty($bhField['label']) === false) {
$label = mb_strimwidth($bhField['label'], 0, Field::LABEL_MAX_LENGTH);
}
$field->length = $bhField['maxLength'] ?? null;
$field->is_mandatory = $isMandatory;
$field->description = $description;
$field->label = $label;
$field->type = $this->convertFieldType(
$bhField['dataType'] ?? null,
$bhField['dataSpecialization'] ?? null,
$bhField['inputType'] ?? null
);
$field->save();
if (empty($bhField['options']) === false) {
$values = [];
// Import all values.
foreach ($bhField['options'] as $i => $option) {
$values[] = [
'value' => $option['value'],
'label' => mb_strimwidth($option['label'], 0, Field::LABEL_MAX_LENGTH),
'sequence' => $i,
];
}
// Take the existing set of field values.
$fieldsToPurge = $field->values()->get()->pluck('value')->toArray();
foreach ($values as $value) {
// For all current values, upsert the field.
$field->values()->updateOrCreate([
'value' => $value['value'],
], $value);
// Remove this value from the ones we are going to purge.
$key = array_search($value['value'], $fieldsToPurge, false);
if ($key !== false) {
unset($fieldsToPurge[$key]);
}
}
// Delete the old values that are no longer used.
$field->values()->whereIn('value', $fieldsToPurge)->delete();
}
}
public function importPicklistValues(Field $field): array
{
// Taken care of in syncField.
return [];
}
private function convertFieldType(?string $type, ?string $specialization, ?string $inputType): string
{
if ($type === null) {
return Field::TYPE_UNSUPPORTED;
}
$type = strtolower($type);
$specialization = strtolower($specialization);
$inputType = strtolower($inputType);
switch ($type) {
case 'boolean':
return Field::TYPE_BOOLEAN;
case 'integer':
switch ($specialization) {
case 'integer':
return Field::TYPE_NUMBER;
default:
return Field::TYPE_NUMBER;
}
// no break
case 'bigdecimal':
switch ($specialization) {
case 'money':
return Field::TYPE_CURRENCY;
default:
return Field::TYPE_NUMBER;
}
// no break
case 'double':
switch ($specialization) {
case 'float':
return Field::TYPE_NUMBER;
default:
return Field::TYPE_NUMBER;
}
// no break
case 'timestamp':
switch ($specialization) {
case 'date':
return Field::TYPE_DATE;
case 'time':
return Field::TYPE_TIME;
case 'datetime':
return Field::TYPE_DATETIME;
default:
return Field::TYPE_DATE;
}
// no break
case 'string':
switch ($inputType) {
case 'textarea':
return Field::TYPE_TEXTAREA;
case 'select':
return Field::TYPE_PICKLIST;
default:
return Field::TYPE_TEXT;
}
// no break
default:
// This is not actually a supported field.
return Field::TYPE_UNSUPPORTED;
}
}
private function importContact($crmData): Contact
{
$account = $mobilePhone = $phone = $email = null;
if (empty($crmData['clientCorporation']) === false) {
$account = $this->team
->accounts()
->where('crm_provider_id', $crmData['clientCorporation']['id'])
->first();
if ($account === null) {
$account = $this->syncAccount($crmData['clientCorporation']['id']);
}
}
$countryCode = $this->convertCountryNameToCode($crmData['address']['countryCode']);
foreach (['mobile', 'phone', 'phone2', 'phone3'] as $type) {
if (empty($crmData[$type])) {
continue;
}
if ($type === 'mobile') {
$mobilePhone = parsePhoneNumber($countryCode, $crmData[$type]);
} else {
$phone = parsePhoneNumber($countryCode, $crmData[$type]);
}
break;
}
foreach (['email', 'email1', 'email2'] as $type) {
if (empty($crmData[$type])) {
continue;
}
$email = mb_strimwidth($crmData[$type], 0, 80);
break;
}
$profile = $this->config->profiles()->where('crm_provider_id', (string) $crmData['owner']['id'])->first();
$data = [
'team_id' => $this->team->id,
'account_id' => $account->id ?? null,
'user_id' => $profile ? $profile->user_id : null,
'owner_id' => $crmData['owner']['id'],
'name' => $crmData['name'],
'email' => $email,
'phone' => empty($phone['phone']) ? null : mb_strimwidth($phone['phone'], 0, 25),
'ext' => $phone['ext'] ?? null,
'mobile_phone' => empty($mobilePhone['phone']) ? null : mb_strimwidth($mobilePhone['phone'], 0, 25),
'title' => $crmData['occupation'],
'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(
crmConfiguration: $this->config,
crmProviderId: $crmData['id'],
modelType: Contact::class,
fileName: $crmData['id'],
avatarText: $crmData['name'] ?? 'Unknown'
),
'remotely_created_at' => $this->filterOutOfBoundDates($crmData['dateAdded']),
];
return $this->config->contacts()->updateOrCreate(['crm_provider_id' => (string) $crmData['id']], $data);
}
private function importOpportunity($crmData): ?Opportunity
{
if ($crmData['clientCorporation']['id']) {
$account = $this->config->accounts()->where('crm_provider_id', (string) $crmData['clientCorporation']['id'])->first();
if ($account === null) {
$account = $this->syncAccount($crmData['clientCorporation']['id']);
}
} else {
// If there is nothing to associate this with, don't import it.
return null;
}
$profile = $this->config->profiles()->where('crm_provider_id', (string) $crmData['owner']['id'])->first();
$stage = $this->config->stages()
->where('crm_provider_id', $crmData['status'])
->where('type', Stage::TYPE_OPPORTUNITY)
->first();
if ($stage === null) {
$stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $crmData['status']);
}
$data = [
'team_id' => $this->team->id,
'account_id' => $account->id,
'user_id' => $profile ? $profile->user_id : null,
'owner_id' => $crmData['owner']['id'],
'name' => mb_strimwidth($crmData['title'], 0, 128),
'value' => $crmData['salary'] ?? $crmData['payRate'],
'currency_code' => CurrencyFormatter::formatCode($crmData['correlatedCustomText10'] ?? 'USD'),
'close_date' => $this->filterOutOfBoundDates($crmData['startDate']),
'is_closed' => $crmData['isOpen'] !== true,
'is_won' => strtolower($crmData['status']) === 'placed',
'stage_id' => $stage->id,
'record_type_id' => null,
'remotely_created_at' => $this->filterOutOfBoundDates($crmData['dateAdded']),
];
return $this->config->opportunities()->updateOrCreate(['crm_provider_id' => (string) $crmData['id']], $data);
}
private function importAccount($crmData): Account
{
$countryCode = $domain = null;
if (empty($crmData['address']['countryCode'])) {
$countryCode = $this->convertCountryNameToCode($crmData['address']['countryCode']);
}
$name = $crmData['name'] ?? 'Unknown';
if ($crmData['companyURL'] !== null) {
$domain = mb_strimwidth($crmData['companyURL'], 0, 191);
$domain = StringUtil::resolveDomain($domain);
}
$data = [
'team_id' => $this->team->id,
'user_id' => null,
'owner_id' => null,
'name' => mb_strimwidth($name, 0, 191),
'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(
crmConfiguration: $this->config,
crmProviderId: $crmData['id'],
modelType: Account::class,
fileName: $crmData['id'],
avatarText: $crmData['name'] ?? 'Unknown'
),
'country_code' => $countryCode,
'domain' => $domain,
'remotely_created_at' => $this->filterOutOfBoundDates($crmData['dateAdded']),
];
return $this->config->accounts()->updateOrCreate(['crm_provider_id' => (string) $crmData['id']], $data);
}
private function importLead($crmData): ?Lead
{
// Get the current stage.
$stage = $this->config->stages()->where('name', $crmData['status'])->where('type', Stage::TYPE_LEAD)->first();
if ($stage === null) {
$stage = $this->importStages([Stage::TYPE_LEAD], $crmData['status']);
}
// If we have no way of importing this, just return null :(
if ($stage === null) {
return null;
}
$countryCode = null;
if (empty($crmData['address']['countryCode'])) {
$countryCode = $this->convertCountryNameToCode($crmData['address']['countryCode']);
}
$mobilePhone = $phone = $email = null;
foreach (['mobile', 'phone', 'phone2', 'phone3', 'workPhone'] as $type) {
if (empty($crmData[$type])) {
continue;
}
if ($type === 'mobile') {
$mobilePhone = parsePhoneNumber($countryCode, $crmData[$type]);
} else {
$phone = parsePhoneNumber($countryCode, $crmData[$type]);
}
break;
}
foreach (['email', 'email1', 'email2'] as $type) {
if (empty($crmData[$type])) {
continue;
}
$email = mb_strimwidth($crmData[$type], 0, 80);
break;
}
$company = 'Unknown';
if (empty($crmData['companyName']) === false) {
$companyName = is_array($crmData['companyName']) ? $crmData['companyName'][0] : $crmData['companyName'];
$company = mb_strimwidth($companyName, 0, 191);
}
$domain = null;
if (empty($crmData['companyURL']) === false) {
$domain = mb_strimwidth($crmData['companyURL'], 0, 191);
$domain = StringUtil::resolveDomain($domain);
}
$profile = $this->config->profiles()->where('crm_provider_id', (string) $crmData['owner']['id'])->first();
$data = [
'team_id' => $this->team->id,
'user_id' => $profile ? $profile->user_id : null,
'owner_id' => $crmData['owner']['id'],
'company' => $company,
'domain' => $domain,
'name' => mb_strimwidth($crmData['name'], 0, 191),
'title' => mb_strimwidth($crmData['occupation'], 0, 128),
'email' => mb_strimwidth($email, 0, 80),
'phone' => empty($phone['phone']) ? null : mb_strimwidth($phone['phone'], 0, 25),
'ext' => $phone['ext'] ?? null,
'mobile_phone' => empty($mobilePhone['phone']) ? null : mb_strimwidth($mobilePhone['phone'], 0, 25),
'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(
crmConfiguration: $this->config,
crmProviderId: $crmData['id'],
modelType: Lead::class,
fileName: $crmData['id'],
avatarText: $crmData['name'] ?? 'Unknown'
),
'stage_id' => $stage->id,
'country_code' => $countryCode,
'remotely_created_at' => $this->filterOutOfBoundDates($crmData['dateAdded']),
];
return $this->config->leads()->updateOrCreate(['crm_provider_id' => (string) $crmData['id']], $data);
}
/**
* @throws ApplicationException
*/
public function syncContacts(Carbon $since, ?Carbon $to = null): int
{
$syncCount = 0;
$params = [
'sort' => '-dateLastModified',
'fields' => $this->getFieldsAsString(self::CONTACT_FIELDS),
'query' => 'isDeleted: false AND dateLastModified:[' . $since->format('YmdHis') . ' TO *]',
];
try {
$bhContacts = $this->client->get('search/ClientContact', $params);
foreach ($bhContacts['data'] as $bhContact) {
// Only sync if previously imported.
if ($this->hasContact($bhContact['id'])) {
$this->importContact($bhContact);
$syncCount++;
}
}
} catch (\Exception $exception) {
throw new ApplicationException('Failed to sync contacts', $exception->getCode(), $exception);
}
return $syncCount;
}
public function syncOpportunities(array $parameters, ?string $strategy = null): int
{
$strategyResolver = app(OpportunitySyncStrategyResolver::class, [
'client' => $this->client,
]);
$strategies = $strategyResolver->getStrategies($this->config, $strategy);
$syncCount = 0;
$total = 0;
$count = 0;
$offset = 0;
try {
$bhOpportunities = [];
foreach ($strategies as $syncStrategy) {
$opportunitiesData = $syncStrategy->fetchOpportunities($parameters);
$total = (int) ($opportunitiesData['total'] ?? 0);
$count = (int) ($opportunitiesData['count'] ?? 0);
$offset = (int) ($opportunitiesData['start'] ?? 0);
$bhOpportunities[] = $opportunitiesData['data'] ?? [];
}
$bhOpportunities = array_merge(...$bhOpportunities);
foreach ($bhOpportunities as $bhOpportunity) {
$this->importOpportunity($bhOpportunity);
$syncCount++;
}
} catch (\Exception $exception) {
$this->logger->info('Failed to sync opportunities', [
'params' => $parameters,
'exception' => $exception->getMessage(),
]);
}
// only log if there are one sync strategy
if (count($strategies) === 1 && $total > $count + $offset) {
$this->logger->info('[Bullhorn] Sync Opportunities - count warning', [
'team_id' => $this->team->getId(),
'total' => $total,
'count' => $count,
'offset' => $offset,
]);
}
return $syncCount;
}
/**
* @throws ApplicationException
*/
public function syncAccounts(Carbon $since, ?Carbon $to = null): int
{
$syncCount = 0;
$params = [
'sort' => '-dateLastModified',
'fields' => $this->getFieldsAsString(self::ACCOUNT_FIELDS),
'query' => 'isDeleted: false AND dateLastModified:[' . $since->format('YmdHis') . ' TO *]',
];
try {
$bhAccounts = $this->client->get('search/ClientCorporation', $params);
foreach ($bhAccounts['data'] as $bhAccount) {
// Only sync if previously imported.
if ($this->hasAccount($bhAccount['id'])) {
$this->importAccount($bhAccount);
$syncCount++;
}
}
} catch (\Exception $exception) {
throw new ApplicationException('Failed to sync accounts', $exception->getCode(), $exception);
}
return $syncCount;
}
/**
* @throws ApplicationException
*/
public function syncLeads(Carbon $since, ?Carbon $to = null, ?string $crmProfileId = null): int
{
$syncCount = 0;
$params = [
'sort' => '-dateLastModified',
'fields' => $this->getFieldsAsString(self::LEAD_FIELDS),
'query' => 'isDeleted: false AND dateLastModified:[' . $since->format('YmdHis') . ' TO *]',
];
try {
$bhLeads = $this->client->get('search/Candidate', $params);
if ($bhLeads === null) {
return $syncCount;
}
foreach ($bhLeads['data'] as $bhLead) {
// Only sync if previously imported.
if ($this->hasLead($bhLead['id'])) {
$this->importLead($bhLead);
$syncCount++;
}
}
} catch (\Exception $exception) {
throw new ApplicationException('Failed to sync leads', $exception->getCode(), $exception);
}
return $syncCount;
}
public function syncContact(string $crmId): ?Contact
{
try {
$data = $this->getRecord(FieldData::OBJECT_CONTACT, $crmId, self::CONTACT_FIELDS);
} catch (HttpNotFoundException $exception) {
return null;
}
return $this->importContact($data['data']);
}
public function syncOpportunity(string $crmId): ?Opportunity
{
$strategyResolver = app(OpportunitySyncStrategyResolver::class, [
'client' => $this->client,
]);
$strategy = $strategyResolver->resolve(
$this->config,
OpportunitySyncStrategyResolver::SINGLE_SYNC_OPPORTUNITY_STRATEGY
);
try {
$data = $strategy->fetchOpportunities(['crm_id' => $crmId]);
} catch (HttpNotFoundException $exception) {
$this->logger->info('[Bullhorn] Opportunity not found', [
'crm_id' => $crmId,
'reason' => $exception->getMessage(),
]);
}
return $this->importOpportunity($data['data'] ?? []);
}
public function syncAccount(string $crmId): ?Account
{
try {
$data = $this->getRecord(FieldData::OBJECT_ACCOUNT, $crmId, self::ACCOUNT_FIELDS);
} catch (HttpNotFoundException $exception) {
return null;
}
return $this->importAccount($data['data']);
}
public function syncLead(string $crmId): ?Lead
{
try {
$data = $this->getRecord(FieldData::OBJECT_LEAD, $crmId, self::LEAD_FIELDS);
} catch (HttpNotFoundException $exception) {
return null;
}
return $this->importLead($data['data']);
}
public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage
{
$missingStage = null;
if ($types === null) {
$types = [Stage::TYPE_LEAD, Stage::TYPE_OPPORTUNITY];
}
foreach ($types as $type) {
$resource = $this->convertObjectTypeToResource($type);
$entity = $this->client->get('meta/' . $resource, [
'fields' => 'status',
'meta' => 'full',
]);
$missingStage = null;
if (! is_array($entity['fields']) || count($entity['fields']) !== 1 || $entity['fields'][0]['name'] !== 'status') {
throw new \UnexpectedValueException('Invalid response from Bullhorn API');
}
// Upsert all stages for the team.
foreach ($entity['fields'][0]['options'] as $i => $option) {
$stage = $this->config->stages()->updateOrCreate([
'crm_provider_id' => $option['value'],
'type' => $type,
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($option['value'], 0, 50),
'label' => mb_strimwidth($option['label'], 0, 191),
'sequence' => $i,
'is_selectable' => 1,
]);
if ($missingStageName && $missingStageName === $option['value']) {
$missingStage = $stage;
}
}
if ($missingStageName && $missingStage === null) {
// If they requested a stage that still doesn't exist, it must be inactive so lazy create it.
$missingStage = $this->config->stages()->create([
'team_id' => $this->team->id,
'crm_provider_id' => Uuid::uuid4(),
'name' => mb_strimwidth($missingStageName, 0, 50),
'label' => mb_strimwidth($missingStageName, 0, 191),
'type' => $type,
'sequence' => 0,
'is_selectable' => 0,
]);
}
}
return $missingStage;
}
public function syncOrganization(): void
{
// There appears to be nothing we can sync/map to.
}
public function find(string $name, array $scopes): array
{
$count = $this->limit ?? 20;
$offset = $this->offset ?? 0;
$data = \Cache::remember($this->team->id . $name . $count . $offset, 300, function () use ($name, $count) {
$result = [];
try {
$objects = $this->client->get('find', [
'query' => $name,
'meta' => 'basic',
'countPerEntity' => $count,
]);
} catch (\GuzzleHttp\Exception\ServerException $exception) {
throw new ServiceUnavailableException($exception->getMessage());
}
foreach ($objects['data'] as $object) {
try {
$prospectType = $this->convertResourceToObjectType($object['entityType']);
} catch (\UnexpectedValueException $exception) {
// We don't support this, so skip it.
continue;
}
$record = [
'crmId' => $object['entityId'],
'crmUrl' => $this->generateProviderUrl($object['entityId'], $object['entityType']),
'prospectType' => $prospectType,
'phoneNumbers' => [],
];
// Unfortunately we need to go and query each entity itself to get the data we need.
switch ($object['entityType']) {
case self::ENTITY_ACCOUNT:
$data = $this->getRecord(FieldData::OBJECT_ACCOUNT, $object['entityId'], self::ACCOUNT_FIELDS);
$entity = $data['data'];
$record['name'] = $entity['name'];
$record['industry'] = $entity['industryList'];
if (empty($entity['phone']) === false) {
$record['phoneNumbers'][] = [
'number' => $entity['phone'],
'nationalFormat' => phone_national(null, $entity['phone']),
'type' => 'phone',
];
}
break;
case self::ENTITY_CONTACT:
$data = $this->getRecord(FieldData::OBJECT_CONTACT, $object['entityId'], self::CONTACT_FIELDS);
$entity = $data['data'];
$record['name'] = $entity['name'];
$record['organization'] = $entity['clientCorporation'] ? $entity['clientCorporation']['name'] : null;
$record['title'] = $entity['occupation'];
foreach (['mobile', 'phone', 'phone2', 'phone3'] as $phone) {
if (empty($entity[$phone]) === false) {
$record['phoneNumbers'][] = [
'number' => $entity[$phone],
'nationalFormat' => phone_national(null, $entity[$phone]),
'type' => $phone === 'mobile' ? 'mobile' : 'phone',
];
}
}
break;
case self::ENTITY_LEAD:
$bhData = $this->getRecord(FieldData::OBJECT_LEAD, $object['entityId'], self::LEAD_FIELDS);
$entity = $bhData['data'];
$record['name'] = $entity['name'];
$record['organization'] = $entity['companyName'];
$record['title'] = $entity['occupation'];
foreach (['mobile', 'phone', 'phone2', 'phone3', 'workPhone'] as $phone) {
if (empty($entity[$phone]) === false) {
$record['phoneNumbers'][] = [
'number' => $entity[$phone],
'nationalFormat' => phone_national(null, $entity[$phone]),
'type' => $phone === 'mobile' ? 'mobile' : 'phone',
];
}
}
break;
default:
// We are not interested in this entity type so skip it.
continue 2;
}
$result[] = $record;
}
return $result;
});
return $data;
}
public function getClient(): BullhornClientInterface
{
return $this->client;
}
private function getFieldsAsString(array $fields)
{
return implode(', ', $fields);
}
public function findOpportunities(?string $crmAccountId, ?string $crmContactId, ?int $userId = null): array
{
$data = [];
$ownerData = [];
$ownerId = null;
$contact = null;
$account = null;
if ($crmAccountId) {
$account = $this->config->accounts()->where('crm_provider_id', $crmAccountId)->first();
if ($account === null) {
$account = $this->syncAccount($crmAccountId);
}
}
if ($crmContactId) {
$contact = $this->config->contacts()->where('crm_provider_id', $crmContactId)->first();
if ($contact === null) {
$contact = $this->syncContact($crmContactId);
}
}
if ($contact || $account) {
if ($contact && $account === null) {
$account = $contact->account;
}
if ($account === null) {
return [];
}
$where = 'isDeleted: false AND clientCorporation.id:' . (int) $account->crm_provider_id;
$params = [
'sort' => '-dateLastModified',
'fields' => $this->getFieldsAsString(self::OPPORTUNITY_FIELDS),
];
$onlyOpen = true;
switch ($this->config->opportunity_assignment_rule) {
case Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_UPDATED:
$params['sort'] = '-dateLastModified';
break;
case Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_CREATED:
$params['sort'] = '-dateAdded';
break;
case Configuration::OPP_ASSIGNMENT_ALL_OPEN_OLDEST_CREATED:
$params['sort'] = '+dateAdded';
break;
case Configuration::OPP_ASSIGNMENT_ALL_RECENTLY_UPDATED:
$params['sort'] = '-dateLastModified';
$onlyOpen = false;
}
if ($onlyOpen) {
$where .= ' AND isOpen: true';
}
$params['query'] = $where;
$bhOpportunities = $this->client->get('search/JobOrder', $params);
if ($userId) {
$profileRepository = app(ProfileRepository::class);
$profile = $profileRepository->findProfileByUserId($this->config, $userId);
$ownerId = $profile instanceof Profile ? $profile->getCrmProviderId() : null;
}
foreach ($bhOpportunities['data'] as $bhOpportunity) {
$stage = $this->config
->stages()
->where('crm_provider_id', $bhOpportunity['status'])
->first();
if ($stage === null) {
// Import it.
$stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $bhOpportunity['status']);
}
$record = [
'crmId' => $bhOpportunity['id'],
'name' => $bhOpportunity['title'],
'value' => currency_format($bhOpportunity['salary'], $bhOpportunity['correlatedCustomText10'] ?? 'USD'),
'won' => strtolower($bhOpportunity['status']) === 'placed',
'closed' => $bhOpportunity['isOpen'] !== true,
'stage' => [
'id' => $stage->id_string,
'name' => $stage->name,
],
'recordType' => [],
];
if ($ownerId && isset($bhOpportunity['owner']['id']) && $bhOpportunity['owner']['id'] === (int) $ownerId) {
$ownerData[] = $record;
}
$data[] = $record;
}
}
if (! empty($ownerData)) {
return $ownerData;
}
return $data;
}
public function getTasks(string $objectType, string $objectId, ?string $opportunityId): array
{
$data = [];
// For now it has not been agreed on how to implement this e.g. appointments or tasks?
return [];
if ($objectType === 'account' && $opportunityId === null) {
// You cannot attach tasks to only accounts.
return [];
}
// Query should default to any open task.
$where = 'isDeleted: false AND isCompleted: false AND (' . $this->convertObjectTypeToResource($objectType) . 'UserId: ' . (int) $objectId;
// If we are also going to check on a specific job order, set that up.
if ($opportunityId) {
$where .= ' OR JobOrder: ' . (int) $opportunityId;
}
$where .= ')';
$params = [
'sort' => '-dateLastModified',
'fields' => $this->getFieldsAsString(self::TASK_FIELDS),
'query' => $where,
];
$bhTasks = $this->client->get('search/Task', $params);
foreach ($bhTasks['data'] as $bhTask) {
$data[] = [
'crmId' => $bhTask['id'],
'subject' => $bhTask['subject'],
'due' => Carbon::createFromTimestampMs($bhTask['dateEnd'])->toIso8601String(),
'type' => null,
];
}
return $data;
}
/**
* @url [URL_WITH_CREDENTIALS] [URL_WITH_CREDENTIALS] [URL_WITH_CREDENTIALS] CrmException
* @throws DomainException
*/
private function buildNotePayload(Activity $activity): Activity
{
if ($activity->hasActivityType() === false) {
throw new DomainException('[Bullhorn] No activity type assigned');
}
$action = $this->getCrmType($activity->getActivityType());
if ($action === null) {
throw new DomainException('[Bullhorn] No mapped CRM type for "' . $activity->category->name . '"');
}
if ($activity->lead_id === null && $activity->contact_id === null) {
throw new DomainException('[Bullhorn] Cannot save note without candidate or DM');
}
$payload = [
'comments' => $this->generateActivityDescription($activity),
'action' => $action->value,
'commentingPerson' => [
'id' => (int) $this->profile->crm_provider_id,
'type' => 'CorporateUser',
],
'externalID' => $activity->id_string,
'minutesSpent' => $activity->duration,
'dateAdded' => $activity->hasActualEndTime() ? $activity->getActualEndTime()?->valueOf() : now()->valueOf(),
];
if ($activity->lead_id) {
$payload['personReference'] = [
'id' => (int) $activity->lead->crm_provider_id,
'type' => 'Candidate',
];
}
if ($activity->contact_id) {
$payload['personReference'] = [
'id' => (int) $activity->contact->crm_provider_id,
'type' => 'ClientContact',
];
}
if ($activity->opportunity_id) {
$payload['jobOrders'] = [
'replaceAll' => [
(int) $activity->opportunity->crm_provider_id,
],
];
}
$bhActivity = $this->client->put('entity/Note', $payload);
$activity->crm_provider_id = $bhActivity['changedEntityId'];
$activity->save();
return $activity;
}
private function generateActivityDescription(Activity $activity): string
{
$description = '';
switch ($activity->type) {
case Activity::TYPE_SOFTPHONE:
case Activity::TYPE_SOFTPHONE_INBOUND:
case Activity::TYPE_CONFERENCE:
if ($activity->isTypeSoftPhone() && $activity->getProvider() !== Activity::PROVIDER_UPLOADER) {
// Dialer calls have no native field for the subject.
if ($activity->title) {
$description .= sprintf(
'<p><span style="font-size: 16px; line-height: 19.2px;">%s</span></p>',
$activity->title,
);
}
// Conference meetings log duration into a distinct field, so log this in the main text.
$description .= sprintf(
'<p><span style="font-size: 13.3333px; line-height: 16px;">Call Duration: %s</span></p>',
$activity->duration_for_humans,
);
// Tell them which number was dialed.
if ($activity->to) {
$description .= sprintf(
'<p><span style="font-size: 13.3333px; line-height: 16px;">Dialed: %s</span></p>',
$activity->to->phone_number,
);
}
$description .= '<p></p>';
}
if ($activity->isTypeSoftphoneInbound() && $activity->getProvider() !== Activity::PROVIDER_UPLOADER) {
...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
45389
|
1626
|
20
|
2026-05-14T14:34:00.396565+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-14/1778 /Users/lukas/.screenpipe/data/data/2026-05-14/1778769240396_m2.jpg...
|
PhpStorm
|
faVsco.js – BullhornService.php
|
1
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
#12066 on JY-20725-handle Project: faVsco.js, menu
#12066 on JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
HandleHubspotRateLimitTest
Run 'HandleHubspotRateLimitTest'
Debug 'HandleHubspotRateLimitTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings...
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"bounds":{"left":0.2962101,"top":1.0,"width":0.03856383,"height":-0.019952059},"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"#12066 on JY-20725-handle-HS-search-rate-limit, menu","depth":5,"bounds":{"left":0.33477393,"top":1.0,"width":0.122340426,"height":-0.019952059},"on_screen":true,"help_text":"Pull request #12066 exists for current branch JY-20725-handle-HS-search-rate-limit, but local branch is out of sync with remote","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"bounds":{"left":0.5731383,"top":1.0,"width":0.011303191,"height":-0.019952059},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"HandleHubspotRateLimitTest","depth":6,"bounds":{"left":0.5884308,"top":1.0,"width":0.076130316,"height":-0.019952059},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'HandleHubspotRateLimitTest'","depth":6,"bounds":{"left":0.66456115,"top":1.0,"width":0.011303191,"height":-0.019952059},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'HandleHubspotRateLimitTest'","depth":6,"bounds":{"left":0.67586434,"top":1.0,"width":0.011303191,"height":-0.019952059},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"bounds":{"left":0.6871675,"top":1.0,"width":0.011303191,"height":-0.019952059},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"bounds":{"left":0.7150931,"top":1.0,"width":0.011303191,"height":-0.019952059},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"bounds":{"left":0.72639626,"top":1.0,"width":0.011303191,"height":-0.019952059},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"IDE and Project Settings","depth":5,"bounds":{"left":0.73769945,"top":1.0,"width":0.011303191,"height":-0.019952059},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
2586234742233688171
|
-7051436856496256053
|
click
|
hybrid
|
NULL
|
Project: faVsco.js, menu
#12066 on JY-20725-handle Project: faVsco.js, menu
#12066 on JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
HandleHubspotRateLimitTest
Run 'HandleHubspotRateLimitTest'
Debug 'HandleHubspotRateLimitTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
ActivityFilesLaterJiminny... ~@ jiminny-x-integrati& platform-inner-team© Channels# ai-chapter# alertsi backend# bugscontusion-cllnia# curiosity_lab# engineering# general# jiminny-bg# platform-tickets# product_launches# random# releases# sofia-office# support# thank-yous# the people of iimi..• Direct messages8. A...• A3 62P. Galya Dimitrova Mvasil VasilevA. Stefka Stoyanova%: Todor Stamatovf. Mario GeorgievP. Nikolay Ivanov2o James Graham "2. Stoyan Tanev. Steliyan Georgiev. Petko Kashinski*. Lukas Kovali...a: Apps® ToastS lira Gloud6 Huddle with Aneliya Angelova& R. Aneliya Angelova •• Messagest Add canvasur FilesAneliya Angelova 2:30 PMЛукаш, само при Сейлсфорс и Хубспот се синкваха активити типовете, когато се направи плейбук,нали?пои останалите скМі тоябва оъчно ла се въвелат.Lukas Kovalik 2:47 PMтряова да се за всички, някьде не се ли попьлвапри зохо май беше hardcoded но май и там си връщаха две категорииAneliya Angelova @ 4:00 PMЛукаш имаш ли време да се чуем за тестването на https://jiminny.atlassian.net/browse/JY-20725X Preview insackt[HubSpot] Optimise CRM rematctC OpenReady for QA- MediumAA Aneliya AngelovaAs of today at 4:00 PM RetreshOpen in Jira+ SummariseLukas Kovalik 4:02 PMzean-iurwiniareelYou joined the huddle LIVE 4:06 PMAneliva Angelova is here toolAneliya Angelova 4:44 PM11512582Lukas Kovalik O 5:16 PMcurl --location "httns:ani.hubani.com/crm/v3/obiects/contacts/search--header 'Content-Type: application/json'\--header "Authorization: BearerC|Kvma?iMx7OINOM8kOFwrAowAcAkUAhIR24?A05D?xiVrIYMILuP6SA01Kwcmhr=n.v2 hytorDniMvDalYck-CMalovolNOM9kOFwrAUAcrkcaws-ThwRARIRAOF-ATFSAOFRAO-ROIEBAQUBEggBAQEBAYICFIґ7Au8O2Nwal9YW2CbCCxYvwngmSgNldTFSAFoAYABoAHAAeAA'\--data "{ "limit", 13'Message Aneliva Angelova+ AaIđ 0Pm~ (Q SearchDate ModifiedYesterdav at 13.3Yesterday at 13:37Yesterday at 13.30Yesterday at 13:35resterday at 15.34Yesterdav at 13:34Yesterday at 13:33Yesterday at 13.32Yesterday at 13:32resterday at 15.3Yesterdav at 13:30Yesterday at 13:30Yesterday at 13:29Yesterday at 13:29Yesterday at 13:28resterday at 13-21Yesterdav at 13:27Yesterday at 13:26Yesterday at 13:26Yesterday at 13:25Yesterday at 13.24Yesterdav at 13:22Yesterday at 13:22resterady al 15.clYesterday at 13:20Yesterday at 13.19Yecterdav at 12:10Yesterday at 13:18Yesterday at 13.10Yesterdav at 13:17Yesterday at 13:17Yesterday at 13:16Yacterdav at 12:15Yesterday at 13.14Yecterdav at 12:14Yesterday at 13:13Yesterday at 13:12Yesterday at 13:12Yesterday at 13.11Yecterdav at 12:10Yesterday at 13:10Yesterdav at 13:0962 K:MPEG-4 movie71 KBMPEG-4 movie23 KBMPEG-4 movieMPEG-4 movie13 KE16 KB17 KBMPE0"4 movie29 K:6 KBMPEG-4 movie12 KBMP2G-4 movieMPEG-4 movie7 KBI8 KB37 KBMPEG"4 movie10 KВIMPEG-4 movie7 KBMPEG-4 movie8 KB9 KB1MPEG-4 movie8 KBMPEG-4 movie72 KB14 KBMPEG-4 movie13 KEMPEG-4 movie9 KB MPEG-4 movie18 KB12 KB|MPEG-4 movie10 KBMPEG-4 movie16 KB6KBMPEG-4 movie6KEMPEG-4 movie12 KBMPEG-4 movie23 KBMPEG-4 movie8 K:MPEG-4 movie6 KBMPEG-4 movie11 KBMPEG-4 movie11 KPMPEG-4 movie20 KB34 KB10 K:MPEG-4 movie7KBMPEG-4 movie5 KB11 KBMPEG-4 movieD6KRMDEG-A movia111 KBMPEG-4 movie102 KBMPEG-4 movie88 KBMPEG-A movie59 KB MPEG-4 movie98 KB97 KBGAKPMPEG-4 movieMPEG-4 movie44 KB93 KBMPEG-4 movie78 KPMDEG-A movid50 KBMPEG-4 movie58 KB17 KBMPEG-4 movieAl Notes: OffLeave• 0FavouritesE jiminny© Recents* ApplicationsiCloudiCloud Drive228 Sync folderLocationsQ DXP4800PLUS-B5FA@ Network|• CRMI• Orange• Red• Yellow• Green• Purple•) All lags..lohlDownloadsNameLoom.pkgAlfred copv.alfredoreterencesB KeychronAssist-1.0.2 (1).dmgA Keychron Assist-1.0.2.dmgmazanoke-images-yWJo.zio• Photos-3-001.zipD Transcript.pdf→mage U.loge1 Orioninstaller.dmaimage.jpg- image (2).1pc• ПО-22221726037035-004-001_ORGES.pdf• %D0%9F%D0%9E-22221726037035-004-001_archive.zipПO-22221726037035-004-001_archive (1).zip• repon 4).XmAltred copy2.altredoreterences05012026_0000000026574472_ SWIFT_OB70501260015890.pdf27022026_0000000026574472_SWIFT_OB72702260049200.pdf= 03042026 [CREDIT_CARD] SWIFT [CREDIT_CARD].001B reporti) xm=pdt.odipdf-1.pdfD pdf-2.pdf-pdf-5.pd1= ndf-1 ndipdf-3.pdfKoválik Family Tree.gedbitwarden export 20251031122528.isonKoválik Family Tree.zip*macOS Storage_Cleanup.mdal favicon icofirst_aid_notes_complete.docxrenortl2).esvconfig.ymlIteration run Search HS.postman_collection.jsonm licence bettertouchtoalMariusHosting Config.json1ooks-891a6503-bbb7-4b2b-9c3.csv•Alfredmazanoke-images-YWJ6ана Ковалик.jpg•искане даниел Ковалик..pa• Фактура Март Даниел Ковалик.jрс• Фактура Април Даниел Ковалик.jpg• Dhotac 2.001Q SearchKind00,4 MDinstdlle..dckage55.9 MBAlfred...ferences10,1 MBDisk Image10,1 MBDisk ImageIL MBLiP archive6,6 MBZIP archive2,5 MB PDF Document2,5 MBJreo lmage2.2 MBDisk Image2,2 MB PDF Document2 MBJPEG image1,9 MBJPEG imaqe192 KBPDF Document140 KbZIP archive148 KВ148 KB122 KB111 KB94 KBZIP archivelZIP archiveXML documentAlfred...ferencesPDF Document92 KBPDr DocumentK:91 KB91 KB30 KB29 KBPDF DocumentXML documentPDF Document28 KBPDF Document28 KR28 KB27 KB14 KB11 KB6 KB6KBPDE DocumentDocumentJSONICSV DocumentZIP archiveMarkdo…..ument5KRWindo...n image4 KB3 KBword ..cumentCSV Document2 KBYAML document1 KBcSV Document928 byteshttlicence183 bytesZero butesJSONAlfred.. ferencesZero bytesFolde1,9 MB1,8 MB17 MB1,7 MBColdorJPEG imageJPEG ImageIPEG imadeJPEG imageColdo1 of 58 selected, 8.48 GB availabld• Inu 14 Mау 1/•34-00Date AddedIs Mdl ZUzo dl 19:4530 Jan 2026 at 12:3617 Mar 2026 at 20:2717 Mar 2026 at 20:26Z3 Aor 2020 al 13:0229 Jan 2026 at 15:2019 Dec 2025 at 10:1619 Dec 2025 at 12:238 Aor 2026 at 20:3519 Dec 2025 at 10:2919 Dec 2025 at 12:1819 Dec 2025 at 12:4026 Mar 2026 at 11:2410 May 2026 at 13:5326 Mar 2026 at 11:2426 Mar 2026 at 11:2426 Mar 2026 at 11:2310 May 2026 at 14:3730 Jan 2026 at 12:3713 Feb 2026 at 11:54Z3 Apr 2026 at 13.0823 Aor 2026 at 13:0810 May 2026 at 13:5410 May 2026 at 14:3710 May 2026 at 13:4910 May 2026 at 13:5010 May 2026 at 13:5110 Mav 2026 at 12:51110 May 2026 at 13:5019 Dec 2025 at 11:3431 Oct 2025 at 12:2525 Nov 2025 at 17:596 Mar 2026 at 11.2224 Anr 2026 at 16:5220 Oct 2025 at 11:0218 Mar 2026 at 15:29• Mav 2026 at 11:09129 Oct 2025 at 19:329 May 2026 at 10:0418 Mar 2026 at 11:5510 May 2026 at 13:5712 Jun 2025 at 19:0430 Jan 2026 at 12:3630 Jan 2026 at 12:3616 Oct 2025 aт 16:0123 Apr 2026 at 13:0223 Apr 2026 at 13:0223 Apr 2026 at 13:0222 Anr 2026 at 12:0223 Apr 2026 at 13:0229 Jan 2026 at 15:20...
|
45387
|
NULL
|
NULL
|
NULL
|
|
45390
|
1625
|
36
|
2026-05-14T14:34:02.329303+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-14/1778 /Users/lukas/.screenpipe/data/data/2026-05-14/1778769242329_m1.jpg...
|
PhpStorm
|
faVsco.js – BullhornService.php
|
1
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
PhpStormFileEditViewNavigateCodeLaravelRefactorRun PhpStormFileEditViewNavigateCodeLaravelRefactorRunToolsGitWindowHelplhl100% C•FV faVsco.jsv* #12066 on JY-20725-handle-HS-search-rate-limit k vHandleHubspotRateLimitTest8• Thu 14 May 17:34:02* :QProject v© UserAutomatedReportsController.php:=custom.logAutomatedReportGenerated.phpTrackAutomatedReportGeneratedEvent.php© BullhornLoginHandler.phpPlanhatService.phpAutomatedReportResult.phpSendReportJob.phpDeleteCrmEntityTrait.phpDeleteAccountJob.php=laravel.log© BullhornLoginHandlerInterfa© ImportActivityTypes.phpT WriteCrmTrait.php© DecorateActivity.php© Salesforce/Service.phpT LogActivityTrait.phpSF [jiminny@localhost]© BullhornService.php© BullhornSessionDecorator.plPipedrive/Service.phpClose/Service.phpCopper/Service.phpBullhornService.php X© PlainTextDecorateActivity.phpA HS_local [jiminny@localho:W© BullhornSessionManager.ph|© ActivityPlaybookTrait.phpCrmHelperRepository.php© AccountController.phpT IntegrationAppTrait.php= .env.staging4 console [QAI PROD] X18• BullhornSessionManagerinte© FieldDefinitions.phpE.envDetachActivityObject.phpRematchActivityOnCrmObjectDetach.php© MatchActivityCrmData.php© Client.php4 console [PROD]v @ Close.> OpportunitySyncStrategy© HubspotPaginationService.php© HandleHubspotRateLimit.phpclassBullhornService extends BaseService implementsA console (EU]> O Processor471550A8 A73 ×3 X11 ^> D ProspectSearchStrategy1 usage> O Translator1337private function buildNotePayload(Activity $activity): Activity© Client.php1338© CloseException.php1339© FieldDefinitions.php1340if (Sactivity->hasActivityType() === false) {throw new DomainException( message:'[Bullhorn] No activity type assigned');© FieldValueConverter.phpid = 33;1341© Service.php1342© StandardFieldMetadata.php1343v Copper1344$action = $this->getCrmType($activity->getActivityType());if ($action === null) {> C Apі|1345> D Exception1346> D OpportunitySyncStrategy1347> D ProspectSearchStrategy1348> @ Traits1349if ($activity->lead_id === null && $activity->contact_id === null) {throw new DomainException( message: '[Bullhorn] Cannot save note without candidate or DM'):© FieldDefinitions.php1350}-© FieldValueConverter.php1351© Service.php1352> @ CrmObjects1353D DecorateActivity1354© BaseDecorateActivity.php13551356DGo jiminny084143 ×4 л215|•m_layout_id =2162,1661,66799,66217218219220221=222223throw new DomainException(message:' (Bullhorn] No mapped CRM type for "'* . Sactivity->category->mg224225=226=227228229—230_231232-233 V234id THEN •(ownhubspot':-© PlainTextDecorateActivity.plC Dummy1357$payload = ['comments' => $this-›generateActivityDescription(Sactivity),'action' = $action->value,'commentingPerson' => ['id'=> (int) $this->profile->crm_provider_id,'type' => 'CorporateUser',= 11512582;v D Helpers1358T ActivityPlaybookTrait.php1359© Arraylterator.php1360# CannantionCtstaTrait nhn1,'externalID' = $activity->id_string,'minutesSpent' = $activity->duration,AHnTEARRATWorkspace associated with branch 'JY-20725-handle-HS-search-rate-limit' has been restored // Rollback // Configure... (today 16:17)AATRATTATCHATMAT T WIATIARCNnAwE)W Windsurf Teams1343:30UTF-8Co 4 spaces...
|
NULL
|
-690484408177067423
|
NULL
|
click
|
ocr
|
NULL
|
PhpStormFileEditViewNavigateCodeLaravelRefactorRun PhpStormFileEditViewNavigateCodeLaravelRefactorRunToolsGitWindowHelplhl100% C•FV faVsco.jsv* #12066 on JY-20725-handle-HS-search-rate-limit k vHandleHubspotRateLimitTest8• Thu 14 May 17:34:02* :QProject v© UserAutomatedReportsController.php:=custom.logAutomatedReportGenerated.phpTrackAutomatedReportGeneratedEvent.php© BullhornLoginHandler.phpPlanhatService.phpAutomatedReportResult.phpSendReportJob.phpDeleteCrmEntityTrait.phpDeleteAccountJob.php=laravel.log© BullhornLoginHandlerInterfa© ImportActivityTypes.phpT WriteCrmTrait.php© DecorateActivity.php© Salesforce/Service.phpT LogActivityTrait.phpSF [jiminny@localhost]© BullhornService.php© BullhornSessionDecorator.plPipedrive/Service.phpClose/Service.phpCopper/Service.phpBullhornService.php X© PlainTextDecorateActivity.phpA HS_local [jiminny@localho:W© BullhornSessionManager.ph|© ActivityPlaybookTrait.phpCrmHelperRepository.php© AccountController.phpT IntegrationAppTrait.php= .env.staging4 console [QAI PROD] X18• BullhornSessionManagerinte© FieldDefinitions.phpE.envDetachActivityObject.phpRematchActivityOnCrmObjectDetach.php© MatchActivityCrmData.php© Client.php4 console [PROD]v @ Close.> OpportunitySyncStrategy© HubspotPaginationService.php© HandleHubspotRateLimit.phpclassBullhornService extends BaseService implementsA console (EU]> O Processor471550A8 A73 ×3 X11 ^> D ProspectSearchStrategy1 usage> O Translator1337private function buildNotePayload(Activity $activity): Activity© Client.php1338© CloseException.php1339© FieldDefinitions.php1340if (Sactivity->hasActivityType() === false) {throw new DomainException( message:'[Bullhorn] No activity type assigned');© FieldValueConverter.phpid = 33;1341© Service.php1342© StandardFieldMetadata.php1343v Copper1344$action = $this->getCrmType($activity->getActivityType());if ($action === null) {> C Apі|1345> D Exception1346> D OpportunitySyncStrategy1347> D ProspectSearchStrategy1348> @ Traits1349if ($activity->lead_id === null && $activity->contact_id === null) {throw new DomainException( message: '[Bullhorn] Cannot save note without candidate or DM'):© FieldDefinitions.php1350}-© FieldValueConverter.php1351© Service.php1352> @ CrmObjects1353D DecorateActivity1354© BaseDecorateActivity.php13551356DGo jiminny084143 ×4 л215|•m_layout_id =2162,1661,66799,66217218219220221=222223throw new DomainException(message:' (Bullhorn] No mapped CRM type for "'* . Sactivity->category->mg224225=226=227228229—230_231232-233 V234id THEN •(ownhubspot':-© PlainTextDecorateActivity.plC Dummy1357$payload = ['comments' => $this-›generateActivityDescription(Sactivity),'action' = $action->value,'commentingPerson' => ['id'=> (int) $this->profile->crm_provider_id,'type' => 'CorporateUser',= 11512582;v D Helpers1358T ActivityPlaybookTrait.php1359© Arraylterator.php1360# CannantionCtstaTrait nhn1,'externalID' = $activity->id_string,'minutesSpent' = $activity->duration,AHnTEARRATWorkspace associated with branch 'JY-20725-handle-HS-search-rate-limit' has been restored // Rollback // Configure... (today 16:17)AATRATTATCHATMAT T WIATIARCNnAwE)W Windsurf Teams1343:30UTF-8Co 4 spaces...
|
45388
|
NULL
|
NULL
|
NULL
|