|
55239
|
1915
|
5
|
2026-05-18T13:58:35.312657+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-18/1779 /Users/lukas/.screenpipe/data/data/2026-05-18/1779112715312_m2.jpg...
|
PhpStorm
|
faVsco.js – Crm/…/Service.php
|
1
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
master, menu
Start Listen Project: faVsco.js, menu
master, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings...
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"bounds":{"left":0.025930852,"top":0.019952115,"width":0.03856383,"height":0.025538707},"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"master, menu","depth":5,"bounds":{"left":0.064494684,"top":0.019952115,"width":0.040226065,"height":0.025538707},"on_screen":true,"help_text":"Git Branch: master<br/>114 incoming commits<br/>","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"bounds":{"left":0.8081782,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"AskJiminnyReportActivityServiceTest","depth":6,"bounds":{"left":0.8234708,"top":0.019952115,"width":0.09208777,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'AskJiminnyReportActivityServiceTest'","depth":6,"bounds":{"left":0.9155585,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'AskJiminnyReportActivityServiceTest'","depth":6,"bounds":{"left":0.9268617,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"bounds":{"left":0.9381649,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"bounds":{"left":0.96609044,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"bounds":{"left":0.9773936,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"IDE and Project Settings","depth":5,"bounds":{"left":0.9886968,"top":0.019952115,"width":0.011303186,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
-4235983745889776938
|
-8204421443435123770
|
click
|
hybrid
|
NULL
|
Project: faVsco.js, menu
master, menu
Start Listen Project: faVsco.js, menu
master, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
PhostormViewINavicarecodeLaravelKeractorFV faVsco.js°9 master k >ProiectC) StandardFieldMetadata,oho• _ CoopenM CrmObiects>→ DecorateActivity→ Dummy>→ Helpersv MHubsnotAccountSyncStrategyM ActionsContactSyncStrategy> DDTO• D Fields•M lournalMetadata> C OpportunitySyncStrategy_racination_ Prospectsearchstrateay• kedisv D service IraitsOpportunitysynclrait.phpusyncermentitieslrait.oho#suncrields.rait.ondT. Writecrmtrait.oho•D Utils• _ WebhookC) BatchSvncCollector.ooC) BatchSvncRedisService.oho© Client.phpC) ClosedDeaStadesService.onoC DealFieldsService.ohoC) DecorateActivitv.oho©) FieldDefinitions.ohv© FieldTypeConverter.php© HubspotClientinterface.php© HubspotTokenManager.php© PayloadBuilder.php© RemoteCrmObjectManipulator.php0a PesnonseNormalize.php(©) Service.php© SyncFieldAction.php© SyncRelatedActivityManager.php© WebhookSyncBatchProcessor.phpv C IntegrationApp> D AccessorsAoil> D Confid• D Filters• ProsoectSearchStratedv• Service iraitsWindowmelpCActivityController.ongC BaseService.php© SoftPhoneManager.php(C) CoreUserRequest.onpconstants.ongscimProvistoning.ong© CoreUser.phpc) RoleAttrTest.png© ACtivity/.../Service.php©Crm/…../Service.php Xclass Service extends BaseService 1mpLementsm A8 A39 M5 лV244255 O>283285 0>294 ot>300E391303 (0г )325326 @331336339 6>390 o>* Qinheritdocpublic function importPicklistValues(Field $field): array{...}* @important We only support stages on the opportunity object* Oparam stringlJlnull $typespublic function importStages(?array Stypes = null, ?string SmissingStageName = null): ?Stagel...}* dinherztdocpublic function syncLeads(Carbon Ssince, ?Carbon $to = null, ?string ScrmProfileId = null): int{..* Ginheritdocnublic function suncleadstrina Scrmid): ?Lead.....* athrows Excentionnublic function svncAccounts(Carbon Ssince. 2Carbon $to = null): intf...}public function syncAccount(string $crmId): ?Account(...}1 usageprivate function importAccount(ScrmData): Account(...}* Othrows CloseExceptionpublic function syncOpportunities(array $parameters, ?string $strategy = null): int{...}public function syncOpportunity(string $crmId): ?0pportunity{...}2 usagesprivate function importOpportunity(OpportunitvMetadata ScrmData): 20pportunity{...}• aparam arrau<strina.string> Scrmbato* dparam stringl ScrmFields= custom.log= laravel.log« SF jiminny@localhost]4 HS_local [jiminny@localhost]& console [PROD]# console [euyA console [STAGING]© CoachingFeedbackCoachUserln.php X<?phpdeclare(scrict_cypes=l)nnamespace Jiminny Component Activ1tySearch rilterbetin1t1on*› use ...final class CoachingFeedbackCoachUserIn extends FilterDefinition implements ValidatedFilterDefinitionInterface29 >34 0 >45 đ >=151—1351146 G- 150151 Gt :1usadeorivate const int No GROUP 10 = 9993 usagesorivate UserRenository SuserRenository:public function __construct(UserRepository $userRepository)f...hpublic function shouldApplyQueries: boolf...}public function getQueries: FilterDefinitionQueryCollection{...hpublic function toArray: array{...hprivate function getOptions(): array{...}public function getValue®: array{...}private function getDefaultValue@: array{...}public function aetValidationRules(2strina Sorefix = null): arravs...?public function aetSortOrder@: intf...}public function shouldBeIncluded(Team $team): bool{...}[Platform] Refinemen... 2m left100% 1• Mon 18 May 16:58:35U AskJiminnyReportActivityServiceTest vCascadeImplement Trial OwneFixing Redis Rate LimSalestorce Token FalliSCIM Role Manageme+0 ..inp on LineWARN Metadata found iin doc-comment for methodw9/ 10 tacke done• Write unit tests for SCIM role management10) Contact Mario to vodate customer10 / 10 tasks doneselm role managementtor leamtallor has been successtully implemented. Here's what was completed:Implementation Summary:1. CoreUserRequest DTO - Added role parsina from SCIM requests2. SCIM Constants - Added META ROLES and USER_PATCH_ATTR_ ROLES constants3. RoleAttr Mutator - Created mutator for handlina role assianments via SCIM PATCH operations4. ScimProvisioning - Updated to handle roles during user creation with proper validation5. ListenerRoleCannotHaveAdminOrManagerPermissionRule - Created validation rule to prevent listener role fromhaving admin/manager permissions6. CoreUser Response - Updated to include roles in SClM responses7. Unit Tests - Created comprehensive tests for the validation ruleKey Features:• Customers can now assign roles via SCIM (recorder, recorder & voice, analyst, listener, admin, manager)• Validation ensures listener role is onlv assianable if the LISTENER ROLE feature flaa is enabledl•LIstener role cannot de comolned with aamin or manager permissions• Uses existing UpdateUserRolesAction for role syncing and logging• Follows platform validation rules (DependentRolesRule, ListenerRoleRequiresListenerFeatureRule)Next Step: Please contact Mario to update the customer that SCIM role management is now available for Teamtailorintearationposwtn chaten vapp/Component/SClM/ Constants.php +3app/Component/SCIM/ @ ScimProvisioning.php +85-15nse/ CoreUser.php +21ann/Comnonent/SCIM/Mutatore/Attributes/Ueer/M PoleAttr.nhn +17-ites/User/ ẞ RoleAttrTest.nhn +224* Reiect alliiAccent alliAsk anvthina (&4-L)« Code SWF-1.6WN Windsurf Toams 225-1 UTF.8io 4 spaces...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
55238
|
1914
|
9
|
2026-05-18T13:58:35.312639+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-18/1779 /Users/lukas/.screenpipe/data/data/2026-05-18/1779112715312_m1.jpg...
|
PhpStorm
|
faVsco.js – Crm/…/Service.php
|
1
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
master, menu
Start Listen Project: faVsco.js, menu
master, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI...
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"master, menu","depth":5,"on_screen":true,"help_text":"Git Branch: master<br/>114 incoming commits<br/>","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"AskJiminnyReportActivityServiceTest","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'AskJiminnyReportActivityServiceTest'","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'AskJiminnyReportActivityServiceTest'","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
-3088599111841370244
|
-8636775528843997756
|
click
|
hybrid
|
NULL
|
Project: faVsco.js, menu
master, menu
Start Listen Project: faVsco.js, menu
master, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
FirefoxFileEditViewHistory→BookmarksProfilesToolsWindowHelpmeet.google.com/cxs-eips-npt?authuser=lukas.kovalik%40jiminny.com[Platform) Refinemen... 2 m left)100% C8 • Mon 18 May 16:58:35)=Galya DimitrovaNikolay YankovNikolay IvanovAneliya AngelovaLukas Kovalik4:58 PM | [Platform] Refinement ®1:45:02...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
55237
|
1915
|
4
|
2026-05-18T13:58:31.403130+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-18/1779 /Users/lukas/.screenpipe/data/data/2026-05-18/1779112711403_m2.jpg...
|
PhpStorm
|
faVsco.js – Crm/…/Service.php
|
1
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
master, menu
Start Listen Project: faVsco.js, menu
master, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
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\OpportunityMetadata;
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(OpportunityMetadata $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());
}
/** @var Profile $profile */
$profile = $this->getConfiguration()
->profiles()
->where('crm_provider_id', $crmData->getUserId())
->first();
$userId = $profile?->getUserId() ?? $account?->getUserId();
if ($userId === null) {
$this->logger->error('[Close] | Skip import, no user_id found', [
'id' => $crmData->getId(),
]);
return null;
}
$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(),
]);
...
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"bounds":{"left":0.025930852,"top":0.019952115,"width":0.03856383,"height":0.025538707},"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"master, menu","depth":5,"bounds":{"left":0.064494684,"top":0.019952115,"width":0.040226065,"height":0.025538707},"on_screen":true,"help_text":"Git Branch: master<br/>114 incoming commits<br/>","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"bounds":{"left":0.8081782,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"AskJiminnyReportActivityServiceTest","depth":6,"bounds":{"left":0.8234708,"top":0.019952115,"width":0.09208777,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'AskJiminnyReportActivityServiceTest'","depth":6,"bounds":{"left":0.9155585,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'AskJiminnyReportActivityServiceTest'","depth":6,"bounds":{"left":0.9268617,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"bounds":{"left":0.9381649,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"bounds":{"left":0.96609044,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"bounds":{"left":0.9773936,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"IDE and Project Settings","depth":5,"bounds":{"left":0.9886968,"top":0.019952115,"width":0.011303186,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"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,"bounds":{"left":0.3799867,"top":0.17478053,"width":0.007978723,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"39","depth":4,"bounds":{"left":0.3899601,"top":0.17478053,"width":0.010305851,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"5","depth":4,"bounds":{"left":0.40226063,"top":0.17478053,"width":0.007978723,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"bounds":{"left":0.4119016,"top":0.17318435,"width":0.00731383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"bounds":{"left":0.4192154,"top":0.17318435,"width":0.006981383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\nnamespace Jiminny\\Services\\Crm\\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\\OpportunityMetadata;\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(OpportunityMetadata $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 if ($account === null) {\n $account = $this->accountProcessor->syncAccount($crmData->getLeadId());\n }\n\n /** @var Profile $profile */\n $profile = $this->getConfiguration()\n ->profiles()\n ->where('crm_provider_id', $crmData->getUserId())\n ->first();\n\n $userId = $profile?->getUserId() ?? $account?->getUserId();\n if ($userId === null) {\n $this->logger->error('[Close] | Skip import, no user_id found', [\n 'id' => $crmData->getId(),\n ]);\n\n return null;\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 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\\OpportunityMetadata;\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(OpportunityMetadata $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 if ($account === null) {\n $account = $this->accountProcessor->syncAccount($crmData->getLeadId());\n }\n\n /** @var Profile $profile */\n $profile = $this->getConfiguration()\n ->profiles()\n ->where('crm_provider_id', $crmData->getUserId())\n ->first();\n\n $userId = $profile?->getUserId() ?? $account?->getUserId();\n if ($userId === null) {\n $this->logger->error('[Close] | Skip import, no user_id found', [\n 'id' => $crmData->getId(),\n ]);\n\n return null;\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 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":"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":"1","depth":4,"bounds":{"left":0.7124335,"top":0.10055866,"width":0.00731383,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"bounds":{"left":0.72140956,"top":0.09896249,"width":0.00731383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"bounds":{"left":0.7287234,"top":0.09896249,"width":0.006981383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\ndeclare(strict_types=1);\n\nnamespace Jiminny\\Component\\ActivitySearch\\FilterDefinition;\n\nuse Illuminate\\Support\\Collection;\nuse Jiminny\\Component\\ActivitySearch\\FilterDefinition;\nuse Jiminny\\Component\\ActivitySearch\\FilterDefinitionQuery;\nuse Jiminny\\Component\\ActivitySearch\\FilterDefinitionQueryCollection;\nuse Jiminny\\Contracts\\ActivitySearch\\ValidatedFilterDefinitionInterface;\nuse Jiminny\\Contracts\\Repositories\\UserRepository;\nuse Jiminny\\Models;\nuse Jiminny\\Models\\Feature\\FeatureEnum;\nuse Jiminny\\Models\\Team;\nuse Jiminny\\Traits\\RequiresUUID;\nuse Elastica\\Query;\n\nfinal class CoachingFeedbackCoachUserIn extends FilterDefinition implements ValidatedFilterDefinitionInterface\n{\n private const int NO_GROUP_ID = 999;\n private UserRepository $userRepository;\n\n public function __construct(UserRepository $userRepository)\n {\n $this->userRepository = $userRepository;\n }\n\n public function shouldApplyQueries(): bool\n {\n return count($this->getValue()) > 0;\n }\n\n public function getQueries(): FilterDefinitionQueryCollection\n {\n $userIds = $this->getValue();\n\n return FilterDefinitionQueryCollection::make([\n FilterDefinitionQuery::instance()\n ->setQuery(new Query\\Terms('coachingFeedbacks.coach.id_string', $userIds))\n ->setPath('coachingFeedbacks.coach', 'coachingFeedbacks'),\n ]);\n }\n\n public function toArray(): array\n {\n return [\n 'id' => 'coaching-feedback-user-filter',\n 'label' => 'Coach',\n 'helpText' => 'The person who completed the Coaching Framework',\n 'placeholder' => 'Search coaches',\n 'inputType' => InputTypeEnum::DROPDOWN_MULTIPLE,\n 'queryParam' => 'coaching_feedback_coach_id',\n 'options' => $this->getOptions(),\n 'groupLabelKey' => 'label',\n 'groupValuesKey' => 'users',\n 'optionLabelKey' => 'name',\n 'optionValueKey' => 'id',\n 'value' => $this->getValue(),\n ];\n }\n\n private function getOptions(): array\n {\n $user = $this->executedBy;\n $query = $user\n ->getTeam()\n ->users()\n ->whereNotIn(\n 'uuid',\n $this->userRepository->findInaccessibleDeactivatedUserUuids($user)\n ->map(static fn (string $uuid): string => RequiresUUID::toOptimized($uuid))\n ->all(),\n )\n ->orderBy('name');\n\n $users = $query\n ->with('group')\n ->get()\n ->reduce(\n static function (array $carry, Models\\User $user): array {\n $group = $user->getGroup();\n $groupId = $group !== null\n ? $group->getUuid()\n : self::NO_GROUP_ID;\n $groupName = $group !== null\n ? $group->getName()\n : '(NON-GROUPED)';\n\n if (! array_key_exists($groupId, $carry)) {\n $carry[$groupId] = [\n 'label' => $groupName,\n 'users' => [],\n ];\n }\n\n $carry[$groupId]['users'][] = [\n 'id' => $user->getUuid(),\n 'name' => sprintf(\n '%s%s',\n $user->getName(),\n ! $user->isStatusActive()\n ? ' (Inactive)'\n : ''\n ),\n ];\n\n return $carry;\n },\n []\n );\n\n return Collection::make($users)\n ->sortBy('label')\n ->values()\n ->toArray();\n }\n\n public function getValue(): array\n {\n if (! $this->criteria->hasCoachingFeedbackCoachUserIds()) {\n return $this->getDefaultValue();\n }\n\n return array_diff(\n $this->criteria->getCoachingFeedbackCoachUserId(),\n $this->userRepository->findInaccessibleDeactivatedUserUuids($this->executedBy)->all(),\n );\n }\n\n private function getDefaultValue(): array\n {\n return [];\n }\n\n public function getValidationRules(?string $prefix = null): array\n {\n $team = $this->executedBy->getTeam();\n\n return [\n $prefix . 'coaching_feedback_coach_id' => 'array',\n $prefix . 'coaching_feedback_coach_id.*' => 'uuid:users,team_id,' . $team->getId(),\n ];\n }\n\n public function getSortOrder(): int\n {\n return 86;\n }\n\n public function shouldBeIncluded(Team $team): bool\n {\n return $team->hasFeature(FeatureEnum::COACHING_FRAMEWORK);\n }\n}","depth":4,"bounds":{"left":0.4481383,"top":0.09736632,"width":0.29288563,"height":0.8818835},"on_screen":true,"value":"<?php\n\ndeclare(strict_types=1);\n\nnamespace Jiminny\\Component\\ActivitySearch\\FilterDefinition;\n\nuse Illuminate\\Support\\Collection;\nuse Jiminny\\Component\\ActivitySearch\\FilterDefinition;\nuse Jiminny\\Component\\ActivitySearch\\FilterDefinitionQuery;\nuse Jiminny\\Component\\ActivitySearch\\FilterDefinitionQueryCollection;\nuse Jiminny\\Contracts\\ActivitySearch\\ValidatedFilterDefinitionInterface;\nuse Jiminny\\Contracts\\Repositories\\UserRepository;\nuse Jiminny\\Models;\nuse Jiminny\\Models\\Feature\\FeatureEnum;\nuse Jiminny\\Models\\Team;\nuse Jiminny\\Traits\\RequiresUUID;\nuse Elastica\\Query;\n\nfinal class CoachingFeedbackCoachUserIn extends FilterDefinition implements ValidatedFilterDefinitionInterface\n{\n private const int NO_GROUP_ID = 999;\n private UserRepository $userRepository;\n\n public function __construct(UserRepository $userRepository)\n {\n $this->userRepository = $userRepository;\n }\n\n public function shouldApplyQueries(): bool\n {\n return count($this->getValue()) > 0;\n }\n\n public function getQueries(): FilterDefinitionQueryCollection\n {\n $userIds = $this->getValue();\n\n return FilterDefinitionQueryCollection::make([\n FilterDefinitionQuery::instance()\n ->setQuery(new Query\\Terms('coachingFeedbacks.coach.id_string', $userIds))\n ->setPath('coachingFeedbacks.coach', 'coachingFeedbacks'),\n ]);\n }\n\n public function toArray(): array\n {\n return [\n 'id' => 'coaching-feedback-user-filter',\n 'label' => 'Coach',\n 'helpText' => 'The person who completed the Coaching Framework',\n 'placeholder' => 'Search coaches',\n 'inputType' => InputTypeEnum::DROPDOWN_MULTIPLE,\n 'queryParam' => 'coaching_feedback_coach_id',\n 'options' => $this->getOptions(),\n 'groupLabelKey' => 'label',\n 'groupValuesKey' => 'users',\n 'optionLabelKey' => 'name',\n 'optionValueKey' => 'id',\n 'value' => $this->getValue(),\n ];\n }\n\n private function getOptions(): array\n {\n $user = $this->executedBy;\n $query = $user\n ->getTeam()\n ->users()\n ->whereNotIn(\n 'uuid',\n $this->userRepository->findInaccessibleDeactivatedUserUuids($user)\n ->map(static fn (string $uuid): string => RequiresUUID::toOptimized($uuid))\n ->all(),\n )\n ->orderBy('name');\n\n $users = $query\n ->with('group')\n ->get()\n ->reduce(\n static function (array $carry, Models\\User $user): array {\n $group = $user->getGroup();\n $groupId = $group !== null\n ? $group->getUuid()\n : self::NO_GROUP_ID;\n $groupName = $group !== null\n ? $group->getName()\n : '(NON-GROUPED)';\n\n if (! array_key_exists($groupId, $carry)) {\n $carry[$groupId] = [\n 'label' => $groupName,\n 'users' => [],\n ];\n }\n\n $carry[$groupId]['users'][] = [\n 'id' => $user->getUuid(),\n 'name' => sprintf(\n '%s%s',\n $user->getName(),\n ! $user->isStatusActive()\n ? ' (Inactive)'\n : ''\n ),\n ];\n\n return $carry;\n },\n []\n );\n\n return Collection::make($users)\n ->sortBy('label')\n ->values()\n ->toArray();\n }\n\n public function getValue(): array\n {\n if (! $this->criteria->hasCoachingFeedbackCoachUserIds()) {\n return $this->getDefaultValue();\n }\n\n return array_diff(\n $this->criteria->getCoachingFeedbackCoachUserId(),\n $this->userRepository->findInaccessibleDeactivatedUserUuids($this->executedBy)->all(),\n );\n }\n\n private function getDefaultValue(): array\n {\n return [];\n }\n\n public function getValidationRules(?string $prefix = null): array\n {\n $team = $this->executedBy->getTeam();\n\n return [\n $prefix . 'coaching_feedback_coach_id' => 'array',\n $prefix . 'coaching_feedback_coach_id.*' => 'uuid:users,team_id,' . $team->getId(),\n ];\n }\n\n public function getSortOrder(): int\n {\n return 86;\n }\n\n public function shouldBeIncluded(Team $team): bool\n {\n return $team->hasFeature(FeatureEnum::COACHING_FRAMEWORK);\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Project","depth":3,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Project","depth":3,"bounds":{"left":0.011968086,"top":0.047885075,"width":0.024268618,"height":0.024740623},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"New File or Directory…","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Expand Selected","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Collapse All","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Options","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
-6754415607117048428
|
-9030663327281178587
|
click
|
accessibility
|
NULL
|
Project: faVsco.js, menu
master, menu
Start Listen Project: faVsco.js, menu
master, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
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\OpportunityMetadata;
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(OpportunityMetadata $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());
}
/** @var Profile $profile */
$profile = $this->getConfiguration()
->profiles()
->where('crm_provider_id', $crmData->getUserId())
->first();
$userId = $profile?->getUserId() ?? $account?->getUserId();
if ($userId === null) {
$this->logger->error('[Close] | Skip import, no user_id found', [
'id' => $crmData->getId(),
]);
return null;
}
$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(),
]);
...
|
55234
|
NULL
|
NULL
|
NULL
|
|
55236
|
1914
|
8
|
2026-05-18T13:58:31.157324+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-18/1779 /Users/lukas/.screenpipe/data/data/2026-05-18/1779112711157_m1.jpg...
|
PhpStorm
|
faVsco.js – Crm/…/Service.php
|
1
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
master, menu
Start Listen Project: faVsco.js, menu
master, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
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\OpportunityMetadata;
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(OpportunityMetadata $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());
}
/** @var Profile $profile */
$profile = $this->getConfiguration()
->profiles()
->where('crm_provider_id', $crmData->getUserId())
->first();
$userId = $profile?->getUserId() ?? $account?->getUserId();
if ($userId === null) {
$this->logger->error('[Close] | Skip import, no user_id found', [
'id' => $crmData->getId(),
]);
return null;
}
$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(),
]);
...
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"master, menu","depth":5,"on_screen":true,"help_text":"Git Branch: master<br/>114 incoming commits<br/>","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"AskJiminnyReportActivityServiceTest","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'AskJiminnyReportActivityServiceTest'","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'AskJiminnyReportActivityServiceTest'","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"IDE and Project Settings","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"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,"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\\OpportunityMetadata;\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(OpportunityMetadata $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 if ($account === null) {\n $account = $this->accountProcessor->syncAccount($crmData->getLeadId());\n }\n\n /** @var Profile $profile */\n $profile = $this->getConfiguration()\n ->profiles()\n ->where('crm_provider_id', $crmData->getUserId())\n ->first();\n\n $userId = $profile?->getUserId() ?? $account?->getUserId();\n if ($userId === null) {\n $this->logger->error('[Close] | Skip import, no user_id found', [\n 'id' => $crmData->getId(),\n ]);\n\n return null;\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 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\\OpportunityMetadata;\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(OpportunityMetadata $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 if ($account === null) {\n $account = $this->accountProcessor->syncAccount($crmData->getLeadId());\n }\n\n /** @var Profile $profile */\n $profile = $this->getConfiguration()\n ->profiles()\n ->where('crm_provider_id', $crmData->getUserId())\n ->first();\n\n $userId = $profile?->getUserId() ?? $account?->getUserId();\n if ($userId === null) {\n $this->logger->error('[Close] | Skip import, no user_id found', [\n 'id' => $crmData->getId(),\n ]);\n\n return null;\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 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":"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":"1","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\ndeclare(strict_types=1);\n\nnamespace Jiminny\\Component\\ActivitySearch\\FilterDefinition;\n\nuse Illuminate\\Support\\Collection;\nuse Jiminny\\Component\\ActivitySearch\\FilterDefinition;\nuse Jiminny\\Component\\ActivitySearch\\FilterDefinitionQuery;\nuse Jiminny\\Component\\ActivitySearch\\FilterDefinitionQueryCollection;\nuse Jiminny\\Contracts\\ActivitySearch\\ValidatedFilterDefinitionInterface;\nuse Jiminny\\Contracts\\Repositories\\UserRepository;\nuse Jiminny\\Models;\nuse Jiminny\\Models\\Feature\\FeatureEnum;\nuse Jiminny\\Models\\Team;\nuse Jiminny\\Traits\\RequiresUUID;\nuse Elastica\\Query;\n\nfinal class CoachingFeedbackCoachUserIn extends FilterDefinition implements ValidatedFilterDefinitionInterface\n{\n private const int NO_GROUP_ID = 999;\n private UserRepository $userRepository;\n\n public function __construct(UserRepository $userRepository)\n {\n $this->userRepository = $userRepository;\n }\n\n public function shouldApplyQueries(): bool\n {\n return count($this->getValue()) > 0;\n }\n\n public function getQueries(): FilterDefinitionQueryCollection\n {\n $userIds = $this->getValue();\n\n return FilterDefinitionQueryCollection::make([\n FilterDefinitionQuery::instance()\n ->setQuery(new Query\\Terms('coachingFeedbacks.coach.id_string', $userIds))\n ->setPath('coachingFeedbacks.coach', 'coachingFeedbacks'),\n ]);\n }\n\n public function toArray(): array\n {\n return [\n 'id' => 'coaching-feedback-user-filter',\n 'label' => 'Coach',\n 'helpText' => 'The person who completed the Coaching Framework',\n 'placeholder' => 'Search coaches',\n 'inputType' => InputTypeEnum::DROPDOWN_MULTIPLE,\n 'queryParam' => 'coaching_feedback_coach_id',\n 'options' => $this->getOptions(),\n 'groupLabelKey' => 'label',\n 'groupValuesKey' => 'users',\n 'optionLabelKey' => 'name',\n 'optionValueKey' => 'id',\n 'value' => $this->getValue(),\n ];\n }\n\n private function getOptions(): array\n {\n $user = $this->executedBy;\n $query = $user\n ->getTeam()\n ->users()\n ->whereNotIn(\n 'uuid',\n $this->userRepository->findInaccessibleDeactivatedUserUuids($user)\n ->map(static fn (string $uuid): string => RequiresUUID::toOptimized($uuid))\n ->all(),\n )\n ->orderBy('name');\n\n $users = $query\n ->with('group')\n ->get()\n ->reduce(\n static function (array $carry, Models\\User $user): array {\n $group = $user->getGroup();\n $groupId = $group !== null\n ? $group->getUuid()\n : self::NO_GROUP_ID;\n $groupName = $group !== null\n ? $group->getName()\n : '(NON-GROUPED)';\n\n if (! array_key_exists($groupId, $carry)) {\n $carry[$groupId] = [\n 'label' => $groupName,\n 'users' => [],\n ];\n }\n\n $carry[$groupId]['users'][] = [\n 'id' => $user->getUuid(),\n 'name' => sprintf(\n '%s%s',\n $user->getName(),\n ! $user->isStatusActive()\n ? ' (Inactive)'\n : ''\n ),\n ];\n\n return $carry;\n },\n []\n );\n\n return Collection::make($users)\n ->sortBy('label')\n ->values()\n ->toArray();\n }\n\n public function getValue(): array\n {\n if (! $this->criteria->hasCoachingFeedbackCoachUserIds()) {\n return $this->getDefaultValue();\n }\n\n return array_diff(\n $this->criteria->getCoachingFeedbackCoachUserId(),\n $this->userRepository->findInaccessibleDeactivatedUserUuids($this->executedBy)->all(),\n );\n }\n\n private function getDefaultValue(): array\n {\n return [];\n }\n\n public function getValidationRules(?string $prefix = null): array\n {\n $team = $this->executedBy->getTeam();\n\n return [\n $prefix . 'coaching_feedback_coach_id' => 'array',\n $prefix . 'coaching_feedback_coach_id.*' => 'uuid:users,team_id,' . $team->getId(),\n ];\n }\n\n public function getSortOrder(): int\n {\n return 86;\n }\n\n public function shouldBeIncluded(Team $team): bool\n {\n return $team->hasFeature(FeatureEnum::COACHING_FRAMEWORK);\n }\n}","depth":4,"on_screen":true,"value":"<?php\n\ndeclare(strict_types=1);\n\nnamespace Jiminny\\Component\\ActivitySearch\\FilterDefinition;\n\nuse Illuminate\\Support\\Collection;\nuse Jiminny\\Component\\ActivitySearch\\FilterDefinition;\nuse Jiminny\\Component\\ActivitySearch\\FilterDefinitionQuery;\nuse Jiminny\\Component\\ActivitySearch\\FilterDefinitionQueryCollection;\nuse Jiminny\\Contracts\\ActivitySearch\\ValidatedFilterDefinitionInterface;\nuse Jiminny\\Contracts\\Repositories\\UserRepository;\nuse Jiminny\\Models;\nuse Jiminny\\Models\\Feature\\FeatureEnum;\nuse Jiminny\\Models\\Team;\nuse Jiminny\\Traits\\RequiresUUID;\nuse Elastica\\Query;\n\nfinal class CoachingFeedbackCoachUserIn extends FilterDefinition implements ValidatedFilterDefinitionInterface\n{\n private const int NO_GROUP_ID = 999;\n private UserRepository $userRepository;\n\n public function __construct(UserRepository $userRepository)\n {\n $this->userRepository = $userRepository;\n }\n\n public function shouldApplyQueries(): bool\n {\n return count($this->getValue()) > 0;\n }\n\n public function getQueries(): FilterDefinitionQueryCollection\n {\n $userIds = $this->getValue();\n\n return FilterDefinitionQueryCollection::make([\n FilterDefinitionQuery::instance()\n ->setQuery(new Query\\Terms('coachingFeedbacks.coach.id_string', $userIds))\n ->setPath('coachingFeedbacks.coach', 'coachingFeedbacks'),\n ]);\n }\n\n public function toArray(): array\n {\n return [\n 'id' => 'coaching-feedback-user-filter',\n 'label' => 'Coach',\n 'helpText' => 'The person who completed the Coaching Framework',\n 'placeholder' => 'Search coaches',\n 'inputType' => InputTypeEnum::DROPDOWN_MULTIPLE,\n 'queryParam' => 'coaching_feedback_coach_id',\n 'options' => $this->getOptions(),\n 'groupLabelKey' => 'label',\n 'groupValuesKey' => 'users',\n 'optionLabelKey' => 'name',\n 'optionValueKey' => 'id',\n 'value' => $this->getValue(),\n ];\n }\n\n private function getOptions(): array\n {\n $user = $this->executedBy;\n $query = $user\n ->getTeam()\n ->users()\n ->whereNotIn(\n 'uuid',\n $this->userRepository->findInaccessibleDeactivatedUserUuids($user)\n ->map(static fn (string $uuid): string => RequiresUUID::toOptimized($uuid))\n ->all(),\n )\n ->orderBy('name');\n\n $users = $query\n ->with('group')\n ->get()\n ->reduce(\n static function (array $carry, Models\\User $user): array {\n $group = $user->getGroup();\n $groupId = $group !== null\n ? $group->getUuid()\n : self::NO_GROUP_ID;\n $groupName = $group !== null\n ? $group->getName()\n : '(NON-GROUPED)';\n\n if (! array_key_exists($groupId, $carry)) {\n $carry[$groupId] = [\n 'label' => $groupName,\n 'users' => [],\n ];\n }\n\n $carry[$groupId]['users'][] = [\n 'id' => $user->getUuid(),\n 'name' => sprintf(\n '%s%s',\n $user->getName(),\n ! $user->isStatusActive()\n ? ' (Inactive)'\n : ''\n ),\n ];\n\n return $carry;\n },\n []\n );\n\n return Collection::make($users)\n ->sortBy('label')\n ->values()\n ->toArray();\n }\n\n public function getValue(): array\n {\n if (! $this->criteria->hasCoachingFeedbackCoachUserIds()) {\n return $this->getDefaultValue();\n }\n\n return array_diff(\n $this->criteria->getCoachingFeedbackCoachUserId(),\n $this->userRepository->findInaccessibleDeactivatedUserUuids($this->executedBy)->all(),\n );\n }\n\n private function getDefaultValue(): array\n {\n return [];\n }\n\n public function getValidationRules(?string $prefix = null): array\n {\n $team = $this->executedBy->getTeam();\n\n return [\n $prefix . 'coaching_feedback_coach_id' => 'array',\n $prefix . 'coaching_feedback_coach_id.*' => 'uuid:users,team_id,' . $team->getId(),\n ];\n }\n\n public function getSortOrder(): int\n {\n return 86;\n }\n\n public function shouldBeIncluded(Team $team): bool\n {\n return $team->hasFeature(FeatureEnum::COACHING_FRAMEWORK);\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Project","depth":3,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Project","depth":3,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"New File or Directory…","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Expand Selected","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Collapse All","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Options","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
-6754415607117048428
|
-9030663327281178587
|
visual_change
|
accessibility
|
NULL
|
Project: faVsco.js, menu
master, menu
Start Listen Project: faVsco.js, menu
master, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
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\OpportunityMetadata;
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(OpportunityMetadata $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());
}
/** @var Profile $profile */
$profile = $this->getConfiguration()
->profiles()
->where('crm_provider_id', $crmData->getUserId())
->first();
$userId = $profile?->getUserId() ?? $account?->getUserId();
if ($userId === null) {
$this->logger->error('[Close] | Skip import, no user_id found', [
'id' => $crmData->getId(),
]);
return null;
}
$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(),
]);
...
|
55235
|
NULL
|
NULL
|
NULL
|
|
55235
|
1914
|
7
|
2026-05-18T13:58:28.117057+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-18/1779 /Users/lukas/.screenpipe/data/data/2026-05-18/1779112708117_m1.jpg...
|
PhpStorm
|
faVsco.js – Crm/…/Service.php
|
1
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
FirefoxFileEditViewHistoryBookmarksProfiles• 0→Too FirefoxFileEditViewHistoryBookmarksProfiles• 0→ToolsWindowHelpmeet.google.com/cxs-eips-npt?authuser=lukas.kovalik%40jiminny.com[Platform] Refinemen... 2 m left]100% <78 • Mon 18 May 16:58:27A05Galya DimitrovaNikolay Yankov*Nikolay IvanovAneliya AngelovaLukas Kovalik4:58 PM | [Platform] Refinement •1:44:54...
|
NULL
|
5081755090974205071
|
NULL
|
visual_change
|
ocr
|
NULL
|
FirefoxFileEditViewHistoryBookmarksProfiles• 0→Too FirefoxFileEditViewHistoryBookmarksProfiles• 0→ToolsWindowHelpmeet.google.com/cxs-eips-npt?authuser=lukas.kovalik%40jiminny.com[Platform] Refinemen... 2 m left]100% <78 • Mon 18 May 16:58:27A05Galya DimitrovaNikolay Yankov*Nikolay IvanovAneliya AngelovaLukas Kovalik4:58 PM | [Platform] Refinement •1:44:54...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
55234
|
1915
|
3
|
2026-05-18T13:58:21.203213+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-18/1779 /Users/lukas/.screenpipe/data/data/2026-05-18/1779112701203_m2.jpg...
|
PhpStorm
|
faVsco.js – Crm/…/Service.php
|
1
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
master, menu
Start Listen Project: faVsco.js, menu
master, menu
Start Listening for PHP Debug Connections...
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"bounds":{"left":0.025930852,"top":0.019952115,"width":0.03856383,"height":0.025538707},"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"master, menu","depth":5,"bounds":{"left":0.064494684,"top":0.019952115,"width":0.040226065,"height":0.025538707},"on_screen":true,"help_text":"Git Branch: master<br/>114 incoming commits<br/>","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"bounds":{"left":0.8081782,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
-7429716278976468786
|
-8636355650190325311
|
click
|
hybrid
|
NULL
|
Project: faVsco.js, menu
master, menu
Start Listen Project: faVsco.js, menu
master, menu
Start Listening for PHP Debug Connections
PhostormVIewINavicarecodeLaravelRefactorFV faVsco.js°9 master kProiectRinaCentralVideoSalesforceSalesioft> D Talkdeskm TeamsD TelusD Twilio>@ TwilioFlex_ I willorlexDireet>C TwilioVideo_Uploader› _ Vonagewxant1400mZoomBot> ZoomPhoneC) ActivitvcrmFieldsResolver.ohpC) ActivitvLoaService.oho© ActivitvProviderClient.ohn(C) ActivitvProviderRedistrv.ohoC. ActivitvProviderService.ohv© CallDenormalizerRegistry.phpC) CrmOwnerResolver.oho© DatalmportHandlerInterface.php© MeetingBotService.php© ParticipantConsentService.php(c) DarticinanteService nhnT PecnonceValidation Trait nhnT SalesforceGetUserTrait.phpSfDenormaliserMainCrmDataTrait.php© TrackRecordingFileSizeService.php© TrackRecordingSizeEnforcer.phpT ValidateEmitProspectEventTrait.phpC AjReports0 AvatarMcolondar0 Conference0 Crm> C Bullhornv closeOnoortunitvsvncstrateav• ProcessonProspectSearchStrateav• M TranslatorC) Client.oho() CloseSxceotion.ohoC) FieldDefinitions.onvc) SieldValueConverter ohnC) Service nhnC) StandardFioldMetadata nhn> MConnenTOOISWindowC ActivityController.ongC BaseService.php© SoftPhoneManager.phpC) CoreUserRequest.pnpscimProvistoning.ong© CoreUser.php©Crm/…../Service.php Xclass Service extends BaseService 1mpLements* A8 A39 M5 ^233248241242244 6t749251255 015282 F285 0>294 6t>303 6>326 6t >335339339 61)oublic tunction suncreldcrield Srleld: vo1d$crmField = $this->getClient->fetchCustomFieldDefinition(Sresource, $field->getCrmProviderIdsch1s->mecadacarrocessor->syncrielascrmrlelamprivate function isCustomField(string $fieldId): boolf...}* oinheritdocpublic function importPicklistValues(Field $field): array{...}29 Ф >34 0 >* Oimportant We only support stages on the opportunity object45 đ >* Qparam strinalinulz Stunespublic function importStages(?array $types = null, ?string SmissingStageName = null): ?Stage{...}.•110119* @inheritdocdnato rnatla onatosescarea sare, fereta io pal, erteg eneortai - MAD:* Ginheritdocpublic function syncLead(string ScrmId): ?Lead{...}146 C)— 150151 C* athrows Except1onpublic function syncAccounts(Carbon Ssince, ?Carbon $to = null): int...}public function syncAccount(string $crmId): ?Account(...}1 usageorivate function importAccount(ScrmData)• Account!...}* athrows Mnsesycentionpublic function syncOpportunities(array $parameters, ?string $strategy = null): int{...}ed with hranch 'macter' hac heon rectored Il Pollback II Confiaure (todav 14-19)= custom.log= laravel.log4 SF jiminny@localhost]4 HS_local [jiminny@localhost]& console [PROD]# console [euyA console [STAGING]© CoachingFeedbackCoachUserln.php Xstohedeclare(scrict_cypes=l)nnamespace Jiminny Component Activ1tySearch rilterbetin1t1on*› use ...final class CoachingFeedbackCoachUserIn extends FilterDefinition implements ValidatedFilterDefinitionInterfaceusadeorivate const int No GROUP 10 = 9993 usagesorivate UserRenository SuserRenository:public function __construct(UserRepository $userRepository)f...hpublic function shouldApplyQueries: boolf...}public function getQueries: FilterDefinitionQueryCollection{...}public function toArray@: array{...;private function getOptions(): array{...}public function getValue: array{...}private function getDefaultValue@: array{...}public function aetValidationRules(2strina Sorefix = null): arravs...?public function aetSortOrder@: intf...}public function shouldBeIncluded(Team $team): bool{...}[Platform] Refinemen... 2m left100% 1• Mon 18 May 16:58:21U AskJiminnyReportActivityServiceTest vCascadeImplement Trial OwneFixing Redis Rate LimSalestorce Token FalliSCIM Role Manageme+0 ..inp on LineWARN Metadata found iin doc-comment for methodw9/ 10 tacke done• Write unit tests for SCIM role management10) Contact Mario to vodate customer10 / 10 tasks doneselm role managementtor leamtallor has been successtully implemented. Here's what was completed:Implementation Summary:1. CoreUserRequest DTO - Added role parsina from SCIM requests2. SCIM Constants - Added META ROLES and USER_PATCH_ATTR ROLES constants3. RoleAttr Mutator - Created mutator for handlina role assianments via SCIM PATCH operations4. ScimProvisioning - Updated to handle roles during user creation with proper validation5. ListenerRoleCannotHaveAdminOrManagerPermissionRule - Created validation rule to prevent listener role fromhaving admin/manager permissions6. CoreUser Response - Updated to include roles in SClM responses7. Unit Tests - Created comprehensive tests for the validation ruleKey Features:• Customers can now assign roles via SCIM (recorder, recorder & voice, analyst, listener, admin, manager)• Validation ensures listener role is onlv assianable if the LISTENER ROLE feature flaa is enabledl•LIstener role cannot de comolned with aamin or manager permissions• Uses existing UpdateUserRolesAction for role syncing and logging• Follows platform validation rules (DependentRolesRule, ListenerRoleRequiresListenerFeatureRule)Next Step: Please contact Mario to update the customer that SCIM role management is now available for Teamtailorintearationposwtn chaten vapp/Component/SClM/ Constants.php +3app/Component/SCIM/ @ ScimProvisioning.php +85-15nse/ CoreUser.php +21ann/Comnonent/SCIM/Mutatore/Attributes/Ueer/M PoleAttr.nhn +17-ites/User/ ẞ RoleAttrTest.nhn +224* Reiect alliiAccent alliAsk anvthina (&4-L)« Code SWF-1.6WN Windsurf Toams 255.52UTF.8io 4 spaces...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
55233
|
1914
|
6
|
2026-05-18T13:58:21.211663+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-18/1779 /Users/lukas/.screenpipe/data/data/2026-05-18/1779112701211_m1.jpg...
|
PhpStorm
|
faVsco.js – Crm/…/Service.php
|
1
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
master, menu
Start Listen Project: faVsco.js, menu
master, menu
Start Listening for PHP Debug Connections...
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"master, menu","depth":5,"on_screen":true,"help_text":"Git Branch: master<br/>114 incoming commits<br/>","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
-7429716278976468786
|
-8636355650190325311
|
click
|
hybrid
|
NULL
|
Project: faVsco.js, menu
master, menu
Start Listen Project: faVsco.js, menu
master, menu
Start Listening for PHP Debug Connections
FirefoxFile• 0EditViewHistory→BookmarksProfilesTools|WindowHelpmeet.google.com/cxs-eips-npt?authuser=lukas.kovalik%40jiminny.com[Platform] Refinemen... 2 m left100% C8 • Mon 18 May 16:58:21|=A05Galya DimitrovaNikolay Yankov*Nikolay IvanovAneliya AngelovaLukas Kovalik4:58 PM | [Platform] Refinement •1:44:48...
|
55232
|
NULL
|
NULL
|
NULL
|
|
55229
|
1915
|
1
|
2026-05-18T13:58:11.669415+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-18/1779 /Users/lukas/.screenpipe/data/data/2026-05-18/1779112691669_m2.jpg...
|
PhpStorm
|
|
1
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
Project Files
Preview
Filter
Open in Find Tool Win Project Files
Preview
Filter
Open in Find Tool Window
fd
FieldData.php .../app/Models/Crm/FieldData.php, class
FieldDefinitionBuilderTest.php .../tests/Unit/Component/AiAutomation/FieldDefinitionBuilderTest.php, class
FieldDataRepository.php .../app/Repositories/Crm/FieldDataRepository.php, class
FieldDefinitionBuilder.php .../app/Component/AiAutomation/FieldDefinitionBuilder.php, class
FieldData.php.html build/coverage/Models/Crm
FieldDefinitions.php .../app/Services/Crm/Close/FieldDefinitions.php, class
FieldDefinitions.php .../app/Services/Crm/Bullhorn/FieldDefinitions.php, class
FieldDefinitions.php .../app/Services/Crm/Copper/FieldDefinitions.php, class
FieldDefinitions.php .../app/Services/Crm/Hubspot/FieldDefinitions.php, class
FieldDefinitions.php .../app/Services/Crm/Pipedrive/FieldDefinitions.php, class
FieldDefinitions.php .../app/Services/Crm/Salesforce/FieldDefinitions.php, class
FieldDefinitions.php .../app/Services/Crm/IntegrationApp/DTO/Utils/FieldDefinitions.php, class
FieldDefinitionsTest.php .../tests/Unit/Integrations/Crm/FieldDefinitionsTest.php, class
FieldDefinitionsTest.php .../tests/Unit/Services/Crm/Bullhorn/FieldDefinitionsTest.php, class
FieldDefinitionsTest.php .../tests/Unit/Services/Crm/Close/FieldDefinitionsTest.php, class
… more
FieldData.php .../app/Models/Crm/FieldData.php, class
FieldDefinitionBuilderTest.php .../tests/Unit/Component/AiAutomation/FieldDefinitionBuilderTest.php, class
FieldDataRepository.php .../app/Repositories/Crm/FieldDataRepository.php, class
FieldDefinitionBuilder.php .../app/Component/AiAutomation/FieldDefinitionBuilder.php, class
FieldData.php.html build/coverage/Models/Crm
FieldDefinitions.php .../app/Services/Crm/Close/FieldDefinitions.php, class
FieldDefinitions.php .../app/Services/Crm/Bullhorn/FieldDefinitions.php, class
FieldDefinitions.php .../app/Services/Crm/Copper/FieldDefinitions.php, class
FieldDefinitions.php .../app/Services/Crm/Hubspot/FieldDefinitions.php, class
FieldDefinitions.php .../app/Services/Crm/Pipedrive/FieldDefinitions.php, class
FieldDefinitions.php .../app/Services/Crm/Salesforce/FieldDefinitions.php, class
FieldDefinitions.php .../app/Services/Crm/IntegrationApp/DTO/Utils/FieldDefinitions.php, class
FieldDefinitionsTest.php .../tests/Unit/Integrations/Crm/FieldDefinitionsTest.php, class
FieldDefinitionsTest.php .../tests/Unit/Services/Crm/Bullhorn/FieldDefinitionsTest.php, class
FieldDefinitionsTest.php .../tests/Unit/Services/Crm/Close/FieldDefinitionsTest.php, class
… more
Models/Crm/FieldData.php
Open In Right Split...
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project Files","depth":2,"bounds":{"left":0.6968085,"top":0.24181964,"width":0.03557181,"height":0.01915403},"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.73238033,"top":0.24181964,"width":0.008643617,"height":0.01915403},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Filter","depth":2,"bounds":{"left":0.74102396,"top":0.24181964,"width":0.008643617,"height":0.01915403},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Open in Find Tool Window","depth":2,"bounds":{"left":0.7496675,"top":0.24181964,"width":0.008643617,"height":0.01915403},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextField","text":"fd","depth":1,"bounds":{"left":0.50232714,"top":0.27214685,"width":0.25598404,"height":0.023144454},"on_screen":true,"value":"fd","role_description":"text field","is_enabled":true,"is_focused":true,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"FieldData.php .../app/Models/Crm/FieldData.php, class","depth":2,"bounds":{"left":0.4993351,"top":0.30407023,"width":0.26196808,"height":0.017557861},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"FieldDefinitionBuilderTest.php .../tests/Unit/Component/AiAutomation/FieldDefinitionBuilderTest.php, class","depth":2,"bounds":{"left":0.4993351,"top":0.3216281,"width":0.26196808,"height":0.017557861},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"FieldDataRepository.php .../app/Repositories/Crm/FieldDataRepository.php, class","depth":2,"bounds":{"left":0.4993351,"top":0.33918595,"width":0.26196808,"height":0.017557861},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"FieldDefinitionBuilder.php .../app/Component/AiAutomation/FieldDefinitionBuilder.php, class","depth":2,"bounds":{"left":0.4993351,"top":0.3567438,"width":0.26196808,"height":0.017557861},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"FieldData.php.html build/coverage/Models/Crm","depth":2,"bounds":{"left":0.4993351,"top":0.37430167,"width":0.26196808,"height":0.017557861},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"FieldDefinitions.php .../app/Services/Crm/Close/FieldDefinitions.php, class","depth":2,"bounds":{"left":0.4993351,"top":0.39185953,"width":0.26196808,"height":0.017557861},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"FieldDefinitions.php .../app/Services/Crm/Bullhorn/FieldDefinitions.php, class","depth":2,"bounds":{"left":0.4993351,"top":0.4094174,"width":0.26196808,"height":0.017557861},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"FieldDefinitions.php .../app/Services/Crm/Copper/FieldDefinitions.php, class","depth":2,"bounds":{"left":0.4993351,"top":0.42697525,"width":0.26196808,"height":0.017557861},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"FieldDefinitions.php .../app/Services/Crm/Hubspot/FieldDefinitions.php, class","depth":2,"bounds":{"left":0.4993351,"top":0.4445331,"width":0.26196808,"height":0.017557861},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"FieldDefinitions.php .../app/Services/Crm/Pipedrive/FieldDefinitions.php, class","depth":2,"bounds":{"left":0.4993351,"top":0.46209097,"width":0.26196808,"height":0.017557861},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"FieldDefinitions.php .../app/Services/Crm/Salesforce/FieldDefinitions.php, class","depth":2,"bounds":{"left":0.4993351,"top":0.47964883,"width":0.26196808,"height":0.017557861},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"FieldDefinitions.php .../app/Services/Crm/IntegrationApp/DTO/Utils/FieldDefinitions.php, class","depth":2,"bounds":{"left":0.4993351,"top":0.49720672,"width":0.26196808,"height":0.017557861},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"FieldDefinitionsTest.php .../tests/Unit/Integrations/Crm/FieldDefinitionsTest.php, class","depth":2,"bounds":{"left":0.4993351,"top":0.51476455,"width":0.26196808,"height":0.017557861},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"FieldDefinitionsTest.php .../tests/Unit/Services/Crm/Bullhorn/FieldDefinitionsTest.php, class","depth":2,"bounds":{"left":0.4993351,"top":0.5323224,"width":0.26196808,"height":0.017557861},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"FieldDefinitionsTest.php .../tests/Unit/Services/Crm/Close/FieldDefinitionsTest.php, class","depth":2,"bounds":{"left":0.4993351,"top":0.54988027,"width":0.26196808,"height":0.017557861},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"… more","depth":2,"bounds":{"left":0.4993351,"top":0.5674381,"width":0.26196808,"height":0.017557861},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"FieldData.php .../app/Models/Crm/FieldData.php, class","depth":4,"bounds":{"left":0.4993351,"top":0.30407023,"width":0.26196808,"height":0.017557861},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"FieldDefinitionBuilderTest.php .../tests/Unit/Component/AiAutomation/FieldDefinitionBuilderTest.php, class","depth":4,"bounds":{"left":0.4993351,"top":0.3216281,"width":0.26196808,"height":0.017557861},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"FieldDataRepository.php .../app/Repositories/Crm/FieldDataRepository.php, class","depth":4,"bounds":{"left":0.4993351,"top":0.33918595,"width":0.26196808,"height":0.017557861},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"FieldDefinitionBuilder.php .../app/Component/AiAutomation/FieldDefinitionBuilder.php, class","depth":4,"bounds":{"left":0.4993351,"top":0.3567438,"width":0.26196808,"height":0.017557861},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"FieldData.php.html build/coverage/Models/Crm","depth":4,"bounds":{"left":0.4993351,"top":0.37430167,"width":0.26196808,"height":0.017557861},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"FieldDefinitions.php .../app/Services/Crm/Close/FieldDefinitions.php, class","depth":4,"bounds":{"left":0.4993351,"top":0.39185953,"width":0.26196808,"height":0.017557861},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"FieldDefinitions.php .../app/Services/Crm/Bullhorn/FieldDefinitions.php, class","depth":4,"bounds":{"left":0.4993351,"top":0.4094174,"width":0.26196808,"height":0.017557861},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"FieldDefinitions.php .../app/Services/Crm/Copper/FieldDefinitions.php, class","depth":4,"bounds":{"left":0.4993351,"top":0.42697525,"width":0.26196808,"height":0.017557861},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"FieldDefinitions.php .../app/Services/Crm/Hubspot/FieldDefinitions.php, class","depth":4,"bounds":{"left":0.4993351,"top":0.4445331,"width":0.26196808,"height":0.017557861},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"FieldDefinitions.php .../app/Services/Crm/Pipedrive/FieldDefinitions.php, class","depth":4,"bounds":{"left":0.4993351,"top":0.46209097,"width":0.26196808,"height":0.017557861},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"FieldDefinitions.php .../app/Services/Crm/Salesforce/FieldDefinitions.php, class","depth":4,"bounds":{"left":0.4993351,"top":0.47964883,"width":0.26196808,"height":0.017557861},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"FieldDefinitions.php .../app/Services/Crm/IntegrationApp/DTO/Utils/FieldDefinitions.php, class","depth":4,"bounds":{"left":0.4993351,"top":0.49720672,"width":0.26196808,"height":0.017557861},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"FieldDefinitionsTest.php .../tests/Unit/Integrations/Crm/FieldDefinitionsTest.php, class","depth":4,"bounds":{"left":0.4993351,"top":0.51476455,"width":0.26196808,"height":0.017557861},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"FieldDefinitionsTest.php .../tests/Unit/Services/Crm/Bullhorn/FieldDefinitionsTest.php, class","depth":4,"bounds":{"left":0.4993351,"top":0.5323224,"width":0.26196808,"height":0.017557861},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"FieldDefinitionsTest.php .../tests/Unit/Services/Crm/Close/FieldDefinitionsTest.php, class","depth":4,"bounds":{"left":0.4993351,"top":0.54988027,"width":0.26196808,"height":0.017557861},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"… more","depth":4,"bounds":{"left":0.4993351,"top":0.5674381,"width":0.26196808,"height":0.017557861},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Models/Crm/FieldData.php","depth":1,"bounds":{"left":0.50598407,"top":0.75259376,"width":0.050199468,"height":0.013567438},"on_screen":true,"help_text":"Models/Crm/FieldData.php","role_description":"text"},{"role":"AXLink","text":"Open In Right Split","depth":1,"bounds":{"left":0.71476066,"top":0.75259376,"width":0.03856383,"height":0.013567438},"on_screen":true,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
-7831095413512522469
|
-278674598107038553
|
visual_change
|
accessibility
|
NULL
|
Project Files
Preview
Filter
Open in Find Tool Win Project Files
Preview
Filter
Open in Find Tool Window
fd
FieldData.php .../app/Models/Crm/FieldData.php, class
FieldDefinitionBuilderTest.php .../tests/Unit/Component/AiAutomation/FieldDefinitionBuilderTest.php, class
FieldDataRepository.php .../app/Repositories/Crm/FieldDataRepository.php, class
FieldDefinitionBuilder.php .../app/Component/AiAutomation/FieldDefinitionBuilder.php, class
FieldData.php.html build/coverage/Models/Crm
FieldDefinitions.php .../app/Services/Crm/Close/FieldDefinitions.php, class
FieldDefinitions.php .../app/Services/Crm/Bullhorn/FieldDefinitions.php, class
FieldDefinitions.php .../app/Services/Crm/Copper/FieldDefinitions.php, class
FieldDefinitions.php .../app/Services/Crm/Hubspot/FieldDefinitions.php, class
FieldDefinitions.php .../app/Services/Crm/Pipedrive/FieldDefinitions.php, class
FieldDefinitions.php .../app/Services/Crm/Salesforce/FieldDefinitions.php, class
FieldDefinitions.php .../app/Services/Crm/IntegrationApp/DTO/Utils/FieldDefinitions.php, class
FieldDefinitionsTest.php .../tests/Unit/Integrations/Crm/FieldDefinitionsTest.php, class
FieldDefinitionsTest.php .../tests/Unit/Services/Crm/Bullhorn/FieldDefinitionsTest.php, class
FieldDefinitionsTest.php .../tests/Unit/Services/Crm/Close/FieldDefinitionsTest.php, class
… more
FieldData.php .../app/Models/Crm/FieldData.php, class
FieldDefinitionBuilderTest.php .../tests/Unit/Component/AiAutomation/FieldDefinitionBuilderTest.php, class
FieldDataRepository.php .../app/Repositories/Crm/FieldDataRepository.php, class
FieldDefinitionBuilder.php .../app/Component/AiAutomation/FieldDefinitionBuilder.php, class
FieldData.php.html build/coverage/Models/Crm
FieldDefinitions.php .../app/Services/Crm/Close/FieldDefinitions.php, class
FieldDefinitions.php .../app/Services/Crm/Bullhorn/FieldDefinitions.php, class
FieldDefinitions.php .../app/Services/Crm/Copper/FieldDefinitions.php, class
FieldDefinitions.php .../app/Services/Crm/Hubspot/FieldDefinitions.php, class
FieldDefinitions.php .../app/Services/Crm/Pipedrive/FieldDefinitions.php, class
FieldDefinitions.php .../app/Services/Crm/Salesforce/FieldDefinitions.php, class
FieldDefinitions.php .../app/Services/Crm/IntegrationApp/DTO/Utils/FieldDefinitions.php, class
FieldDefinitionsTest.php .../tests/Unit/Integrations/Crm/FieldDefinitionsTest.php, class
FieldDefinitionsTest.php .../tests/Unit/Services/Crm/Bullhorn/FieldDefinitionsTest.php, class
FieldDefinitionsTest.php .../tests/Unit/Services/Crm/Close/FieldDefinitionsTest.php, class
… more
Models/Crm/FieldData.php
Open In Right Split...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
55228
|
1914
|
3
|
2026-05-18T13:58:09.790476+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-18/1779 /Users/lukas/.screenpipe/data/data/2026-05-18/1779112689790_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
Ringcentral
RingCentral app/DTO/ImportCall/RingCentral
RingCentral app/Jobs/Activity/RingCentral
RingCentral app/Services/Activity/RingCentral
RingCentral app/Console/Commands/Activities/RingCentral
RingCentral tests/Unit/DTO/ImportCall/RingCentral
RingCentral tests/Unit/Jobs/Activity/RingCentral
RingCentral tests/Unit/Services/Activity/RingCentral
RingCentral build/coverage/DTO/ImportCall/RingCentral
RingCentral build/coverage/Jobs/Activity/RingCentral
RingCentral build/coverage/Services/Activity/RingCentral
RingCentral build/coverage/Console/Commands/Activities/RingCentral
RingCentralException.php .../app/Exceptions/RingCentralException.php, exception class...
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project Files","depth":2,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Preview","depth":2,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Filter","depth":2,"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,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextField","text":"Ringcentral","depth":1,"on_screen":true,"value":"Ringcentral","role_description":"text field","is_enabled":true,"is_focused":true,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"RingCentral app/DTO/ImportCall/RingCentral","depth":2,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"RingCentral app/Jobs/Activity/RingCentral","depth":2,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"RingCentral app/Services/Activity/RingCentral","depth":2,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"RingCentral app/Console/Commands/Activities/RingCentral","depth":2,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"RingCentral tests/Unit/DTO/ImportCall/RingCentral","depth":2,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"RingCentral tests/Unit/Jobs/Activity/RingCentral","depth":2,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"RingCentral tests/Unit/Services/Activity/RingCentral","depth":2,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"RingCentral build/coverage/DTO/ImportCall/RingCentral","depth":2,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"RingCentral build/coverage/Jobs/Activity/RingCentral","depth":2,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"RingCentral build/coverage/Services/Activity/RingCentral","depth":2,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"RingCentral build/coverage/Console/Commands/Activities/RingCentral","depth":2,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"RingCentralException.php .../app/Exceptions/RingCentralException.php, exception class","depth":2,"on_screen":true,"role_description":"text"}]...
|
9079820741256413983
|
-4025493993621278703
|
visual_change
|
accessibility
|
NULL
|
Project Files
Preview
Filter
Open in Find Tool Win Project Files
Preview
Filter
Open in Find Tool Window
Ringcentral
RingCentral app/DTO/ImportCall/RingCentral
RingCentral app/Jobs/Activity/RingCentral
RingCentral app/Services/Activity/RingCentral
RingCentral app/Console/Commands/Activities/RingCentral
RingCentral tests/Unit/DTO/ImportCall/RingCentral
RingCentral tests/Unit/Jobs/Activity/RingCentral
RingCentral tests/Unit/Services/Activity/RingCentral
RingCentral build/coverage/DTO/ImportCall/RingCentral
RingCentral build/coverage/Jobs/Activity/RingCentral
RingCentral build/coverage/Services/Activity/RingCentral
RingCentral build/coverage/Console/Commands/Activities/RingCentral
RingCentralException.php .../app/Exceptions/RingCentralException.php, exception class...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
55227
|
1915
|
0
|
2026-05-18T13:58:05.133101+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-18/1779 /Users/lukas/.screenpipe/data/data/2026-05-18/1779112685133_m2.jpg...
|
PhpStorm
|
faVsco.js – Crm/…/Service.php
|
1
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
PhostormVIewINavicarecodeLaravelKeractorFV faVsco. PhostormVIewINavicarecodeLaravelKeractorFV faVsco.js°9 master k >Proiect vRinaCentralVideoSalesforceSalesioft> D Talkdeskm TeamsD Telus>D Twilio>@ TwilioFlex_ I willorlexDireet• _ I Willo Videc_Uploader› _ VonagewxantDZ00ПZoomBot> ZoomPhoneC) ActivitvcrmFieldsResolver.ohpC) ActivitvLoaService.oho© ActivitvProviderClient.ohn(C) ActivitvProviderRedistrv.oho© ActivityProviderService.php© CallDenormalizerRegistry.phpC) [EMAIL]© DatalmportHandlerInterface.php© MeetingBotService.php© ParticipantConsentService.php(c) DarticinanteService nhnT PecnonceValidation Trait nhnT SalesforceGetUserTrait.php- S/DenormaliserMainCrmDatalrait.onp@ TrackRecordinoFllesizeservice.one© TrackRecordingSizeEnforcer.phpT ValidateEmitProspectEventTrait.phpC AjReports0 AvatarMColondar0 Conference0 Crm> C Bullhorn• D CloseOnoortunitvsvncstrateav• ProcessonProspectSearchStrateav• M TranslatorC) Client.ohr() CloseSxceotion.ohoC) FieldDefinitions.onvc) SieldValueConverter ohnC) Service nhnC) StandardFioldMetadata nhn> MConnenC ActivityController.ong© SoftPhoneManager.phpC) CoreUserRequest.pnpscimProvistoning.ong© CoreUser.php©Crm/…../Service.php Xclass Service extends BaseService 1mpLements* A8 A39 M5 ^248242244 6t749251255 @>282 F285 0>294 6>303 6>326 6t >335336339 61)oublic tunction suncreldcrield Srleld: vo1d$crmField = $this->getClient->fetchCustomFieldDefinition(Sresource, $field->getCrmProviderIdsch1s->mecadacarrocessor->syncrielascrmrlelamprivate function isCustomField(string $fieldId): boolf...}* oinheritdocpublic function importPicklistValues(Field $field): array{...}29 Ф >34 0 >* Oimportant We only support stages on the opportunity object45 đ >* Aparam strinalnul Stunesnublic function imnortStades@arrav Stvnes = null. Ostrina SmissinoStageName = nulb): ?Stade.....Т119* @inheritdocdnato rntle onatosescarean sare, fereten fo natl, Pertng deneortail e mn):* dinheritdoepublic function syncLead(string ScrmId): ?Lead{...}Isul to Lascade, al co Lo146 C)— 150151 C* athrows Except1onpublic function syncAccounts(Carbon Ssince, ?Carbon $to = null): int{...}public function syncAccount(string $crmId): ?Account(...}1 usageorivate function importAccount(ScrmData)• Account!...}* athrows Mnsesycentionpublic function syncOpportunities(array $parameters, ?string $strategy = null): int{...}od with hranch 'macter' hac heon rectored Il Pollback II Confiaure (todav 14-19)= custom.log= laravel.log4 SF jiminny@localhost]4 HS_local [jiminny@localhost]& console [PROD]# console [euyA console [STAGING]© CoachingFeedbackCoachUserln.php Xstohedeclare(scrict_cypes=l)nnamespace Jiminny Component Activ1tySearch rilterbetin1t1on*› use ...final class CoachingFeedbackCoachUserIn extends FilterDefinition implements ValidatedFilterDefinitionInterfaceusadeorivate const int No GROUP 10 = 9993 usagesorivate UserRenository SuserRenository:public function __construct(UserRepository $userRepository)f...hpublic function shouldApplyQueries: boolf...}public function getQueries: FilterDefinitionQueryCollection{...}public function toArray@: array{...hprivate function getOptions(): array{...}public function getValue: array{...}lprivate function getDefaultValue@: array{...}public function aetValidationRules(2strina Sorefix = null): arravs...?public function aetSortOrder@: intf...}public function shouldBeIncluded(Team $team): bool{...}[Platform] Refinemen... 2m left100% 1• Mon 18 May 16:58:05U AskJiminnyReportActivityServiceTest vCascadeImplement Trial OwneFixing Redis Rate LimSalestorce Token FalliSCIM Role Manageme+0 ..inp on LineWARN Metadata found iin doc-comment for methodw9/ 10 tacke done• Write unit tests for SCIM role management10) Contact Mario to vodate customer10 / 10 tasks doneselm role managementtor leamtallor has been successtully implemented. Here's what was completed:Implementation Summary:1. CoreUserRequest DTO - Added role parsina from SCIM requests2. SCIM Constants - Added META ROLES and USER_PATCH_ATTR ROLES constants3. RoleAttr Mutator - Created mutator for handlina role assianments via SCIM PATCH operations.4. ScimProvisioning - Updated to handle roles during user creation with proper validation5. ListenerRoleCannotHaveAdminOrManagerPermissionRule - Created validation rule to prevent listener role fromhaving admin/manager permissions6. CoreUser Response - Updated to include roles in SClM responses7. Unit Tests - Created comprehensive tests for the validation ruleKey Features:• Customers can now assign roles via SCIM (recorder, recorder & voice, analyst, listener, admin, manager)• Validation ensures listener role is onlv assianable if the LISTENER ROLE feature flaa is enabled•LIstener role cannot de comolned with aamin or manager permissions• Uses existing UpdateUserRolesAction for role syncing and logging• Follows platform validation rules (DependentRolesRule, ListenerRoleRequiresListenerFeatureRule)Next Step: Please contact Mario to update the customer that SCIM role management is now available for Teamtailorintearationposwtn chaten vapp/Component/SClM/ Constants.php +3app/Component/SCIM/ @ ScimProvisioning.php +85-15nse/ CoreUser.php +21ann/Comnonent/SCIM/Mutatore/Attributes/Ueer/M PoleAttr.nhn +17-ites/User/ ẞ RoleAttrTest.nhn +224* Reiect alliiAccent alliAsk anvthina (&4-L)« Code SWF-1.6WN Windsurf Toams 208•6 UTF.8io 4 spaces...
|
NULL
|
-6631006506530695928
|
NULL
|
click
|
ocr
|
NULL
|
PhostormVIewINavicarecodeLaravelKeractorFV faVsco. PhostormVIewINavicarecodeLaravelKeractorFV faVsco.js°9 master k >Proiect vRinaCentralVideoSalesforceSalesioft> D Talkdeskm TeamsD Telus>D Twilio>@ TwilioFlex_ I willorlexDireet• _ I Willo Videc_Uploader› _ VonagewxantDZ00ПZoomBot> ZoomPhoneC) ActivitvcrmFieldsResolver.ohpC) ActivitvLoaService.oho© ActivitvProviderClient.ohn(C) ActivitvProviderRedistrv.oho© ActivityProviderService.php© CallDenormalizerRegistry.phpC) [EMAIL]© DatalmportHandlerInterface.php© MeetingBotService.php© ParticipantConsentService.php(c) DarticinanteService nhnT PecnonceValidation Trait nhnT SalesforceGetUserTrait.php- S/DenormaliserMainCrmDatalrait.onp@ TrackRecordinoFllesizeservice.one© TrackRecordingSizeEnforcer.phpT ValidateEmitProspectEventTrait.phpC AjReports0 AvatarMColondar0 Conference0 Crm> C Bullhorn• D CloseOnoortunitvsvncstrateav• ProcessonProspectSearchStrateav• M TranslatorC) Client.ohr() CloseSxceotion.ohoC) FieldDefinitions.onvc) SieldValueConverter ohnC) Service nhnC) StandardFioldMetadata nhn> MConnenC ActivityController.ong© SoftPhoneManager.phpC) CoreUserRequest.pnpscimProvistoning.ong© CoreUser.php©Crm/…../Service.php Xclass Service extends BaseService 1mpLements* A8 A39 M5 ^248242244 6t749251255 @>282 F285 0>294 6>303 6>326 6t >335336339 61)oublic tunction suncreldcrield Srleld: vo1d$crmField = $this->getClient->fetchCustomFieldDefinition(Sresource, $field->getCrmProviderIdsch1s->mecadacarrocessor->syncrielascrmrlelamprivate function isCustomField(string $fieldId): boolf...}* oinheritdocpublic function importPicklistValues(Field $field): array{...}29 Ф >34 0 >* Oimportant We only support stages on the opportunity object45 đ >* Aparam strinalnul Stunesnublic function imnortStades@arrav Stvnes = null. Ostrina SmissinoStageName = nulb): ?Stade.....Т119* @inheritdocdnato rntle onatosescarean sare, fereten fo natl, Pertng deneortail e mn):* dinheritdoepublic function syncLead(string ScrmId): ?Lead{...}Isul to Lascade, al co Lo146 C)— 150151 C* athrows Except1onpublic function syncAccounts(Carbon Ssince, ?Carbon $to = null): int{...}public function syncAccount(string $crmId): ?Account(...}1 usageorivate function importAccount(ScrmData)• Account!...}* athrows Mnsesycentionpublic function syncOpportunities(array $parameters, ?string $strategy = null): int{...}od with hranch 'macter' hac heon rectored Il Pollback II Confiaure (todav 14-19)= custom.log= laravel.log4 SF jiminny@localhost]4 HS_local [jiminny@localhost]& console [PROD]# console [euyA console [STAGING]© CoachingFeedbackCoachUserln.php Xstohedeclare(scrict_cypes=l)nnamespace Jiminny Component Activ1tySearch rilterbetin1t1on*› use ...final class CoachingFeedbackCoachUserIn extends FilterDefinition implements ValidatedFilterDefinitionInterfaceusadeorivate const int No GROUP 10 = 9993 usagesorivate UserRenository SuserRenository:public function __construct(UserRepository $userRepository)f...hpublic function shouldApplyQueries: boolf...}public function getQueries: FilterDefinitionQueryCollection{...}public function toArray@: array{...hprivate function getOptions(): array{...}public function getValue: array{...}lprivate function getDefaultValue@: array{...}public function aetValidationRules(2strina Sorefix = null): arravs...?public function aetSortOrder@: intf...}public function shouldBeIncluded(Team $team): bool{...}[Platform] Refinemen... 2m left100% 1• Mon 18 May 16:58:05U AskJiminnyReportActivityServiceTest vCascadeImplement Trial OwneFixing Redis Rate LimSalestorce Token FalliSCIM Role Manageme+0 ..inp on LineWARN Metadata found iin doc-comment for methodw9/ 10 tacke done• Write unit tests for SCIM role management10) Contact Mario to vodate customer10 / 10 tasks doneselm role managementtor leamtallor has been successtully implemented. Here's what was completed:Implementation Summary:1. CoreUserRequest DTO - Added role parsina from SCIM requests2. SCIM Constants - Added META ROLES and USER_PATCH_ATTR ROLES constants3. RoleAttr Mutator - Created mutator for handlina role assianments via SCIM PATCH operations.4. ScimProvisioning - Updated to handle roles during user creation with proper validation5. ListenerRoleCannotHaveAdminOrManagerPermissionRule - Created validation rule to prevent listener role fromhaving admin/manager permissions6. CoreUser Response - Updated to include roles in SClM responses7. Unit Tests - Created comprehensive tests for the validation ruleKey Features:• Customers can now assign roles via SCIM (recorder, recorder & voice, analyst, listener, admin, manager)• Validation ensures listener role is onlv assianable if the LISTENER ROLE feature flaa is enabled•LIstener role cannot de comolned with aamin or manager permissions• Uses existing UpdateUserRolesAction for role syncing and logging• Follows platform validation rules (DependentRolesRule, ListenerRoleRequiresListenerFeatureRule)Next Step: Please contact Mario to update the customer that SCIM role management is now available for Teamtailorintearationposwtn chaten vapp/Component/SClM/ Constants.php +3app/Component/SCIM/ @ ScimProvisioning.php +85-15nse/ CoreUser.php +21ann/Comnonent/SCIM/Mutatore/Attributes/Ueer/M PoleAttr.nhn +17-ites/User/ ẞ RoleAttrTest.nhn +224* Reiect alliiAccent alliAsk anvthina (&4-L)« Code SWF-1.6WN Windsurf Toams 208•6 UTF.8io 4 spaces...
|
55221
|
NULL
|
NULL
|
NULL
|
|
55226
|
1914
|
2
|
2026-05-18T13:58:05.133180+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-18/1779 /Users/lukas/.screenpipe/data/data/2026-05-18/1779112685133_m1.jpg...
|
PhpStorm
|
faVsco.js – Crm/…/Service.php
|
1
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
FirefoxFileEditViewHistoryBookmarksProfiles• 0→Too FirefoxFileEditViewHistoryBookmarksProfiles• 0→ToolsWindowHelpmeet.google.com/cxs-eips-npt?authuser=lukas.kovalik%40jiminny.com[Platform] Refinemen... 2 m left100% C8 • Mon 18 May 16:58:05)=40 5Galya DimitrovaNikolay Yankov*Nikolay IvanovAneliya AngelovaLukas Kovalik4:58 PM | [Platform] Refinement •1:44:32...
|
NULL
|
739323608817908319
|
NULL
|
click
|
ocr
|
NULL
|
FirefoxFileEditViewHistoryBookmarksProfiles• 0→Too FirefoxFileEditViewHistoryBookmarksProfiles• 0→ToolsWindowHelpmeet.google.com/cxs-eips-npt?authuser=lukas.kovalik%40jiminny.com[Platform] Refinemen... 2 m left100% C8 • Mon 18 May 16:58:05)=40 5Galya DimitrovaNikolay Yankov*Nikolay IvanovAneliya AngelovaLukas Kovalik4:58 PM | [Platform] Refinement •1:44:32...
|
55225
|
NULL
|
NULL
|
NULL
|
|
55225
|
1914
|
1
|
2026-05-18T13:58:03.743521+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-18/1779 /Users/lukas/.screenpipe/data/data/2026-05-18/1779112683743_m1.jpg...
|
PhpStorm
|
faVsco.js – Crm/…/Service.php
|
1
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
FirefoxFileEditViewHistoryBookmarksProfiles→ToolsW FirefoxFileEditViewHistoryBookmarksProfiles→ToolsWindowHelpmeet.google.com/cxs-eips-npt?authuser=lukas.kovalik%40jiminny.com[Platform] Refinemen... 2 m left100% <28 • Mon 18 May 16:58:03)40 5Galya DimitrovaNikolay Yankov*Nikolay IvanovAneliya AngelovaLukas Kovalik4:58 PM | [Platform] Refinement ®1:44:30...
|
NULL
|
-6670935600957242123
|
NULL
|
visual_change
|
ocr
|
NULL
|
FirefoxFileEditViewHistoryBookmarksProfiles→ToolsW FirefoxFileEditViewHistoryBookmarksProfiles→ToolsWindowHelpmeet.google.com/cxs-eips-npt?authuser=lukas.kovalik%40jiminny.com[Platform] Refinemen... 2 m left100% <28 • Mon 18 May 16:58:03)40 5Galya DimitrovaNikolay Yankov*Nikolay IvanovAneliya AngelovaLukas Kovalik4:58 PM | [Platform] Refinement ®1:44:30...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
55224
|
1914
|
0
|
2026-05-18T13:58:00.720229+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-18/1779 /Users/lukas/.screenpipe/data/data/2026-05-18/1779112680720_m1.jpg...
|
PhpStorm
|
faVsco.js – Crm/…/Service.php
|
1
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
master, menu
Start Listen Project: faVsco.js, menu
master, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Code changed:
Hide
Sync Changes
Hide This Notification
8
39
5
Previous Highlighted Error...
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"master, menu","depth":5,"on_screen":true,"help_text":"Git Branch: master<br/>114 incoming commits<br/>","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"AskJiminnyReportActivityServiceTest","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'AskJiminnyReportActivityServiceTest'","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'AskJiminnyReportActivityServiceTest'","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"IDE and Project Settings","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"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,"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}]...
|
-6721269282289072541
|
-8348545464111354938
|
visual_change
|
hybrid
|
NULL
|
Project: faVsco.js, menu
master, menu
Start Listen Project: faVsco.js, menu
master, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Code changed:
Hide
Sync Changes
Hide This Notification
8
39
5
Previous Highlighted Error
FirefoxFileEditViewHistoryBookmarksProfiles• 0→ToolsWindowHelpmeet.google.com/cxs-eips-npt?authuser=lukas.kovalik%40jiminny.com[Platform] Refinemen... 3 m left100% C78 • Mon 18 May 16:58:0040 5Galya DimitrovaNikolay Yankov*Nikolay IvanovAneliya AngelovaLukas Kovalik4:58 PM | [Platform] Refinement •1:44:27...
|
55223
|
NULL
|
NULL
|
NULL
|
|
55223
|
NULL
|
0
|
2026-05-18T13:57:51.571340+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-18/1779 /Users/lukas/.screenpipe/data/data/2026-05-18/1779112671571_m1.jpg...
|
PhpStorm
|
faVsco.js – Crm/…/Service.php
|
1
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
master, menu
Start Listen Project: faVsco.js, menu
master, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
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\OpportunityMetadata;
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(OpportunityMetadata $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());
}
/** @var Profile $profile */
$profile = $this->getConfiguration()
->profiles()
->where('crm_provider_id', $crmData->getUserId())
->first();
$userId = $profile?->getUserId() ?? $account?->getUserId();
if ($userId === null) {
$this->logger->error('[Close] | Skip import, no user_id found', [
'id' => $crmData->getId(),
]);
return null;
}
$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(),
]);
...
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"master, menu","depth":5,"on_screen":true,"help_text":"Git Branch: master<br/>114 incoming commits<br/>","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"AskJiminnyReportActivityServiceTest","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'AskJiminnyReportActivityServiceTest'","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'AskJiminnyReportActivityServiceTest'","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"IDE and Project Settings","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"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,"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\\OpportunityMetadata;\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(OpportunityMetadata $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 if ($account === null) {\n $account = $this->accountProcessor->syncAccount($crmData->getLeadId());\n }\n\n /** @var Profile $profile */\n $profile = $this->getConfiguration()\n ->profiles()\n ->where('crm_provider_id', $crmData->getUserId())\n ->first();\n\n $userId = $profile?->getUserId() ?? $account?->getUserId();\n if ($userId === null) {\n $this->logger->error('[Close] | Skip import, no user_id found', [\n 'id' => $crmData->getId(),\n ]);\n\n return null;\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 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\\OpportunityMetadata;\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(OpportunityMetadata $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 if ($account === null) {\n $account = $this->accountProcessor->syncAccount($crmData->getLeadId());\n }\n\n /** @var Profile $profile */\n $profile = $this->getConfiguration()\n ->profiles()\n ->where('crm_provider_id', $crmData->getUserId())\n ->first();\n\n $userId = $profile?->getUserId() ?? $account?->getUserId();\n if ($userId === null) {\n $this->logger->error('[Close] | Skip import, no user_id found', [\n 'id' => $crmData->getId(),\n ]);\n\n return null;\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 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":"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":"1","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
-6754415607117048428
|
-9030663327281178587
|
visual_change
|
accessibility
|
NULL
|
Project: faVsco.js, menu
master, menu
Start Listen Project: faVsco.js, menu
master, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
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\OpportunityMetadata;
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(OpportunityMetadata $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());
}
/** @var Profile $profile */
$profile = $this->getConfiguration()
->profiles()
->where('crm_provider_id', $crmData->getUserId())
->first();
$userId = $profile?->getUserId() ?? $account?->getUserId();
if ($userId === null) {
$this->logger->error('[Close] | Skip import, no user_id found', [
'id' => $crmData->getId(),
]);
return null;
}
$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(),
]);
...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
55222
|
1912
|
85
|
2026-05-18T13:57:48.556374+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-18/1779 /Users/lukas/.screenpipe/data/data/2026-05-18/1779112668556_m1.jpg...
|
PhpStorm
|
faVsco.js – Crm/…/Service.php
|
1
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
master, menu
Start Listen Project: faVsco.js, menu
master, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Code changed:
Hide...
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"master, menu","depth":5,"on_screen":true,"help_text":"Git Branch: master<br/>114 incoming commits<br/>","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"AskJiminnyReportActivityServiceTest","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'AskJiminnyReportActivityServiceTest'","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'AskJiminnyReportActivityServiceTest'","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"IDE and Project Settings","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"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}]...
|
6361780243830459554
|
-7051217364339675194
|
visual_change
|
hybrid
|
NULL
|
Project: faVsco.js, menu
master, menu
Start Listen Project: faVsco.js, menu
master, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Code changed:
Hide
FirefoxFileEditViewHistory→BookmarksProfilesToolsWindowHelpmeet.google.com/cxs-eips-npt?authuser=lukas.kovalik%40jiminny.com[Platform] Refinemen... 3 m left100% <78 • Mon 18 May 16:57:48)=A05Galya DimitrovaNikolay Yankov*Nikolay IvanovAneliya AngelovaLukas Kovalik4:57 PM | [Platform] Refinement®1:44:15...
|
55220
|
NULL
|
NULL
|
NULL
|
|
55221
|
NULL
|
0
|
2026-05-18T13:57:46.520143+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-18/1779 /Users/lukas/.screenpipe/data/data/2026-05-18/1779112666520_m2.jpg...
|
PhpStorm
|
faVsco.js – Crm/…/Service.php
|
1
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
PhostormVIewINavicarecodeLaravelKeractorFV faVsco. PhostormVIewINavicarecodeLaravelKeractorFV faVsco.js°9 master k >ProiectRinaCentralVideoSalesforceIm Salesloft> D Talkdeskm TeamsD Telus>D Twilio>@ TwilioFlex_ I willorlexDireet• _ I Willo Videc_Uploader› _ VonagewxantDZ00ПZoomBot|> ZoomPhoneC) ActivitvcrmFieldsResolver.ohpC) ActivitvLoaService.oho© ActivitvProviderClient.ohp(C) ActivitvProviderRedistrv.ohoC. ActivitvProviderService.ohv© CallDenormalizerRegistry.phpC) [EMAIL]© DatalmportHandlerInterface.php© MeetingBotService.php© ParticipantConsentService.php(c) DarticinanteService nhnT PecnonceValidation Trait nhnT SalesforceGetUserTrait.php- S/DenormaliserMainCrmDatalrait.onp@ TrackRecordinoFllesizeservice.one©1rаскkecoraingsizetntorcer.onpT ValidateEmitProspectEventTrait.phpC AjReports0 AvatarMColondar0 Conferencem Crm> C Bullhorn• D CloseOnoortunitvsvncstrateav• ProcessonProspectSearchStrateav• M TranslatorC) Client.ohr() CloseSxceotion.ohoC) FieldDefinitions.onvc) SieldValueConverter ohn•) Service nhnC) StandardFioldMetadata nhn> MConnen© SoftPhoneManager.phpC) CoreUserRequest.pnp© CoreUser.php© ACtivity/.../Service.php©Crm/…../Service.php Xclass Service extends BaseService 1mpLements* 48 439 M5 ^248244 6t749255 @>284285 0>294 6t>300 F303 6326 6t >336339 61)oublic tunction suncreldcrield Srleld: vo1d$crmField = $this->getClient->fetchCustomFieldDefinition(Sresource, $field->getCrmProviderIdsch1s->mecadacarrocessor->syncrielascrmrlelamprivate function isCustomField(string $fieldId): boolf...}* oinheritdocpublic function importPicklistValues(Field $field): array{...}29 Ф >34 0 >* Oimportant We only support stages on the opportunity object45 đ >* Aparam strinalnul Stunesnublic function imnortStades@arrav Stvnes = null. Ostrina SmissinoStageName = nulb): ?Stade.....Т119* @inheritdocdnato rntle onatosescarean sare, fereten fo natl, Pertng deneortail e mn):* oinertcdoc1146 Gpublic function syncLead(string ScrmId): ?Lead{...}151 C* athrows Except1onpublic function svncAccounts(Carbon Ssince. ?Carbon $to = null): int{...}public function syncAccount(string $crmId): ?Account(...}1 usageorivate function importAccount(ScrmData)• Account!...}* athrows Mnsesycentionpublic function syncOpportunities(array $parameters, ?string $strategy = null): int{...}cter' hac heon rectored /l Pollback II Confiaure (todav 14-19)= custom.log= laravel.log« SF jiminny@localhost]4 HS_local [jiminny@localhost]& console [PROD]# console [euyA console [STAGING]© CoachingFeedbackCoachUserln.php Xstohedeclare(scrict_cypes=l)nnamespace Jiminny Component Activ1tySearch rilterbetin1t1on*› use ...final class CoachingFeedbackCoachUserIn extends FilterDefinition implements ValidatedFilterDefinitionInterface1usadeorivate const int No GROUP 10 = 9993 usagesorivate UserRenository SuserRenository:public function __construct(UserRepository $userRepository)f...hpublic function shouldApplyQueries: boolf...}public function getQueries: FilterDefinitionQueryCollection{...}public function toArray@: array{...;private function getOptions(): array{...}public function getValue®: array{...}private function getDefaultValue@: array{...}public function aetValidationRules(2strina Sorefix = null): arravs...?public function aetSortOrder@: intf...}public function shouldBeIncluded(Team $team): bool{...}[Platform] Refinemen... 3 m left100% 2• Mon 18 May 16:57:45U AskJiminnyReportActivityServiceTest vCascadeImplement Trial OwneFixing Redis Rate LimSalestorce Token FalliSCIM Role Manageme+0 ..inp on LineWARN Metadata found iin doc-comment for methodw9/ 10 tacke done• Write unit tests for SCIM role management10) Contact Mario to vodate customer10 / 10 tasks doneselm role managementtor leamtallor has been successtully implemented. Here's what was completed:Implementation Summary:1. CoreUserRequest DTO - Added role parsina from SCIM requests2. SCIM Constants - Added META ROLES and USER_PATCH_ATTR ROLES constants3. RoleAttr Mutator - Created mutator for handlina role assianments via SCIM PATCH operations.4. ScimProvisioning - Updated to handle roles during user creation with proper validation5. ListenerRoleCannotHaveAdminOrManagerPermissionRule - Created validation rule to prevent listener role fromhaving admin/manager permissions6. CoreUser Response - Updated to include roles in SClM responses7. Unit Tests - Created comprehensive tests for the validation ruleKey Features:• Customers can now assign roles via SCIM (recorder, recorder & voice, analyst, listener, admin, manager)• Validation ensures listener role is onlv assianable if the LISTENER ROLE feature flaa is enabled•LIstener role cannot de comolned with aamin or manager permissions• Uses existing UpdateUserRolesAction for role syncing and logging• Follows platform validation rules (DependentRolesRule, ListenerRoleRequiresListenerFeatureRule)Next Step: Please contact Mario to update the customer that SCIM role management is now available for Teamtailorintearationposwtn chaten vapp/Component/SClM/ Constants.php +3app/Component/SCIM/ @ ScimProvisioning.php +85-15nse/ CoreUser.php +21ann/Comnonent/SCIM/Mutatore/Attributes/Ueer/M PoleAttr.nhn +17-ites/User/ ẞ RoleAttrTest.nhn +224* Reiect alliiAccent alliAsk anvthina (&4-L)« Code SWF-1.6WN Windsurf Teamo164•6UTE.8io 4 spaces...
|
NULL
|
-2376477822431713534
|
NULL
|
click
|
ocr
|
NULL
|
PhostormVIewINavicarecodeLaravelKeractorFV faVsco. PhostormVIewINavicarecodeLaravelKeractorFV faVsco.js°9 master k >ProiectRinaCentralVideoSalesforceIm Salesloft> D Talkdeskm TeamsD Telus>D Twilio>@ TwilioFlex_ I willorlexDireet• _ I Willo Videc_Uploader› _ VonagewxantDZ00ПZoomBot|> ZoomPhoneC) ActivitvcrmFieldsResolver.ohpC) ActivitvLoaService.oho© ActivitvProviderClient.ohp(C) ActivitvProviderRedistrv.ohoC. ActivitvProviderService.ohv© CallDenormalizerRegistry.phpC) [EMAIL]© DatalmportHandlerInterface.php© MeetingBotService.php© ParticipantConsentService.php(c) DarticinanteService nhnT PecnonceValidation Trait nhnT SalesforceGetUserTrait.php- S/DenormaliserMainCrmDatalrait.onp@ TrackRecordinoFllesizeservice.one©1rаскkecoraingsizetntorcer.onpT ValidateEmitProspectEventTrait.phpC AjReports0 AvatarMColondar0 Conferencem Crm> C Bullhorn• D CloseOnoortunitvsvncstrateav• ProcessonProspectSearchStrateav• M TranslatorC) Client.ohr() CloseSxceotion.ohoC) FieldDefinitions.onvc) SieldValueConverter ohn•) Service nhnC) StandardFioldMetadata nhn> MConnen© SoftPhoneManager.phpC) CoreUserRequest.pnp© CoreUser.php© ACtivity/.../Service.php©Crm/…../Service.php Xclass Service extends BaseService 1mpLements* 48 439 M5 ^248244 6t749255 @>284285 0>294 6t>300 F303 6326 6t >336339 61)oublic tunction suncreldcrield Srleld: vo1d$crmField = $this->getClient->fetchCustomFieldDefinition(Sresource, $field->getCrmProviderIdsch1s->mecadacarrocessor->syncrielascrmrlelamprivate function isCustomField(string $fieldId): boolf...}* oinheritdocpublic function importPicklistValues(Field $field): array{...}29 Ф >34 0 >* Oimportant We only support stages on the opportunity object45 đ >* Aparam strinalnul Stunesnublic function imnortStades@arrav Stvnes = null. Ostrina SmissinoStageName = nulb): ?Stade.....Т119* @inheritdocdnato rntle onatosescarean sare, fereten fo natl, Pertng deneortail e mn):* oinertcdoc1146 Gpublic function syncLead(string ScrmId): ?Lead{...}151 C* athrows Except1onpublic function svncAccounts(Carbon Ssince. ?Carbon $to = null): int{...}public function syncAccount(string $crmId): ?Account(...}1 usageorivate function importAccount(ScrmData)• Account!...}* athrows Mnsesycentionpublic function syncOpportunities(array $parameters, ?string $strategy = null): int{...}cter' hac heon rectored /l Pollback II Confiaure (todav 14-19)= custom.log= laravel.log« SF jiminny@localhost]4 HS_local [jiminny@localhost]& console [PROD]# console [euyA console [STAGING]© CoachingFeedbackCoachUserln.php Xstohedeclare(scrict_cypes=l)nnamespace Jiminny Component Activ1tySearch rilterbetin1t1on*› use ...final class CoachingFeedbackCoachUserIn extends FilterDefinition implements ValidatedFilterDefinitionInterface1usadeorivate const int No GROUP 10 = 9993 usagesorivate UserRenository SuserRenository:public function __construct(UserRepository $userRepository)f...hpublic function shouldApplyQueries: boolf...}public function getQueries: FilterDefinitionQueryCollection{...}public function toArray@: array{...;private function getOptions(): array{...}public function getValue®: array{...}private function getDefaultValue@: array{...}public function aetValidationRules(2strina Sorefix = null): arravs...?public function aetSortOrder@: intf...}public function shouldBeIncluded(Team $team): bool{...}[Platform] Refinemen... 3 m left100% 2• Mon 18 May 16:57:45U AskJiminnyReportActivityServiceTest vCascadeImplement Trial OwneFixing Redis Rate LimSalestorce Token FalliSCIM Role Manageme+0 ..inp on LineWARN Metadata found iin doc-comment for methodw9/ 10 tacke done• Write unit tests for SCIM role management10) Contact Mario to vodate customer10 / 10 tasks doneselm role managementtor leamtallor has been successtully implemented. Here's what was completed:Implementation Summary:1. CoreUserRequest DTO - Added role parsina from SCIM requests2. SCIM Constants - Added META ROLES and USER_PATCH_ATTR ROLES constants3. RoleAttr Mutator - Created mutator for handlina role assianments via SCIM PATCH operations.4. ScimProvisioning - Updated to handle roles during user creation with proper validation5. ListenerRoleCannotHaveAdminOrManagerPermissionRule - Created validation rule to prevent listener role fromhaving admin/manager permissions6. CoreUser Response - Updated to include roles in SClM responses7. Unit Tests - Created comprehensive tests for the validation ruleKey Features:• Customers can now assign roles via SCIM (recorder, recorder & voice, analyst, listener, admin, manager)• Validation ensures listener role is onlv assianable if the LISTENER ROLE feature flaa is enabled•LIstener role cannot de comolned with aamin or manager permissions• Uses existing UpdateUserRolesAction for role syncing and logging• Follows platform validation rules (DependentRolesRule, ListenerRoleRequiresListenerFeatureRule)Next Step: Please contact Mario to update the customer that SCIM role management is now available for Teamtailorintearationposwtn chaten vapp/Component/SClM/ Constants.php +3app/Component/SCIM/ @ ScimProvisioning.php +85-15nse/ CoreUser.php +21ann/Comnonent/SCIM/Mutatore/Attributes/Ueer/M PoleAttr.nhn +17-ites/User/ ẞ RoleAttrTest.nhn +224* Reiect alliiAccent alliAsk anvthina (&4-L)« Code SWF-1.6WN Windsurf Teamo164•6UTE.8io 4 spaces...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
55220
|
1912
|
84
|
2026-05-18T13:57:46.514964+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-18/1779 /Users/lukas/.screenpipe/data/data/2026-05-18/1779112666514_m1.jpg...
|
PhpStorm
|
faVsco.js – Crm/…/Service.php
|
1
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
FirefoxFileEditViewHistoryBookmarksProfiles• 0→To FirefoxFileEditViewHistoryBookmarksProfiles• 0→ToolsWindowHelpmeet.google.com/cxs-eips-npt?authuser=lukas.kovalik%40jiminny.com[Platform) Refinemen….. 3 m left100% <28 • Mon 18 May 16:57:45)=Galya Dimitrova (Presenting)Galya DimitrovaNikolay YankovNikolay IvanovAneliya AngelovaLukas Kovalik4:57 PM | [Platform] Refinement ®1:44:13Lộ3...
|
NULL
|
-9220807439074457183
|
NULL
|
click
|
ocr
|
NULL
|
FirefoxFileEditViewHistoryBookmarksProfiles• 0→To FirefoxFileEditViewHistoryBookmarksProfiles• 0→ToolsWindowHelpmeet.google.com/cxs-eips-npt?authuser=lukas.kovalik%40jiminny.com[Platform) Refinemen….. 3 m left100% <28 • Mon 18 May 16:57:45)=Galya Dimitrova (Presenting)Galya DimitrovaNikolay YankovNikolay IvanovAneliya AngelovaLukas Kovalik4:57 PM | [Platform] Refinement ®1:44:13Lộ3...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
55219
|
1912
|
83
|
2026-05-18T13:57:38.306238+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-18/1779 /Users/lukas/.screenpipe/data/data/2026-05-18/1779112658306_m1.jpg...
|
PhpStorm
|
faVsco.js – Crm/…/Service.php
|
1
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
master, menu
Start Listen Project: faVsco.js, menu
master, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
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\OpportunityMetadata;
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(OpportunityMetadata $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());
}
/** @var Profile $profile */
$profile = $this->getConfiguration()
->profiles()
->where('crm_provider_id', $crmData->getUserId())
->first();
$userId = $profile?->getUserId() ?? $account?->getUserId();
if ($userId === null) {
$this->logger->error('[Close] | Skip import, no user_id found', [
'id' => $crmData->getId(),
]);
return null;
}
$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(),
]);
...
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"master, menu","depth":5,"on_screen":true,"help_text":"Git Branch: master<br/>114 incoming commits<br/>","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"AskJiminnyReportActivityServiceTest","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'AskJiminnyReportActivityServiceTest'","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'AskJiminnyReportActivityServiceTest'","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"IDE and Project Settings","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"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,"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\\OpportunityMetadata;\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(OpportunityMetadata $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 if ($account === null) {\n $account = $this->accountProcessor->syncAccount($crmData->getLeadId());\n }\n\n /** @var Profile $profile */\n $profile = $this->getConfiguration()\n ->profiles()\n ->where('crm_provider_id', $crmData->getUserId())\n ->first();\n\n $userId = $profile?->getUserId() ?? $account?->getUserId();\n if ($userId === null) {\n $this->logger->error('[Close] | Skip import, no user_id found', [\n 'id' => $crmData->getId(),\n ]);\n\n return null;\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 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\\OpportunityMetadata;\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(OpportunityMetadata $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 if ($account === null) {\n $account = $this->accountProcessor->syncAccount($crmData->getLeadId());\n }\n\n /** @var Profile $profile */\n $profile = $this->getConfiguration()\n ->profiles()\n ->where('crm_provider_id', $crmData->getUserId())\n ->first();\n\n $userId = $profile?->getUserId() ?? $account?->getUserId();\n if ($userId === null) {\n $this->logger->error('[Close] | Skip import, no user_id found', [\n 'id' => $crmData->getId(),\n ]);\n\n return null;\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 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}]...
|
-6754415607117048428
|
-9030663327281178587
|
click
|
accessibility
|
NULL
|
Project: faVsco.js, menu
master, menu
Start Listen Project: faVsco.js, menu
master, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
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\OpportunityMetadata;
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(OpportunityMetadata $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());
}
/** @var Profile $profile */
$profile = $this->getConfiguration()
->profiles()
->where('crm_provider_id', $crmData->getUserId())
->first();
$userId = $profile?->getUserId() ?? $account?->getUserId();
if ($userId === null) {
$this->logger->error('[Close] | Skip import, no user_id found', [
'id' => $crmData->getId(),
]);
return null;
}
$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(),
]);
...
|
55217
|
NULL
|
NULL
|
NULL
|
|
55218
|
1913
|
27
|
2026-05-18T13:57:34.849597+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-18/1779 /Users/lukas/.screenpipe/data/data/2026-05-18/1779112654849_m2.jpg...
|
PhpStorm
|
faVsco.js – Crm/…/Service.php
|
1
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
master, menu
Start Listen Project: faVsco.js, menu
master, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest...
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"bounds":{"left":0.025930852,"top":0.019952115,"width":0.03856383,"height":0.025538707},"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"master, menu","depth":5,"bounds":{"left":0.064494684,"top":0.019952115,"width":0.040226065,"height":0.025538707},"on_screen":true,"help_text":"Git Branch: master<br/>114 incoming commits<br/>","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"bounds":{"left":0.8081782,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"AskJiminnyReportActivityServiceTest","depth":6,"bounds":{"left":0.8234708,"top":0.019952115,"width":0.09208777,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
5222591262487654398
|
-8636637129462003327
|
click
|
hybrid
|
NULL
|
Project: faVsco.js, menu
master, menu
Start Listen Project: faVsco.js, menu
master, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
PhostormVIewINavicarecodeLaravelKeractorFV faVsco.js°9 master k >Proiect vRinaCentralVideo• SalesforceIm Salesloft> D Talkdeskm Teams>D Telus>D Twilio>@ TwilioFlex_ I willorlexDireet• _ I willoVideo_Uploader› _ Vonagewxant>400mZoomBot> ZoomPhoneC) ActivitvcrmFieldsResolver.ohpC) ActivitvLoaService.oho© ActivitvProviderClient.ohn(C) ActivitvProviderRedistrv.oho© ActivityProviderService.php© CallDenormalizerRegistry.phpC) [EMAIL]© DatalmportHandlerInterface.php© MeetingBotService.php© ParticipantConsentService.php(c) DarticinanteService nhnT PecnonceValidation Trait nhnT SalesforceGetUserTrait.php- S/DenormaliserMainCrmDatalrait.onp@ TrackRecordinoFllesizeservice.one© TrackRecordingSizeEnforcer.phpT ValidateEmitProspectEventTrait.phpC AjReports0 AvatarMColondar0 Conference0 Crm> C Bullhornv closeOnoortunitvsvncstrateav• ProcessonProspectSearchStrateav• M TranslatorC) Client.oho() CloseSxceotion.ohoC) FieldDefinitions.onvc) SieldValueConverter ohnC) Service nhnC) StandardFioldMetadata nhn> MConnenC ActivityController.ong© SoftPhoneManager.php(C) CoreUserRequest.onpscimProvistoning.ong© CoreUser.phpC ACtiVity/.../Service.php©Crm/…../Service.php Xclass Service extends BaseService 1mpLementsm A8 A39 M5 лV127 (or)132 (011MoTS147 E150 ›169 G19416189 Gt >194 6t >213 G215217public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): strprotected function getFieldTypes: array{...}protected function getFields(string $crm0bject): array{...}* cinherzzdocpublic function getDefaultFields(string $activityType): array{...}*dinherzzdocpublic function getdefaul tactivitvfieldstring Sactivitvivoe): FieldiSet un the activity field as the default Tvoe.** ovar Field Sactiviturield *^SactivityField = Sthis->confia->fields(->where(ferm provider 1d' =>"type','object_type' => SactivitvTyoe.ll1->firstO:return SactivitvField.* @inheritdocpublic function getSupportedPlaybookTypes: arrayreturn [Playbook::ACTIVITY_TYPE_TASK]:public function getDealInsightsFieldsO: arrav{...}protected function getDefaultFollowupLavoutFields(string SactivityType): arrav{...}public function syncFieldso: vo1dsthis->syncStandardFieldso:sthns->svnccustomfteldsoed with hranch 'macter' hac heon rectored Il Pollback II Confiaure (todav 14-19)= custom.log= laravel.log4 SF jiminny@localhost]4 HS_local [jiminny@localhost]& console [PROD]# console [euyA console [STAGING]© CoachingFeedbackCoachUserln.php Xstohedeclare(scrict_cypes=l)nnamespace Jiminny Component Activ1tySearch rilterbetin1t1on*› use ...final class CoachingFeedbackCoachUserIn extends FilterDefinition implements ValidatedFilterDefinitionInterface29 Ф >34 0 >45 đ >=119131-139— 156 U146 C)— 150|151 Gt :1usadeorivate const int No GROUP 10 = 9993 usagesorivate UserRenository suserRenository:public function __construct(UserRepository $userRepository)f...hpublic function shouldApplyQueries: boolf...}public function getQueries: FilterDefinitionQueryCollection...hpublic function toArray: array{...hprivate function getOptions(): array{...}public function getValue: array{...}private function getDefaultValue: array{...}public function aetValidationRules(2strina Sorefix = null): arravs...?public function aetSortOrder@: intf...}public function shouldBeIncluded(Team $team): bool{...}[Platform] Refinemen... 3 m left100% 2• Mon 18 May 16:57:34AskJiminnyReportActivityServiceTest vCascadeImplement Trial OwneFixing Redis Rate LimSalestorce Token FalliSClM Role Manageme+0 ..inp on LineWARN Metadata found iin doc-comment for methodw9/ 10 tacke done• Write unit tests for SCIM role management10) Contact Mario to vodate customer10 / 10 tasks doneselm role managementtor leamtallor has been successtully implemented. Here's what was completed:Implementation Summary:1. CoreUserRequest DTO - Added role parsina from SCIM requests2. SCIM Constants - Added META ROLES and USER_PATCH_ATTR ROLES constants3. RoleAttr Mutator - Created mutator for handlina role assianments via SCIM PATCH operations4. ScimProvisioning - Updated to handle roles during user creation with proper validation5. ListenerRoleCannotHaveAdminOrManagerPermissionRule - Created validation rule to prevent listener role fromhaving admin/manager permissions6. CoreUser Response - Updated to include roles in SClM responses7. Unit Tests - Created comprehensive tests for the validation ruleKey Features:• Customers can now assign roles via SCIM (recorder, recorder & voice, analyst, listener, admin, manager)• Validation ensures listener role is onlv assianable if the LISTENER ROLE feature flaa is enabledl•LIstener role cannot de comolned with aamin or manager permissions• Uses existing UpdateUserRolesAction for role syncing and logging• Follows platform validation rules (DependentRolesRule, ListenerRoleRequiresListenerFeatureRule)Next Step: Please contact Mario to update the customer that SCIM role management is now available for Teamtailorintearationposwtn chaten vapp/Component/SClM/ Constants.php +3app/Component/SCIM/ @ ScimProvisioning.php +85-15nse/ CoreUser.php +21ann/Comnonent/SCIM/Mutatore/Attributes/Ueer/M PoleAttr.nhn +17-ites/User/ ẞ RoleAttrTest.nhn +224* Reiect alliiAccent alliAsk anvthina (&4-L)« Code SWF-1.6W Windsurf Toams 175-41UTF.8io 4 spaces...
|
55216
|
NULL
|
NULL
|
NULL
|
|
55217
|
1912
|
82
|
2026-05-18T13:57:34.823865+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-18/1779 /Users/lukas/.screenpipe/data/data/2026-05-18/1779112654823_m1.jpg...
|
PhpStorm
|
faVsco.js – Crm/…/Service.php
|
1
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
FirefoxFileEditViewHistoryBookmarksProfilesToolsWi FirefoxFileEditViewHistoryBookmarksProfilesToolsWindowHelp[Platform) Refinemen... 3 m left)• Mon 18 May 16:57:34meet.google.com/cxs-eips-npt?authuser=lukas.kovalik%40jiminny.comGalya Dimitrova (Presenting, annotating)ChromeFileEditViewHistoryBookmarksProfilesTabWindowHelpmeet.google.coClaude (MCP)|Claude (MCP)881HomeGalya Dimitrova (You, presentir+DMsActivityLaterMoreJiminny Inc~E becky butler, GregE becky butler, Greg. Oli..4º, CalumCalum, Greg. James Gr...& Desislav DamyanovC. Gabriela Dureva&. GregGreg. Oliver (Oly)P Ilian Kyuchukov&. James Graham X2. Lukas Kovaliki. Mario Georgiev. Martin PetkovC. Nikolay IvanovD, Nikolay Nikolov "7P, Nikolay Yankov48 Oliver (Oly) E. Petko Kashinskilâ. Stefka Stoyanova. Steliyan Georgiev2 Stoyan TanevP. Stoyan Tomov. Vasil Vasilev9.02:57 PM | [Platform] Refinement •$Describe what you are looking for. Stoyan TanevAdd canvasP FilesGalya Dimitrova 13.23Today~изненадана съм какво общо има това owner-a дали се е логвал с това дали дърпаме дайлърStoyan Tanev 13:23Ами пробвах да рьнна синк и ми изписа че не са вързани за CRM|[2026-85-18 10:35:03] production.INFO: [CrmOnnerResolver] No tean members found with octivecrm connection {"crm_provider":"hubspot", "team_id":548} {"correlation_id":"17854355-b67đ-|4ccf-a0f6-c2258a54de47", "trace_id": *99f7eee6-dodS-4678-b0f3-ffd871576246*}[2026-05-18 10:35:03] production.ALERT: [SyncActivity] Failed{"import_id":8098825, "provider":"ringcentral", "provider_id":2764, "team": "bt-local-business-coventry", "team_id":548, "reason": "Your HubSpot account has becone disconnected. Pleaselogin to Jiminny toreconnect.*, "file":*/hone/jtminny/opp/Services/Crm/BaseService.php","line":654)("correlation_id":"17854355-b67d-4ccf-a0f6-c2258a54de47", "trace_id": *99f7eee6-dodS-4678-b0f3-ffd071576246*]И аз не знаех, че имат общоGalya Dimitrova 13.29а ние от кьде им дърпаме дайльр коловетенали нищо не дърпаме от CRMaMessage Stoyan TanevАаmeet.google.com is sharing your screen.Stop sharingHide8•Mon 18 May 14:57ch to updateGalya DimitrovaNikolay YankovNikolay IvanovAneliya AngelovaCò:meet.google.com4:57 PM | [Platform] Refinement ®Lukas Kovalik1:44:01...
|
NULL
|
-4605707971003664839
|
NULL
|
click
|
ocr
|
NULL
|
FirefoxFileEditViewHistoryBookmarksProfilesToolsWi FirefoxFileEditViewHistoryBookmarksProfilesToolsWindowHelp[Platform) Refinemen... 3 m left)• Mon 18 May 16:57:34meet.google.com/cxs-eips-npt?authuser=lukas.kovalik%40jiminny.comGalya Dimitrova (Presenting, annotating)ChromeFileEditViewHistoryBookmarksProfilesTabWindowHelpmeet.google.coClaude (MCP)|Claude (MCP)881HomeGalya Dimitrova (You, presentir+DMsActivityLaterMoreJiminny Inc~E becky butler, GregE becky butler, Greg. Oli..4º, CalumCalum, Greg. James Gr...& Desislav DamyanovC. Gabriela Dureva&. GregGreg. Oliver (Oly)P Ilian Kyuchukov&. James Graham X2. Lukas Kovaliki. Mario Georgiev. Martin PetkovC. Nikolay IvanovD, Nikolay Nikolov "7P, Nikolay Yankov48 Oliver (Oly) E. Petko Kashinskilâ. Stefka Stoyanova. Steliyan Georgiev2 Stoyan TanevP. Stoyan Tomov. Vasil Vasilev9.02:57 PM | [Platform] Refinement •$Describe what you are looking for. Stoyan TanevAdd canvasP FilesGalya Dimitrova 13.23Today~изненадана съм какво общо има това owner-a дали се е логвал с това дали дърпаме дайлърStoyan Tanev 13:23Ами пробвах да рьнна синк и ми изписа че не са вързани за CRM|[2026-85-18 10:35:03] production.INFO: [CrmOnnerResolver] No tean members found with octivecrm connection {"crm_provider":"hubspot", "team_id":548} {"correlation_id":"17854355-b67đ-|4ccf-a0f6-c2258a54de47", "trace_id": *99f7eee6-dodS-4678-b0f3-ffd871576246*}[2026-05-18 10:35:03] production.ALERT: [SyncActivity] Failed{"import_id":8098825, "provider":"ringcentral", "provider_id":2764, "team": "bt-local-business-coventry", "team_id":548, "reason": "Your HubSpot account has becone disconnected. Pleaselogin to Jiminny toreconnect.*, "file":*/hone/jtminny/opp/Services/Crm/BaseService.php","line":654)("correlation_id":"17854355-b67d-4ccf-a0f6-c2258a54de47", "trace_id": *99f7eee6-dodS-4678-b0f3-ffd071576246*]И аз не знаех, че имат общоGalya Dimitrova 13.29а ние от кьде им дърпаме дайльр коловетенали нищо не дърпаме от CRMaMessage Stoyan TanevАаmeet.google.com is sharing your screen.Stop sharingHide8•Mon 18 May 14:57ch to updateGalya DimitrovaNikolay YankovNikolay IvanovAneliya AngelovaCò:meet.google.com4:57 PM | [Platform] Refinement ®Lukas Kovalik1:44:01...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
55216
|
1913
|
26
|
2026-05-18T13:57:31.818199+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-18/1779 /Users/lukas/.screenpipe/data/data/2026-05-18/1779112651818_m2.jpg...
|
PhpStorm
|
|
1
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
Project Files
Preview
Filter
Open in Find Tool Win Project Files
Preview
Filter
Open in Find Tool Window
Ringcentral
RingCentral app/DTO/ImportCall/RingCentral
RingCentral app/Jobs/Activity/RingCentral
RingCentral app/Services/Activity/RingCentral
RingCentral app/Console/Commands/Activities/RingCentral
RingCentral tests/Unit/DTO/ImportCall/RingCentral
RingCentral tests/Unit/Jobs/Activity/RingCentral
RingCentral tests/Unit/Services/Activity/RingCentral
RingCentral build/coverage/DTO/ImportCall/RingCentral
RingCentral build/coverage/Jobs/Activity/RingCentral
RingCentral build/coverage/Services/Activity/RingCentral
RingCentral build/coverage/Console/Commands/Activities/RingCentral
RingCentralException.php .../app/Exceptions/RingCentralException.php, exception class
RingCentralExtensionNotFound.php .../app/Exceptions/RingCentralExtensionNotFound.php, exception class
RingCentralException.php.html build/coverage/Exceptions
RingCentralExtensionNotFound.php.html build/coverage/Exceptions...
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project Files","depth":2,"bounds":{"left":0.6968085,"top":0.24181964,"width":0.03557181,"height":0.01915403},"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.73238033,"top":0.24181964,"width":0.008643617,"height":0.01915403},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Filter","depth":2,"bounds":{"left":0.74102396,"top":0.24181964,"width":0.008643617,"height":0.01915403},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Open in Find Tool Window","depth":2,"bounds":{"left":0.7496675,"top":0.24181964,"width":0.008643617,"height":0.01915403},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextField","text":"Ringcentral","depth":1,"bounds":{"left":0.50232714,"top":0.27214685,"width":0.25598404,"height":0.023144454},"on_screen":true,"value":"Ringcentral","role_description":"text field","is_enabled":true,"is_focused":true,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"RingCentral app/DTO/ImportCall/RingCentral","depth":2,"bounds":{"left":0.4993351,"top":0.30407023,"width":0.26196808,"height":0.017557861},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"RingCentral app/Jobs/Activity/RingCentral","depth":2,"bounds":{"left":0.4993351,"top":0.3216281,"width":0.26196808,"height":0.017557861},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"RingCentral app/Services/Activity/RingCentral","depth":2,"bounds":{"left":0.4993351,"top":0.33918595,"width":0.26196808,"height":0.017557861},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"RingCentral app/Console/Commands/Activities/RingCentral","depth":2,"bounds":{"left":0.4993351,"top":0.3567438,"width":0.26196808,"height":0.017557861},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"RingCentral tests/Unit/DTO/ImportCall/RingCentral","depth":2,"bounds":{"left":0.4993351,"top":0.37430167,"width":0.26196808,"height":0.017557861},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"RingCentral tests/Unit/Jobs/Activity/RingCentral","depth":2,"bounds":{"left":0.4993351,"top":0.39185953,"width":0.26196808,"height":0.017557861},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"RingCentral tests/Unit/Services/Activity/RingCentral","depth":2,"bounds":{"left":0.4993351,"top":0.4094174,"width":0.26196808,"height":0.017557861},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"RingCentral build/coverage/DTO/ImportCall/RingCentral","depth":2,"bounds":{"left":0.4993351,"top":0.42697525,"width":0.26196808,"height":0.017557861},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"RingCentral build/coverage/Jobs/Activity/RingCentral","depth":2,"bounds":{"left":0.4993351,"top":0.4445331,"width":0.26196808,"height":0.017557861},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"RingCentral build/coverage/Services/Activity/RingCentral","depth":2,"bounds":{"left":0.4993351,"top":0.46209097,"width":0.26196808,"height":0.017557861},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"RingCentral build/coverage/Console/Commands/Activities/RingCentral","depth":2,"bounds":{"left":0.4993351,"top":0.47964883,"width":0.26196808,"height":0.017557861},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"RingCentralException.php .../app/Exceptions/RingCentralException.php, exception class","depth":2,"bounds":{"left":0.4993351,"top":0.49720672,"width":0.26196808,"height":0.017557861},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"RingCentralExtensionNotFound.php .../app/Exceptions/RingCentralExtensionNotFound.php, exception class","depth":2,"bounds":{"left":0.4993351,"top":0.51476455,"width":0.26196808,"height":0.017557861},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"RingCentralException.php.html build/coverage/Exceptions","depth":2,"bounds":{"left":0.4993351,"top":0.5323224,"width":0.26196808,"height":0.017557861},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"RingCentralExtensionNotFound.php.html build/coverage/Exceptions","depth":2,"bounds":{"left":0.4993351,"top":0.54988027,"width":0.26196808,"height":0.017557861},"on_screen":true,"role_description":"text"}]...
|
-8808977734328582326
|
-8636049748463423375
|
click
|
accessibility
|
NULL
|
Project Files
Preview
Filter
Open in Find Tool Win Project Files
Preview
Filter
Open in Find Tool Window
Ringcentral
RingCentral app/DTO/ImportCall/RingCentral
RingCentral app/Jobs/Activity/RingCentral
RingCentral app/Services/Activity/RingCentral
RingCentral app/Console/Commands/Activities/RingCentral
RingCentral tests/Unit/DTO/ImportCall/RingCentral
RingCentral tests/Unit/Jobs/Activity/RingCentral
RingCentral tests/Unit/Services/Activity/RingCentral
RingCentral build/coverage/DTO/ImportCall/RingCentral
RingCentral build/coverage/Jobs/Activity/RingCentral
RingCentral build/coverage/Services/Activity/RingCentral
RingCentral build/coverage/Console/Commands/Activities/RingCentral
RingCentralException.php .../app/Exceptions/RingCentralException.php, exception class
RingCentralExtensionNotFound.php .../app/Exceptions/RingCentralExtensionNotFound.php, exception class
RingCentralException.php.html build/coverage/Exceptions
RingCentralExtensionNotFound.php.html build/coverage/Exceptions...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
55215
|
1912
|
81
|
2026-05-18T13:57:31.818199+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-18/1779 /Users/lukas/.screenpipe/data/data/2026-05-18/1779112651818_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
Ringcentral
RingCentral app/DTO/ImportCall/RingCentral
RingCentral app/Jobs/Activity/RingCentral
RingCentral app/Services/Activity/RingCentral
RingCentral app/Console/Commands/Activities/RingCentral
RingCentral tests/Unit/DTO/ImportCall/RingCentral
RingCentral tests/Unit/Jobs/Activity/RingCentral
RingCentral tests/Unit/Services/Activity/RingCentral
RingCentral build/coverage/DTO/ImportCall/RingCentral
RingCentral build/coverage/Jobs/Activity/RingCentral
RingCentral build/coverage/Services/Activity/RingCentral
RingCentral build/coverage/Console/Commands/Activities/RingCentral
RingCentralException.php .../app/Exceptions/RingCentralException.php, exception class
RingCentralExtensionNotFound.php .../app/Exceptions/RingCentralExtensionNotFound.php, exception class
RingCentralException.php.html build/coverage/Exceptions
RingCentralExtensionNotFound.php.html build/coverage/Exceptions
… more
RingCentral app/DTO/ImportCall/RingCentral...
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project Files","depth":2,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Preview","depth":2,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Filter","depth":2,"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,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextField","text":"Ringcentral","depth":1,"on_screen":true,"value":"Ringcentral","role_description":"text field","is_enabled":true,"is_focused":true,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"RingCentral app/DTO/ImportCall/RingCentral","depth":2,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"RingCentral app/Jobs/Activity/RingCentral","depth":2,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"RingCentral app/Services/Activity/RingCentral","depth":2,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"RingCentral app/Console/Commands/Activities/RingCentral","depth":2,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"RingCentral tests/Unit/DTO/ImportCall/RingCentral","depth":2,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"RingCentral tests/Unit/Jobs/Activity/RingCentral","depth":2,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"RingCentral tests/Unit/Services/Activity/RingCentral","depth":2,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"RingCentral build/coverage/DTO/ImportCall/RingCentral","depth":2,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"RingCentral build/coverage/Jobs/Activity/RingCentral","depth":2,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"RingCentral build/coverage/Services/Activity/RingCentral","depth":2,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"RingCentral build/coverage/Console/Commands/Activities/RingCentral","depth":2,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"RingCentralException.php .../app/Exceptions/RingCentralException.php, exception class","depth":2,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"RingCentralExtensionNotFound.php .../app/Exceptions/RingCentralExtensionNotFound.php, exception class","depth":2,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"RingCentralException.php.html build/coverage/Exceptions","depth":2,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"RingCentralExtensionNotFound.php.html build/coverage/Exceptions","depth":2,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"… more","depth":2,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"RingCentral app/DTO/ImportCall/RingCentral","depth":4,"on_screen":true,"role_description":"text"}]...
|
-2009537806725389334
|
-8636049749538148239
|
click
|
accessibility
|
NULL
|
Project Files
Preview
Filter
Open in Find Tool Win Project Files
Preview
Filter
Open in Find Tool Window
Ringcentral
RingCentral app/DTO/ImportCall/RingCentral
RingCentral app/Jobs/Activity/RingCentral
RingCentral app/Services/Activity/RingCentral
RingCentral app/Console/Commands/Activities/RingCentral
RingCentral tests/Unit/DTO/ImportCall/RingCentral
RingCentral tests/Unit/Jobs/Activity/RingCentral
RingCentral tests/Unit/Services/Activity/RingCentral
RingCentral build/coverage/DTO/ImportCall/RingCentral
RingCentral build/coverage/Jobs/Activity/RingCentral
RingCentral build/coverage/Services/Activity/RingCentral
RingCentral build/coverage/Console/Commands/Activities/RingCentral
RingCentralException.php .../app/Exceptions/RingCentralException.php, exception class
RingCentralExtensionNotFound.php .../app/Exceptions/RingCentralExtensionNotFound.php, exception class
RingCentralException.php.html build/coverage/Exceptions
RingCentralExtensionNotFound.php.html build/coverage/Exceptions
… more
RingCentral app/DTO/ImportCall/RingCentral...
|
55214
|
NULL
|
NULL
|
NULL
|
|
55214
|
1912
|
80
|
2026-05-18T13:57:30.400895+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-18/1779 /Users/lukas/.screenpipe/data/data/2026-05-18/1779112650400_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
Ringcentral
RingCentral app/DTO/ImportCall/RingCentral
RingCentral app/Jobs/Activity/RingCentral
RingCentral app/Services/Activity/RingCentral
RingCentral app/Console/Commands/Activities/RingCentral
RingCentral tests/Unit/DTO/ImportCall/RingCentral
RingCentral tests/Unit/Jobs/Activity/RingCentral
RingCentral tests/Unit/Services/Activity/RingCentral
RingCentral build/coverage/DTO/ImportCall/RingCentral
RingCentral build/coverage/Jobs/Activity/RingCentral
RingCentral build/coverage/Services/Activity/RingCentral
RingCentral build/coverage/Console/Commands/Activities/RingCentral
RingCentralException.php .../app/Exceptions/RingCentralException.php, exception class
RingCentralExtensionNotFound.php .../app/Exceptions/RingCentralExtensionNotFound.php, exception class
RingCentralException.php.html build/coverage/Exceptions
RingCentralExtensionNotFound.php.html build/coverage/Exceptions
… more
RingCentral app/DTO/ImportCall/RingCentral
RingCentral app/Jobs/Activity/RingCentral
RingCentral app/Services/Activity/RingCentral
RingCentral app/Console/Commands/Activities/RingCentral
RingCentral tests/Unit/DTO/ImportCall/RingCentral
RingCentral tests/Unit/Jobs/Activity/RingCentral
RingCentral tests/Unit/Services/Activity/RingCentral
RingCentral build/coverage/DTO/ImportCall/RingCentral
RingCentral build/coverage/Jobs/Activity/RingCentral
RingCentral build/coverage/Services/Activity/RingCentral
RingCentral build/coverage/Console/Commands/Activities/RingCentral
RingCentralException.php .../app/Exceptions/RingCentralException.php, exception class
RingCentralExtensionNotFound.php .../app/Exceptions/RingCentralExtensionNotFound.php, exception class
RingCentralException.php.html build/coverage/Exceptions
RingCentralExtensionNotFound.php.html build/coverage/Exceptions
… more...
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project Files","depth":2,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Preview","depth":2,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Filter","depth":2,"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,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextField","text":"Ringcentral","depth":1,"on_screen":true,"value":"Ringcentral","role_description":"text field","is_enabled":true,"is_focused":true,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"RingCentral app/DTO/ImportCall/RingCentral","depth":2,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"RingCentral app/Jobs/Activity/RingCentral","depth":2,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"RingCentral app/Services/Activity/RingCentral","depth":2,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"RingCentral app/Console/Commands/Activities/RingCentral","depth":2,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"RingCentral tests/Unit/DTO/ImportCall/RingCentral","depth":2,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"RingCentral tests/Unit/Jobs/Activity/RingCentral","depth":2,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"RingCentral tests/Unit/Services/Activity/RingCentral","depth":2,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"RingCentral build/coverage/DTO/ImportCall/RingCentral","depth":2,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"RingCentral build/coverage/Jobs/Activity/RingCentral","depth":2,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"RingCentral build/coverage/Services/Activity/RingCentral","depth":2,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"RingCentral build/coverage/Console/Commands/Activities/RingCentral","depth":2,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"RingCentralException.php .../app/Exceptions/RingCentralException.php, exception class","depth":2,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"RingCentralExtensionNotFound.php .../app/Exceptions/RingCentralExtensionNotFound.php, exception class","depth":2,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"RingCentralException.php.html build/coverage/Exceptions","depth":2,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"RingCentralExtensionNotFound.php.html build/coverage/Exceptions","depth":2,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"… more","depth":2,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"RingCentral app/DTO/ImportCall/RingCentral","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"RingCentral app/Jobs/Activity/RingCentral","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"RingCentral app/Services/Activity/RingCentral","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"RingCentral app/Console/Commands/Activities/RingCentral","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"RingCentral tests/Unit/DTO/ImportCall/RingCentral","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"RingCentral tests/Unit/Jobs/Activity/RingCentral","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"RingCentral tests/Unit/Services/Activity/RingCentral","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"RingCentral build/coverage/DTO/ImportCall/RingCentral","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"RingCentral build/coverage/Jobs/Activity/RingCentral","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"RingCentral build/coverage/Services/Activity/RingCentral","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"RingCentral build/coverage/Console/Commands/Activities/RingCentral","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"RingCentralException.php .../app/Exceptions/RingCentralException.php, exception class","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"RingCentralExtensionNotFound.php .../app/Exceptions/RingCentralExtensionNotFound.php, exception class","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"RingCentralException.php.html build/coverage/Exceptions","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"RingCentralExtensionNotFound.php.html build/coverage/Exceptions","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"… more","depth":4,"on_screen":true,"role_description":"text"}]...
|
9066100255472655993
|
-8636040951163490191
|
visual_change
|
accessibility
|
NULL
|
Project Files
Preview
Filter
Open in Find Tool Win Project Files
Preview
Filter
Open in Find Tool Window
Ringcentral
RingCentral app/DTO/ImportCall/RingCentral
RingCentral app/Jobs/Activity/RingCentral
RingCentral app/Services/Activity/RingCentral
RingCentral app/Console/Commands/Activities/RingCentral
RingCentral tests/Unit/DTO/ImportCall/RingCentral
RingCentral tests/Unit/Jobs/Activity/RingCentral
RingCentral tests/Unit/Services/Activity/RingCentral
RingCentral build/coverage/DTO/ImportCall/RingCentral
RingCentral build/coverage/Jobs/Activity/RingCentral
RingCentral build/coverage/Services/Activity/RingCentral
RingCentral build/coverage/Console/Commands/Activities/RingCentral
RingCentralException.php .../app/Exceptions/RingCentralException.php, exception class
RingCentralExtensionNotFound.php .../app/Exceptions/RingCentralExtensionNotFound.php, exception class
RingCentralException.php.html build/coverage/Exceptions
RingCentralExtensionNotFound.php.html build/coverage/Exceptions
… more
RingCentral app/DTO/ImportCall/RingCentral
RingCentral app/Jobs/Activity/RingCentral
RingCentral app/Services/Activity/RingCentral
RingCentral app/Console/Commands/Activities/RingCentral
RingCentral tests/Unit/DTO/ImportCall/RingCentral
RingCentral tests/Unit/Jobs/Activity/RingCentral
RingCentral tests/Unit/Services/Activity/RingCentral
RingCentral build/coverage/DTO/ImportCall/RingCentral
RingCentral build/coverage/Jobs/Activity/RingCentral
RingCentral build/coverage/Services/Activity/RingCentral
RingCentral build/coverage/Console/Commands/Activities/RingCentral
RingCentralException.php .../app/Exceptions/RingCentralException.php, exception class
RingCentralExtensionNotFound.php .../app/Exceptions/RingCentralExtensionNotFound.php, exception class
RingCentralException.php.html build/coverage/Exceptions
RingCentralExtensionNotFound.php.html build/coverage/Exceptions
… more...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
55213
|
1913
|
25
|
2026-05-18T13:57:26.264258+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-18/1779 /Users/lukas/.screenpipe/data/data/2026-05-18/1779112646264_m2.jpg...
|
PhpStorm
|
|
1
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
Project Files
Preview
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project Files","depth":2,"bounds":{"left":0.6968085,"top":0.24181964,"width":0.03557181,"height":0.01915403},"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.73238033,"top":0.24181964,"width":0.008643617,"height":0.01915403},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
4334510354158737033
|
1200849742094770613
|
visual_change
|
hybrid
|
NULL
|
Project Files
Preview
PhpStormVIewINavigareCodeLar Project Files
Preview
PhpStormVIewINavigareCodeLaravelFV faVsco.js v°9 master kProjectRinaCentralVideoD SalesforceIm Salesloft> D Talkdeskm Teams> D Telus> D Twilio> D TwilioFlex_ I willorlexDireet• _ I willoVideo_Uploader› _ Vonagewxant>400mZoomBot> ZoomPhoneC) ActivitvcrmFieldsResolver.ohpC) ActivitvLoaService.oho© ActivityProviderClient.php(C) ActivitvProviderRedistrv.oho© ActivityProviderService.php© CallDenormalizerRegistry.phpC) CrmOwnerResolver.oho© DatalmportHandlerInterface.php© MeetingBotService.php© ParticipantConsentService.php© ParticipantsService.phpT PecnonceValidation Trait nhn• SalesforceGetUserTrait.php© SfDenormaliserMainCrmDataTrait.php© TrackRecordingFileSizeService.php© TrackRecordingSizeEnforcer.php© ValidateEmitProspectEventTrait.phpD AjReportsAvatarD CalendarConference0 Crm> D Bullhornv closeOnoortunitvsvncstrateav• ProcessonProspectSearchStrateav• M TranslatorC) Client.oho() CloseSxcertion.ohoC) FieldDefinitions.onvc) SieldValueConverter ohnC) Service nhnC) StandardFieldMetadata nhn> MConnenKeractor® ActivityController.php© CoreUserRequest.php127 €132 6141 o>150 €* >169 C184 6t189 €* >194 đ >213 €215217© SoftPhoneManager.phpscimProvistoning.ong© CoreUser.php© ACtivity/.../Service.php© Crm/../Service.php x)class Service extends BaseService 1mpLementsA8 A39 X5 A Ypublic function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): str:protected function getFieldTypes(): arrayf...,protected function getFields(string $crm0bject): arrayf..,* cinherzzdocpublic function getDefaultFields(string $activityType): arrayf…..,*oinherzzdocpublic function getdefaultactzvitvFieldstring Sactivitvivoe): FieldSet un the activity field as the default Tvoe.** ovar Field Sactiviturield *^SactivityField = $this->config->fields() ->where(lerm provider 1d' =>'type",'object_type' => $activityType,1->firstO:return SactivitvField.* @inheritdocpublic function getSupportedPlaybookTypes(): arrayreturn [Playbook::ACTIVITY_TYPE_TASK];public function getDealInsightsFields(): arrayf...}protected function getDefaultFollowupLayoutFields(string $activityType): arrayf…..,public function syncrieldso: vo1dsthis->syncStandardFieldso:sthns->svnccustomfteldso=custom.logA console [STAGING]stoheE laravel.log4 SF [jiminny@localhost]© CoachingFeedbackCoachUserin.phpxA HS_Jocal [jiminny@localhost]A console [PROD]# console [euydeclare(scrict_cypes=l)nnamespace Jiminny\Component\ActivitySearch\FilterDefinition;› use ..tinal class coachino29 đ >34 đ >45 đ >63118-119_131=135- 136 Gt ›1145)146 C-150-151 @ ›1 usageprivate const ir3 usagesprivate UserRepcpublic functionpublic functionpublic functionpublic functionprivate functionpubLic tunctionprivate tunctlornuouic tunctzonoublic functionoublic functionClassesFilesSymbolsActionsTextProject FilesQ- RingcenRingCentral app/DTO/ImportCall/RingCentralRinaCentral aooJloos/Activitv/RinaCentra_ RingCentral app/Services/Activity/RingCentraI7 RinaCentral aoo/Console/Commands/Activities/RinaCentrallRingCentral tests/Unit/DTO/ImportCall/RingCentralIN RinaCentral tests/UnitAlobs/Activitv/RinaCentrall[ RingCentral tests/Unit/Services/Activity/RingCentral• RingCentral build/coverage/DTO/ImportCall/RingCentral[ RingCentral build/coverage/Jobs/Activity/RingCentral( PinaCentral build/coverade/Services/Activitv/RinaCentrall© RingCentralException.php .../app/Exceptions/RingCentralException.php© RingCentralExtensionNotFound.php .../app/Exceptions/RingCentralExtensionNotFound.php© RingCentralSupportedUrls Test.p.../ests.../Activity Providers/MeetingUrIRegex/RingCentralSupportedUrisTest.php<> RingCentralException.php.html build/coverage/Exceptions<> RingCentralExtensionNotFound.php.html build/coverage/Exceptionsmore[Platform] Refinemen... 3 m leftU AskJiminnyReportActivityServiceTest~100% 18• Mon 18 May 16:57:25CascadeImplement Trial OwneFixing Redis Rate LimSalestorce Token FalliSClM Role Manageme+0 ..php on LineWARN Metadata found iin doc-comment for method9 / 10 tasks done :a unit tests for SCIM role managementact Mario to update customerks donemanagementtor leamtallor nas been successtully implemented. Here's what was completed:itation Summary:UserRequest DTO - Added role parsina from SCIM requestsConstants - Added META_ROLES and USER_PATCH_ATTR_ROLES constantsAttr Mutator - Created mutator for handlina role assianments via SCIM PATCH operations.Provisioning - Updated to handle roles during user creation with proper validationnerRoleCannotHaveAdminOrManagerPermissionRule - Created validation rule to prevent listener role fromg admin/manager permissionsUser Response - Updated to include roles in SClM responsesTests - Created comprehensive tests for the validation rulemers can now assign roles via SCIM (recorder, recorder & voice, analyst, listener, admin, manager)ation ensures listener role is onlv assianable if the LISTENER ROLE feature flaa is enabledlner role cannot be combined with admin or manager permissionsexisting UpdateUserRolesAction for role syncing and loggingNs platform validation rules (DependentRolesRule, ListenerRoleRequiresListenerFeatureRule)›: Please contact Mario to update the customer that SCIM role management is now available for TeamtailorDTO/ImoortCall/RinaCentrallOpen In Right Splitapp/Component/SClM/ Constants.php +3app/Component/SCIM/ ScimProvisioning.php +85-15nse/D CoreUser.php +21ann/Comnonent/SCIM/Mutatore/Attributes/lcer/M RoleAttr.ohn t17ites/User/ ẞ RoleAttrTest.nhn +224Ask anvthina (&4-L)« Code SWF-1.6View allAccent alliWN Windsurf Toams 175-41UTF.8f 4 spaces...
|
55210
|
NULL
|
NULL
|
NULL
|
|
55212
|
1912
|
79
|
2026-05-18T13:57:27.390483+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-18/1779 /Users/lukas/.screenpipe/data/data/2026-05-18/1779112647390_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
Ringcentral
RingCentral app/DTO/ImportCall/RingCentral
RingCentral app/Jobs/Activity/RingCentral
RingCentral app/Services/Activity/RingCentral
RingCentral app/Console/Commands/Activities/RingCentral
RingCentral tests/Unit/DTO/ImportCall/RingCentral
RingCentral tests/Unit/Jobs/Activity/RingCentral
RingCentral tests/Unit/Services/Activity/RingCentral
RingCentral build/coverage/DTO/ImportCall/RingCentral
RingCentral build/coverage/Jobs/Activity/RingCentral
RingCentral build/coverage/Services/Activity/RingCentral
RingCentral build/coverage/Console/Commands/Activities/RingCentral...
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project Files","depth":2,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Preview","depth":2,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Filter","depth":2,"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,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextField","text":"Ringcentral","depth":1,"on_screen":true,"value":"Ringcentral","role_description":"text field","is_enabled":true,"is_focused":true,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"RingCentral app/DTO/ImportCall/RingCentral","depth":2,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"RingCentral app/Jobs/Activity/RingCentral","depth":2,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"RingCentral app/Services/Activity/RingCentral","depth":2,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"RingCentral app/Console/Commands/Activities/RingCentral","depth":2,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"RingCentral tests/Unit/DTO/ImportCall/RingCentral","depth":2,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"RingCentral tests/Unit/Jobs/Activity/RingCentral","depth":2,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"RingCentral tests/Unit/Services/Activity/RingCentral","depth":2,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"RingCentral build/coverage/DTO/ImportCall/RingCentral","depth":2,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"RingCentral build/coverage/Jobs/Activity/RingCentral","depth":2,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"RingCentral build/coverage/Services/Activity/RingCentral","depth":2,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"RingCentral build/coverage/Console/Commands/Activities/RingCentral","depth":2,"on_screen":true,"role_description":"text"}]...
|
-8611722366820706075
|
-4024367956275482544
|
visual_change
|
accessibility
|
NULL
|
Project Files
Preview
Filter
Open in Find Tool Win Project Files
Preview
Filter
Open in Find Tool Window
Ringcentral
RingCentral app/DTO/ImportCall/RingCentral
RingCentral app/Jobs/Activity/RingCentral
RingCentral app/Services/Activity/RingCentral
RingCentral app/Console/Commands/Activities/RingCentral
RingCentral tests/Unit/DTO/ImportCall/RingCentral
RingCentral tests/Unit/Jobs/Activity/RingCentral
RingCentral tests/Unit/Services/Activity/RingCentral
RingCentral build/coverage/DTO/ImportCall/RingCentral
RingCentral build/coverage/Jobs/Activity/RingCentral
RingCentral build/coverage/Services/Activity/RingCentral
RingCentral build/coverage/Console/Commands/Activities/RingCentral...
|
55211
|
NULL
|
NULL
|
NULL
|
|
55211
|
1912
|
78
|
2026-05-18T13:57:24.382330+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-18/1779 /Users/lukas/.screenpipe/data/data/2026-05-18/1779112644382_m1.jpg...
|
PhpStorm
|
|
1
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
FirefoxFilelEditViewHistoryBookmarksProfilesToolsW FirefoxFilelEditViewHistoryBookmarksProfilesToolsWindowHelp[Platform) Refinemen... 3 m left• Mon 18 May 16:57:23meet.google.com/cxs-eips-npt?authuser=lukas.kovalik%40jiminny.comGalya Dimitrova (Presenting, annotating)ChromeFileEditHistoryBookmarksProfilesTabWindowHelpmeet.google.coClaude (MCP)% Claude (MCP)881HomeGalya Dimitrova (You, presentin+DMsActivityLaterMoreJiminny IncE becky butler, GregE becky butler, Greg, Oli...R, CalumCalum, Greg. James Gr.Ga Desislav DamyanovC. Gabriela Dureva&. GregE Greg, Oliver (Oly)Iitan Kyuchukov& James Graham X2. Lukas Kovalikir. Mario GeorgievR. Martin PetkoyC. Nikolay Ivanoy, Nikolay NikolovP. Nikolay Yankov4% Oliver (Oly) #Petkc Kashinskil. Stefka StoyanovaStellyan GeorgievStoyan TanevPP. Stoyan Tamov€. Vasil VasilevAP. cavaDmmurova•2:57 PM | [Platform] Refinement ®Dexribe what you are looking forStoyan Tanevt Add canvasP FilesGalya Dimitrova 13.23Todayизненадана съм какво общо има това owner-a дали се е логвал с това дали дърпаме дайлърStoyan Tanev 13.23Ами пробвах да рьнна синх и ми изписа че не са вързани за CRM|[2826-05-18 10:35:03] production,INFO: [CrOwnerResolver] No teom menbers found with octivecrm connection {"crnuprovider":"hubspot", "team_id":548} ("correlation_id":"1785435S-b67d-|4ccf-a0f6-c2250a54de47"trace_id*: *99f7eee6-dodS-4678-b0f3-ffd071576246*)[2026-05-18 10:35:03] production.ALERT: [SyncActivity) Failed("irport_id": 8098825, "provider": "ringcentral", "provider_ta":2764, "teon": "bt-Local-business-coventry". "team_id":548, "reason": "Your HubSpotaccount has becone disconnected. Pleaselogin to Jiminny toreconnect. ", "file":*/hone/jtmnny/app/Services/Crm/BaseService.php*,"1tne":654)("correlation_id": "17854355-b67d-4ccf-a0fG-c2258a54de4T", "trace_id": *99f7eee6-dodS-4678-b0f3-ff0071576246*}И аз не энаех, че имат общоGalya Dimitrova1329а ние от кьде им дьрпаме дайльр коловетенали нищо не дърпаме от CRMa|Message Stoyan TanevАа®meet.google.com is sharing your screon.Stop sharingHideMon 18 May 14:57ch to updateGalya DimitrovaNikolay YankovNikolay IvanovAneliya AngelovaCò5:meet.gnogis.comD4:57 PM | [Platform] Refinement ®Lukas Kovalik1:43:50...
|
NULL
|
3794624898286157878
|
NULL
|
visual_change
|
ocr
|
NULL
|
FirefoxFilelEditViewHistoryBookmarksProfilesToolsW FirefoxFilelEditViewHistoryBookmarksProfilesToolsWindowHelp[Platform) Refinemen... 3 m left• Mon 18 May 16:57:23meet.google.com/cxs-eips-npt?authuser=lukas.kovalik%40jiminny.comGalya Dimitrova (Presenting, annotating)ChromeFileEditHistoryBookmarksProfilesTabWindowHelpmeet.google.coClaude (MCP)% Claude (MCP)881HomeGalya Dimitrova (You, presentin+DMsActivityLaterMoreJiminny IncE becky butler, GregE becky butler, Greg, Oli...R, CalumCalum, Greg. James Gr.Ga Desislav DamyanovC. Gabriela Dureva&. GregE Greg, Oliver (Oly)Iitan Kyuchukov& James Graham X2. Lukas Kovalikir. Mario GeorgievR. Martin PetkoyC. Nikolay Ivanoy, Nikolay NikolovP. Nikolay Yankov4% Oliver (Oly) #Petkc Kashinskil. Stefka StoyanovaStellyan GeorgievStoyan TanevPP. Stoyan Tamov€. Vasil VasilevAP. cavaDmmurova•2:57 PM | [Platform] Refinement ®Dexribe what you are looking forStoyan Tanevt Add canvasP FilesGalya Dimitrova 13.23Todayизненадана съм какво общо има това owner-a дали се е логвал с това дали дърпаме дайлърStoyan Tanev 13.23Ами пробвах да рьнна синх и ми изписа че не са вързани за CRM|[2826-05-18 10:35:03] production,INFO: [CrOwnerResolver] No teom menbers found with octivecrm connection {"crnuprovider":"hubspot", "team_id":548} ("correlation_id":"1785435S-b67d-|4ccf-a0f6-c2250a54de47"trace_id*: *99f7eee6-dodS-4678-b0f3-ffd071576246*)[2026-05-18 10:35:03] production.ALERT: [SyncActivity) Failed("irport_id": 8098825, "provider": "ringcentral", "provider_ta":2764, "teon": "bt-Local-business-coventry". "team_id":548, "reason": "Your HubSpotaccount has becone disconnected. Pleaselogin to Jiminny toreconnect. ", "file":*/hone/jtmnny/app/Services/Crm/BaseService.php*,"1tne":654)("correlation_id": "17854355-b67d-4ccf-a0fG-c2258a54de4T", "trace_id": *99f7eee6-dodS-4678-b0f3-ff0071576246*}И аз не энаех, че имат общоGalya Dimitrova1329а ние от кьде им дьрпаме дайльр коловетенали нищо не дърпаме от CRMa|Message Stoyan TanevАа®meet.google.com is sharing your screon.Stop sharingHideMon 18 May 14:57ch to updateGalya DimitrovaNikolay YankovNikolay IvanovAneliya AngelovaCò5:meet.gnogis.comD4:57 PM | [Platform] Refinement ®Lukas Kovalik1:43:50...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
55210
|
1913
|
24
|
2026-05-18T13:57:22.221027+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-18/1779 /Users/lukas/.screenpipe/data/data/2026-05-18/1779112642221_m2.jpg...
|
PhpStorm
|
faVsco.js – Crm/…/Service.php
|
1
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
PnostormVIewINavicatecodeFV faVsco.js°9 master kPr PnostormVIewINavicatecodeFV faVsco.js°9 master kProiectRinaCentralVideo• SalesforceIm Salesloft> D Talkdeski Teams> D Telus)M Twilia>@ TwilioFlex_ I willorlexDireet• _ I willoVideo_Uploader› _ Vonagewxant>400mZoomBot> ZoomPhoneC) ActivitvcrmFieldsResolver.ohpC) ActivitvLoaService.oho© ActivitvProviderClient.ohp(C) ActivitvProviderRedistrv.oho© ActivityProviderService.php© CallDenormalizerRegistry.phpC) CrmOwnerResolver.oho© DatalmportHandlerInterface.php© MeetingBotService.php© ParticipantConsentService.php(c) DarticinanteService nhnT PecnonceValidation Trait nhnT SalesforceGetUserTrait.php- S/DenormaliserMainCrmDatalrait.onp@ TrackRecordinoFllesizeservice.one© TrackRecordingSizeEnforcer.phpT ValidateEmitProspectEventTrait.phpC AjReports0 AvatarMcolondar0 Conference0 Crm> C Bullhornv closeOnoortunitvsvncstrateav• ProcessonProspectSearchStrateav• M TranslatorC) Client.oho() CloseSxceotion.ohoC) FieldDefinitions.onvc) SieldValueConverter ohnC) Service nhnC) StandardFioldMetadata nhn> MConnenC ActivityController.ong© SoftPhoneManager.php(C) CoreUserRequest.onpscimProvistoning.ong© CoreUser.php© ACtivity/.../Service.php© Crm/../Service.php xclass Service extends BaseService 1mpLementsm A8 A39 M5 лV127 (01132 (011MoTS150 ›169 G1916189 Gt >194 6t >213 G215public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): strprotected function getFieldTypes: array{...}protected function getFields(string $crm0bject): array{...}* cinherzzdocpublic function getDefaultFields(string $activityType): array{...}*dinherzzdocpublic function getdefaul tactivitvfieldstring Sactivitvivoe): FieldiSet un the activity field as the default Tvoe.** ovar Field Sactiviturield *^SactivityField = Sthis->confia->fields(->where(ferm provider 1d' =>"type','obiect tvnel => Sactivitvivoe.1->firstO:return SactivitvField.* @inheritdocpublic function getSupportedPlaybookTypes: arrayreturn [Playbook::ACTIVITY_TYPE_TASK]:public function getDealInsightsFields: arrav{...}protected function getDefaultFollowupLavoutFields(string SactivityType): arrav{...}public function syncFieldso: vo1dsthis->syncStandardFieldso:sthns->svnccustomfteldso= custom.log= laravel.log« SF jiminny@localhost]4 HS_local [jiminny@localhost]& console [PROD]& console [EU]A console [STAGING]© CoachingFeedbackCoachUserln.php xstohedeclare(scrict_cypes=l)nnamespace Jiminny Component Activ1tySearch rilterbetin1t1on*› use ...final class CoachingFeedbackCoachUserIn extends FilterDefinition implements ValidatedFilterDefinitionInterface29 Ф >34 0 >45 đ >63=119131|—139— 156 01146 G— 150|151 G>1usadeorivate const int No GROUP 10 = 999-3 usagesorivate UserRenository suserRenository:public function __construct(UserRepository $userRepository)f...hpublic function shouldApplyQueries: boolf...}public function getQueries: FilterDefinitionQueryCollection...hpublic function toArray: array{...hprivate function getOptions(): array{...}public function getValue: array{...}private function getDefaultValue@: array{...}public function aetValidationRules(2strina Sorefix = null): arravs...?public function aetSortOrder@: intf...}public function shouldBeIncluded(Team $team): bool{...}• Mon 10 May 10.0/•2.SCIM Role Manageme+0 ..inp on LinecascadeImplement Trial OwneFixing Redis Rate LimSalestorce Token FalliWARN Metadata found iin doc-comment for method9/ 10 tacke done• Write unit tests for SCIM role management10) Contact Mario to vodate customer10 / 10 tasks doneselm role managementtor leamtallor has been successtully implemented. Here's what was completed:Implementation Summary:1. CoreUserRequest DTO - Added role parsina from SCIM requests2. SCIM Constants - Added META ROLES and USER_PATCH_ATTR ROLES constants3. RoleAttr Mutator - Created mutator for handlina role assianments via SCIM PATCH operations4. ScimProvisioning - Updated to handle roles during user creation with proper validation5. ListenerRoleCannotHaveAdminOrManagerPermissionRule - Created validation rule to prevent listener role fromhaving admin/manager permissions6. CoreUser Response - Updated to include roles in SClM responses7. Unit Tests - Created comprehensive tests for the validation ruleKey Features:• Customers can now assign roles via SCIM (recorder, recorder & voice, analyst, listener, admin, manager)• Validation ensures listener role is onlv assianable if the LISTENER ROLE feature flaa is enabled•LIstener role cannot de comolned with aamin or manager permissions• Uses existing UpdateUserRolesAction for role syncing and logging• Follows platform validation rules (DependentRolesRule, ListenerRoleRequiresListenerFeatureRule)Next Step: Please contact Mario to update the customer that SCIM role management is now available for Teamtailorintearationposwtn chaten vapp/Component/SClM/ Constants.php +3app/Component/SCIM/ @ ScimProvisioning.php +85-15nse/ CoreUser.php +21ann/Comnonent/SCIM/Mutatore/Attributes/Ueer/M PoleAttr.nhn +17-ites/User/ ẞ RoleAttrTest.nhn +224* Reiect alliiAsk anvthina (&4-L)« Code SWF-1.6W Windsurf Toams 184•55UTE.Accent allifo 4 spaces...
|
NULL
|
-8731767252719339677
|
NULL
|
click
|
ocr
|
NULL
|
PnostormVIewINavicatecodeFV faVsco.js°9 master kPr PnostormVIewINavicatecodeFV faVsco.js°9 master kProiectRinaCentralVideo• SalesforceIm Salesloft> D Talkdeski Teams> D Telus)M Twilia>@ TwilioFlex_ I willorlexDireet• _ I willoVideo_Uploader› _ Vonagewxant>400mZoomBot> ZoomPhoneC) ActivitvcrmFieldsResolver.ohpC) ActivitvLoaService.oho© ActivitvProviderClient.ohp(C) ActivitvProviderRedistrv.oho© ActivityProviderService.php© CallDenormalizerRegistry.phpC) CrmOwnerResolver.oho© DatalmportHandlerInterface.php© MeetingBotService.php© ParticipantConsentService.php(c) DarticinanteService nhnT PecnonceValidation Trait nhnT SalesforceGetUserTrait.php- S/DenormaliserMainCrmDatalrait.onp@ TrackRecordinoFllesizeservice.one© TrackRecordingSizeEnforcer.phpT ValidateEmitProspectEventTrait.phpC AjReports0 AvatarMcolondar0 Conference0 Crm> C Bullhornv closeOnoortunitvsvncstrateav• ProcessonProspectSearchStrateav• M TranslatorC) Client.oho() CloseSxceotion.ohoC) FieldDefinitions.onvc) SieldValueConverter ohnC) Service nhnC) StandardFioldMetadata nhn> MConnenC ActivityController.ong© SoftPhoneManager.php(C) CoreUserRequest.onpscimProvistoning.ong© CoreUser.php© ACtivity/.../Service.php© Crm/../Service.php xclass Service extends BaseService 1mpLementsm A8 A39 M5 лV127 (01132 (011MoTS150 ›169 G1916189 Gt >194 6t >213 G215public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): strprotected function getFieldTypes: array{...}protected function getFields(string $crm0bject): array{...}* cinherzzdocpublic function getDefaultFields(string $activityType): array{...}*dinherzzdocpublic function getdefaul tactivitvfieldstring Sactivitvivoe): FieldiSet un the activity field as the default Tvoe.** ovar Field Sactiviturield *^SactivityField = Sthis->confia->fields(->where(ferm provider 1d' =>"type','obiect tvnel => Sactivitvivoe.1->firstO:return SactivitvField.* @inheritdocpublic function getSupportedPlaybookTypes: arrayreturn [Playbook::ACTIVITY_TYPE_TASK]:public function getDealInsightsFields: arrav{...}protected function getDefaultFollowupLavoutFields(string SactivityType): arrav{...}public function syncFieldso: vo1dsthis->syncStandardFieldso:sthns->svnccustomfteldso= custom.log= laravel.log« SF jiminny@localhost]4 HS_local [jiminny@localhost]& console [PROD]& console [EU]A console [STAGING]© CoachingFeedbackCoachUserln.php xstohedeclare(scrict_cypes=l)nnamespace Jiminny Component Activ1tySearch rilterbetin1t1on*› use ...final class CoachingFeedbackCoachUserIn extends FilterDefinition implements ValidatedFilterDefinitionInterface29 Ф >34 0 >45 đ >63=119131|—139— 156 01146 G— 150|151 G>1usadeorivate const int No GROUP 10 = 999-3 usagesorivate UserRenository suserRenository:public function __construct(UserRepository $userRepository)f...hpublic function shouldApplyQueries: boolf...}public function getQueries: FilterDefinitionQueryCollection...hpublic function toArray: array{...hprivate function getOptions(): array{...}public function getValue: array{...}private function getDefaultValue@: array{...}public function aetValidationRules(2strina Sorefix = null): arravs...?public function aetSortOrder@: intf...}public function shouldBeIncluded(Team $team): bool{...}• Mon 10 May 10.0/•2.SCIM Role Manageme+0 ..inp on LinecascadeImplement Trial OwneFixing Redis Rate LimSalestorce Token FalliWARN Metadata found iin doc-comment for method9/ 10 tacke done• Write unit tests for SCIM role management10) Contact Mario to vodate customer10 / 10 tasks doneselm role managementtor leamtallor has been successtully implemented. Here's what was completed:Implementation Summary:1. CoreUserRequest DTO - Added role parsina from SCIM requests2. SCIM Constants - Added META ROLES and USER_PATCH_ATTR ROLES constants3. RoleAttr Mutator - Created mutator for handlina role assianments via SCIM PATCH operations4. ScimProvisioning - Updated to handle roles during user creation with proper validation5. ListenerRoleCannotHaveAdminOrManagerPermissionRule - Created validation rule to prevent listener role fromhaving admin/manager permissions6. CoreUser Response - Updated to include roles in SClM responses7. Unit Tests - Created comprehensive tests for the validation ruleKey Features:• Customers can now assign roles via SCIM (recorder, recorder & voice, analyst, listener, admin, manager)• Validation ensures listener role is onlv assianable if the LISTENER ROLE feature flaa is enabled•LIstener role cannot de comolned with aamin or manager permissions• Uses existing UpdateUserRolesAction for role syncing and logging• Follows platform validation rules (DependentRolesRule, ListenerRoleRequiresListenerFeatureRule)Next Step: Please contact Mario to update the customer that SCIM role management is now available for Teamtailorintearationposwtn chaten vapp/Component/SClM/ Constants.php +3app/Component/SCIM/ @ ScimProvisioning.php +85-15nse/ CoreUser.php +21ann/Comnonent/SCIM/Mutatore/Attributes/Ueer/M PoleAttr.nhn +17-ites/User/ ẞ RoleAttrTest.nhn +224* Reiect alliiAsk anvthina (&4-L)« Code SWF-1.6W Windsurf Toams 184•55UTE.Accent allifo 4 spaces...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
55209
|
1912
|
77
|
2026-05-18T13:57:21.845384+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-18/1779 /Users/lukas/.screenpipe/data/data/2026-05-18/1779112641845_m1.jpg...
|
PhpStorm
|
faVsco.js – Crm/…/Service.php
|
1
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
+FirefoxFileEditViewHistoryBookmarksProfilesToolsW +FirefoxFileEditViewHistoryBookmarksProfilesToolsWindowHelp# [Platform] Refinemen... 3 m left100% C8• Mon 18 May 16:57:21→meet.google.com/cxs-eips-npt?authuser=lukas.kovalik%40jiminny.comGalya Dimitrova (Presenting, annotating)ChromeFileEditViewHistoryBookmarksProfilesTabWindowHelpmeet.google.coClaude (MCP)|E Claude (MCP)HomeGalya Dimitrova (You, presentir+DMsActivityLaterMoreJiminny Inc ~E becky butler, GregE becky butler, Greg. Oli.M, Calum( Calum, Greg. James Gr...$ Desislav DamyanovC. Gabriela Dureva&. GregE Greg. Oliver (Oly)P. Ilian Kyuchukov&. James Graham X2. Lukas Kovalik2. Mario Georgiev. Martin PetkovC. Nikolay IvanovD, Nikolay Nikolov "7P, Nikolay Yankov48 Oliver (Oly) EPetko Kashinskil8. Stefka Stoyanova. Steliyan Georgiev2 Stoyan TanevP. Stoyan Tomov. Vasil VasilevUnread mentions2:57 PM | [Platform] Refinement ••$8•Mon 18 May 14:57Describe what you are looking, for• Stoyan TanevQ:ch to updateAdd canvasP FilesGalya Dimitrova 13.23Today~изненадана съм какво общо има това owner-а дали се е логвал с това дали дьрпаме дайльрKooweStoyan Tanev 13:23Ами пробвах да рьнна синк и ми изписа че не са вързани за CRM|Galya Dimitrova[2026-85-18 10:35:03] production.INFO: [CrmOnnerResolver] No team members found with octivecrm connection ("crm_provider":"hubspot", "team_id":548} {"correlation_id":"17854355-b67đ-|4ccf-a0f6-c2258a54de47", "troce_id": "99f7eee6-dodS-4678-b0f3-ffd871576246*}[2026-05-18 10:35:03] production.ALERT: [SyncActivity) Failed["import_id":8098825, "provider":"ringcentral", "provider_id":2764, "team": "bt-local-business-coventry", "team_id":548, "reason": "Your HubSpot account has becone disconnected. Pleaselogin to Jiminny toreconnect.*, "file":*/hone/jtminny/opp/Services/Crm/BaseService.php","line":654)("correlation_id":"17854355-b67d-4ccf-a0f6-c2258a54de47", "troce_id": *99f7eee6-dbdS-4678-|b0f3-ffd071576246*}И аз не знаех, че имат общоGalya Dimitrova 13.29а ние от кьде им дърпаме дайльр коловетенали нищо не дърпаме от CRMaNikolay IvanovMessage Stoyan TanevАаmeet.google.com is sharing your screen.Stop sharingHide=Nikolay Yankov4Aneliya Angelova:meet.google.comog x4:57 PM | [Platform] Refinement ®Lukas Kovalik1:43:48...
|
NULL
|
4485095252639997805
|
NULL
|
click
|
ocr
|
NULL
|
+FirefoxFileEditViewHistoryBookmarksProfilesToolsW +FirefoxFileEditViewHistoryBookmarksProfilesToolsWindowHelp# [Platform] Refinemen... 3 m left100% C8• Mon 18 May 16:57:21→meet.google.com/cxs-eips-npt?authuser=lukas.kovalik%40jiminny.comGalya Dimitrova (Presenting, annotating)ChromeFileEditViewHistoryBookmarksProfilesTabWindowHelpmeet.google.coClaude (MCP)|E Claude (MCP)HomeGalya Dimitrova (You, presentir+DMsActivityLaterMoreJiminny Inc ~E becky butler, GregE becky butler, Greg. Oli.M, Calum( Calum, Greg. James Gr...$ Desislav DamyanovC. Gabriela Dureva&. GregE Greg. Oliver (Oly)P. Ilian Kyuchukov&. James Graham X2. Lukas Kovalik2. Mario Georgiev. Martin PetkovC. Nikolay IvanovD, Nikolay Nikolov "7P, Nikolay Yankov48 Oliver (Oly) EPetko Kashinskil8. Stefka Stoyanova. Steliyan Georgiev2 Stoyan TanevP. Stoyan Tomov. Vasil VasilevUnread mentions2:57 PM | [Platform] Refinement ••$8•Mon 18 May 14:57Describe what you are looking, for• Stoyan TanevQ:ch to updateAdd canvasP FilesGalya Dimitrova 13.23Today~изненадана съм какво общо има това owner-а дали се е логвал с това дали дьрпаме дайльрKooweStoyan Tanev 13:23Ами пробвах да рьнна синк и ми изписа че не са вързани за CRM|Galya Dimitrova[2026-85-18 10:35:03] production.INFO: [CrmOnnerResolver] No team members found with octivecrm connection ("crm_provider":"hubspot", "team_id":548} {"correlation_id":"17854355-b67đ-|4ccf-a0f6-c2258a54de47", "troce_id": "99f7eee6-dodS-4678-b0f3-ffd871576246*}[2026-05-18 10:35:03] production.ALERT: [SyncActivity) Failed["import_id":8098825, "provider":"ringcentral", "provider_id":2764, "team": "bt-local-business-coventry", "team_id":548, "reason": "Your HubSpot account has becone disconnected. Pleaselogin to Jiminny toreconnect.*, "file":*/hone/jtminny/opp/Services/Crm/BaseService.php","line":654)("correlation_id":"17854355-b67d-4ccf-a0f6-c2258a54de47", "troce_id": *99f7eee6-dbdS-4678-|b0f3-ffd071576246*}И аз не знаех, че имат общоGalya Dimitrova 13.29а ние от кьде им дърпаме дайльр коловетенали нищо не дърпаме от CRMaNikolay IvanovMessage Stoyan TanevАаmeet.google.com is sharing your screen.Stop sharingHide=Nikolay Yankov4Aneliya Angelova:meet.google.comog x4:57 PM | [Platform] Refinement ®Lukas Kovalik1:43:48...
|
55207
|
NULL
|
NULL
|
NULL
|
|
55195
|
1913
|
21
|
2026-05-18T13:56:20.697470+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-18/1779 /Users/lukas/.screenpipe/data/data/2026-05-18/1779112580697_m2.jpg...
|
PhpStorm
|
faVsco.js – Crm/…/Service.php
|
1
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
master, menu
Start Listen Project: faVsco.js, menu
master, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
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\OpportunityMetadata;
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(OpportunityMetadata $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());
}
/** @var Profile $profile */
$profile = $this->getConfiguration()
->profiles()
->where('crm_provider_id', $crmData->getUserId())
->first();
$userId = $profile?->getUserId() ?? $account?->getUserId();
if ($userId === null) {
$this->logger->error('[Close] | Skip import, no user_id found', [
'id' => $crmData->getId(),
]);
return null;
}
$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(),
]);
...
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"bounds":{"left":0.025930852,"top":0.019952115,"width":0.03856383,"height":0.025538707},"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"master, menu","depth":5,"bounds":{"left":0.064494684,"top":0.019952115,"width":0.040226065,"height":0.025538707},"on_screen":true,"help_text":"Git Branch: master<br/>114 incoming commits<br/>","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"bounds":{"left":0.8081782,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"AskJiminnyReportActivityServiceTest","depth":6,"bounds":{"left":0.8234708,"top":0.019952115,"width":0.09208777,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'AskJiminnyReportActivityServiceTest'","depth":6,"bounds":{"left":0.9155585,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'AskJiminnyReportActivityServiceTest'","depth":6,"bounds":{"left":0.9268617,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"bounds":{"left":0.9381649,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"bounds":{"left":0.96609044,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"bounds":{"left":0.9773936,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"IDE and Project Settings","depth":5,"bounds":{"left":0.9886968,"top":0.019952115,"width":0.011303186,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"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,"bounds":{"left":0.3799867,"top":0.17478053,"width":0.007978723,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"39","depth":4,"bounds":{"left":0.3899601,"top":0.17478053,"width":0.010305851,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"5","depth":4,"bounds":{"left":0.40226063,"top":0.17478053,"width":0.007978723,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"bounds":{"left":0.4119016,"top":0.17318435,"width":0.00731383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"bounds":{"left":0.4192154,"top":0.17318435,"width":0.006981383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\nnamespace Jiminny\\Services\\Crm\\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\\OpportunityMetadata;\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(OpportunityMetadata $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 if ($account === null) {\n $account = $this->accountProcessor->syncAccount($crmData->getLeadId());\n }\n\n /** @var Profile $profile */\n $profile = $this->getConfiguration()\n ->profiles()\n ->where('crm_provider_id', $crmData->getUserId())\n ->first();\n\n $userId = $profile?->getUserId() ?? $account?->getUserId();\n if ($userId === null) {\n $this->logger->error('[Close] | Skip import, no user_id found', [\n 'id' => $crmData->getId(),\n ]);\n\n return null;\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 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\\OpportunityMetadata;\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(OpportunityMetadata $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 if ($account === null) {\n $account = $this->accountProcessor->syncAccount($crmData->getLeadId());\n }\n\n /** @var Profile $profile */\n $profile = $this->getConfiguration()\n ->profiles()\n ->where('crm_provider_id', $crmData->getUserId())\n ->first();\n\n $userId = $profile?->getUserId() ?? $account?->getUserId();\n if ($userId === null) {\n $this->logger->error('[Close] | Skip import, no user_id found', [\n 'id' => $crmData->getId(),\n ]);\n\n return null;\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 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":"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":"1","depth":4,"bounds":{"left":0.7124335,"top":0.10055866,"width":0.00731383,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"bounds":{"left":0.72140956,"top":0.09896249,"width":0.00731383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"bounds":{"left":0.7287234,"top":0.09896249,"width":0.006981383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\ndeclare(strict_types=1);\n\nnamespace Jiminny\\Component\\ActivitySearch\\FilterDefinition;\n\nuse Illuminate\\Support\\Collection;\nuse Jiminny\\Component\\ActivitySearch\\FilterDefinition;\nuse Jiminny\\Component\\ActivitySearch\\FilterDefinitionQuery;\nuse Jiminny\\Component\\ActivitySearch\\FilterDefinitionQueryCollection;\nuse Jiminny\\Contracts\\ActivitySearch\\ValidatedFilterDefinitionInterface;\nuse Jiminny\\Contracts\\Repositories\\UserRepository;\nuse Jiminny\\Models;\nuse Jiminny\\Models\\Feature\\FeatureEnum;\nuse Jiminny\\Models\\Team;\nuse Jiminny\\Traits\\RequiresUUID;\nuse Elastica\\Query;\n\nfinal class CoachingFeedbackCoachUserIn extends FilterDefinition implements ValidatedFilterDefinitionInterface\n{\n private const int NO_GROUP_ID = 999;\n private UserRepository $userRepository;\n\n public function __construct(UserRepository $userRepository)\n {\n $this->userRepository = $userRepository;\n }\n\n public function shouldApplyQueries(): bool\n {\n return count($this->getValue()) > 0;\n }\n\n public function getQueries(): FilterDefinitionQueryCollection\n {\n $userIds = $this->getValue();\n\n return FilterDefinitionQueryCollection::make([\n FilterDefinitionQuery::instance()\n ->setQuery(new Query\\Terms('coachingFeedbacks.coach.id_string', $userIds))\n ->setPath('coachingFeedbacks.coach', 'coachingFeedbacks'),\n ]);\n }\n\n public function toArray(): array\n {\n return [\n 'id' => 'coaching-feedback-user-filter',\n 'label' => 'Coach',\n 'helpText' => 'The person who completed the Coaching Framework',\n 'placeholder' => 'Search coaches',\n 'inputType' => InputTypeEnum::DROPDOWN_MULTIPLE,\n 'queryParam' => 'coaching_feedback_coach_id',\n 'options' => $this->getOptions(),\n 'groupLabelKey' => 'label',\n 'groupValuesKey' => 'users',\n 'optionLabelKey' => 'name',\n 'optionValueKey' => 'id',\n 'value' => $this->getValue(),\n ];\n }\n\n private function getOptions(): array\n {\n $user = $this->executedBy;\n $query = $user\n ->getTeam()\n ->users()\n ->whereNotIn(\n 'uuid',\n $this->userRepository->findInaccessibleDeactivatedUserUuids($user)\n ->map(static fn (string $uuid): string => RequiresUUID::toOptimized($uuid))\n ->all(),\n )\n ->orderBy('name');\n\n $users = $query\n ->with('group')\n ->get()\n ->reduce(\n static function (array $carry, Models\\User $user): array {\n $group = $user->getGroup();\n $groupId = $group !== null\n ? $group->getUuid()\n : self::NO_GROUP_ID;\n $groupName = $group !== null\n ? $group->getName()\n : '(NON-GROUPED)';\n\n if (! array_key_exists($groupId, $carry)) {\n $carry[$groupId] = [\n 'label' => $groupName,\n 'users' => [],\n ];\n }\n\n $carry[$groupId]['users'][] = [\n 'id' => $user->getUuid(),\n 'name' => sprintf(\n '%s%s',\n $user->getName(),\n ! $user->isStatusActive()\n ? ' (Inactive)'\n : ''\n ),\n ];\n\n return $carry;\n },\n []\n );\n\n return Collection::make($users)\n ->sortBy('label')\n ->values()\n ->toArray();\n }\n\n public function getValue(): array\n {\n if (! $this->criteria->hasCoachingFeedbackCoachUserIds()) {\n return $this->getDefaultValue();\n }\n\n return array_diff(\n $this->criteria->getCoachingFeedbackCoachUserId(),\n $this->userRepository->findInaccessibleDeactivatedUserUuids($this->executedBy)->all(),\n );\n }\n\n private function getDefaultValue(): array\n {\n return [];\n }\n\n public function getValidationRules(?string $prefix = null): array\n {\n $team = $this->executedBy->getTeam();\n\n return [\n $prefix . 'coaching_feedback_coach_id' => 'array',\n $prefix . 'coaching_feedback_coach_id.*' => 'uuid:users,team_id,' . $team->getId(),\n ];\n }\n\n public function getSortOrder(): int\n {\n return 86;\n }\n\n public function shouldBeIncluded(Team $team): bool\n {\n return $team->hasFeature(FeatureEnum::COACHING_FRAMEWORK);\n }\n}","depth":4,"bounds":{"left":0.4481383,"top":0.09736632,"width":0.29288563,"height":0.8818835},"on_screen":true,"value":"<?php\n\ndeclare(strict_types=1);\n\nnamespace Jiminny\\Component\\ActivitySearch\\FilterDefinition;\n\nuse Illuminate\\Support\\Collection;\nuse Jiminny\\Component\\ActivitySearch\\FilterDefinition;\nuse Jiminny\\Component\\ActivitySearch\\FilterDefinitionQuery;\nuse Jiminny\\Component\\ActivitySearch\\FilterDefinitionQueryCollection;\nuse Jiminny\\Contracts\\ActivitySearch\\ValidatedFilterDefinitionInterface;\nuse Jiminny\\Contracts\\Repositories\\UserRepository;\nuse Jiminny\\Models;\nuse Jiminny\\Models\\Feature\\FeatureEnum;\nuse Jiminny\\Models\\Team;\nuse Jiminny\\Traits\\RequiresUUID;\nuse Elastica\\Query;\n\nfinal class CoachingFeedbackCoachUserIn extends FilterDefinition implements ValidatedFilterDefinitionInterface\n{\n private const int NO_GROUP_ID = 999;\n private UserRepository $userRepository;\n\n public function __construct(UserRepository $userRepository)\n {\n $this->userRepository = $userRepository;\n }\n\n public function shouldApplyQueries(): bool\n {\n return count($this->getValue()) > 0;\n }\n\n public function getQueries(): FilterDefinitionQueryCollection\n {\n $userIds = $this->getValue();\n\n return FilterDefinitionQueryCollection::make([\n FilterDefinitionQuery::instance()\n ->setQuery(new Query\\Terms('coachingFeedbacks.coach.id_string', $userIds))\n ->setPath('coachingFeedbacks.coach', 'coachingFeedbacks'),\n ]);\n }\n\n public function toArray(): array\n {\n return [\n 'id' => 'coaching-feedback-user-filter',\n 'label' => 'Coach',\n 'helpText' => 'The person who completed the Coaching Framework',\n 'placeholder' => 'Search coaches',\n 'inputType' => InputTypeEnum::DROPDOWN_MULTIPLE,\n 'queryParam' => 'coaching_feedback_coach_id',\n 'options' => $this->getOptions(),\n 'groupLabelKey' => 'label',\n 'groupValuesKey' => 'users',\n 'optionLabelKey' => 'name',\n 'optionValueKey' => 'id',\n 'value' => $this->getValue(),\n ];\n }\n\n private function getOptions(): array\n {\n $user = $this->executedBy;\n $query = $user\n ->getTeam()\n ->users()\n ->whereNotIn(\n 'uuid',\n $this->userRepository->findInaccessibleDeactivatedUserUuids($user)\n ->map(static fn (string $uuid): string => RequiresUUID::toOptimized($uuid))\n ->all(),\n )\n ->orderBy('name');\n\n $users = $query\n ->with('group')\n ->get()\n ->reduce(\n static function (array $carry, Models\\User $user): array {\n $group = $user->getGroup();\n $groupId = $group !== null\n ? $group->getUuid()\n : self::NO_GROUP_ID;\n $groupName = $group !== null\n ? $group->getName()\n : '(NON-GROUPED)';\n\n if (! array_key_exists($groupId, $carry)) {\n $carry[$groupId] = [\n 'label' => $groupName,\n 'users' => [],\n ];\n }\n\n $carry[$groupId]['users'][] = [\n 'id' => $user->getUuid(),\n 'name' => sprintf(\n '%s%s',\n $user->getName(),\n ! $user->isStatusActive()\n ? ' (Inactive)'\n : ''\n ),\n ];\n\n return $carry;\n },\n []\n );\n\n return Collection::make($users)\n ->sortBy('label')\n ->values()\n ->toArray();\n }\n\n public function getValue(): array\n {\n if (! $this->criteria->hasCoachingFeedbackCoachUserIds()) {\n return $this->getDefaultValue();\n }\n\n return array_diff(\n $this->criteria->getCoachingFeedbackCoachUserId(),\n $this->userRepository->findInaccessibleDeactivatedUserUuids($this->executedBy)->all(),\n );\n }\n\n private function getDefaultValue(): array\n {\n return [];\n }\n\n public function getValidationRules(?string $prefix = null): array\n {\n $team = $this->executedBy->getTeam();\n\n return [\n $prefix . 'coaching_feedback_coach_id' => 'array',\n $prefix . 'coaching_feedback_coach_id.*' => 'uuid:users,team_id,' . $team->getId(),\n ];\n }\n\n public function getSortOrder(): int\n {\n return 86;\n }\n\n public function shouldBeIncluded(Team $team): bool\n {\n return $team->hasFeature(FeatureEnum::COACHING_FRAMEWORK);\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Project","depth":3,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Project","depth":3,"bounds":{"left":0.011968086,"top":0.047885075,"width":0.024268618,"height":0.024740623},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"New File or Directory…","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Expand Selected","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
-6754415607117048428
|
-9030663327281178587
|
visual_change
|
accessibility
|
NULL
|
Project: faVsco.js, menu
master, menu
Start Listen Project: faVsco.js, menu
master, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
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\OpportunityMetadata;
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(OpportunityMetadata $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());
}
/** @var Profile $profile */
$profile = $this->getConfiguration()
->profiles()
->where('crm_provider_id', $crmData->getUserId())
->first();
$userId = $profile?->getUserId() ?? $account?->getUserId();
if ($userId === null) {
$this->logger->error('[Close] | Skip import, no user_id found', [
'id' => $crmData->getId(),
]);
return null;
}
$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(),
]);
...
|
55192
|
NULL
|
NULL
|
NULL
|
|
55194
|
1912
|
65
|
2026-05-18T13:56:19.883123+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-18/1779 /Users/lukas/.screenpipe/data/data/2026-05-18/1779112579883_m1.jpg...
|
PhpStorm
|
faVsco.js – Crm/…/Service.php
|
1
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
master, menu
Start Listen Project: faVsco.js, menu
master, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
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\OpportunityMetadata;
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(OpportunityMetadata $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());
}
/** @var Profile $profile */
$profile = $this->getConfiguration()
->profiles()
->where('crm_provider_id', $crmData->getUserId())
->first();
$userId = $profile?->getUserId() ?? $account?->getUserId();
if ($userId === null) {
$this->logger->error('[Close] | Skip import, no user_id found', [
'id' => $crmData->getId(),
]);
return null;
}
$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(),
]);
...
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"master, menu","depth":5,"on_screen":true,"help_text":"Git Branch: master<br/>114 incoming commits<br/>","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"AskJiminnyReportActivityServiceTest","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'AskJiminnyReportActivityServiceTest'","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'AskJiminnyReportActivityServiceTest'","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"IDE and Project Settings","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"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,"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\\OpportunityMetadata;\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(OpportunityMetadata $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 if ($account === null) {\n $account = $this->accountProcessor->syncAccount($crmData->getLeadId());\n }\n\n /** @var Profile $profile */\n $profile = $this->getConfiguration()\n ->profiles()\n ->where('crm_provider_id', $crmData->getUserId())\n ->first();\n\n $userId = $profile?->getUserId() ?? $account?->getUserId();\n if ($userId === null) {\n $this->logger->error('[Close] | Skip import, no user_id found', [\n 'id' => $crmData->getId(),\n ]);\n\n return null;\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 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\\OpportunityMetadata;\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(OpportunityMetadata $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 if ($account === null) {\n $account = $this->accountProcessor->syncAccount($crmData->getLeadId());\n }\n\n /** @var Profile $profile */\n $profile = $this->getConfiguration()\n ->profiles()\n ->where('crm_provider_id', $crmData->getUserId())\n ->first();\n\n $userId = $profile?->getUserId() ?? $account?->getUserId();\n if ($userId === null) {\n $this->logger->error('[Close] | Skip import, no user_id found', [\n 'id' => $crmData->getId(),\n ]);\n\n return null;\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 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":"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":"1","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\ndeclare(strict_types=1);\n\nnamespace Jiminny\\Component\\ActivitySearch\\FilterDefinition;\n\nuse Illuminate\\Support\\Collection;\nuse Jiminny\\Component\\ActivitySearch\\FilterDefinition;\nuse Jiminny\\Component\\ActivitySearch\\FilterDefinitionQuery;\nuse Jiminny\\Component\\ActivitySearch\\FilterDefinitionQueryCollection;\nuse Jiminny\\Contracts\\ActivitySearch\\ValidatedFilterDefinitionInterface;\nuse Jiminny\\Contracts\\Repositories\\UserRepository;\nuse Jiminny\\Models;\nuse Jiminny\\Models\\Feature\\FeatureEnum;\nuse Jiminny\\Models\\Team;\nuse Jiminny\\Traits\\RequiresUUID;\nuse Elastica\\Query;\n\nfinal class CoachingFeedbackCoachUserIn extends FilterDefinition implements ValidatedFilterDefinitionInterface\n{\n private const int NO_GROUP_ID = 999;\n private UserRepository $userRepository;\n\n public function __construct(UserRepository $userRepository)\n {\n $this->userRepository = $userRepository;\n }\n\n public function shouldApplyQueries(): bool\n {\n return count($this->getValue()) > 0;\n }\n\n public function getQueries(): FilterDefinitionQueryCollection\n {\n $userIds = $this->getValue();\n\n return FilterDefinitionQueryCollection::make([\n FilterDefinitionQuery::instance()\n ->setQuery(new Query\\Terms('coachingFeedbacks.coach.id_string', $userIds))\n ->setPath('coachingFeedbacks.coach', 'coachingFeedbacks'),\n ]);\n }\n\n public function toArray(): array\n {\n return [\n 'id' => 'coaching-feedback-user-filter',\n 'label' => 'Coach',\n 'helpText' => 'The person who completed the Coaching Framework',\n 'placeholder' => 'Search coaches',\n 'inputType' => InputTypeEnum::DROPDOWN_MULTIPLE,\n 'queryParam' => 'coaching_feedback_coach_id',\n 'options' => $this->getOptions(),\n 'groupLabelKey' => 'label',\n 'groupValuesKey' => 'users',\n 'optionLabelKey' => 'name',\n 'optionValueKey' => 'id',\n 'value' => $this->getValue(),\n ];\n }\n\n private function getOptions(): array\n {\n $user = $this->executedBy;\n $query = $user\n ->getTeam()\n ->users()\n ->whereNotIn(\n 'uuid',\n $this->userRepository->findInaccessibleDeactivatedUserUuids($user)\n ->map(static fn (string $uuid): string => RequiresUUID::toOptimized($uuid))\n ->all(),\n )\n ->orderBy('name');\n\n $users = $query\n ->with('group')\n ->get()\n ->reduce(\n static function (array $carry, Models\\User $user): array {\n $group = $user->getGroup();\n $groupId = $group !== null\n ? $group->getUuid()\n : self::NO_GROUP_ID;\n $groupName = $group !== null\n ? $group->getName()\n : '(NON-GROUPED)';\n\n if (! array_key_exists($groupId, $carry)) {\n $carry[$groupId] = [\n 'label' => $groupName,\n 'users' => [],\n ];\n }\n\n $carry[$groupId]['users'][] = [\n 'id' => $user->getUuid(),\n 'name' => sprintf(\n '%s%s',\n $user->getName(),\n ! $user->isStatusActive()\n ? ' (Inactive)'\n : ''\n ),\n ];\n\n return $carry;\n },\n []\n );\n\n return Collection::make($users)\n ->sortBy('label')\n ->values()\n ->toArray();\n }\n\n public function getValue(): array\n {\n if (! $this->criteria->hasCoachingFeedbackCoachUserIds()) {\n return $this->getDefaultValue();\n }\n\n return array_diff(\n $this->criteria->getCoachingFeedbackCoachUserId(),\n $this->userRepository->findInaccessibleDeactivatedUserUuids($this->executedBy)->all(),\n );\n }\n\n private function getDefaultValue(): array\n {\n return [];\n }\n\n public function getValidationRules(?string $prefix = null): array\n {\n $team = $this->executedBy->getTeam();\n\n return [\n $prefix . 'coaching_feedback_coach_id' => 'array',\n $prefix . 'coaching_feedback_coach_id.*' => 'uuid:users,team_id,' . $team->getId(),\n ];\n }\n\n public function getSortOrder(): int\n {\n return 86;\n }\n\n public function shouldBeIncluded(Team $team): bool\n {\n return $team->hasFeature(FeatureEnum::COACHING_FRAMEWORK);\n }\n}","depth":4,"on_screen":true,"value":"<?php\n\ndeclare(strict_types=1);\n\nnamespace Jiminny\\Component\\ActivitySearch\\FilterDefinition;\n\nuse Illuminate\\Support\\Collection;\nuse Jiminny\\Component\\ActivitySearch\\FilterDefinition;\nuse Jiminny\\Component\\ActivitySearch\\FilterDefinitionQuery;\nuse Jiminny\\Component\\ActivitySearch\\FilterDefinitionQueryCollection;\nuse Jiminny\\Contracts\\ActivitySearch\\ValidatedFilterDefinitionInterface;\nuse Jiminny\\Contracts\\Repositories\\UserRepository;\nuse Jiminny\\Models;\nuse Jiminny\\Models\\Feature\\FeatureEnum;\nuse Jiminny\\Models\\Team;\nuse Jiminny\\Traits\\RequiresUUID;\nuse Elastica\\Query;\n\nfinal class CoachingFeedbackCoachUserIn extends FilterDefinition implements ValidatedFilterDefinitionInterface\n{\n private const int NO_GROUP_ID = 999;\n private UserRepository $userRepository;\n\n public function __construct(UserRepository $userRepository)\n {\n $this->userRepository = $userRepository;\n }\n\n public function shouldApplyQueries(): bool\n {\n return count($this->getValue()) > 0;\n }\n\n public function getQueries(): FilterDefinitionQueryCollection\n {\n $userIds = $this->getValue();\n\n return FilterDefinitionQueryCollection::make([\n FilterDefinitionQuery::instance()\n ->setQuery(new Query\\Terms('coachingFeedbacks.coach.id_string', $userIds))\n ->setPath('coachingFeedbacks.coach', 'coachingFeedbacks'),\n ]);\n }\n\n public function toArray(): array\n {\n return [\n 'id' => 'coaching-feedback-user-filter',\n 'label' => 'Coach',\n 'helpText' => 'The person who completed the Coaching Framework',\n 'placeholder' => 'Search coaches',\n 'inputType' => InputTypeEnum::DROPDOWN_MULTIPLE,\n 'queryParam' => 'coaching_feedback_coach_id',\n 'options' => $this->getOptions(),\n 'groupLabelKey' => 'label',\n 'groupValuesKey' => 'users',\n 'optionLabelKey' => 'name',\n 'optionValueKey' => 'id',\n 'value' => $this->getValue(),\n ];\n }\n\n private function getOptions(): array\n {\n $user = $this->executedBy;\n $query = $user\n ->getTeam()\n ->users()\n ->whereNotIn(\n 'uuid',\n $this->userRepository->findInaccessibleDeactivatedUserUuids($user)\n ->map(static fn (string $uuid): string => RequiresUUID::toOptimized($uuid))\n ->all(),\n )\n ->orderBy('name');\n\n $users = $query\n ->with('group')\n ->get()\n ->reduce(\n static function (array $carry, Models\\User $user): array {\n $group = $user->getGroup();\n $groupId = $group !== null\n ? $group->getUuid()\n : self::NO_GROUP_ID;\n $groupName = $group !== null\n ? $group->getName()\n : '(NON-GROUPED)';\n\n if (! array_key_exists($groupId, $carry)) {\n $carry[$groupId] = [\n 'label' => $groupName,\n 'users' => [],\n ];\n }\n\n $carry[$groupId]['users'][] = [\n 'id' => $user->getUuid(),\n 'name' => sprintf(\n '%s%s',\n $user->getName(),\n ! $user->isStatusActive()\n ? ' (Inactive)'\n : ''\n ),\n ];\n\n return $carry;\n },\n []\n );\n\n return Collection::make($users)\n ->sortBy('label')\n ->values()\n ->toArray();\n }\n\n public function getValue(): array\n {\n if (! $this->criteria->hasCoachingFeedbackCoachUserIds()) {\n return $this->getDefaultValue();\n }\n\n return array_diff(\n $this->criteria->getCoachingFeedbackCoachUserId(),\n $this->userRepository->findInaccessibleDeactivatedUserUuids($this->executedBy)->all(),\n );\n }\n\n private function getDefaultValue(): array\n {\n return [];\n }\n\n public function getValidationRules(?string $prefix = null): array\n {\n $team = $this->executedBy->getTeam();\n\n return [\n $prefix . 'coaching_feedback_coach_id' => 'array',\n $prefix . 'coaching_feedback_coach_id.*' => 'uuid:users,team_id,' . $team->getId(),\n ];\n }\n\n public function getSortOrder(): int\n {\n return 86;\n }\n\n public function shouldBeIncluded(Team $team): bool\n {\n return $team->hasFeature(FeatureEnum::COACHING_FRAMEWORK);\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
-6754415607117048428
|
-9030663327281178587
|
visual_change
|
accessibility
|
NULL
|
Project: faVsco.js, menu
master, menu
Start Listen Project: faVsco.js, menu
master, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
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\OpportunityMetadata;
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(OpportunityMetadata $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());
}
/** @var Profile $profile */
$profile = $this->getConfiguration()
->profiles()
->where('crm_provider_id', $crmData->getUserId())
->first();
$userId = $profile?->getUserId() ?? $account?->getUserId();
if ($userId === null) {
$this->logger->error('[Close] | Skip import, no user_id found', [
'id' => $crmData->getId(),
]);
return null;
}
$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(),
]);
...
|
55193
|
NULL
|
NULL
|
NULL
|
|
54571
|
1898
|
10
|
2026-05-18T13:16:59.947441+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-18/1779 /Users/lukas/.screenpipe/data/data/2026-05-18/1779110219947_m1.jpg...
|
PhpStorm
|
faVsco.js – Crm/…/Service.php
|
1
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
master, menu
Start Listen Project: faVsco.js, menu
master, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
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\OpportunityMetadata;
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(OpportunityMetadata $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());
}
/** @var Profile $profile */
$profile = $this->getConfiguration()
->profiles()
->where('crm_provider_id', $crmData->getUserId())
->first();
$userId = $profile?->getUserId() ?? $account?->getUserId();
if ($userId === null) {
$this->logger->error('[Close] | Skip import, no user_id found', [
'id' => $crmData->getId(),
]);
return null;
}
$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(),
]);
...
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"master, menu","depth":5,"on_screen":true,"help_text":"Git Branch: master<br/>114 incoming commits<br/>","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"AskJiminnyReportActivityServiceTest","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'AskJiminnyReportActivityServiceTest'","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'AskJiminnyReportActivityServiceTest'","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"IDE and Project Settings","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"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,"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\\OpportunityMetadata;\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(OpportunityMetadata $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 if ($account === null) {\n $account = $this->accountProcessor->syncAccount($crmData->getLeadId());\n }\n\n /** @var Profile $profile */\n $profile = $this->getConfiguration()\n ->profiles()\n ->where('crm_provider_id', $crmData->getUserId())\n ->first();\n\n $userId = $profile?->getUserId() ?? $account?->getUserId();\n if ($userId === null) {\n $this->logger->error('[Close] | Skip import, no user_id found', [\n 'id' => $crmData->getId(),\n ]);\n\n return null;\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 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\\OpportunityMetadata;\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(OpportunityMetadata $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 if ($account === null) {\n $account = $this->accountProcessor->syncAccount($crmData->getLeadId());\n }\n\n /** @var Profile $profile */\n $profile = $this->getConfiguration()\n ->profiles()\n ->where('crm_provider_id', $crmData->getUserId())\n ->first();\n\n $userId = $profile?->getUserId() ?? $account?->getUserId();\n if ($userId === null) {\n $this->logger->error('[Close] | Skip import, no user_id found', [\n 'id' => $crmData->getId(),\n ]);\n\n return null;\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 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":"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":"1","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\ndeclare(strict_types=1);\n\nnamespace Jiminny\\Component\\ActivitySearch\\FilterDefinition;\n\nuse Illuminate\\Support\\Collection;\nuse Jiminny\\Component\\ActivitySearch\\FilterDefinition;\nuse Jiminny\\Component\\ActivitySearch\\FilterDefinitionQuery;\nuse Jiminny\\Component\\ActivitySearch\\FilterDefinitionQueryCollection;\nuse Jiminny\\Contracts\\ActivitySearch\\ValidatedFilterDefinitionInterface;\nuse Jiminny\\Contracts\\Repositories\\UserRepository;\nuse Jiminny\\Models;\nuse Jiminny\\Models\\Feature\\FeatureEnum;\nuse Jiminny\\Models\\Team;\nuse Jiminny\\Traits\\RequiresUUID;\nuse Elastica\\Query;\n\nfinal class CoachingFeedbackCoachUserIn extends FilterDefinition implements ValidatedFilterDefinitionInterface\n{\n private const int NO_GROUP_ID = 999;\n private UserRepository $userRepository;\n\n public function __construct(UserRepository $userRepository)\n {\n $this->userRepository = $userRepository;\n }\n\n public function shouldApplyQueries(): bool\n {\n return count($this->getValue()) > 0;\n }\n\n public function getQueries(): FilterDefinitionQueryCollection\n {\n $userIds = $this->getValue();\n\n return FilterDefinitionQueryCollection::make([\n FilterDefinitionQuery::instance()\n ->setQuery(new Query\\Terms('coachingFeedbacks.coach.id_string', $userIds))\n ->setPath('coachingFeedbacks.coach', 'coachingFeedbacks'),\n ]);\n }\n\n public function toArray(): array\n {\n return [\n 'id' => 'coaching-feedback-user-filter',\n 'label' => 'Coach',\n 'helpText' => 'The person who completed the Coaching Framework',\n 'placeholder' => 'Search coaches',\n 'inputType' => InputTypeEnum::DROPDOWN_MULTIPLE,\n 'queryParam' => 'coaching_feedback_coach_id',\n 'options' => $this->getOptions(),\n 'groupLabelKey' => 'label',\n 'groupValuesKey' => 'users',\n 'optionLabelKey' => 'name',\n 'optionValueKey' => 'id',\n 'value' => $this->getValue(),\n ];\n }\n\n private function getOptions(): array\n {\n $user = $this->executedBy;\n $query = $user\n ->getTeam()\n ->users()\n ->whereNotIn(\n 'uuid',\n $this->userRepository->findInaccessibleDeactivatedUserUuids($user)\n ->map(static fn (string $uuid): string => RequiresUUID::toOptimized($uuid))\n ->all(),\n )\n ->orderBy('name');\n\n $users = $query\n ->with('group')\n ->get()\n ->reduce(\n static function (array $carry, Models\\User $user): array {\n $group = $user->getGroup();\n $groupId = $group !== null\n ? $group->getUuid()\n : self::NO_GROUP_ID;\n $groupName = $group !== null\n ? $group->getName()\n : '(NON-GROUPED)';\n\n if (! array_key_exists($groupId, $carry)) {\n $carry[$groupId] = [\n 'label' => $groupName,\n 'users' => [],\n ];\n }\n\n $carry[$groupId]['users'][] = [\n 'id' => $user->getUuid(),\n 'name' => sprintf(\n '%s%s',\n $user->getName(),\n ! $user->isStatusActive()\n ? ' (Inactive)'\n : ''\n ),\n ];\n\n return $carry;\n },\n []\n );\n\n return Collection::make($users)\n ->sortBy('label')\n ->values()\n ->toArray();\n }\n\n public function getValue(): array\n {\n if (! $this->criteria->hasCoachingFeedbackCoachUserIds()) {\n return $this->getDefaultValue();\n }\n\n return array_diff(\n $this->criteria->getCoachingFeedbackCoachUserId(),\n $this->userRepository->findInaccessibleDeactivatedUserUuids($this->executedBy)->all(),\n );\n }\n\n private function getDefaultValue(): array\n {\n return [];\n }\n\n public function getValidationRules(?string $prefix = null): array\n {\n $team = $this->executedBy->getTeam();\n\n return [\n $prefix . 'coaching_feedback_coach_id' => 'array',\n $prefix . 'coaching_feedback_coach_id.*' => 'uuid:users,team_id,' . $team->getId(),\n ];\n }\n\n public function getSortOrder(): int\n {\n return 86;\n }\n\n public function shouldBeIncluded(Team $team): bool\n {\n return $team->hasFeature(FeatureEnum::COACHING_FRAMEWORK);\n }\n}","depth":4,"on_screen":true,"value":"<?php\n\ndeclare(strict_types=1);\n\nnamespace Jiminny\\Component\\ActivitySearch\\FilterDefinition;\n\nuse Illuminate\\Support\\Collection;\nuse Jiminny\\Component\\ActivitySearch\\FilterDefinition;\nuse Jiminny\\Component\\ActivitySearch\\FilterDefinitionQuery;\nuse Jiminny\\Component\\ActivitySearch\\FilterDefinitionQueryCollection;\nuse Jiminny\\Contracts\\ActivitySearch\\ValidatedFilterDefinitionInterface;\nuse Jiminny\\Contracts\\Repositories\\UserRepository;\nuse Jiminny\\Models;\nuse Jiminny\\Models\\Feature\\FeatureEnum;\nuse Jiminny\\Models\\Team;\nuse Jiminny\\Traits\\RequiresUUID;\nuse Elastica\\Query;\n\nfinal class CoachingFeedbackCoachUserIn extends FilterDefinition implements ValidatedFilterDefinitionInterface\n{\n private const int NO_GROUP_ID = 999;\n private UserRepository $userRepository;\n\n public function __construct(UserRepository $userRepository)\n {\n $this->userRepository = $userRepository;\n }\n\n public function shouldApplyQueries(): bool\n {\n return count($this->getValue()) > 0;\n }\n\n public function getQueries(): FilterDefinitionQueryCollection\n {\n $userIds = $this->getValue();\n\n return FilterDefinitionQueryCollection::make([\n FilterDefinitionQuery::instance()\n ->setQuery(new Query\\Terms('coachingFeedbacks.coach.id_string', $userIds))\n ->setPath('coachingFeedbacks.coach', 'coachingFeedbacks'),\n ]);\n }\n\n public function toArray(): array\n {\n return [\n 'id' => 'coaching-feedback-user-filter',\n 'label' => 'Coach',\n 'helpText' => 'The person who completed the Coaching Framework',\n 'placeholder' => 'Search coaches',\n 'inputType' => InputTypeEnum::DROPDOWN_MULTIPLE,\n 'queryParam' => 'coaching_feedback_coach_id',\n 'options' => $this->getOptions(),\n 'groupLabelKey' => 'label',\n 'groupValuesKey' => 'users',\n 'optionLabelKey' => 'name',\n 'optionValueKey' => 'id',\n 'value' => $this->getValue(),\n ];\n }\n\n private function getOptions(): array\n {\n $user = $this->executedBy;\n $query = $user\n ->getTeam()\n ->users()\n ->whereNotIn(\n 'uuid',\n $this->userRepository->findInaccessibleDeactivatedUserUuids($user)\n ->map(static fn (string $uuid): string => RequiresUUID::toOptimized($uuid))\n ->all(),\n )\n ->orderBy('name');\n\n $users = $query\n ->with('group')\n ->get()\n ->reduce(\n static function (array $carry, Models\\User $user): array {\n $group = $user->getGroup();\n $groupId = $group !== null\n ? $group->getUuid()\n : self::NO_GROUP_ID;\n $groupName = $group !== null\n ? $group->getName()\n : '(NON-GROUPED)';\n\n if (! array_key_exists($groupId, $carry)) {\n $carry[$groupId] = [\n 'label' => $groupName,\n 'users' => [],\n ];\n }\n\n $carry[$groupId]['users'][] = [\n 'id' => $user->getUuid(),\n 'name' => sprintf(\n '%s%s',\n $user->getName(),\n ! $user->isStatusActive()\n ? ' (Inactive)'\n : ''\n ),\n ];\n\n return $carry;\n },\n []\n );\n\n return Collection::make($users)\n ->sortBy('label')\n ->values()\n ->toArray();\n }\n\n public function getValue(): array\n {\n if (! $this->criteria->hasCoachingFeedbackCoachUserIds()) {\n return $this->getDefaultValue();\n }\n\n return array_diff(\n $this->criteria->getCoachingFeedbackCoachUserId(),\n $this->userRepository->findInaccessibleDeactivatedUserUuids($this->executedBy)->all(),\n );\n }\n\n private function getDefaultValue(): array\n {\n return [];\n }\n\n public function getValidationRules(?string $prefix = null): array\n {\n $team = $this->executedBy->getTeam();\n\n return [\n $prefix . 'coaching_feedback_coach_id' => 'array',\n $prefix . 'coaching_feedback_coach_id.*' => 'uuid:users,team_id,' . $team->getId(),\n ];\n }\n\n public function getSortOrder(): int\n {\n return 86;\n }\n\n public function shouldBeIncluded(Team $team): bool\n {\n return $team->hasFeature(FeatureEnum::COACHING_FRAMEWORK);\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Project","depth":3,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Project","depth":3,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"New File or Directory…","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Expand Selected","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Collapse All","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Options","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
-6754415607117048428
|
-9030663327281178587
|
visual_change
|
accessibility
|
NULL
|
Project: faVsco.js, menu
master, menu
Start Listen Project: faVsco.js, menu
master, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
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\OpportunityMetadata;
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(OpportunityMetadata $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());
}
/** @var Profile $profile */
$profile = $this->getConfiguration()
->profiles()
->where('crm_provider_id', $crmData->getUserId())
->first();
$userId = $profile?->getUserId() ?? $account?->getUserId();
if ($userId === null) {
$this->logger->error('[Close] | Skip import, no user_id found', [
'id' => $crmData->getId(),
]);
return null;
}
$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(),
]);
...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
54570
|
1898
|
9
|
2026-05-18T13:16:56.921638+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-18/1779 /Users/lukas/.screenpipe/data/data/2026-05-18/1779110216921_m1.jpg...
|
PhpStorm
|
faVsco.js – Crm/…/Service.php
|
1
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
FirefoxFileEditViewHistoryBookmarksProfilesToolsWi FirefoxFileEditViewHistoryBookmarksProfilesToolsWindowHelp(Platform) Refinemen…44 m left• Mon 18 May 16:16:56meet.google.com/cxs-eips-npt?authuser=lukas.kovalik%40jiminny.com=+Galya Dimitrova (Presenting, annotating)ChromeFileEditViewHistoryBookmarksProfilesTabWindowHelpWelt Prot PE• Pro ThoCovapp.jiminny.com/settings/organization/ai-automation/crm-fillingClaude (MCP)|Jiminny MercuryJiminny Staging1D$E Claude (MCP)• Jiminny ProdJiminny EU ProdOrganization SettingsAl AutomationGeneralCRM FillingCall Scoring |UsersЗАХСH ITeamsSLECT TEAMO!Select ootiorIntegrationsV • Customer SuccessJob Titles• Opp Red FlagsActivity• Opp RisksRecording• ContactsInvolvedTestAl Context• StageTest|Al Automation› • Sales - ForecastSidekick> • Sales - Manual fillingDeal Insights> • Petko ClosedWon TriggersVocabulary• TestTopicsPlanhat Data Enrichment; SalesKey Words ScoringPlaybooks & Coaching FrameworksNotificationsSettingsmeet.google.com is sharing your screen.8• Mon 18 May 14:16HoO: Aut© travJimAut8 Rec₴ WorkRelaunch to updateJiminny Saturn• Jiminny QAI|487 times for last 7 days36 times for last 7 dayso times tor last / ouys4 times for last 7 daysO times for last 7 daysAUTOMATION DISABLEDIJiminny QA• Salesforce9 Outlook• CRM Filling |÷ ExportAdd AutomationOPP RED FLAGS© DeleteCRM FIELDOpp Red FlagsField type: textareaNot seeing your field? It it was added today. Svncnow to refreshPROMPTGoalAnalyze the full opportunity context (meeting transcripts and emails) for the last9 months. The goal is to extract all service or product-related red flags - explicitor implicit negative thoughts, concerns, dissatisfaction, or hesitation expressedby the client about our service or product. These should highlight issues thatAALTXOTAnalyze entire dealSAVE METHODUpdate and overwrite existing dataTest your promptTry running your prompt on a deal to see how it performsSelect DealCancelStop sharingHideGalya DimitrovaNikolay YankovAneliya AngelovaNikolay Ivanov4:16 PM| [Platform] Refinement•Lukas Kovalik1:03:23Lộ3...
|
NULL
|
3954478894729402975
|
NULL
|
visual_change
|
ocr
|
NULL
|
FirefoxFileEditViewHistoryBookmarksProfilesToolsWi FirefoxFileEditViewHistoryBookmarksProfilesToolsWindowHelp(Platform) Refinemen…44 m left• Mon 18 May 16:16:56meet.google.com/cxs-eips-npt?authuser=lukas.kovalik%40jiminny.com=+Galya Dimitrova (Presenting, annotating)ChromeFileEditViewHistoryBookmarksProfilesTabWindowHelpWelt Prot PE• Pro ThoCovapp.jiminny.com/settings/organization/ai-automation/crm-fillingClaude (MCP)|Jiminny MercuryJiminny Staging1D$E Claude (MCP)• Jiminny ProdJiminny EU ProdOrganization SettingsAl AutomationGeneralCRM FillingCall Scoring |UsersЗАХСH ITeamsSLECT TEAMO!Select ootiorIntegrationsV • Customer SuccessJob Titles• Opp Red FlagsActivity• Opp RisksRecording• ContactsInvolvedTestAl Context• StageTest|Al Automation› • Sales - ForecastSidekick> • Sales - Manual fillingDeal Insights> • Petko ClosedWon TriggersVocabulary• TestTopicsPlanhat Data Enrichment; SalesKey Words ScoringPlaybooks & Coaching FrameworksNotificationsSettingsmeet.google.com is sharing your screen.8• Mon 18 May 14:16HoO: Aut© travJimAut8 Rec₴ WorkRelaunch to updateJiminny Saturn• Jiminny QAI|487 times for last 7 days36 times for last 7 dayso times tor last / ouys4 times for last 7 daysO times for last 7 daysAUTOMATION DISABLEDIJiminny QA• Salesforce9 Outlook• CRM Filling |÷ ExportAdd AutomationOPP RED FLAGS© DeleteCRM FIELDOpp Red FlagsField type: textareaNot seeing your field? It it was added today. Svncnow to refreshPROMPTGoalAnalyze the full opportunity context (meeting transcripts and emails) for the last9 months. The goal is to extract all service or product-related red flags - explicitor implicit negative thoughts, concerns, dissatisfaction, or hesitation expressedby the client about our service or product. These should highlight issues thatAALTXOTAnalyze entire dealSAVE METHODUpdate and overwrite existing dataTest your promptTry running your prompt on a deal to see how it performsSelect DealCancelStop sharingHideGalya DimitrovaNikolay YankovAneliya AngelovaNikolay Ivanov4:16 PM| [Platform] Refinement•Lukas Kovalik1:03:23Lộ3...
|
54568
|
NULL
|
NULL
|
NULL
|
|
54569
|
1899
|
2
|
2026-05-18T13:16:54.333637+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-18/1779 /Users/lukas/.screenpipe/data/data/2026-05-18/1779110214333_m2.jpg...
|
PhpStorm
|
faVsco.js – Crm/…/Service.php
|
1
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
master, menu
Start Listen Project: faVsco.js, menu
master, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Code changed:
Hide
Sync Changes...
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"bounds":{"left":0.025930852,"top":0.019952115,"width":0.03856383,"height":0.025538707},"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"master, menu","depth":5,"bounds":{"left":0.064494684,"top":0.019952115,"width":0.040226065,"height":0.025538707},"on_screen":true,"help_text":"Git Branch: master<br/>114 incoming commits<br/>","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"bounds":{"left":0.8081782,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"AskJiminnyReportActivityServiceTest","depth":6,"bounds":{"left":0.8234708,"top":0.019952115,"width":0.09208777,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'AskJiminnyReportActivityServiceTest'","depth":6,"bounds":{"left":0.9155585,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'AskJiminnyReportActivityServiceTest'","depth":6,"bounds":{"left":0.9268617,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"bounds":{"left":0.9381649,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"bounds":{"left":0.96609044,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"bounds":{"left":0.9773936,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"IDE and Project Settings","depth":5,"bounds":{"left":0.9886968,"top":0.019952115,"width":0.011303186,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"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}]...
|
-83047842901774713
|
-7051219563329770554
|
idle
|
hybrid
|
NULL
|
Project: faVsco.js, menu
master, menu
Start Listen Project: faVsco.js, menu
master, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Code changed:
Hide
Sync Changes
PhostormVIewINavicarecodeLaravelFV faVsco.js°9 master k >roledeyRinaCentralVideo• SalesforceIm Salesloft> D TalkdeskD Teams>D Telus>D Twilio>@ TwilioFlex_ I willorlexDireet• _ I willoVideo_Uploader› _ Vonagewxant>400mZoomBot> ZoomPhoneC) ActivitvcrmFieldsResolver.ohpC) ActivitvLoaService.oho© ActivitvProviderClient.ohp(C) ActivitvProviderRedistrv.oho© ActivityProviderService.php© CallDenormalizerRegistry.phpC) [EMAIL]© DatalmportHandlerInterface.php© MeetingBotService.php© ParticipantConsentService.php(c) DarticinanteService nhnT PecnonceValidation Trait nhnT SalesforceGetUserTrait.php- S/DenormaliserMainCrmDatalrait.onp@ TrackRecordinoFllesizeservice.one© TrackRecordingSizeEnforcer.phpT ValidateEmitProspectEventTrait.phpC AjReports0 AvatarMColondar0 Conference0 Crm> C Bullhornv closeOnoortunitvsvncstrateav• ProcessonProspectSearchStrateav• M TranslatorC) Client.oho() CloseSxceotion.ohoC) FieldDefinitions.onvc) SieldValueConverter ohnC) Service nhnC) StandardFioldMetadata nhn> MConnenKeractorC ActivityController.ong(C) CoreUserRequest.onp127 (01132 (011MoTS150 ›169 G1916189 Gt >194 6t >213 G215217© SoftPhoneManager.phpscimProvistoning.ong© CoreUser.php© ACtivity/.../Service.php©Crm/…../Service.php Xclass Service extends BaseService 1mpLementsm A8 A39 M5 лVpublic function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): strprotected function getFieldTypes: array{...}protected function getFields(string $crm0bject): array{...}* cinherzzdocpublic function getDefaultFields(string $activityType): array{...}*dinherzzdocpublic function getdefaul tactivitvfieldstring Sactivitvivoe): FieldiSet un the activity field as the default Tvoe.** ovar Field Sactiviturield *^SactivityField = Sthis->confia->fields(->where(ferm provider 1d' =>"type','obiect tvnel => Sactivitvivoe.1->firstO:return SactivitvField.* @inheritdocpublic function getSupportedPlaybookTypes: arraylreturn [Playbook::ACTIVITY_TYPE_TASK]:public function getDealInsightsFields: arrav{...}protected function getDefaultFollowupLavoutFields(string SactivityType): arrav{...}public function syncFieldso: vo1dsthis->syncStandardFieldso:sthns->svnccustomfteldso= custom.log= laravel.log4 SF jiminny@localhost]4 HS_local [jiminny@localhost]& console [PROD]# console [euyA console [STAGING]© CoachingFeedbackCoachUserln.php Xstohedeclare(scrict_cypes=l)nnamespace Jiminny Component Activ1tySearch rilterbetin1t1on*› use ...final class CoachingFeedbackCoachUserIn extends FilterDefinition implements ValidatedFilterDefinitionInterface29 Ф >34 0 >45 đ >63=119-131—139— 156 U1146 G— 150151 G>1usadeorivate const int No GROUP 10 = 999-3 usagesorivate UserRenository suserRenository:public function __construct(UserRepository $userRepository)f...hpublic function shouldApplyQueries: boolf...}public function getQueries: FilterDefinitionQueryCollection...hpublic function toArray: array{...hprivate function getOptions(): array{...}public function getValue: array{...}private function getDefaultValue@: array{...}public function aetValidationRules(2strina Sorefix = null): arravs...?public function aetSortOrder@: intf...}public function shouldBeIncluded(Team $team): bool{...}* [Platform] Refinemen... 44 m left100% 1• Mon 18 May 16:16:53AskJiminnyReportActivityServiceTest vCascadeImplement Trial OwneFixing Redis Rate LimSalestorce Token FalliSClM Role Manageme+0 ..inp on LineWARN Metadata found iin doc-comment for methodw9/ 10 tacke done• Write unit tests for SCIM role management10) Contact Mario to vodate customer10 / 10 tasks doneselm role managementtor leamtallor has been successtully implemented. Here's what was completed:Implementation Summary:1. CoreUserRequest DTO - Added role parsina from SCIM requests2. SCIM Constants - Added META ROLES and USER_PATCH_ATTR ROLES constants3. RoleAttr Mutator - Created mutator for handlina role assianments via SCIM PATCH operations4. ScimProvisioning - Updated to handle roles during user creation with proper validation5. ListenerRoleCannotHaveAdminOrManagerPermissionRule - Created validation rule to prevent listener role fromhaving admin/manager permissions6. CoreUser Response - Updated to include roles in SClM responses7. Unit Tests - Created comprehensive tests for the validation ruleKey Features:• Customers can now assign roles via SCIM (recorder, recorder & voice, analyst, listener, admin, manager)• Validation ensures listener role is onlv assianable if the LISTENER ROLE feature flaa is enabledl•LIstener role cannot de comolned with aamin or manager permissions• Uses existing UpdateUserRolesAction for role syncing and logging• Follows platform validation rules (DependentRolesRule, ListenerRoleRequiresListenerFeatureRule)Next Step: Please contact Mario to update the customer that SCIM role management is now available for Teamtailorintearationposwtn chaten vapp/Component/SClM/ Constants.php +3app/Component/SCIM/ @ ScimProvisioning.php +85-15nse/ CoreUser.php +21ann/Comnonent/SCIM/Mutatore/Attributes/Ueer/M PoleAttr.nhn +17-ites/User/ ẞ RoleAttrTest.nhn +224* Reiect alliiAccent alliAsk anvthina (&4-L)« Code SWF-1.6WN Windsurf Toams 184-55UTF.8io 4 spaces...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
54568
|
1898
|
8
|
2026-05-18T13:16:32.641084+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-18/1779 /Users/lukas/.screenpipe/data/data/2026-05-18/1779110192641_m1.jpg...
|
PhpStorm
|
faVsco.js – Crm/…/Service.php
|
1
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
master, menu
Start Listen Project: faVsco.js, menu
master, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
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\OpportunityMetadata;
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(OpportunityMetadata $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());
}
/** @var Profile $profile */
$profile = $this->getConfiguration()
->profiles()
->where('crm_provider_id', $crmData->getUserId())
->first();
$userId = $profile?->getUserId() ?? $account?->getUserId();
if ($userId === null) {
$this->logger->error('[Close] | Skip import, no user_id found', [
'id' => $crmData->getId(),
]);
return null;
}
$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(),
]);
...
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"master, menu","depth":5,"on_screen":true,"help_text":"Git Branch: master<br/>114 incoming commits<br/>","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"AskJiminnyReportActivityServiceTest","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'AskJiminnyReportActivityServiceTest'","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'AskJiminnyReportActivityServiceTest'","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"IDE and Project Settings","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"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,"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\\OpportunityMetadata;\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(OpportunityMetadata $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 if ($account === null) {\n $account = $this->accountProcessor->syncAccount($crmData->getLeadId());\n }\n\n /** @var Profile $profile */\n $profile = $this->getConfiguration()\n ->profiles()\n ->where('crm_provider_id', $crmData->getUserId())\n ->first();\n\n $userId = $profile?->getUserId() ?? $account?->getUserId();\n if ($userId === null) {\n $this->logger->error('[Close] | Skip import, no user_id found', [\n 'id' => $crmData->getId(),\n ]);\n\n return null;\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 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\\OpportunityMetadata;\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(OpportunityMetadata $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 if ($account === null) {\n $account = $this->accountProcessor->syncAccount($crmData->getLeadId());\n }\n\n /** @var Profile $profile */\n $profile = $this->getConfiguration()\n ->profiles()\n ->where('crm_provider_id', $crmData->getUserId())\n ->first();\n\n $userId = $profile?->getUserId() ?? $account?->getUserId();\n if ($userId === null) {\n $this->logger->error('[Close] | Skip import, no user_id found', [\n 'id' => $crmData->getId(),\n ]);\n\n return null;\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 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":"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":"1","depth":4,"on_screen":true,"role_description":"text"}]...
|
-6754415607117048428
|
-9030663327281178587
|
visual_change
|
accessibility
|
NULL
|
Project: faVsco.js, menu
master, menu
Start Listen Project: faVsco.js, menu
master, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
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\OpportunityMetadata;
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(OpportunityMetadata $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());
}
/** @var Profile $profile */
$profile = $this->getConfiguration()
->profiles()
->where('crm_provider_id', $crmData->getUserId())
->first();
$userId = $profile?->getUserId() ?? $account?->getUserId();
if ($userId === null) {
$this->logger->error('[Close] | Skip import, no user_id found', [
'id' => $crmData->getId(),
]);
return null;
}
$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(),
]);
...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
54567
|
1899
|
1
|
2026-05-18T13:16:23.989543+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-18/1779 /Users/lukas/.screenpipe/data/data/2026-05-18/1779110183989_m2.jpg...
|
PhpStorm
|
faVsco.js – Crm/…/Service.php
|
1
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
master, menu
Start Listen Project: faVsco.js, menu
master, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
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\OpportunityMetadata;
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(OpportunityMetadata $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());
}
/** @var Profile $profile */
$profile = $this->getConfiguration()
->profiles()
->where('crm_provider_id', $crmData->getUserId())
->first();
$userId = $profile?->getUserId() ?? $account?->getUserId();
if ($userId === null) {
$this->logger->error('[Close] | Skip import, no user_id found', [
'id' => $crmData->getId(),
]);
return null;
}
$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(),
]);
...
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"bounds":{"left":0.025930852,"top":0.019952115,"width":0.03856383,"height":0.025538707},"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"master, menu","depth":5,"bounds":{"left":0.064494684,"top":0.019952115,"width":0.040226065,"height":0.025538707},"on_screen":true,"help_text":"Git Branch: master<br/>114 incoming commits<br/>","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"bounds":{"left":0.8081782,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"AskJiminnyReportActivityServiceTest","depth":6,"bounds":{"left":0.8234708,"top":0.019952115,"width":0.09208777,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'AskJiminnyReportActivityServiceTest'","depth":6,"bounds":{"left":0.9155585,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'AskJiminnyReportActivityServiceTest'","depth":6,"bounds":{"left":0.9268617,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"bounds":{"left":0.9381649,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"bounds":{"left":0.96609044,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"bounds":{"left":0.9773936,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"IDE and Project Settings","depth":5,"bounds":{"left":0.9886968,"top":0.019952115,"width":0.011303186,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"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,"bounds":{"left":0.3799867,"top":0.17478053,"width":0.007978723,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"39","depth":4,"bounds":{"left":0.3899601,"top":0.17478053,"width":0.010305851,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"5","depth":4,"bounds":{"left":0.40226063,"top":0.17478053,"width":0.007978723,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"bounds":{"left":0.4119016,"top":0.17318435,"width":0.00731383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"bounds":{"left":0.4192154,"top":0.17318435,"width":0.006981383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\nnamespace Jiminny\\Services\\Crm\\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\\OpportunityMetadata;\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(OpportunityMetadata $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 if ($account === null) {\n $account = $this->accountProcessor->syncAccount($crmData->getLeadId());\n }\n\n /** @var Profile $profile */\n $profile = $this->getConfiguration()\n ->profiles()\n ->where('crm_provider_id', $crmData->getUserId())\n ->first();\n\n $userId = $profile?->getUserId() ?? $account?->getUserId();\n if ($userId === null) {\n $this->logger->error('[Close] | Skip import, no user_id found', [\n 'id' => $crmData->getId(),\n ]);\n\n return null;\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 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\\OpportunityMetadata;\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(OpportunityMetadata $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 if ($account === null) {\n $account = $this->accountProcessor->syncAccount($crmData->getLeadId());\n }\n\n /** @var Profile $profile */\n $profile = $this->getConfiguration()\n ->profiles()\n ->where('crm_provider_id', $crmData->getUserId())\n ->first();\n\n $userId = $profile?->getUserId() ?? $account?->getUserId();\n if ($userId === null) {\n $this->logger->error('[Close] | Skip import, no user_id found', [\n 'id' => $crmData->getId(),\n ]);\n\n return null;\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 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":"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":"1","depth":4,"bounds":{"left":0.7124335,"top":0.10055866,"width":0.00731383,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"bounds":{"left":0.72140956,"top":0.09896249,"width":0.00731383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"bounds":{"left":0.7287234,"top":0.09896249,"width":0.006981383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\ndeclare(strict_types=1);\n\nnamespace Jiminny\\Component\\ActivitySearch\\FilterDefinition;\n\nuse Illuminate\\Support\\Collection;\nuse Jiminny\\Component\\ActivitySearch\\FilterDefinition;\nuse Jiminny\\Component\\ActivitySearch\\FilterDefinitionQuery;\nuse Jiminny\\Component\\ActivitySearch\\FilterDefinitionQueryCollection;\nuse Jiminny\\Contracts\\ActivitySearch\\ValidatedFilterDefinitionInterface;\nuse Jiminny\\Contracts\\Repositories\\UserRepository;\nuse Jiminny\\Models;\nuse Jiminny\\Models\\Feature\\FeatureEnum;\nuse Jiminny\\Models\\Team;\nuse Jiminny\\Traits\\RequiresUUID;\nuse Elastica\\Query;\n\nfinal class CoachingFeedbackCoachUserIn extends FilterDefinition implements ValidatedFilterDefinitionInterface\n{\n private const int NO_GROUP_ID = 999;\n private UserRepository $userRepository;\n\n public function __construct(UserRepository $userRepository)\n {\n $this->userRepository = $userRepository;\n }\n\n public function shouldApplyQueries(): bool\n {\n return count($this->getValue()) > 0;\n }\n\n public function getQueries(): FilterDefinitionQueryCollection\n {\n $userIds = $this->getValue();\n\n return FilterDefinitionQueryCollection::make([\n FilterDefinitionQuery::instance()\n ->setQuery(new Query\\Terms('coachingFeedbacks.coach.id_string', $userIds))\n ->setPath('coachingFeedbacks.coach', 'coachingFeedbacks'),\n ]);\n }\n\n public function toArray(): array\n {\n return [\n 'id' => 'coaching-feedback-user-filter',\n 'label' => 'Coach',\n 'helpText' => 'The person who completed the Coaching Framework',\n 'placeholder' => 'Search coaches',\n 'inputType' => InputTypeEnum::DROPDOWN_MULTIPLE,\n 'queryParam' => 'coaching_feedback_coach_id',\n 'options' => $this->getOptions(),\n 'groupLabelKey' => 'label',\n 'groupValuesKey' => 'users',\n 'optionLabelKey' => 'name',\n 'optionValueKey' => 'id',\n 'value' => $this->getValue(),\n ];\n }\n\n private function getOptions(): array\n {\n $user = $this->executedBy;\n $query = $user\n ->getTeam()\n ->users()\n ->whereNotIn(\n 'uuid',\n $this->userRepository->findInaccessibleDeactivatedUserUuids($user)\n ->map(static fn (string $uuid): string => RequiresUUID::toOptimized($uuid))\n ->all(),\n )\n ->orderBy('name');\n\n $users = $query\n ->with('group')\n ->get()\n ->reduce(\n static function (array $carry, Models\\User $user): array {\n $group = $user->getGroup();\n $groupId = $group !== null\n ? $group->getUuid()\n : self::NO_GROUP_ID;\n $groupName = $group !== null\n ? $group->getName()\n : '(NON-GROUPED)';\n\n if (! array_key_exists($groupId, $carry)) {\n $carry[$groupId] = [\n 'label' => $groupName,\n 'users' => [],\n ];\n }\n\n $carry[$groupId]['users'][] = [\n 'id' => $user->getUuid(),\n 'name' => sprintf(\n '%s%s',\n $user->getName(),\n ! $user->isStatusActive()\n ? ' (Inactive)'\n : ''\n ),\n ];\n\n return $carry;\n },\n []\n );\n\n return Collection::make($users)\n ->sortBy('label')\n ->values()\n ->toArray();\n }\n\n public function getValue(): array\n {\n if (! $this->criteria->hasCoachingFeedbackCoachUserIds()) {\n return $this->getDefaultValue();\n }\n\n return array_diff(\n $this->criteria->getCoachingFeedbackCoachUserId(),\n $this->userRepository->findInaccessibleDeactivatedUserUuids($this->executedBy)->all(),\n );\n }\n\n private function getDefaultValue(): array\n {\n return [];\n }\n\n public function getValidationRules(?string $prefix = null): array\n {\n $team = $this->executedBy->getTeam();\n\n return [\n $prefix . 'coaching_feedback_coach_id' => 'array',\n $prefix . 'coaching_feedback_coach_id.*' => 'uuid:users,team_id,' . $team->getId(),\n ];\n }\n\n public function getSortOrder(): int\n {\n return 86;\n }\n\n public function shouldBeIncluded(Team $team): bool\n {\n return $team->hasFeature(FeatureEnum::COACHING_FRAMEWORK);\n }\n}","depth":4,"bounds":{"left":0.4481383,"top":0.09736632,"width":0.29288563,"height":0.8818835},"on_screen":true,"value":"<?php\n\ndeclare(strict_types=1);\n\nnamespace Jiminny\\Component\\ActivitySearch\\FilterDefinition;\n\nuse Illuminate\\Support\\Collection;\nuse Jiminny\\Component\\ActivitySearch\\FilterDefinition;\nuse Jiminny\\Component\\ActivitySearch\\FilterDefinitionQuery;\nuse Jiminny\\Component\\ActivitySearch\\FilterDefinitionQueryCollection;\nuse Jiminny\\Contracts\\ActivitySearch\\ValidatedFilterDefinitionInterface;\nuse Jiminny\\Contracts\\Repositories\\UserRepository;\nuse Jiminny\\Models;\nuse Jiminny\\Models\\Feature\\FeatureEnum;\nuse Jiminny\\Models\\Team;\nuse Jiminny\\Traits\\RequiresUUID;\nuse Elastica\\Query;\n\nfinal class CoachingFeedbackCoachUserIn extends FilterDefinition implements ValidatedFilterDefinitionInterface\n{\n private const int NO_GROUP_ID = 999;\n private UserRepository $userRepository;\n\n public function __construct(UserRepository $userRepository)\n {\n $this->userRepository = $userRepository;\n }\n\n public function shouldApplyQueries(): bool\n {\n return count($this->getValue()) > 0;\n }\n\n public function getQueries(): FilterDefinitionQueryCollection\n {\n $userIds = $this->getValue();\n\n return FilterDefinitionQueryCollection::make([\n FilterDefinitionQuery::instance()\n ->setQuery(new Query\\Terms('coachingFeedbacks.coach.id_string', $userIds))\n ->setPath('coachingFeedbacks.coach', 'coachingFeedbacks'),\n ]);\n }\n\n public function toArray(): array\n {\n return [\n 'id' => 'coaching-feedback-user-filter',\n 'label' => 'Coach',\n 'helpText' => 'The person who completed the Coaching Framework',\n 'placeholder' => 'Search coaches',\n 'inputType' => InputTypeEnum::DROPDOWN_MULTIPLE,\n 'queryParam' => 'coaching_feedback_coach_id',\n 'options' => $this->getOptions(),\n 'groupLabelKey' => 'label',\n 'groupValuesKey' => 'users',\n 'optionLabelKey' => 'name',\n 'optionValueKey' => 'id',\n 'value' => $this->getValue(),\n ];\n }\n\n private function getOptions(): array\n {\n $user = $this->executedBy;\n $query = $user\n ->getTeam()\n ->users()\n ->whereNotIn(\n 'uuid',\n $this->userRepository->findInaccessibleDeactivatedUserUuids($user)\n ->map(static fn (string $uuid): string => RequiresUUID::toOptimized($uuid))\n ->all(),\n )\n ->orderBy('name');\n\n $users = $query\n ->with('group')\n ->get()\n ->reduce(\n static function (array $carry, Models\\User $user): array {\n $group = $user->getGroup();\n $groupId = $group !== null\n ? $group->getUuid()\n : self::NO_GROUP_ID;\n $groupName = $group !== null\n ? $group->getName()\n : '(NON-GROUPED)';\n\n if (! array_key_exists($groupId, $carry)) {\n $carry[$groupId] = [\n 'label' => $groupName,\n 'users' => [],\n ];\n }\n\n $carry[$groupId]['users'][] = [\n 'id' => $user->getUuid(),\n 'name' => sprintf(\n '%s%s',\n $user->getName(),\n ! $user->isStatusActive()\n ? ' (Inactive)'\n : ''\n ),\n ];\n\n return $carry;\n },\n []\n );\n\n return Collection::make($users)\n ->sortBy('label')\n ->values()\n ->toArray();\n }\n\n public function getValue(): array\n {\n if (! $this->criteria->hasCoachingFeedbackCoachUserIds()) {\n return $this->getDefaultValue();\n }\n\n return array_diff(\n $this->criteria->getCoachingFeedbackCoachUserId(),\n $this->userRepository->findInaccessibleDeactivatedUserUuids($this->executedBy)->all(),\n );\n }\n\n private function getDefaultValue(): array\n {\n return [];\n }\n\n public function getValidationRules(?string $prefix = null): array\n {\n $team = $this->executedBy->getTeam();\n\n return [\n $prefix . 'coaching_feedback_coach_id' => 'array',\n $prefix . 'coaching_feedback_coach_id.*' => 'uuid:users,team_id,' . $team->getId(),\n ];\n }\n\n public function getSortOrder(): int\n {\n return 86;\n }\n\n public function shouldBeIncluded(Team $team): bool\n {\n return $team->hasFeature(FeatureEnum::COACHING_FRAMEWORK);\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
-6754415607117048428
|
-9030663327281178587
|
idle
|
accessibility
|
NULL
|
Project: faVsco.js, menu
master, menu
Start Listen Project: faVsco.js, menu
master, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
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\OpportunityMetadata;
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(OpportunityMetadata $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());
}
/** @var Profile $profile */
$profile = $this->getConfiguration()
->profiles()
->where('crm_provider_id', $crmData->getUserId())
->first();
$userId = $profile?->getUserId() ?? $account?->getUserId();
if ($userId === null) {
$this->logger->error('[Close] | Skip import, no user_id found', [
'id' => $crmData->getId(),
]);
return null;
}
$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(),
]);
...
|
54563
|
NULL
|
NULL
|
NULL
|
|
54566
|
1898
|
7
|
2026-05-18T13:15:59.328441+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-18/1779 /Users/lukas/.screenpipe/data/data/2026-05-18/1779110159328_m1.jpg...
|
PhpStorm
|
faVsco.js – Crm/…/Service.php
|
1
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
master, menu
Start Listen Project: faVsco.js, menu
master, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
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\OpportunityMetadata;
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(OpportunityMetadata $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());
}
/** @var Profile $profile */
$profile = $this->getConfiguration()
->profiles()
->where('crm_provider_id', $crmData->getUserId())
->first();
$userId = $profile?->getUserId() ?? $account?->getUserId();
if ($userId === null) {
$this->logger->error('[Close] | Skip import, no user_id found', [
'id' => $crmData->getId(),
]);
return null;
}
$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(),
]);
...
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"master, menu","depth":5,"on_screen":true,"help_text":"Git Branch: master<br/>114 incoming commits<br/>","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"AskJiminnyReportActivityServiceTest","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'AskJiminnyReportActivityServiceTest'","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'AskJiminnyReportActivityServiceTest'","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"IDE and Project Settings","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"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,"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\\OpportunityMetadata;\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(OpportunityMetadata $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 if ($account === null) {\n $account = $this->accountProcessor->syncAccount($crmData->getLeadId());\n }\n\n /** @var Profile $profile */\n $profile = $this->getConfiguration()\n ->profiles()\n ->where('crm_provider_id', $crmData->getUserId())\n ->first();\n\n $userId = $profile?->getUserId() ?? $account?->getUserId();\n if ($userId === null) {\n $this->logger->error('[Close] | Skip import, no user_id found', [\n 'id' => $crmData->getId(),\n ]);\n\n return null;\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 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\\OpportunityMetadata;\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(OpportunityMetadata $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 if ($account === null) {\n $account = $this->accountProcessor->syncAccount($crmData->getLeadId());\n }\n\n /** @var Profile $profile */\n $profile = $this->getConfiguration()\n ->profiles()\n ->where('crm_provider_id', $crmData->getUserId())\n ->first();\n\n $userId = $profile?->getUserId() ?? $account?->getUserId();\n if ($userId === null) {\n $this->logger->error('[Close] | Skip import, no user_id found', [\n 'id' => $crmData->getId(),\n ]);\n\n return null;\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 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":"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":"1","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\ndeclare(strict_types=1);\n\nnamespace Jiminny\\Component\\ActivitySearch\\FilterDefinition;\n\nuse Illuminate\\Support\\Collection;\nuse Jiminny\\Component\\ActivitySearch\\FilterDefinition;\nuse Jiminny\\Component\\ActivitySearch\\FilterDefinitionQuery;\nuse Jiminny\\Component\\ActivitySearch\\FilterDefinitionQueryCollection;\nuse Jiminny\\Contracts\\ActivitySearch\\ValidatedFilterDefinitionInterface;\nuse Jiminny\\Contracts\\Repositories\\UserRepository;\nuse Jiminny\\Models;\nuse Jiminny\\Models\\Feature\\FeatureEnum;\nuse Jiminny\\Models\\Team;\nuse Jiminny\\Traits\\RequiresUUID;\nuse Elastica\\Query;\n\nfinal class CoachingFeedbackCoachUserIn extends FilterDefinition implements ValidatedFilterDefinitionInterface\n{\n private const int NO_GROUP_ID = 999;\n private UserRepository $userRepository;\n\n public function __construct(UserRepository $userRepository)\n {\n $this->userRepository = $userRepository;\n }\n\n public function shouldApplyQueries(): bool\n {\n return count($this->getValue()) > 0;\n }\n\n public function getQueries(): FilterDefinitionQueryCollection\n {\n $userIds = $this->getValue();\n\n return FilterDefinitionQueryCollection::make([\n FilterDefinitionQuery::instance()\n ->setQuery(new Query\\Terms('coachingFeedbacks.coach.id_string', $userIds))\n ->setPath('coachingFeedbacks.coach', 'coachingFeedbacks'),\n ]);\n }\n\n public function toArray(): array\n {\n return [\n 'id' => 'coaching-feedback-user-filter',\n 'label' => 'Coach',\n 'helpText' => 'The person who completed the Coaching Framework',\n 'placeholder' => 'Search coaches',\n 'inputType' => InputTypeEnum::DROPDOWN_MULTIPLE,\n 'queryParam' => 'coaching_feedback_coach_id',\n 'options' => $this->getOptions(),\n 'groupLabelKey' => 'label',\n 'groupValuesKey' => 'users',\n 'optionLabelKey' => 'name',\n 'optionValueKey' => 'id',\n 'value' => $this->getValue(),\n ];\n }\n\n private function getOptions(): array\n {\n $user = $this->executedBy;\n $query = $user\n ->getTeam()\n ->users()\n ->whereNotIn(\n 'uuid',\n $this->userRepository->findInaccessibleDeactivatedUserUuids($user)\n ->map(static fn (string $uuid): string => RequiresUUID::toOptimized($uuid))\n ->all(),\n )\n ->orderBy('name');\n\n $users = $query\n ->with('group')\n ->get()\n ->reduce(\n static function (array $carry, Models\\User $user): array {\n $group = $user->getGroup();\n $groupId = $group !== null\n ? $group->getUuid()\n : self::NO_GROUP_ID;\n $groupName = $group !== null\n ? $group->getName()\n : '(NON-GROUPED)';\n\n if (! array_key_exists($groupId, $carry)) {\n $carry[$groupId] = [\n 'label' => $groupName,\n 'users' => [],\n ];\n }\n\n $carry[$groupId]['users'][] = [\n 'id' => $user->getUuid(),\n 'name' => sprintf(\n '%s%s',\n $user->getName(),\n ! $user->isStatusActive()\n ? ' (Inactive)'\n : ''\n ),\n ];\n\n return $carry;\n },\n []\n );\n\n return Collection::make($users)\n ->sortBy('label')\n ->values()\n ->toArray();\n }\n\n public function getValue(): array\n {\n if (! $this->criteria->hasCoachingFeedbackCoachUserIds()) {\n return $this->getDefaultValue();\n }\n\n return array_diff(\n $this->criteria->getCoachingFeedbackCoachUserId(),\n $this->userRepository->findInaccessibleDeactivatedUserUuids($this->executedBy)->all(),\n );\n }\n\n private function getDefaultValue(): array\n {\n return [];\n }\n\n public function getValidationRules(?string $prefix = null): array\n {\n $team = $this->executedBy->getTeam();\n\n return [\n $prefix . 'coaching_feedback_coach_id' => 'array',\n $prefix . 'coaching_feedback_coach_id.*' => 'uuid:users,team_id,' . $team->getId(),\n ];\n }\n\n public function getSortOrder(): int\n {\n return 86;\n }\n\n public function shouldBeIncluded(Team $team): bool\n {\n return $team->hasFeature(FeatureEnum::COACHING_FRAMEWORK);\n }\n}","depth":4,"on_screen":true,"value":"<?php\n\ndeclare(strict_types=1);\n\nnamespace Jiminny\\Component\\ActivitySearch\\FilterDefinition;\n\nuse Illuminate\\Support\\Collection;\nuse Jiminny\\Component\\ActivitySearch\\FilterDefinition;\nuse Jiminny\\Component\\ActivitySearch\\FilterDefinitionQuery;\nuse Jiminny\\Component\\ActivitySearch\\FilterDefinitionQueryCollection;\nuse Jiminny\\Contracts\\ActivitySearch\\ValidatedFilterDefinitionInterface;\nuse Jiminny\\Contracts\\Repositories\\UserRepository;\nuse Jiminny\\Models;\nuse Jiminny\\Models\\Feature\\FeatureEnum;\nuse Jiminny\\Models\\Team;\nuse Jiminny\\Traits\\RequiresUUID;\nuse Elastica\\Query;\n\nfinal class CoachingFeedbackCoachUserIn extends FilterDefinition implements ValidatedFilterDefinitionInterface\n{\n private const int NO_GROUP_ID = 999;\n private UserRepository $userRepository;\n\n public function __construct(UserRepository $userRepository)\n {\n $this->userRepository = $userRepository;\n }\n\n public function shouldApplyQueries(): bool\n {\n return count($this->getValue()) > 0;\n }\n\n public function getQueries(): FilterDefinitionQueryCollection\n {\n $userIds = $this->getValue();\n\n return FilterDefinitionQueryCollection::make([\n FilterDefinitionQuery::instance()\n ->setQuery(new Query\\Terms('coachingFeedbacks.coach.id_string', $userIds))\n ->setPath('coachingFeedbacks.coach', 'coachingFeedbacks'),\n ]);\n }\n\n public function toArray(): array\n {\n return [\n 'id' => 'coaching-feedback-user-filter',\n 'label' => 'Coach',\n 'helpText' => 'The person who completed the Coaching Framework',\n 'placeholder' => 'Search coaches',\n 'inputType' => InputTypeEnum::DROPDOWN_MULTIPLE,\n 'queryParam' => 'coaching_feedback_coach_id',\n 'options' => $this->getOptions(),\n 'groupLabelKey' => 'label',\n 'groupValuesKey' => 'users',\n 'optionLabelKey' => 'name',\n 'optionValueKey' => 'id',\n 'value' => $this->getValue(),\n ];\n }\n\n private function getOptions(): array\n {\n $user = $this->executedBy;\n $query = $user\n ->getTeam()\n ->users()\n ->whereNotIn(\n 'uuid',\n $this->userRepository->findInaccessibleDeactivatedUserUuids($user)\n ->map(static fn (string $uuid): string => RequiresUUID::toOptimized($uuid))\n ->all(),\n )\n ->orderBy('name');\n\n $users = $query\n ->with('group')\n ->get()\n ->reduce(\n static function (array $carry, Models\\User $user): array {\n $group = $user->getGroup();\n $groupId = $group !== null\n ? $group->getUuid()\n : self::NO_GROUP_ID;\n $groupName = $group !== null\n ? $group->getName()\n : '(NON-GROUPED)';\n\n if (! array_key_exists($groupId, $carry)) {\n $carry[$groupId] = [\n 'label' => $groupName,\n 'users' => [],\n ];\n }\n\n $carry[$groupId]['users'][] = [\n 'id' => $user->getUuid(),\n 'name' => sprintf(\n '%s%s',\n $user->getName(),\n ! $user->isStatusActive()\n ? ' (Inactive)'\n : ''\n ),\n ];\n\n return $carry;\n },\n []\n );\n\n return Collection::make($users)\n ->sortBy('label')\n ->values()\n ->toArray();\n }\n\n public function getValue(): array\n {\n if (! $this->criteria->hasCoachingFeedbackCoachUserIds()) {\n return $this->getDefaultValue();\n }\n\n return array_diff(\n $this->criteria->getCoachingFeedbackCoachUserId(),\n $this->userRepository->findInaccessibleDeactivatedUserUuids($this->executedBy)->all(),\n );\n }\n\n private function getDefaultValue(): array\n {\n return [];\n }\n\n public function getValidationRules(?string $prefix = null): array\n {\n $team = $this->executedBy->getTeam();\n\n return [\n $prefix . 'coaching_feedback_coach_id' => 'array',\n $prefix . 'coaching_feedback_coach_id.*' => 'uuid:users,team_id,' . $team->getId(),\n ];\n }\n\n public function getSortOrder(): int\n {\n return 86;\n }\n\n public function shouldBeIncluded(Team $team): bool\n {\n return $team->hasFeature(FeatureEnum::COACHING_FRAMEWORK);\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Project","depth":3,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Project","depth":3,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"New File or Directory…","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Expand Selected","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Collapse All","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Options","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
-6754415607117048428
|
-9030663327281178587
|
visual_change
|
accessibility
|
NULL
|
Project: faVsco.js, menu
master, menu
Start Listen Project: faVsco.js, menu
master, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
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\OpportunityMetadata;
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(OpportunityMetadata $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());
}
/** @var Profile $profile */
$profile = $this->getConfiguration()
->profiles()
->where('crm_provider_id', $crmData->getUserId())
->first();
$userId = $profile?->getUserId() ?? $account?->getUserId();
if ($userId === null) {
$this->logger->error('[Close] | Skip import, no user_id found', [
'id' => $crmData->getId(),
]);
return null;
}
$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(),
]);
...
|
54565
|
NULL
|
NULL
|
NULL
|
|
54565
|
1898
|
6
|
2026-05-18T13:15:56.321814+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-18/1779 /Users/lukas/.screenpipe/data/data/2026-05-18/1779110156321_m1.jpg...
|
PhpStorm
|
faVsco.js – Crm/…/Service.php
|
1
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
+FirefoxFileProfiles:[Platform) Refinemen….45 m le +FirefoxFileProfiles:[Platform) Refinemen….45 m left• Mon 18 May 16:15:56|EditViewHistoryBookmarksToolsWindowHelpmeet.google.com/cxs-eips-npt?authuser=lukas.kovalik%40jiminny.comGalya Dimitrova (Presenting, annotating)ChromeFileEditHistoryBookmarksProfilesTabWindowHelp8• Mon 18 May 14:15t Prot PE© Dis• Pror | Ta The• CouО: HonO AIEO: Aut©, traAut8 Recapp.jiminny.com/settings/organization/ai-automation/crm-filling3 WorkRelaunch to updateClaude (MCP)|t Claude (MCP)• Jiminny ProdJiminny Mercury Jiminny StagingJiminny Saturn• Jiminny QAI|Jiminny QAw Userpilot|• Salesforce9 OutlookOrganization SettingsAl Automation• CRM Filling÷ ExportAdd AutomationGeneralCRM FillingCall Scoring |UsersЗАХCHIXUECTTLAMATeamsCUSTOMER SUCCESS© DeleteIntegrationsJob Titles> • Customer Success› ©Sales - Forecast487 times for last 7 daysAUTOMATION NAMECustomer SuccessAdd heldActivity> • Sales - Manual filling6 times for last 7 daysОВJЕСТDeal$Recording> • Petko ClosedWon Triggers |4 times for last 7 daysAl Context• TestO times for last 7 daysTEAMClient Success x)Customer Success BG *)Al Automation› Planhat Data Enrichment: Sales& AUTOMATION DISABLEDSidekickDeal InsightsWRNCR UTNew Conference x(New Dialler call *]Deal Stage ChangedNew Email xVocabularyTopicsCommitted xDowngrade Risk xClosed Won xClosed Lost xChurn Risk xLikely to Renew xKey Words ScoringPlaybooks & Coaching Frameworks• Auto syncManual syncNotificationsCancelSettingsmeet.google.com is sharing your screen.Stop sharingHide200mGálya DimitrovaAneliya AngelovaNikolay YankovNikolay Ivanov4:15 PM| [Platform] RefinementLukas Kovalik1:02:23Lộ3...
|
NULL
|
8506828165517590091
|
NULL
|
visual_change
|
ocr
|
NULL
|
+FirefoxFileProfiles:[Platform) Refinemen….45 m le +FirefoxFileProfiles:[Platform) Refinemen….45 m left• Mon 18 May 16:15:56|EditViewHistoryBookmarksToolsWindowHelpmeet.google.com/cxs-eips-npt?authuser=lukas.kovalik%40jiminny.comGalya Dimitrova (Presenting, annotating)ChromeFileEditHistoryBookmarksProfilesTabWindowHelp8• Mon 18 May 14:15t Prot PE© Dis• Pror | Ta The• CouО: HonO AIEO: Aut©, traAut8 Recapp.jiminny.com/settings/organization/ai-automation/crm-filling3 WorkRelaunch to updateClaude (MCP)|t Claude (MCP)• Jiminny ProdJiminny Mercury Jiminny StagingJiminny Saturn• Jiminny QAI|Jiminny QAw Userpilot|• Salesforce9 OutlookOrganization SettingsAl Automation• CRM Filling÷ ExportAdd AutomationGeneralCRM FillingCall Scoring |UsersЗАХCHIXUECTTLAMATeamsCUSTOMER SUCCESS© DeleteIntegrationsJob Titles> • Customer Success› ©Sales - Forecast487 times for last 7 daysAUTOMATION NAMECustomer SuccessAdd heldActivity> • Sales - Manual filling6 times for last 7 daysОВJЕСТDeal$Recording> • Petko ClosedWon Triggers |4 times for last 7 daysAl Context• TestO times for last 7 daysTEAMClient Success x)Customer Success BG *)Al Automation› Planhat Data Enrichment: Sales& AUTOMATION DISABLEDSidekickDeal InsightsWRNCR UTNew Conference x(New Dialler call *]Deal Stage ChangedNew Email xVocabularyTopicsCommitted xDowngrade Risk xClosed Won xClosed Lost xChurn Risk xLikely to Renew xKey Words ScoringPlaybooks & Coaching Frameworks• Auto syncManual syncNotificationsCancelSettingsmeet.google.com is sharing your screen.Stop sharingHide200mGálya DimitrovaAneliya AngelovaNikolay YankovNikolay Ivanov4:15 PM| [Platform] RefinementLukas Kovalik1:02:23Lộ3...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
54564
|
1898
|
5
|
2026-05-18T13:15:53.269401+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-18/1779 /Users/lukas/.screenpipe/data/data/2026-05-18/1779110153269_m1.jpg...
|
PhpStorm
|
faVsco.js – Crm/…/Service.php
|
1
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
master, menu
Start Listen Project: faVsco.js, menu
master, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Code changed:...
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"master, menu","depth":5,"on_screen":true,"help_text":"Git Branch: master<br/>114 incoming commits<br/>","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"AskJiminnyReportActivityServiceTest","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'AskJiminnyReportActivityServiceTest'","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'AskJiminnyReportActivityServiceTest'","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"IDE and Project Settings","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"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}]...
|
-6555592100605644990
|
-8204429277455601722
|
visual_change
|
hybrid
|
NULL
|
Project: faVsco.js, menu
master, menu
Start Listen Project: faVsco.js, menu
master, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Code changed:
+FirefoxFileEditViewHistoryBookmarksProfilesToolsWindowHelp: [Platform) Refinemen…45 m leftmeet.google.com/cxs-eips-npt?authuser=lukas.kovalik%40jiminny.comGalya Dimitrova (Presenting, annotating)ChromeFileEditHistoryBookmarksProfiesTabWindowHelp$Mon 18 May 14:15Wellt Pro+ PE• Dis• ProTheCovHoO AIEO AutraAulEl Recapp.jminny.com/settings/organizatlon/ai-automation/crm-filling& WorkRelaunch to updateClaude (MCP)|* Claude (MCP)• Jiminny ProdJiminny EU ProdJiminny MercuryJiminny StagingJiminny Saturn© Jiminny QAIJiminny QAUserpilot• Salesforce3 OutlookOrganization SettingsAl Automation• CRM FilingAdd AutomationGeneralCRM FillingCall Scoring |UsersJAXCHKuCTTLANSTeamsCUSTOMER SUCCESS© DeleteIntegrations> • Customer Success487 fimes for hast. 2 digsJob TitlesAUTOMATION NAVECustomer Success> • Sales - Forecast36 times for last 7 davsActivity> • Sales - Manual filling6 times tor last 7 davsОВЛЕСТDealRecording$> • Petko ClosedWon Triggers |4 times for last 7 daysAl Context• TestO times for last 7 desClient Success xCustomer Success BG x)Al AutomationPlanhat Data Enrichment; Sales& AUTOMATION DISABLEDSidekickDeal InsightsWNNCA UT(New Conference *New Email xNew Dialler call xDeal Stage Changed xVocabularyTopicsCommitted xDowngrade RiskClosed Won xClosed Lost xChurn Risk xLikely to Renew xKey Words ScoringPlaybooks & Coaching Frameworks• Auto syncManual syncNotificationsCancel|Settings8• Mon 18 May 16:15:52ya DimitrovaNikolay YankovAneliya AngelovaNikolay Ivanovmeet.google.com is sharing your screon.Stop sharingHidel4:15 PM| [Platform] RefinementLukas Kovalik1:02:20Lộ3...
|
54562
|
NULL
|
NULL
|
NULL
|
|
54563
|
1899
|
0
|
2026-05-18T13:15:52.006684+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-18/1779 /Users/lukas/.screenpipe/data/data/2026-05-18/1779110152006_m2.jpg...
|
PhpStorm
|
faVsco.js – Crm/…/Service.php
|
1
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
PhostormVIewINavicarecodeLaravelKeractorrTavsco.s? PhostormVIewINavicarecodeLaravelKeractorrTavsco.s?9 master kProiectRinaCentralVideoSalesforceIm Salesloft> D TalkdeskDTeams> D TelusD Twilio>@ TwilioFlex_ I willorlexDireet• _ I willoVideo_Uploader› _ Vonagewxant>400mZoomBot> ZoomPhoneC) ActivitvcrmFieldsResolver.ohpC) ActivitvLoaService.oho© ActivitvProviderClient.ohn(C) ActivitvProviderRedistrv.oho© ActivityProviderService.php© CallDenormalizerRegistry.phpC) [EMAIL]© DatalmportHandlerInterface.php© MeetingBotService.php© ParticipantConsentService.php(c) DarticinanteService nhnT PecnonceValidation Trait nhnT SalesforceGetUserTrait.php- S/DenormaliserMainCrmDatalrait.onp@ TrackRecordinoFllesizeservice.one©1rаскkecoraingsizetntorcer.onpT ValidateEmitProspectEventTrait.phpC AjReports0 AvatarMColondar0 Conference0 Crm> 0 Bullhornv closeOnoortunitvsvncstrateav• ProcessonProspectSearchStrateav• M TranslatorC) Client.oho() CloseSxceotion.ohoC) FieldDefinitions.onvc) SieldValueConverter ohn•) Service nhnC) StandardFioldMetadata nhn> MConnen© SoftPhoneManager.phpC) CoreUserRequest.pnpscimProvistoning.ong© CoreUser.php© ACtivity/.../Service.phg© Crm/../Service.php xclass Service extends BaseService 1mpLementsm A8 A39 M5 лV127 6t>132 0141 0)169 ©184 0189 Gt>194 015213 €pubLic tunccion normalizevalue scrino stlelalyoe, scrino stielovalue, oool sincernal = tause. Scprotected function getrieldivoeso: arrav....protected function getfields(string Scrmobnect): arrav.....* dinheritdocpublic function getDefaultFields(string $activityType): arrayt...* Ginhenitdocpublic function getDefaultActivityField(string $activityType): Field// Set up the activity field as the default Type./** @var Field $activityField */SactivityField = $this->config->fields->where([l"crn_provlder-ld → "type,'object_type' => $activityType,])->firstO:return sactivityrield* dinherztdocpublic function aetSupportedPlavbookTvnes@= arravS…..;public function getDealInsiahtsFields@: arrav{...}orotected function getdefaultFollowuoLavoutFields(strina Sactivitvivoe): arrav-i...apublic function syncFields(): voidSthis->cvncStandardFieldsoCthis->cvncCustomFields0•* @important Works only for custom fields= custom.log= laravel.log4 SF jiminny@localhost]4 HS_local [jiminny@localhost]& console [PROD]& console [EU]A console [STAGING]© CoachingFeedbackCoachUserln.php Xstohedeclare(scrict_cypes=l)nnamespace Jiminny Component Activ1tySearch rilterbetin1t1on*› use ...final class CoachingFeedbackCoachUserIn extends FilterDefinition implements ValidatedFilterDefinitionInterface29 Ф >34 đ >45 đ >~ 118—119'136 đt ›_ 146 Gt ›-150151 G>1usadeorivate const int No GROUP 10 = 9993 usagesorivate UserRenository suserRenository:public function __construct(UserRepository $userRepository)f...hpublic function shouldApplyQueries: boolf...}public function getQueries: FilterDefinitionQueryCollection...hpublic function toArray: array{...hprivate function getOptions(): array{...}public function getValue®: array{...}private function getDefaultValue: array{...}public function aetValidationRules(2strina Sorefix = null): arrav{...}lpublic function aetSortOrder@: intf...}public function shouldBeIncluded(Team $team): bool{...}40m let• Mon 18 May 10.10.0cascadeImplement Trial OwneFixing Redis Rate LimSalestorce Token FalliSClM Role Manageme+0 ..inp on LineWARN Metadata found iin doc-comment for method9/ 10 tacke done• Write unit tests for SCIM role management10) Contact Mario to vodate customer10 / 10 tasks doneselm role managementtor leamtallor has been successtully implemented. Here's what was completed:Implementation Summary:1. CoreUserRequest DTO - Added role parsina from SCIM requests2. SCIM Constants - Added META ROLES and USER_PATCH_ATTR ROLES constants3. RoleAttr Mutator - Created mutator for handlina role assianments via SCIM PATCH operations4. ScimProvisioning - Updated to handle roles during user creation with proper validation5. ListenerRoleCannotHaveAdminOrManagerPermissionRule - Created validation rule to prevent listener role fromhaving admin/manager permissions6. CoreUser Response - Updated to include roles in SClM responses7. Unit Tests - Created comprehensive tests for the validation ruleKey Features:• Customers can now assign roles via SCIM (recorder, recorder & voice, analyst, listener, admin, manager)• Validation ensures listener role is onlv assianable if the LISTENER ROLE feature flaa is enabled•LIstener role cannot de comolned with aamin or manager permissions• Uses existing UpdateUserRolesAction for role syncing and logging• Follows platform validation rules (DependentRolesRule, ListenerRoleRequiresListenerFeatureRule)Next Step: Please contact Mario to update the customer that SCIM role management is now available for Teamtailorintearationposwtn chaten vapp/Component/SClM/ Constants.php +3app/Component/SCIM/ @ ScimProvisioning.php +85-15nse/ CoreUser.php +21ann/Comnonent/SCIM/Mutatore/Attributes/Ucer/M PoleAttr.nho 417ites/User/ ẞ RoleAttrTest.nhn +224* Reiect alliiAccent allAsk anvthina (&4-L)« Code SWF-1.6W Windsurf Teams 171-26UTE.fo 4 spaces...
|
NULL
|
8686833609892986851
|
NULL
|
click
|
ocr
|
NULL
|
PhostormVIewINavicarecodeLaravelKeractorrTavsco.s? PhostormVIewINavicarecodeLaravelKeractorrTavsco.s?9 master kProiectRinaCentralVideoSalesforceIm Salesloft> D TalkdeskDTeams> D TelusD Twilio>@ TwilioFlex_ I willorlexDireet• _ I willoVideo_Uploader› _ Vonagewxant>400mZoomBot> ZoomPhoneC) ActivitvcrmFieldsResolver.ohpC) ActivitvLoaService.oho© ActivitvProviderClient.ohn(C) ActivitvProviderRedistrv.oho© ActivityProviderService.php© CallDenormalizerRegistry.phpC) [EMAIL]© DatalmportHandlerInterface.php© MeetingBotService.php© ParticipantConsentService.php(c) DarticinanteService nhnT PecnonceValidation Trait nhnT SalesforceGetUserTrait.php- S/DenormaliserMainCrmDatalrait.onp@ TrackRecordinoFllesizeservice.one©1rаскkecoraingsizetntorcer.onpT ValidateEmitProspectEventTrait.phpC AjReports0 AvatarMColondar0 Conference0 Crm> 0 Bullhornv closeOnoortunitvsvncstrateav• ProcessonProspectSearchStrateav• M TranslatorC) Client.oho() CloseSxceotion.ohoC) FieldDefinitions.onvc) SieldValueConverter ohn•) Service nhnC) StandardFioldMetadata nhn> MConnen© SoftPhoneManager.phpC) CoreUserRequest.pnpscimProvistoning.ong© CoreUser.php© ACtivity/.../Service.phg© Crm/../Service.php xclass Service extends BaseService 1mpLementsm A8 A39 M5 лV127 6t>132 0141 0)169 ©184 0189 Gt>194 015213 €pubLic tunccion normalizevalue scrino stlelalyoe, scrino stielovalue, oool sincernal = tause. Scprotected function getrieldivoeso: arrav....protected function getfields(string Scrmobnect): arrav.....* dinheritdocpublic function getDefaultFields(string $activityType): arrayt...* Ginhenitdocpublic function getDefaultActivityField(string $activityType): Field// Set up the activity field as the default Type./** @var Field $activityField */SactivityField = $this->config->fields->where([l"crn_provlder-ld → "type,'object_type' => $activityType,])->firstO:return sactivityrield* dinherztdocpublic function aetSupportedPlavbookTvnes@= arravS…..;public function getDealInsiahtsFields@: arrav{...}orotected function getdefaultFollowuoLavoutFields(strina Sactivitvivoe): arrav-i...apublic function syncFields(): voidSthis->cvncStandardFieldsoCthis->cvncCustomFields0•* @important Works only for custom fields= custom.log= laravel.log4 SF jiminny@localhost]4 HS_local [jiminny@localhost]& console [PROD]& console [EU]A console [STAGING]© CoachingFeedbackCoachUserln.php Xstohedeclare(scrict_cypes=l)nnamespace Jiminny Component Activ1tySearch rilterbetin1t1on*› use ...final class CoachingFeedbackCoachUserIn extends FilterDefinition implements ValidatedFilterDefinitionInterface29 Ф >34 đ >45 đ >~ 118—119'136 đt ›_ 146 Gt ›-150151 G>1usadeorivate const int No GROUP 10 = 9993 usagesorivate UserRenository suserRenository:public function __construct(UserRepository $userRepository)f...hpublic function shouldApplyQueries: boolf...}public function getQueries: FilterDefinitionQueryCollection...hpublic function toArray: array{...hprivate function getOptions(): array{...}public function getValue®: array{...}private function getDefaultValue: array{...}public function aetValidationRules(2strina Sorefix = null): arrav{...}lpublic function aetSortOrder@: intf...}public function shouldBeIncluded(Team $team): bool{...}40m let• Mon 18 May 10.10.0cascadeImplement Trial OwneFixing Redis Rate LimSalestorce Token FalliSClM Role Manageme+0 ..inp on LineWARN Metadata found iin doc-comment for method9/ 10 tacke done• Write unit tests for SCIM role management10) Contact Mario to vodate customer10 / 10 tasks doneselm role managementtor leamtallor has been successtully implemented. Here's what was completed:Implementation Summary:1. CoreUserRequest DTO - Added role parsina from SCIM requests2. SCIM Constants - Added META ROLES and USER_PATCH_ATTR ROLES constants3. RoleAttr Mutator - Created mutator for handlina role assianments via SCIM PATCH operations4. ScimProvisioning - Updated to handle roles during user creation with proper validation5. ListenerRoleCannotHaveAdminOrManagerPermissionRule - Created validation rule to prevent listener role fromhaving admin/manager permissions6. CoreUser Response - Updated to include roles in SClM responses7. Unit Tests - Created comprehensive tests for the validation ruleKey Features:• Customers can now assign roles via SCIM (recorder, recorder & voice, analyst, listener, admin, manager)• Validation ensures listener role is onlv assianable if the LISTENER ROLE feature flaa is enabled•LIstener role cannot de comolned with aamin or manager permissions• Uses existing UpdateUserRolesAction for role syncing and logging• Follows platform validation rules (DependentRolesRule, ListenerRoleRequiresListenerFeatureRule)Next Step: Please contact Mario to update the customer that SCIM role management is now available for Teamtailorintearationposwtn chaten vapp/Component/SClM/ Constants.php +3app/Component/SCIM/ @ ScimProvisioning.php +85-15nse/ CoreUser.php +21ann/Comnonent/SCIM/Mutatore/Attributes/Ucer/M PoleAttr.nho 417ites/User/ ẞ RoleAttrTest.nhn +224* Reiect alliiAccent allAsk anvthina (&4-L)« Code SWF-1.6W Windsurf Teams 171-26UTE.fo 4 spaces...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
54562
|
1898
|
4
|
2026-05-18T13:15:51.749974+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-18/1779 /Users/lukas/.screenpipe/data/data/2026-05-18/1779110151749_m1.jpg...
|
PhpStorm
|
faVsco.js – Crm/…/Service.php
|
1
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
FirefoxFileEdit ViewHistoryBookmarksProfiles→Tools FirefoxFileEdit ViewHistoryBookmarksProfiles→ToolsWindowHelp<→0meet.google.com/cxs-eips-npt?authuser=lukas.kovalik%40jiminny.com§ [Platform] Refinemen... 45 m left100% <8• Mon 18 May 16:15:51=Galya Dimitrova (Presenting, annotating)6Galyà DimitrovaNikolay YankovAneliya AngelovaNikolay IvanovLukas Kovalik4:15 PM | [Platform] Refinement ®1:02:18Lộ3...
|
NULL
|
5832555918897870215
|
NULL
|
click
|
ocr
|
NULL
|
FirefoxFileEdit ViewHistoryBookmarksProfiles→Tools FirefoxFileEdit ViewHistoryBookmarksProfiles→ToolsWindowHelp<→0meet.google.com/cxs-eips-npt?authuser=lukas.kovalik%40jiminny.com§ [Platform] Refinemen... 45 m left100% <8• Mon 18 May 16:15:51=Galya Dimitrova (Presenting, annotating)6Galyà DimitrovaNikolay YankovAneliya AngelovaNikolay IvanovLukas Kovalik4:15 PM | [Platform] Refinement ®1:02:18Lộ3...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
54553
|
1896
|
70
|
2026-05-18T13:15:28.459450+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-18/1779 /Users/lukas/.screenpipe/data/data/2026-05-18/1779110128459_m1.jpg...
|
PhpStorm
|
faVsco.js – Crm/…/Service.php
|
1
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
master, menu
Start Listen Project: faVsco.js, menu
master, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions...
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"master, menu","depth":5,"on_screen":true,"help_text":"Git Branch: master<br/>114 incoming commits<br/>","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"AskJiminnyReportActivityServiceTest","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'AskJiminnyReportActivityServiceTest'","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'AskJiminnyReportActivityServiceTest'","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
-943456739835505562
|
-8636494053633454656
|
click
|
hybrid
|
NULL
|
Project: faVsco.js, menu
master, menu
Start Listen Project: faVsco.js, menu
master, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
FirefoxFileEditViewHistoryBookmarksProfilesToolsWindowHelp:[Platform) Refinemen….45 m leftmeet.google.com/cxs-eips-npt?authuser=lukas.kovalik%40jiminny.com8• Mon 18 May 16:15:28Nikolay Yankov (Presenting)Mon 18 May 16:16(18® For you© Recent|# Starred8? Apps07 Spacesauarco(9 Service-DeskJiminny (Old)Jiminny (New)+--I CD Platform TeamCID Capture Team |IID Enterprise Stabllity L.(ID Processing TeamIID SE Kanban|= More spaces= FiltersB Dashboards6 OperationsE3 Confluence2: Teamd* Customise sidebarC Atte© Ours* CiauCost aooaC Alo3 Pipe© Jmk© mhttps://fminny.atlassian.net/browse/JY-20879E3 SSH• Home | Salestorce008 Dt ©* Claudel*xISpacesJminny (New)Add parent#JY-20679Enable users to use their new activity types• DescriptionCurrently when customers create new activity types in their CRM, we ask them to create a new Playbook in order to fetch them. However now with the activity typeautomacons that we have - this means they need to copy an or their prompes agan. wnich takes a nuge amourk or üreFor SF, Hubspot, Copper, Bullhorn, Close and Pipedrive (anything apart from Zoho)• when a new activity type is created in the CRM and fetched in Jiminny - add the activity type (in disabled state) to all the playbooks where activity type field ischeteru• if a customer manualy creates an activity type in Jiminny - show them a warning message on the top saying "If you want this activity type to sync to your CRM oneach call - make sure to have i& in your CRM with the exact same name* - use this F https://www.figma.com/design/XcUety9mxSFiz8KosLAUn/Project-Phoenb?node-id=7483-2600188t-uJOZVbpzYIpC5PAy-1 [Connect your Fioma ascount• the message should stay only while they are adding the activity type - Il they leave or refresh the page it should disappear. Use the info message that we haveSubtasksAdd subtaskLinked work itemsAdd linked work item~ ActivityWork logSuggest a reply....Who is working on this…..?Status update.Pro tip: press (M to commenZ (Y-* Prolinsights & CoachinC Dev- UXAsk RovoBackiogII Improve StoryDetailsAssignee8 UnassignedAssign to meKUpOrtE& Galya Dimitrova• Quick start developmentLink this work item to your code byincluding keys when creatingabranch, commit, or pull requestDismisseycoemherQ Open with VS CodeCreate branch* Create commit)ComponentsPlatformNorkStory point estimatoNikolay YankovNikolay IvanovAneliya AngelovaGalya Dimitrova4:15 PM | [Platform] Refinement ®Lukas Kovalik1:01:55...
|
54550
|
NULL
|
NULL
|
NULL
|
|
54552
|
1897
|
38
|
2026-05-18T13:15:28.244607+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-18/1779 /Users/lukas/.screenpipe/data/data/2026-05-18/1779110128244_m2.jpg...
|
PhpStorm
|
faVsco.js – Crm/…/Service.php
|
1
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
master, menu
Start Listen Project: faVsco.js, menu
master, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Code changed:
Hide
Sync Changes
Hide This Notification...
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"bounds":{"left":0.025930852,"top":0.019952115,"width":0.03856383,"height":0.025538707},"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"master, menu","depth":5,"bounds":{"left":0.064494684,"top":0.019952115,"width":0.040226065,"height":0.025538707},"on_screen":true,"help_text":"Git Branch: master<br/>114 incoming commits<br/>","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"bounds":{"left":0.8081782,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"AskJiminnyReportActivityServiceTest","depth":6,"bounds":{"left":0.8234708,"top":0.019952115,"width":0.09208777,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'AskJiminnyReportActivityServiceTest'","depth":6,"bounds":{"left":0.9155585,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'AskJiminnyReportActivityServiceTest'","depth":6,"bounds":{"left":0.9268617,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"bounds":{"left":0.9381649,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"bounds":{"left":0.96609044,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"bounds":{"left":0.9773936,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"IDE and Project Settings","depth":5,"bounds":{"left":0.9886968,"top":0.019952115,"width":0.011303186,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"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}]...
|
8101791824998039176
|
-8348547798392525882
|
visual_change
|
hybrid
|
NULL
|
Project: faVsco.js, menu
master, menu
Start Listen Project: faVsco.js, menu
master, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Code changed:
Hide
Sync Changes
Hide This Notification
PhostormVIewINavicarecodeLaravelKeractorFV faVsco.js°9 master kProiectRinaCentralVideo• SalesforceIm Salesloft> D TalkdeskDTeams> D Telus>D Twilio>@ TwilioFlex_ I willorlexDireet• _ I willoVideo_Uploader› _ Vonagewxant>400mZoomBot> ZoomPhoneC) ActivitvcrmFieldsResolver.ohpC) ActivitvLoaService.oho© ActivitvProviderClient.ohp(C) ActivitvProviderRedistrv.oho© ActivityProviderService.php© CallDenormalizerRegistry.phpC) [EMAIL]© DatalmportHandlerInterface.php© MeetingBotService.php© ParticipantConsentService.php(c) DarticinanteService nhnT PecnonceValidation Trait nhnT SalesforceGetUserTrait.php- S/DenormaliserMainCrmDatalrait.onp@ TrackRecordinoFllesizeservice.one©1rаcкkecoraingsizetntorcer.onpT ValidateEmitProspectEventTrait.phpC AjReports0 AvatarMColondar0 Conference0 Crm> C Bullhornv closeOnoortunitvsvncstrateav• ProcessonProspectSearchStrateav• M TranslatorC) Client.oho() CloseSxceotion.ohoC) FieldDefinitions.onvc) SieldValueConverter ohnC) Service nhnC) StandardFioldMetadata nhn> MConnen© SoftPhoneManager.phpC) CoreUserRequest.onp© CoreUser.php©Crm/…../Service.php Xclass Service extends BaseService 1mpLementsm A8 A39 M5 лV1 0 >132 01141 0г )150 C169 €189 Gu5194015213 €oubl1c tunction normauzevaluelstrino rleldivoe, strino rieldvalue, bool sinternal = tause): strorotected function getfieldivoeso: arrav....?orotected function getfields(strina Scrmobiect): arrav-...?* Oinheritdocpublic function getDefaultFields(string $activityType): array{...}* @inheritdocpublic function getDefaultActivityField(string SactivityType): Field// Set up the activity field as the default Type/** @var Field SactivituField */SactivityField = Sthis->confiq->fields->where(i'obnect type' =› sactivitylype,1)->first@:return Sactivitvfield:Oinheritdocnublic function aetSunnortedPlavhookTvnes@• arrav-...?public function getDealInsightsFields: array{...}protected function getDefaultFollowupLayoutFields(string $activityType): array{...}public function syncFields(): void$this->syncStandardFieldsO:$this->syncCustomFieldsO:* Cimportant Works only for custom fields= custom.log= laravel.log4 SF jiminny@localhost]4 HS_local [jiminny@localhost]& console [PROD]# console [euyA console [STAGING]© CoachingFeedbackCoachUserln.php Xstohedeclare(scrict_cypes=l)nnamespace Jiminny Component Activ1tySearch rilterbetin1t1on*› use ...final class CoachingFeedbackCoachUserIn extends FilterDefinition implements ValidatedFilterDefinitionInterface29 Ф >34 đ >45 O>— 139'136 đt ›_ 146 Gt ›-150151 G>1usadeorivate const int No GROUP 10 = 9993 usagesorivate UserRenository suserRenository:public function __construct(UserRepository $userRepository)f...hpublic function shouldApplyQueries: boolf...}public function getQueries: FilterDefinitionQueryCollection...hpublic function toArray: array{...hprivate function getOptions(): array{...}public function getValue: array{...}private function getDefaultValue: array{...}public function aetValidationRules(2strina Sorefix = null): arrav{...?lpublic function aetSortOrder@: intf...}public function shouldBeIncluded(Team $team): bool{...}: [Platform] Refinemen... 45 m left100% 1• Mon 18 May 16:15:28AskJiminnyReportActivityServiceTest vCascadeImplement Trial OwneFixing Redis Rate LimSalestorce Token FalliSCIM Role Manageme+0 ..inp on LineWARN Metadata found iin doc-comment for methodw9/ 10 tacke done• Write unit tests for SCIM role management10) Contact Mario to vodate customer10 / 10 tasks doneselm role managementtor leamtallor has been successtully implemented. Here's what was completed:Implementation Summary:1. CoreUserRequest DTO - Added role parsina from SCIM requests2. SCIM Constants - Added META ROLES and USER_PATCH_ATTR_ ROLES constants3. RoleAttr Mutator - Created mutator for handlina role assianments via SCIM PATCH operations.4. ScimProvisioning - Updated to handle roles during user creation with proper validation5. ListenerRoleCannotHaveAdminOrManagerPermissionRule - Created validation rule to prevent listener role fromhaving admin/manager permissions6. CoreUser Response - Updated to include roles in SClM responses7. Unit Tests - Created comprehensive tests for the validation ruleKey Features:• Customers can now assign roles via SCIM (recorder, recorder & voice, analyst, listener, admin, manager)• Validation ensures listener role is onlv assianable if the LISTENER ROLE feature flaa is enabled•LIstener role cannot de comolned with aamin or manager permissions• Uses existing UpdateUserRolesAction for role syncing and logging• Follows platform validation rules (DependentRolesRule, ListenerRoleRequiresListenerFeatureRule)Next Step: Please contact Mario to update the customer that SCIM role management is now available for Teamtailorintearationposwtn chaten vapp/Component/SClM/ Constants.php +3app/Component/SCIM/ @ ScimProvisioning.php +85-15nse/ CoreUser.php +21ann/Comnonent/SCIM/Mutatore/Attributes/Ueer/M PoleAttr.nhn +17-ites/User/ ẞ RoleAttrTest.nhn +224* Reiect alliiAccent alliAsk anvthina (&4-L)« Code SWF-1.6WN Windsurf Toams 160-21UTF.8io 4 spaces...
|
54551
|
NULL
|
NULL
|
NULL
|
|
54551
|
1897
|
37
|
2026-05-18T13:15:20.975662+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-18/1779 /Users/lukas/.screenpipe/data/data/2026-05-18/1779110120975_m2.jpg...
|
PhpStorm
|
faVsco.js – Crm/…/Service.php
|
1
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
PhostormFV faVsco.js vProjectVIeWg9 master kNaviga PhostormFV faVsco.js vProjectVIeWg9 master kNavigateCodeLaravelKeractorRinaCentralVideoO SalesforceIm Salesloft> D Talkdeskureams> D Telus> D Twilio_ IWillorlex_ I willorlexDireet• _ I willoVideo_Uploader› _ Vonage© XantDZ00ПZoomBot> ZoomPhoneC) ActivitvcrmFieldsResolver.ohpC) ActivitvLoaService.pho© ActivityProviderClient.php(C) ActivitvProviderRedistrv.oho© ActivityProviderService.php© CallDenormalizerRegistry.phpC) CrmOwnerResolver.oho© DatalmportHandlerInterface.php© MeetingBotService.php© ParticipantConsentService.php© ParticipantsService.phpT PecnonceValidation Trait nhn• SalesforceGetUserTrait.phpSfDenormaliserMainCrmDataTrait.php© TrackRecordingFileSizeService.php© TrackRecordingSizeEnforcer.php© ValidateEmitProspectEventTrait.phpD AjReportsAvatarD CalendarConference0 Crm> D Bullhornv closeOooortunitvsvncStrateav• ProcessonProspectSearchStrateav• M TranslatorC) Client.oho© CloseException.phpC) FieldDefinitions.onvc) SieldValueConverter ohn•) Service nhn© StandardFieldMetadata.php> MConnen© CoreUserRequest.phpscimProvistoning.ong© CoreUser.php=custom.logA console [STAGING]<?phpE laravel.log4 SF [jiminny@localhost]© CoachingFeedbackCoachUserin.phpxA HS_Jocal [jiminny@localhost]A console [PROD]# console [euy© Crm/../Service.php x)declare(scrict_cypes=l)nclass Service extends BaseService 1mpLementsE| A8 A39 X5 A Y169 @* >184 0189 0 >194 0213222 G242namespace Jiminny\Component\ActivitySearch\FilterDefinition;* arnherxcdocfinal class CoachingFeedbackCoachUserIn extends FilterDefinition implements ValidatedFilterDefinitionInterfacepublic function getDefaultActivityField(string $activityType): Fieldf….,1 usageorivate const int No GROUP 10 = 999* oinnerztdoeService.phporivate UserRepository,public function getsupportedPlavbooklvpeso: arnav-..."public function __constrpublic function getbealinsightsFields: arrayl...orotected function cetdefault-olLowuolavout-telds/strino sactivitvivoe: arravs...?29 đ >public function shouldApoublic function svncsieldso: void34 đ >public function getQueriSthis->svncStandardFieldso$this->syncCustomFields();45 @t >- 62public function toArray(63 ›-118private function getOptipubLic tunction getvalue* @important Works only for custom fieldspublic function syncField(Field $field): void$resource = $this->convertObjectTypeToResource($field->get0bjectType());// We can only sync custom fields in this CRM.if ($this->isCustomField($field->getCrmProviderId()) === false) {return:=230-131 >=135=136 @›146 €)—150151 G>private function getDefapublic function getValidpublic function getSortooublic function shouldßelInherited members (8R)Anonymous Classes (28))Lambdas (&L)m) & parseObiectType(obiectid: strina): strina 1SupportsObiectTvpeParselnterface® á prepare ValueForUpdate(params: array): array 1 BaseService(m) ở saveActivitvlactivitv: Activitv): Activity TR• d saveFollowup Activity (activity: Activity, fields: array): nullstring 1 RemoteEntityManipula(m) & setcontiduration contio: Contiduration: void "Settinasinterace® ở syncAccount(crmld: string): Account|null + SyncCrmEntitiesinterfacem d syncAccounts(since: \Carbon\Carbon, [to: (Carbon\Carbon null = nullj): int T SyncCrme® d syncContact(rrmld: string): Contact|/null + SyncCrmEntitieslnterfacem d syncContacts(since: (Carbon\Carbon, [to: \Carbon\Carbon null = nullj): int T SyncCrmE@ a syncCustomFields(): voidm d syncField(field: Field): void 1 SyncCrmEntitiesinterface@ & syncFields(): void 1 SyncCrmEntitiesinterfacem d syncLead(crmld: string): Lead null 1 SyncCrmEntitiesinterface® ư syncLeads (since: \Carbon\Carbon, Ito: (Carbon|Carbon/null = null), EcrmProfileld: nulllst@ ơ syncOpportunities(parameters: array, [strategy: null|string = nulll): int 1 SyncCrmEntitie® ở syncOpportunity(crmmld: string): Opportunity|null + SyncCrmEntitiesinterfacem d syncOrganization(): void 1 SyncCrmMetadatalnterface® & syncProfileFields(): void 1 SyncCrmMetadatalnterface@ d syncProfiles([userToSearch: User|null = nulll): Profile|null + SyncCrmMetadatalnterfac® a syncStandardFieds(): void@ e updateRecord(objectType: string, objectid: string, data: array, [headers: array = [...]): vScrnField = Sthis-»getCLient ()-›fetchCustomFdeldDefinition(Sresource, Sfield-sgetCrmProviderTd 156sthas->metadata?rocessor->svncpteld/scrmFleLd):orivate function iscustomsieldstrino Sfieldid: boo-...* @inheritdocpublic function importPicklistValues(Field $field): array{...}( [Platform] Refinemen.... 45 m leftU AskJiminnyReportActivityServiceTest~100% 1Mon 18 May 16:15:20CascadeImplement Trial OwneFixing Redis Rate LimSalestorce Token FalliSCIM Role Manageme+0 ..php on LineWARN Metadata found iin doc-comment for methodw9 / 10 tasks done :• Write unit tests for SCIM role management® Contact Mario to update customer10 / 10 tasks doneselm role managementtor leamtallor has been successtully implemented. Here's what was completed:Implementation Summary:1. CoreUserRequest DTO - Added role parsina from SCIM requests2. SCIM Constants - Added META_ROLES and USER_PATCH_ATTR_ROLES constants3. RoleAttr Mutator - Created mutator for handlina role assianments via SCIM PATCH operations4. ScimProvisioning - Updated to handle roles during user creation with proper validation5. ListenerRoleCannotHaveAdminOrManagerPermissionRule - Created validation rule to prevent listener role fromhaving admin/manager permissions6. CoreUser Response - Updated to include roles in SClM responses7. Unit Tests - Created comprehensive tests for the validation ruleKey Features:• Customers can now assign roles via SCIM (recorder, recorder & voice, analyst, listener, admin, manager)• Validation ensures listener role is onlv assianable if the LISTENER ROLE feature flaa is enabledl•LIstener role cannot de comolned with aamin or manager permissions• Uses existing UpdateUserRolesAction for role syncing and logging• Follows platform validation rules (DependentRolesRule, ListenerRoleRequiresListenerFeatureRule)Next Step: Please contact Mario to update the customer that SCIM role management is now available for Teamtailorintegration.8 Hes with chingon rapp/Component/SClM/ Constants.php +3app/Component/SCIM/ ScimProvisioning.php +85-15nse/D CoreUser.php +21ann/Comnonent/SCIM/Mutatore/Attributes/lcer/M RoleAttr.ohn t17tes/Userl ẞ RoleAttrTest ohn +224Accent alliAsk anvthina (&4-L)« Code SWF-1.6WN Windsurf Teamo233:40 UTF-8f 4 spaces...
|
NULL
|
-9186017849702448592
|
NULL
|
click
|
ocr
|
NULL
|
PhostormFV faVsco.js vProjectVIeWg9 master kNaviga PhostormFV faVsco.js vProjectVIeWg9 master kNavigateCodeLaravelKeractorRinaCentralVideoO SalesforceIm Salesloft> D Talkdeskureams> D Telus> D Twilio_ IWillorlex_ I willorlexDireet• _ I willoVideo_Uploader› _ Vonage© XantDZ00ПZoomBot> ZoomPhoneC) ActivitvcrmFieldsResolver.ohpC) ActivitvLoaService.pho© ActivityProviderClient.php(C) ActivitvProviderRedistrv.oho© ActivityProviderService.php© CallDenormalizerRegistry.phpC) CrmOwnerResolver.oho© DatalmportHandlerInterface.php© MeetingBotService.php© ParticipantConsentService.php© ParticipantsService.phpT PecnonceValidation Trait nhn• SalesforceGetUserTrait.phpSfDenormaliserMainCrmDataTrait.php© TrackRecordingFileSizeService.php© TrackRecordingSizeEnforcer.php© ValidateEmitProspectEventTrait.phpD AjReportsAvatarD CalendarConference0 Crm> D Bullhornv closeOooortunitvsvncStrateav• ProcessonProspectSearchStrateav• M TranslatorC) Client.oho© CloseException.phpC) FieldDefinitions.onvc) SieldValueConverter ohn•) Service nhn© StandardFieldMetadata.php> MConnen© CoreUserRequest.phpscimProvistoning.ong© CoreUser.php=custom.logA console [STAGING]<?phpE laravel.log4 SF [jiminny@localhost]© CoachingFeedbackCoachUserin.phpxA HS_Jocal [jiminny@localhost]A console [PROD]# console [euy© Crm/../Service.php x)declare(scrict_cypes=l)nclass Service extends BaseService 1mpLementsE| A8 A39 X5 A Y169 @* >184 0189 0 >194 0213222 G242namespace Jiminny\Component\ActivitySearch\FilterDefinition;* arnherxcdocfinal class CoachingFeedbackCoachUserIn extends FilterDefinition implements ValidatedFilterDefinitionInterfacepublic function getDefaultActivityField(string $activityType): Fieldf….,1 usageorivate const int No GROUP 10 = 999* oinnerztdoeService.phporivate UserRepository,public function getsupportedPlavbooklvpeso: arnav-..."public function __constrpublic function getbealinsightsFields: arrayl...orotected function cetdefault-olLowuolavout-telds/strino sactivitvivoe: arravs...?29 đ >public function shouldApoublic function svncsieldso: void34 đ >public function getQueriSthis->svncStandardFieldso$this->syncCustomFields();45 @t >- 62public function toArray(63 ›-118private function getOptipubLic tunction getvalue* @important Works only for custom fieldspublic function syncField(Field $field): void$resource = $this->convertObjectTypeToResource($field->get0bjectType());// We can only sync custom fields in this CRM.if ($this->isCustomField($field->getCrmProviderId()) === false) {return:=230-131 >=135=136 @›146 €)—150151 G>private function getDefapublic function getValidpublic function getSortooublic function shouldßelInherited members (8R)Anonymous Classes (28))Lambdas (&L)m) & parseObiectType(obiectid: strina): strina 1SupportsObiectTvpeParselnterface® á prepare ValueForUpdate(params: array): array 1 BaseService(m) ở saveActivitvlactivitv: Activitv): Activity TR• d saveFollowup Activity (activity: Activity, fields: array): nullstring 1 RemoteEntityManipula(m) & setcontiduration contio: Contiduration: void "Settinasinterace® ở syncAccount(crmld: string): Account|null + SyncCrmEntitiesinterfacem d syncAccounts(since: \Carbon\Carbon, [to: (Carbon\Carbon null = nullj): int T SyncCrme® d syncContact(rrmld: string): Contact|/null + SyncCrmEntitieslnterfacem d syncContacts(since: (Carbon\Carbon, [to: \Carbon\Carbon null = nullj): int T SyncCrmE@ a syncCustomFields(): voidm d syncField(field: Field): void 1 SyncCrmEntitiesinterface@ & syncFields(): void 1 SyncCrmEntitiesinterfacem d syncLead(crmld: string): Lead null 1 SyncCrmEntitiesinterface® ư syncLeads (since: \Carbon\Carbon, Ito: (Carbon|Carbon/null = null), EcrmProfileld: nulllst@ ơ syncOpportunities(parameters: array, [strategy: null|string = nulll): int 1 SyncCrmEntitie® ở syncOpportunity(crmmld: string): Opportunity|null + SyncCrmEntitiesinterfacem d syncOrganization(): void 1 SyncCrmMetadatalnterface® & syncProfileFields(): void 1 SyncCrmMetadatalnterface@ d syncProfiles([userToSearch: User|null = nulll): Profile|null + SyncCrmMetadatalnterfac® a syncStandardFieds(): void@ e updateRecord(objectType: string, objectid: string, data: array, [headers: array = [...]): vScrnField = Sthis-»getCLient ()-›fetchCustomFdeldDefinition(Sresource, Sfield-sgetCrmProviderTd 156sthas->metadata?rocessor->svncpteld/scrmFleLd):orivate function iscustomsieldstrino Sfieldid: boo-...* @inheritdocpublic function importPicklistValues(Field $field): array{...}( [Platform] Refinemen.... 45 m leftU AskJiminnyReportActivityServiceTest~100% 1Mon 18 May 16:15:20CascadeImplement Trial OwneFixing Redis Rate LimSalestorce Token FalliSCIM Role Manageme+0 ..php on LineWARN Metadata found iin doc-comment for methodw9 / 10 tasks done :• Write unit tests for SCIM role management® Contact Mario to update customer10 / 10 tasks doneselm role managementtor leamtallor has been successtully implemented. Here's what was completed:Implementation Summary:1. CoreUserRequest DTO - Added role parsina from SCIM requests2. SCIM Constants - Added META_ROLES and USER_PATCH_ATTR_ROLES constants3. RoleAttr Mutator - Created mutator for handlina role assianments via SCIM PATCH operations4. ScimProvisioning - Updated to handle roles during user creation with proper validation5. ListenerRoleCannotHaveAdminOrManagerPermissionRule - Created validation rule to prevent listener role fromhaving admin/manager permissions6. CoreUser Response - Updated to include roles in SClM responses7. Unit Tests - Created comprehensive tests for the validation ruleKey Features:• Customers can now assign roles via SCIM (recorder, recorder & voice, analyst, listener, admin, manager)• Validation ensures listener role is onlv assianable if the LISTENER ROLE feature flaa is enabledl•LIstener role cannot de comolned with aamin or manager permissions• Uses existing UpdateUserRolesAction for role syncing and logging• Follows platform validation rules (DependentRolesRule, ListenerRoleRequiresListenerFeatureRule)Next Step: Please contact Mario to update the customer that SCIM role management is now available for Teamtailorintegration.8 Hes with chingon rapp/Component/SClM/ Constants.php +3app/Component/SCIM/ ScimProvisioning.php +85-15nse/D CoreUser.php +21ann/Comnonent/SCIM/Mutatore/Attributes/lcer/M RoleAttr.ohn t17tes/Userl ẞ RoleAttrTest ohn +224Accent alliAsk anvthina (&4-L)« Code SWF-1.6WN Windsurf Teamo233:40 UTF-8f 4 spaces...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
54550
|
1896
|
69
|
2026-05-18T13:15:21.019543+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-18/1779 /Users/lukas/.screenpipe/data/data/2026-05-18/1779110121019_m1.jpg...
|
PhpStorm
|
faVsco.js – Crm/…/Service.php
|
1
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
FirefoxFileEditViewHistoryBookmarksProfilesToolsWi FirefoxFileEditViewHistoryBookmarksProfilesToolsWindowHelp:[Platform) Refinemen….45 m leftmeet.google.com/cxs-eips-npt?authuser=lukas.kovalik%40jiminny.com8• Mon 18 May 16:15:20Nikolay Yankov (Presenting)Mon 18 May 16:16$(18® For you© Recent|# Starred8? Apps07 SpacesSaarco59 Service-DeskJiminny (Oid)Jiminny (New)1 CID Platform TeamCID Capture Team|IID Enterprise Stabllity L.(ID Processing TeamIID SE Kanban|= More spaces= FiltersCB Dashboards6 Operations83 Confluence2: Teamd% Customise sidebarC Ate© ours* CiaoosapoaC Alo3 Ppe0 3mi© 3mi2 SSH• Home | SalestorceDatadog* Claude*xISpaces Jminny (New)Add parent#JY-20679Enable users to use their new activity types• DescriptionCurrently when customers create new activity types in their CRM, we ask them to create a new Playbook in order to fetch them. However now with the activity typeautomacons that we have - this means they need to copy ar or their prompes egan. wnich takes a nuge amourk or üreFor SF, Hubspot, Copper, Bullhorn, Close and Pipedrive (anything apart from Zoho)• when a new activity type is created in the CRM and fetched in Jiminny - add the activity type (in disabled state) to all the playbooks where activity type field ischeneru• if a customer manualy creates an activity type in Jiminny - show them a warning message on the top saying "If you want this activity type to sync to your CRM oneach call - make sure to have it in your CRM with lne exact same name*" - use this https://www.figma.com/design/XcUety9mx5Fiz8KosLAUn/Project-Phoenix2node-id=7483-2600188t-uJOZVbpzYIpC5PAy-1 [Connect your Floma ascount• the message should stay only while they are adding the activity type - il they leave or refresh the page it should disappear. Use the info message that we haveSubtasksAdd subtaskLinked work itemsAdd linked work item~ ActivityWork logRehconmeneSuggest a reply.….Who is working on this…..?Status update.Pro tip: press M to commer1 шr-Z tY-* ProlInsights & CoachinC DevAsk RovoBackiogyIX Improve Story~ DetailsAssignee8 UnassignedAssign to meKUpOrtE& Galya Dimitrova• Quick start developmentLink this work item to your code byincluding keys when creatingabranch, commit, or pull requestDismissycoemher@ Open with VS CodeCreate branch* Create commit)ComponentsPlatformSo-rtoNoneStory point estimatoNikolay YankovNikolay IvanovAneliya AngelovaGalya Dimitrova4:15 PM | [Platform] Refinement ®Lukas Kovalik1:01:48...
|
NULL
|
-1854099937965702864
|
NULL
|
click
|
ocr
|
NULL
|
FirefoxFileEditViewHistoryBookmarksProfilesToolsWi FirefoxFileEditViewHistoryBookmarksProfilesToolsWindowHelp:[Platform) Refinemen….45 m leftmeet.google.com/cxs-eips-npt?authuser=lukas.kovalik%40jiminny.com8• Mon 18 May 16:15:20Nikolay Yankov (Presenting)Mon 18 May 16:16$(18® For you© Recent|# Starred8? Apps07 SpacesSaarco59 Service-DeskJiminny (Oid)Jiminny (New)1 CID Platform TeamCID Capture Team|IID Enterprise Stabllity L.(ID Processing TeamIID SE Kanban|= More spaces= FiltersCB Dashboards6 Operations83 Confluence2: Teamd% Customise sidebarC Ate© ours* CiaoosapoaC Alo3 Ppe0 3mi© 3mi2 SSH• Home | SalestorceDatadog* Claude*xISpaces Jminny (New)Add parent#JY-20679Enable users to use their new activity types• DescriptionCurrently when customers create new activity types in their CRM, we ask them to create a new Playbook in order to fetch them. However now with the activity typeautomacons that we have - this means they need to copy ar or their prompes egan. wnich takes a nuge amourk or üreFor SF, Hubspot, Copper, Bullhorn, Close and Pipedrive (anything apart from Zoho)• when a new activity type is created in the CRM and fetched in Jiminny - add the activity type (in disabled state) to all the playbooks where activity type field ischeneru• if a customer manualy creates an activity type in Jiminny - show them a warning message on the top saying "If you want this activity type to sync to your CRM oneach call - make sure to have it in your CRM with lne exact same name*" - use this https://www.figma.com/design/XcUety9mx5Fiz8KosLAUn/Project-Phoenix2node-id=7483-2600188t-uJOZVbpzYIpC5PAy-1 [Connect your Floma ascount• the message should stay only while they are adding the activity type - il they leave or refresh the page it should disappear. Use the info message that we haveSubtasksAdd subtaskLinked work itemsAdd linked work item~ ActivityWork logRehconmeneSuggest a reply.….Who is working on this…..?Status update.Pro tip: press M to commer1 шr-Z tY-* ProlInsights & CoachinC DevAsk RovoBackiogyIX Improve Story~ DetailsAssignee8 UnassignedAssign to meKUpOrtE& Galya Dimitrova• Quick start developmentLink this work item to your code byincluding keys when creatingabranch, commit, or pull requestDismissycoemher@ Open with VS CodeCreate branch* Create commit)ComponentsPlatformSo-rtoNoneStory point estimatoNikolay YankovNikolay IvanovAneliya AngelovaGalya Dimitrova4:15 PM | [Platform] Refinement ®Lukas Kovalik1:01:48...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
54549
|
1896
|
68
|
2026-05-18T13:15:16.790621+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-18/1779 /Users/lukas/.screenpipe/data/data/2026-05-18/1779110116790_m1.jpg...
|
PhpStorm
|
faVsco.js – Crm/…/Service.php
|
1
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
master, menu
Start Listen Project: faVsco.js, menu
master, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions...
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"master, menu","depth":5,"on_screen":true,"help_text":"Git Branch: master<br/>114 incoming commits<br/>","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"AskJiminnyReportActivityServiceTest","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'AskJiminnyReportActivityServiceTest'","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'AskJiminnyReportActivityServiceTest'","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
-943456739835505562
|
-8636494053633454656
|
visual_change
|
hybrid
|
NULL
|
Project: faVsco.js, menu
master, menu
Start Listen Project: faVsco.js, menu
master, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
FirefoxFileEditViewHistoryBookmarksProfilesToolsWindowHelp:[Platform) Refinemen….45 m leftmeet.google.com/cxs-eips-npt?authuser=lukas.kovalik%40jiminny.com8 • Mon 18 May 16:15:16Nikolay Yankov (Presenting)Mon 18 May 16:16$(18® For you© Recent|# Starred8? Apps0 Spacesaurco(9 Service-DeskJiminny (Oid)Jiminny (New)1 CID Platform TeamCID Capture Team |IID Enterprise Stabllity L.CID Processing TeamIID SE Kanban= More spaces= FitersB Dashboards€ OperationsE3 Confluence2: Teamt% Customise sidebarC Ate© Ours* СаCost© a0pC AlOIPpe0 3m0 smioJimiC Projects|E SSH• Home | SalesforceE Datadog* Claudel*xISearchSpacesJminny (New)Add parentEnable users to use their new activity typesDescriptionCurrently when customers create new activity types in their CRM, we ask them to create a new Playbook in order to fetch them. However now with the activity typeautomatons that we have - this means they need to copy an of their prompes egan. wrich takes à nuge amourk or treFor SF, Hubspot, Copper, Bullhorn, Close and Pipedrive (anything apart from Zoho)• when a new activity type is created in the CRM and fetched in Jiminny - add the activity type (in disabled state) to all the playbooks where activity type field isCheneru• if a customer manualy creates an activity type in Jiminny - show them a warning message on the top saying "If you want this activity type to sync to your CRM oneach call - make sure to have ik in your CRM with lne exact same name*" - use this https://www.figma.com/design/XcUety9mx5Fiz8KosLAUn/Project-Phoenix2node-id=7483-2600188t-uOZVbpzYIpC5PAy-1 Connect your Fioma account• the message should stay only while they are adding the activity type - il they leave or refresh the page it should disappear. Use the info message that we haveSubtasksAdd subtaskLinked work itemsAdd linked work item~ ActivityWork logSuggest a reply.…..Who is working on this…..?Status update.Pro tip: press (M to commentZ tY-insights & CoachinC DevAsk RovoBackiogyIX Improve Story|DetailsAssignee& UnassignedAssign to meKoportet& Galya DimitrovaQuick start developmentLink this work item to your code byincluding keys when creatingabranch, commit, or pull requestDismissycoemet@ Open with VS CodeCreate branch* Create commitlComponentsPlatformSo-rtoNoneStory point estimatoNikolay YankovNikolay IvanovAneliya AngelovaGalya Dimitrova4:15 PM | [Platform] Refinement ®Lukas Kovalik1:01:44...
|
54547
|
NULL
|
NULL
|
NULL
|
|
54548
|
1897
|
36
|
2026-05-18T13:15:14.163149+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-18/1779 /Users/lukas/.screenpipe/data/data/2026-05-18/1779110114163_m2.jpg...
|
PhpStorm
|
faVsco.js – Crm/…/Service.php
|
1
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
PnostormVIewINavicatecodeFV faVsco.js°9 master kPr PnostormVIewINavicatecodeFV faVsco.js°9 master kProiectRinaCentralVideoSalesforceIm Salesloft> D TalkdeskDTeams> D Telus>D Twilio>@ TwilioFlex_ I willorlexDireet• _ willoVidec_Uploader› _ Vonagewxant• 400mZoomBot> ZoomPhoneC) ActivitvcrmFieldsResolver.ohpC) ActivitvLoaService.oho© ActivitvProviderClient.ohp(C) ActivitvProviderRedistrv.oho© ActivityProviderService.php© CallDenormalizerRegistry.phpC) CrmOwnerResolver.oho© DatalmportHandlerInterface.php© MeetingBotService.php© ParticipantConsentService.php© ParticipantsService.phpT PecnonceValidation Trait nhnT SalesforceGetUserTrait.php- S/DenormaliserMainCrmDatalrait.onp@ TrackRecordinoFllesizeservice.one© TrackRecordingSizeEnforcer.phpT ValidateEmitProspectEventTrait.phpC AjReports0 AvatarMcolondar0 Conference0 Crm> C Bullhornv closeOooortunitvsvncStrateav• ProcessonProspectSearchStrateav• M TranslatorC) Client.oho() CloseSxceotion.ohoC) FieldDefinitions.onvc) SieldValueConverter ohn•) Service nhnC) StandardFioldMetadata nhn> MConnen© SoftPhoneManager.php= custom.log= laravel.log4 SF jiminny@localhost]4 HS_local [jiminny@localhost]& console [PROD]& console [EU]C) CoreUserRequest.pnp© CoreUser.phpA console [STAGING]© CoachingFeedbackCoachUserln.php Xstohe© Crm/../Service.php xdeclare(scrict_cypes=l)nnamespace Jiminny Component Activ1tySearch rilterbetin1t1on*class Service extends BaseService 1mpLementsm A8 A39 M5 лV169 @184 (0'189 0 >194 0213222 ©› use ...* ainherxcdocfinal class CoachingFeedbackCoachUserIn extends FilterDefinition implements ValidatedFilterDefinitionInterfacepublic function getDefaultActivityField(string $activityType): Field(...}* oinnerztdocpublic function getsupportedPlavbooklvpeso: arnav-...public function getbealinsightsFields: arrayl...29 Ф >orotected function cetdefault-olLowuolavout-telds/strino sactivitvivoe: arravs...?34 0 >oublic function svncsieldso: void45 đ >Sthis->svncStandardFieldsoSthis->svncCustomFieldso* @important Works only for custom fieldspublic function syncField(Field $field): void=230=131 ;-1 z0$resource = $this->convert0bjectTypeToResource($field->get0bjectType):136 @t›I/ We can only sync custom fields in this CRM.if (Sthis->isCustomField(Sfield->getCrmProviderIdO) === false) {return:146 Gt >-150151 CSormField = Sthis-›getCLient()-›fetchCustomFieldDefinition(Sresource, field-ygetCrmProviderId 156sthas->metadata?rocessor->svnc-1eldscrmF1eLd)sorivate function iscustonsieldstrino Sfieldid: bool-...?* Oinheritdocpublic function importPicklistValues(Field $field): array{...}usadeorivate const int No GROUP 10 = 9993 usagesorivate UserRenository SuserRenository:public function __construct(UserRepository $userRepository)f...hpublic function shouldApplyQueries: boolf...}public function getQueries: FilterDefinitionQueryCollection{...hpublic function toArray: array{...hprivate function getOptions(): array{...}public function getValue: array{...}lprivate function getDefaultValue@: array{...}public function aetValidationRules(2strina Sorefix = null): arravs...?public function aetSortOrder@: intf...}public function shouldBeIncluded(Team $team): bool{...}40m let• мon 10 May 10.10.14cascadeImplement Trial OwneFixing Redis Rate LimSalestorce Token FalliSClM Role Manageme+0 ..inp on LineWARN Metadata found iin doc-comment for method9/ 10 tacke done• Write unit tests for SCIM role management10) Contact Mario to vodate customer10 / 10 tasks doneselm role managementtor leamtallor has been successtully implemented. Here's what was completed:Implementation Summary:1. CoreUserRequest DTO - Added role parsina from SCIM requests2. SCIM Constants - Added META ROLES and USER_PATCH_ATTR_ ROLES constants3. RoleAttr Mutator - Created mutator for handlina role assianments via SCIM PATCH operations4. ScimProvisioning - Updated to handle roles during user creation with proper validation5. ListenerRoleCannotHaveAdminOrManagerPermissionRule - Created validation rule to prevent listener role fromhaving admin/manager permissions6. CoreUser Response - Updated to include roles in SClM responses7. Unit Tests - Created comprehensive tests for the validation ruleKey Features:• Customers can now assign roles via SCIM (recorder, recorder & voice, analyst, listener, admin, manager)• Validation ensures listener role is onlv assianable if the LISTENER ROLE feature flaa is enabled•LIstener role cannot de comolned with aamin or manager permissions• Uses existing UpdateUserRolesAction for role syncing and logging• Follows platform validation rules (DependentRolesRule, ListenerRoleRequiresListenerFeatureRule)Next Step: Please contact Mario to update the customer that SCIM role management is now available for Teamtailorintearationposwtn chaten vapp/Component/SClM/ Constants.php +3app/Component/SCIM/ @ ScimProvisioning.php +85-15nse/ CoreUser.php +21ann/Comnonent/SCIM/Mutatore/Attributes/Ueer/M PoleAttr.nhn +17-ites/User/ ẞ RoleAttrTest.nhn +224* Reiect allAccent alliAsk anvthina (&4-L)« Code SWF-1.6222-50UTE..fo 4 spaces...
|
NULL
|
5254158447170114025
|
NULL
|
click
|
ocr
|
NULL
|
PnostormVIewINavicatecodeFV faVsco.js°9 master kPr PnostormVIewINavicatecodeFV faVsco.js°9 master kProiectRinaCentralVideoSalesforceIm Salesloft> D TalkdeskDTeams> D Telus>D Twilio>@ TwilioFlex_ I willorlexDireet• _ willoVidec_Uploader› _ Vonagewxant• 400mZoomBot> ZoomPhoneC) ActivitvcrmFieldsResolver.ohpC) ActivitvLoaService.oho© ActivitvProviderClient.ohp(C) ActivitvProviderRedistrv.oho© ActivityProviderService.php© CallDenormalizerRegistry.phpC) CrmOwnerResolver.oho© DatalmportHandlerInterface.php© MeetingBotService.php© ParticipantConsentService.php© ParticipantsService.phpT PecnonceValidation Trait nhnT SalesforceGetUserTrait.php- S/DenormaliserMainCrmDatalrait.onp@ TrackRecordinoFllesizeservice.one© TrackRecordingSizeEnforcer.phpT ValidateEmitProspectEventTrait.phpC AjReports0 AvatarMcolondar0 Conference0 Crm> C Bullhornv closeOooortunitvsvncStrateav• ProcessonProspectSearchStrateav• M TranslatorC) Client.oho() CloseSxceotion.ohoC) FieldDefinitions.onvc) SieldValueConverter ohn•) Service nhnC) StandardFioldMetadata nhn> MConnen© SoftPhoneManager.php= custom.log= laravel.log4 SF jiminny@localhost]4 HS_local [jiminny@localhost]& console [PROD]& console [EU]C) CoreUserRequest.pnp© CoreUser.phpA console [STAGING]© CoachingFeedbackCoachUserln.php Xstohe© Crm/../Service.php xdeclare(scrict_cypes=l)nnamespace Jiminny Component Activ1tySearch rilterbetin1t1on*class Service extends BaseService 1mpLementsm A8 A39 M5 лV169 @184 (0'189 0 >194 0213222 ©› use ...* ainherxcdocfinal class CoachingFeedbackCoachUserIn extends FilterDefinition implements ValidatedFilterDefinitionInterfacepublic function getDefaultActivityField(string $activityType): Field(...}* oinnerztdocpublic function getsupportedPlavbooklvpeso: arnav-...public function getbealinsightsFields: arrayl...29 Ф >orotected function cetdefault-olLowuolavout-telds/strino sactivitvivoe: arravs...?34 0 >oublic function svncsieldso: void45 đ >Sthis->svncStandardFieldsoSthis->svncCustomFieldso* @important Works only for custom fieldspublic function syncField(Field $field): void=230=131 ;-1 z0$resource = $this->convert0bjectTypeToResource($field->get0bjectType):136 @t›I/ We can only sync custom fields in this CRM.if (Sthis->isCustomField(Sfield->getCrmProviderIdO) === false) {return:146 Gt >-150151 CSormField = Sthis-›getCLient()-›fetchCustomFieldDefinition(Sresource, field-ygetCrmProviderId 156sthas->metadata?rocessor->svnc-1eldscrmF1eLd)sorivate function iscustonsieldstrino Sfieldid: bool-...?* Oinheritdocpublic function importPicklistValues(Field $field): array{...}usadeorivate const int No GROUP 10 = 9993 usagesorivate UserRenository SuserRenository:public function __construct(UserRepository $userRepository)f...hpublic function shouldApplyQueries: boolf...}public function getQueries: FilterDefinitionQueryCollection{...hpublic function toArray: array{...hprivate function getOptions(): array{...}public function getValue: array{...}lprivate function getDefaultValue@: array{...}public function aetValidationRules(2strina Sorefix = null): arravs...?public function aetSortOrder@: intf...}public function shouldBeIncluded(Team $team): bool{...}40m let• мon 10 May 10.10.14cascadeImplement Trial OwneFixing Redis Rate LimSalestorce Token FalliSClM Role Manageme+0 ..inp on LineWARN Metadata found iin doc-comment for method9/ 10 tacke done• Write unit tests for SCIM role management10) Contact Mario to vodate customer10 / 10 tasks doneselm role managementtor leamtallor has been successtully implemented. Here's what was completed:Implementation Summary:1. CoreUserRequest DTO - Added role parsina from SCIM requests2. SCIM Constants - Added META ROLES and USER_PATCH_ATTR_ ROLES constants3. RoleAttr Mutator - Created mutator for handlina role assianments via SCIM PATCH operations4. ScimProvisioning - Updated to handle roles during user creation with proper validation5. ListenerRoleCannotHaveAdminOrManagerPermissionRule - Created validation rule to prevent listener role fromhaving admin/manager permissions6. CoreUser Response - Updated to include roles in SClM responses7. Unit Tests - Created comprehensive tests for the validation ruleKey Features:• Customers can now assign roles via SCIM (recorder, recorder & voice, analyst, listener, admin, manager)• Validation ensures listener role is onlv assianable if the LISTENER ROLE feature flaa is enabled•LIstener role cannot de comolned with aamin or manager permissions• Uses existing UpdateUserRolesAction for role syncing and logging• Follows platform validation rules (DependentRolesRule, ListenerRoleRequiresListenerFeatureRule)Next Step: Please contact Mario to update the customer that SCIM role management is now available for Teamtailorintearationposwtn chaten vapp/Component/SClM/ Constants.php +3app/Component/SCIM/ @ ScimProvisioning.php +85-15nse/ CoreUser.php +21ann/Comnonent/SCIM/Mutatore/Attributes/Ueer/M PoleAttr.nhn +17-ites/User/ ẞ RoleAttrTest.nhn +224* Reiect allAccent alliAsk anvthina (&4-L)« Code SWF-1.6222-50UTE..fo 4 spaces...
|
54545
|
NULL
|
NULL
|
NULL
|
|
54547
|
1896
|
67
|
2026-05-18T13:15:14.162792+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-18/1779 /Users/lukas/.screenpipe/data/data/2026-05-18/1779110114162_m1.jpg...
|
PhpStorm
|
faVsco.js – Crm/…/Service.php
|
1
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
FirefoxFileEditViewHistoryBookmarksProfilesToolsWi FirefoxFileEditViewHistoryBookmarksProfilesToolsWindowHelp> 0.§ [Platform] Refinemen... 45 m left100% <78• Mon 18 May 16:15:14meet.google.com/cxs-eips-npt?authuser=lukas.kovalik%40jiminny.com=Nikolay Yankov (Presenting)+Mon 18 May 16:16$*(18® For you© Recent|# Starred8? Apps0 Spacesaurco(9 Service-DeskJiminny (Oid)Jiminny (New)1 CID Platform TeamCID Capture Team|IID Enterprise Stabllity L.(ID Processing TeamIID SE Kanban= More spaces=FiltersCB Dashboards® OperationsE3 Confluence3: Team-% Customise sidebarC Atte© Ours* CіauCos:© a0pC AlOIPipe0 miZ UY-C Projects|E3 SSH• Home | Salestorce00 8.Datadog*xInsignts & Coachn.C DevSearch+ CreateAsk RovoSpacesJminny (New)Add paremEnable users to use their new activity typesBackiogyIX Improve Story|Details• DescriptionCurrently when customers create new activity types in their CRM, we ask them to create a new Playbook in order to fetch them. However now with the activity typeautomacons that we have - this means they need to copy an of their prompes egan. wnich takes à nuge amourk or üreFor SF, Hubspot, Copper, Bullhorn, Close and Pipedrive (anything apart from Zoho)• when a new activity type is created in the CRM and fetched in Jiminny - add the activity type (in disabled state) to all the playbooks where activity type field isChenesu• if a customer manualy creates an activity type in Jiminny - show them a warning message on the top saying "If you want this activity type to sync to your CRM onesch call - make sure to have it in your CRM with dhe exact same name" - use this F https://www.figma.com/design/XcUe1y9mxSFiz8KosLAUn/Project-PhoenbCnode-id=7483-2600188t-uJOZVbpzYIpC5PAy-1 [Connect your Fioma ascount• the message should stay only while they are adding the activity type - il they leave or refresh the page it should disappear. Use the info message that we haveAssignee& UnassignedAssign to meKUpоrtE& Galya DimitrovaQuick start developmentLink this work item to your code byincluding keys when creating abranch, commit, or pull requestSubtasksDismissycoemenAdd sudtasxQ Open with VS CodeLinked work itemsCreate branchAdd linked work item* Create commitl~ ActivityComponentsPlatformWork logSuggest a reply.….Who is working on this...?Status update.Pro tip: press (M to commentNoneStory point estimatoNikolay YankovNikolay IvanovAneliya Angelova•Galya Dimitrova4:15 PM | [Platform] Refinement ®Lukas Kovalik1:01:41...
|
NULL
|
-8860704730790595634
|
NULL
|
click
|
ocr
|
NULL
|
FirefoxFileEditViewHistoryBookmarksProfilesToolsWi FirefoxFileEditViewHistoryBookmarksProfilesToolsWindowHelp> 0.§ [Platform] Refinemen... 45 m left100% <78• Mon 18 May 16:15:14meet.google.com/cxs-eips-npt?authuser=lukas.kovalik%40jiminny.com=Nikolay Yankov (Presenting)+Mon 18 May 16:16$*(18® For you© Recent|# Starred8? Apps0 Spacesaurco(9 Service-DeskJiminny (Oid)Jiminny (New)1 CID Platform TeamCID Capture Team|IID Enterprise Stabllity L.(ID Processing TeamIID SE Kanban= More spaces=FiltersCB Dashboards® OperationsE3 Confluence3: Team-% Customise sidebarC Atte© Ours* CіauCos:© a0pC AlOIPipe0 miZ UY-C Projects|E3 SSH• Home | Salestorce00 8.Datadog*xInsignts & Coachn.C DevSearch+ CreateAsk RovoSpacesJminny (New)Add paremEnable users to use their new activity typesBackiogyIX Improve Story|Details• DescriptionCurrently when customers create new activity types in their CRM, we ask them to create a new Playbook in order to fetch them. However now with the activity typeautomacons that we have - this means they need to copy an of their prompes egan. wnich takes à nuge amourk or üreFor SF, Hubspot, Copper, Bullhorn, Close and Pipedrive (anything apart from Zoho)• when a new activity type is created in the CRM and fetched in Jiminny - add the activity type (in disabled state) to all the playbooks where activity type field isChenesu• if a customer manualy creates an activity type in Jiminny - show them a warning message on the top saying "If you want this activity type to sync to your CRM onesch call - make sure to have it in your CRM with dhe exact same name" - use this F https://www.figma.com/design/XcUe1y9mxSFiz8KosLAUn/Project-PhoenbCnode-id=7483-2600188t-uJOZVbpzYIpC5PAy-1 [Connect your Fioma ascount• the message should stay only while they are adding the activity type - il they leave or refresh the page it should disappear. Use the info message that we haveAssignee& UnassignedAssign to meKUpоrtE& Galya DimitrovaQuick start developmentLink this work item to your code byincluding keys when creating abranch, commit, or pull requestSubtasksDismissycoemenAdd sudtasxQ Open with VS CodeLinked work itemsCreate branchAdd linked work item* Create commitl~ ActivityComponentsPlatformWork logSuggest a reply.….Who is working on this...?Status update.Pro tip: press (M to commentNoneStory point estimatoNikolay YankovNikolay IvanovAneliya Angelova•Galya Dimitrova4:15 PM | [Platform] Refinement ®Lukas Kovalik1:01:41...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
54543
|
1896
|
64
|
2026-05-18T13:14:54.050099+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-18/1779 /Users/lukas/.screenpipe/data/data/2026-05-18/1779110094050_m1.jpg...
|
PhpStorm
|
faVsco.js – Crm/…/Service.php
|
1
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
master, menu
Start Listen Project: faVsco.js, menu
master, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
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\OpportunityMetadata;
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(OpportunityMetadata $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());
}
/** @var Profile $profile */
$profile = $this->getConfiguration()
->profiles()
->where('crm_provider_id', $crmData->getUserId())
->first();
$userId = $profile?->getUserId() ?? $account?->getUserId();
if ($userId === null) {
$this->logger->error('[Close] | Skip import, no user_id found', [
'id' => $crmData->getId(),
]);
return null;
}
$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(),
]);
...
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"master, menu","depth":5,"on_screen":true,"help_text":"Git Branch: master<br/>114 incoming commits<br/>","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"AskJiminnyReportActivityServiceTest","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'AskJiminnyReportActivityServiceTest'","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'AskJiminnyReportActivityServiceTest'","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"IDE and Project Settings","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"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,"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\\OpportunityMetadata;\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(OpportunityMetadata $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 if ($account === null) {\n $account = $this->accountProcessor->syncAccount($crmData->getLeadId());\n }\n\n /** @var Profile $profile */\n $profile = $this->getConfiguration()\n ->profiles()\n ->where('crm_provider_id', $crmData->getUserId())\n ->first();\n\n $userId = $profile?->getUserId() ?? $account?->getUserId();\n if ($userId === null) {\n $this->logger->error('[Close] | Skip import, no user_id found', [\n 'id' => $crmData->getId(),\n ]);\n\n return null;\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 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\\OpportunityMetadata;\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(OpportunityMetadata $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 if ($account === null) {\n $account = $this->accountProcessor->syncAccount($crmData->getLeadId());\n }\n\n /** @var Profile $profile */\n $profile = $this->getConfiguration()\n ->profiles()\n ->where('crm_provider_id', $crmData->getUserId())\n ->first();\n\n $userId = $profile?->getUserId() ?? $account?->getUserId();\n if ($userId === null) {\n $this->logger->error('[Close] | Skip import, no user_id found', [\n 'id' => $crmData->getId(),\n ]);\n\n return null;\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 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":"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":"1","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\ndeclare(strict_types=1);\n\nnamespace Jiminny\\Component\\ActivitySearch\\FilterDefinition;\n\nuse Illuminate\\Support\\Collection;\nuse Jiminny\\Component\\ActivitySearch\\FilterDefinition;\nuse Jiminny\\Component\\ActivitySearch\\FilterDefinitionQuery;\nuse Jiminny\\Component\\ActivitySearch\\FilterDefinitionQueryCollection;\nuse Jiminny\\Contracts\\ActivitySearch\\ValidatedFilterDefinitionInterface;\nuse Jiminny\\Contracts\\Repositories\\UserRepository;\nuse Jiminny\\Models;\nuse Jiminny\\Models\\Feature\\FeatureEnum;\nuse Jiminny\\Models\\Team;\nuse Jiminny\\Traits\\RequiresUUID;\nuse Elastica\\Query;\n\nfinal class CoachingFeedbackCoachUserIn extends FilterDefinition implements ValidatedFilterDefinitionInterface\n{\n private const int NO_GROUP_ID = 999;\n private UserRepository $userRepository;\n\n public function __construct(UserRepository $userRepository)\n {\n $this->userRepository = $userRepository;\n }\n\n public function shouldApplyQueries(): bool\n {\n return count($this->getValue()) > 0;\n }\n\n public function getQueries(): FilterDefinitionQueryCollection\n {\n $userIds = $this->getValue();\n\n return FilterDefinitionQueryCollection::make([\n FilterDefinitionQuery::instance()\n ->setQuery(new Query\\Terms('coachingFeedbacks.coach.id_string', $userIds))\n ->setPath('coachingFeedbacks.coach', 'coachingFeedbacks'),\n ]);\n }\n\n public function toArray(): array\n {\n return [\n 'id' => 'coaching-feedback-user-filter',\n 'label' => 'Coach',\n 'helpText' => 'The person who completed the Coaching Framework',\n 'placeholder' => 'Search coaches',\n 'inputType' => InputTypeEnum::DROPDOWN_MULTIPLE,\n 'queryParam' => 'coaching_feedback_coach_id',\n 'options' => $this->getOptions(),\n 'groupLabelKey' => 'label',\n 'groupValuesKey' => 'users',\n 'optionLabelKey' => 'name',\n 'optionValueKey' => 'id',\n 'value' => $this->getValue(),\n ];\n }\n\n private function getOptions(): array\n {\n $user = $this->executedBy;\n $query = $user\n ->getTeam()\n ->users()\n ->whereNotIn(\n 'uuid',\n $this->userRepository->findInaccessibleDeactivatedUserUuids($user)\n ->map(static fn (string $uuid): string => RequiresUUID::toOptimized($uuid))\n ->all(),\n )\n ->orderBy('name');\n\n $users = $query\n ->with('group')\n ->get()\n ->reduce(\n static function (array $carry, Models\\User $user): array {\n $group = $user->getGroup();\n $groupId = $group !== null\n ? $group->getUuid()\n : self::NO_GROUP_ID;\n $groupName = $group !== null\n ? $group->getName()\n : '(NON-GROUPED)';\n\n if (! array_key_exists($groupId, $carry)) {\n $carry[$groupId] = [\n 'label' => $groupName,\n 'users' => [],\n ];\n }\n\n $carry[$groupId]['users'][] = [\n 'id' => $user->getUuid(),\n 'name' => sprintf(\n '%s%s',\n $user->getName(),\n ! $user->isStatusActive()\n ? ' (Inactive)'\n : ''\n ),\n ];\n\n return $carry;\n },\n []\n );\n\n return Collection::make($users)\n ->sortBy('label')\n ->values()\n ->toArray();\n }\n\n public function getValue(): array\n {\n if (! $this->criteria->hasCoachingFeedbackCoachUserIds()) {\n return $this->getDefaultValue();\n }\n\n return array_diff(\n $this->criteria->getCoachingFeedbackCoachUserId(),\n $this->userRepository->findInaccessibleDeactivatedUserUuids($this->executedBy)->all(),\n );\n }\n\n private function getDefaultValue(): array\n {\n return [];\n }\n\n public function getValidationRules(?string $prefix = null): array\n {\n $team = $this->executedBy->getTeam();\n\n return [\n $prefix . 'coaching_feedback_coach_id' => 'array',\n $prefix . 'coaching_feedback_coach_id.*' => 'uuid:users,team_id,' . $team->getId(),\n ];\n }\n\n public function getSortOrder(): int\n {\n return 86;\n }\n\n public function shouldBeIncluded(Team $team): bool\n {\n return $team->hasFeature(FeatureEnum::COACHING_FRAMEWORK);\n }\n}","depth":4,"on_screen":true,"value":"<?php\n\ndeclare(strict_types=1);\n\nnamespace Jiminny\\Component\\ActivitySearch\\FilterDefinition;\n\nuse Illuminate\\Support\\Collection;\nuse Jiminny\\Component\\ActivitySearch\\FilterDefinition;\nuse Jiminny\\Component\\ActivitySearch\\FilterDefinitionQuery;\nuse Jiminny\\Component\\ActivitySearch\\FilterDefinitionQueryCollection;\nuse Jiminny\\Contracts\\ActivitySearch\\ValidatedFilterDefinitionInterface;\nuse Jiminny\\Contracts\\Repositories\\UserRepository;\nuse Jiminny\\Models;\nuse Jiminny\\Models\\Feature\\FeatureEnum;\nuse Jiminny\\Models\\Team;\nuse Jiminny\\Traits\\RequiresUUID;\nuse Elastica\\Query;\n\nfinal class CoachingFeedbackCoachUserIn extends FilterDefinition implements ValidatedFilterDefinitionInterface\n{\n private const int NO_GROUP_ID = 999;\n private UserRepository $userRepository;\n\n public function __construct(UserRepository $userRepository)\n {\n $this->userRepository = $userRepository;\n }\n\n public function shouldApplyQueries(): bool\n {\n return count($this->getValue()) > 0;\n }\n\n public function getQueries(): FilterDefinitionQueryCollection\n {\n $userIds = $this->getValue();\n\n return FilterDefinitionQueryCollection::make([\n FilterDefinitionQuery::instance()\n ->setQuery(new Query\\Terms('coachingFeedbacks.coach.id_string', $userIds))\n ->setPath('coachingFeedbacks.coach', 'coachingFeedbacks'),\n ]);\n }\n\n public function toArray(): array\n {\n return [\n 'id' => 'coaching-feedback-user-filter',\n 'label' => 'Coach',\n 'helpText' => 'The person who completed the Coaching Framework',\n 'placeholder' => 'Search coaches',\n 'inputType' => InputTypeEnum::DROPDOWN_MULTIPLE,\n 'queryParam' => 'coaching_feedback_coach_id',\n 'options' => $this->getOptions(),\n 'groupLabelKey' => 'label',\n 'groupValuesKey' => 'users',\n 'optionLabelKey' => 'name',\n 'optionValueKey' => 'id',\n 'value' => $this->getValue(),\n ];\n }\n\n private function getOptions(): array\n {\n $user = $this->executedBy;\n $query = $user\n ->getTeam()\n ->users()\n ->whereNotIn(\n 'uuid',\n $this->userRepository->findInaccessibleDeactivatedUserUuids($user)\n ->map(static fn (string $uuid): string => RequiresUUID::toOptimized($uuid))\n ->all(),\n )\n ->orderBy('name');\n\n $users = $query\n ->with('group')\n ->get()\n ->reduce(\n static function (array $carry, Models\\User $user): array {\n $group = $user->getGroup();\n $groupId = $group !== null\n ? $group->getUuid()\n : self::NO_GROUP_ID;\n $groupName = $group !== null\n ? $group->getName()\n : '(NON-GROUPED)';\n\n if (! array_key_exists($groupId, $carry)) {\n $carry[$groupId] = [\n 'label' => $groupName,\n 'users' => [],\n ];\n }\n\n $carry[$groupId]['users'][] = [\n 'id' => $user->getUuid(),\n 'name' => sprintf(\n '%s%s',\n $user->getName(),\n ! $user->isStatusActive()\n ? ' (Inactive)'\n : ''\n ),\n ];\n\n return $carry;\n },\n []\n );\n\n return Collection::make($users)\n ->sortBy('label')\n ->values()\n ->toArray();\n }\n\n public function getValue(): array\n {\n if (! $this->criteria->hasCoachingFeedbackCoachUserIds()) {\n return $this->getDefaultValue();\n }\n\n return array_diff(\n $this->criteria->getCoachingFeedbackCoachUserId(),\n $this->userRepository->findInaccessibleDeactivatedUserUuids($this->executedBy)->all(),\n );\n }\n\n private function getDefaultValue(): array\n {\n return [];\n }\n\n public function getValidationRules(?string $prefix = null): array\n {\n $team = $this->executedBy->getTeam();\n\n return [\n $prefix . 'coaching_feedback_coach_id' => 'array',\n $prefix . 'coaching_feedback_coach_id.*' => 'uuid:users,team_id,' . $team->getId(),\n ];\n }\n\n public function getSortOrder(): int\n {\n return 86;\n }\n\n public function shouldBeIncluded(Team $team): bool\n {\n return $team->hasFeature(FeatureEnum::COACHING_FRAMEWORK);\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Project","depth":3,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Project","depth":3,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"New File or Directory…","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Expand Selected","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Collapse All","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Options","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
-6754415607117048428
|
-9030663327281178587
|
visual_change
|
accessibility
|
NULL
|
Project: faVsco.js, menu
master, menu
Start Listen Project: faVsco.js, menu
master, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
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\OpportunityMetadata;
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(OpportunityMetadata $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());
}
/** @var Profile $profile */
$profile = $this->getConfiguration()
->profiles()
->where('crm_provider_id', $crmData->getUserId())
->first();
$userId = $profile?->getUserId() ?? $account?->getUserId();
if ($userId === null) {
$this->logger->error('[Close] | Skip import, no user_id found', [
'id' => $crmData->getId(),
]);
return null;
}
$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(),
]);
...
|
54542
|
NULL
|
NULL
|
NULL
|
|
54542
|
1896
|
63
|
2026-05-18T13:14:44.470451+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-18/1779 /Users/lukas/.screenpipe/data/data/2026-05-18/1779110084470_m1.jpg...
|
PhpStorm
|
faVsco.js – Crm/…/Service.php
|
1
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
FirefoxFileEditViewHistoryBookmarksProfilesToolsWi FirefoxFileEditViewHistoryBookmarksProfilesToolsWindowHelp[Platform) Refinemen…46 m lmeet.google.com/cxs-eips-npt?authuser=lukas.kovalik%40jiminny.comNikolay Yankov (Presenting)• Mon 18 May 16:14:436Mon 18 May 16:14**(18® For you© Recent|# Starred8? Apps07 SpacesSaarco(9 Service-DeskJiminny (Oid)Jiminny (New)+--I CD Platform TeamIID Capture Team|IID Enterprise Stabllity L.(ID Processing TeamCID SE Kanban= More spaces=Filters(B Dashboards® OperationsE3 Confluence3: Team-% Customise sidebarC Atte* 0%CossaoouAlolPipe© Jmi |oJiml23 SSH• Home | Salestorce* ClaudeSentry*xI+ CreateSpacesJminty (New)Add parent#JY-20679Enable users to use their new activity types• DescriptionCurrently when customers create new activity types in their CRM, we ask them to create a new Playbook in order to fetch them. However now with the activity typeautomacons that we have - this means they need to copy ar or their prompes egan. wnich takes a nuge amourk or üreFor SF, Hubspot, Copper, Bullhorn, Close and Pipedrive (anything apart from Zoho)• when a new activity type is created in the CRM and fetched in Jiminny - add the activity type (in disabled state) to all the playbooks where activity type field isCheterd• if a customer manualy creates an activity type in Jiminny - show them a warning message on the top saying "If you want this activity type to sync to your CRM oneach call - make sure to have i& in your CRM with the exact same name* - use this F https://www.figma.com/design/XcUety9mxSFiz8KosLAUn/Project-Phoenb?node-id=7483-2600188t-uJOZVbpzYIpC5PAy-1 [Connect your Fioma account• the message should stay only while they are adding the activity type - Il they leave or refresh the page it should disappear. Use the info message that we haveSubtasksAdd subtaskLinked work itemsAdd linked work item~ ActivityWork logSuggest a reply.….Who is working on this...?Status update…Pro tip: press (M to comment7 tY-F ProlInsights & CoachinC DevAsk RovoBackiogIX Improve Story~ DetailsAssignee8 UnassignedAssign to meKUpOrtE& Galya Dimitrova• Quick start developmentLink this work item to your code byincluding keys when creating abranch, commit, or pull requestDismissyeoemer@ Open with VS CodeCreate branch* Create commit)ComponentsPlatformSo-rtoNomkStory point estimatoNikolay YankovNikolay IvanovAneliya Angelovaalya DimitrovaLukas Kovalik1:01:114:14 PM | [Platform] Refinement ®Lộ3...
|
NULL
|
5614435375578836636
|
NULL
|
visual_change
|
ocr
|
NULL
|
FirefoxFileEditViewHistoryBookmarksProfilesToolsWi FirefoxFileEditViewHistoryBookmarksProfilesToolsWindowHelp[Platform) Refinemen…46 m lmeet.google.com/cxs-eips-npt?authuser=lukas.kovalik%40jiminny.comNikolay Yankov (Presenting)• Mon 18 May 16:14:436Mon 18 May 16:14**(18® For you© Recent|# Starred8? Apps07 SpacesSaarco(9 Service-DeskJiminny (Oid)Jiminny (New)+--I CD Platform TeamIID Capture Team|IID Enterprise Stabllity L.(ID Processing TeamCID SE Kanban= More spaces=Filters(B Dashboards® OperationsE3 Confluence3: Team-% Customise sidebarC Atte* 0%CossaoouAlolPipe© Jmi |oJiml23 SSH• Home | Salestorce* ClaudeSentry*xI+ CreateSpacesJminty (New)Add parent#JY-20679Enable users to use their new activity types• DescriptionCurrently when customers create new activity types in their CRM, we ask them to create a new Playbook in order to fetch them. However now with the activity typeautomacons that we have - this means they need to copy ar or their prompes egan. wnich takes a nuge amourk or üreFor SF, Hubspot, Copper, Bullhorn, Close and Pipedrive (anything apart from Zoho)• when a new activity type is created in the CRM and fetched in Jiminny - add the activity type (in disabled state) to all the playbooks where activity type field isCheterd• if a customer manualy creates an activity type in Jiminny - show them a warning message on the top saying "If you want this activity type to sync to your CRM oneach call - make sure to have i& in your CRM with the exact same name* - use this F https://www.figma.com/design/XcUety9mxSFiz8KosLAUn/Project-Phoenb?node-id=7483-2600188t-uJOZVbpzYIpC5PAy-1 [Connect your Fioma account• the message should stay only while they are adding the activity type - Il they leave or refresh the page it should disappear. Use the info message that we haveSubtasksAdd subtaskLinked work itemsAdd linked work item~ ActivityWork logSuggest a reply.….Who is working on this...?Status update…Pro tip: press (M to comment7 tY-F ProlInsights & CoachinC DevAsk RovoBackiogIX Improve Story~ DetailsAssignee8 UnassignedAssign to meKUpOrtE& Galya Dimitrova• Quick start developmentLink this work item to your code byincluding keys when creating abranch, commit, or pull requestDismissyeoemer@ Open with VS CodeCreate branch* Create commit)ComponentsPlatformSo-rtoNomkStory point estimatoNikolay YankovNikolay IvanovAneliya Angelovaalya DimitrovaLukas Kovalik1:01:114:14 PM | [Platform] Refinement ®Lộ3...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
54541
|
1897
|
34
|
2026-05-18T13:14:41.469676+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-18/1779 /Users/lukas/.screenpipe/data/data/2026-05-18/1779110081469_m2.jpg...
|
PhpStorm
|
faVsco.js – Crm/…/Service.php
|
1
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
PnostormVIewINavicatecodeFV faVsco.js°9 master kPr PnostormVIewINavicatecodeFV faVsco.js°9 master kProiect vRinaCentralVideo• SalesforceIm Salesloft> D Talkdeskureams> D Telus>D Twilio_ IWillorlex_ IwilorlexDirect• _ I willoVideo_Uploader› _ VonagewxantDZ00ПZoomBot> ZoomPhoneC) ActivitvcrmFieldsResolver.ohpC) ActivitvLoaService.oho© ActivitvProviderClient.ohp(C) ActivitvProviderRedistrv.oho© ActivityProviderService.php© CallDenormalizerRegistry.phpC) [EMAIL]© DatalmportHandlerInterface.php© MeetingBotService.php© ParticipantConsentService.php(c) DarticinanteService nhnT PecnonceValidation Trait nhnT SalesforceGetUserTrait.php- S/DenormaliserMainCrmDatalrait.onp@ TrackRecordinoFllesizeservice.one(c) TrackRecordinaSizeEntorcer.onpT ValidateEmitProspectEventTrait.phpC AjReports0 AvatarMcolondar0 Conference0 Crm> C Bullhornv closeOnoortunitvsvncstrateav• ProcessonProspectSearchStrateav• M TranslatorC) Client.oho) CloseExcertion.ohoC) FieldDefinitions.onvc) SieldValueConverter ohn•) Service nhnC) StandardFioldMetadata nhn> MConnen© SoftPhoneManager.phpC) CoreUserRequest.onp© CoreUser.php© Crm/../Service.php xclass Service extends BaseService 1mpLementsm A8 A39 M5 лV169 @184 0189 0 >194 0213 0222 0>244 6t>295 (б1* ainherxcdocpublic function getDefaultActivityField(string $activityType): Field{...}* oinnerztdocpublic function getsupportedPlavbooklvpeso: arnav-...public function getbealinsightsFields: arrayl...protected function getbefaultFolLowupLayoutFields(string sactivitylype): arrayi...public function syncFields: voidSthis->svncStandardFieldsoSthis->svncCustomFieldso* @important Works only for custom fieldspublic function syncField(Field $field): void{...}Zusagesprivate function isCustomField(string $fieldId): bool{...}* Oinheritdocpublic function importPicklistValues(Field Sfield): array{...}l* oimoortant "e ontu suooort stades on the obdortunitu obtecti* dparam strinalnul Stuoespublie function importStages(Parnay Stvnes = null. Istrina SmissinaStageName = null)e eStager....-* Oinheritdocpublic function syncLeads(Carbon Ssince, ?Carbon $to = null, ?string $crmProfileId = null): int‹..= custom.log= laravel.log« SF jiminny@localhost]4 HS_local [jiminny@localhost]& console [PROD]& console [EU]A console [STAGING]© CoachingFeedbackCoachUserln.php Xstohedeclare(scrict_cypes=l)nnamespace Jiminny Component Activ1tySearch rilterbetin1t1on*› use ...final class CoachingFeedbackCoachUserIn extends FilterDefinition implements ValidatedFilterDefinitionInterface29 Ф >34 0 >45 0г >'118-131?135136 @t›— 145-1460)150151 G>usadeorivate const int No GROUP 10 = 999orivate UserRenository suserRenository:public function __construct(UserRepository $userRepository)f...hpublic function shouldApplyQueries: boolf...}public function getQueries: FilterDefinitionQueryCollection{...hpublic function toArray: array{...hprivate function getOptions(): array{...}public function getValue®: array{...}private function getDefaultValue: array{...}public function aetValidationRules(2strina Sorefix = null): arrav{...?lpublic function aetSortOrder@: intf...}public function shouldBeIncluded(Team $team): bool{...}40m lel• мon 10 Mаy 10.14.40cascadeImplement Trial OwneFixing Redis Rate LimSalestorce Token FalliSClM Role Manageme+0 ..inp on LineWARN Metadata found iin doc-comment for method9/ 10 tacke done• Write unit tests for SCIM role management10) Contact Mario to vodate customer10 / 10 tasks doneselm role managementtor leamtallor has been successtully implemented. Here's what was completed:Implementation Summary:1. CoreUserRequest DTO - Added role parsina from SCIM requests2. SCIM Constants - Added META ROLES and USER_PATCH_ATTR_ ROLES constants3. RoleAttr Mutator - Created mutator for handlina role assianments via SCIM PATCH operations4. ScimProvisioning - Updated to handle roles during user creation with proper validation5. ListenerRoleCannotHaveAdminOrManagerPermissionRule - Created validation rule to prevent listener role fromhaving admin/manager permissions6. CoreUser Response - Updated to include roles in SClM responses7. Unit Tests - Created comprehensive tests for the validation ruleKey Features:• Customers can now assign roles via SCIM (recorder, recorder & voice, analyst, listener, admin, manager)• Validation ensures listener role is onlv assianable if the LISTENER ROLE feature flaa is enabledl•LIstener role cannot de comolned with aamin or manager permissions• Uses existing UpdateUserRolesAction for role syncing and logging• Follows platform validation rules (DependentRolesRule, ListenerRoleRequiresListenerFeatureRule)Next Step: Please contact Mario to update the customer that SCIM role management is now available for Teamtailorintearationposwtn chaten vapp/Component/SClM/ Constants.php +3app/Component/SCIM/ @ ScimProvisioning.php +85-15nse/ CoreUser.php +21ann/Comnonent/SCIM/Mutatore/Attributes/Ueer/M PoleAttr.nhn +17-ites/User/ ẞ RoleAttrTest.nhn +224* Reiect allAccent alliAsk anvthina (&4-L)« Code SWF-1.6W Windsurf Teams 212-21UTF.Afo 4 spaces...
|
NULL
|
-6231794697482593788
|
NULL
|
click
|
ocr
|
NULL
|
PnostormVIewINavicatecodeFV faVsco.js°9 master kPr PnostormVIewINavicatecodeFV faVsco.js°9 master kProiect vRinaCentralVideo• SalesforceIm Salesloft> D Talkdeskureams> D Telus>D Twilio_ IWillorlex_ IwilorlexDirect• _ I willoVideo_Uploader› _ VonagewxantDZ00ПZoomBot> ZoomPhoneC) ActivitvcrmFieldsResolver.ohpC) ActivitvLoaService.oho© ActivitvProviderClient.ohp(C) ActivitvProviderRedistrv.oho© ActivityProviderService.php© CallDenormalizerRegistry.phpC) [EMAIL]© DatalmportHandlerInterface.php© MeetingBotService.php© ParticipantConsentService.php(c) DarticinanteService nhnT PecnonceValidation Trait nhnT SalesforceGetUserTrait.php- S/DenormaliserMainCrmDatalrait.onp@ TrackRecordinoFllesizeservice.one(c) TrackRecordinaSizeEntorcer.onpT ValidateEmitProspectEventTrait.phpC AjReports0 AvatarMcolondar0 Conference0 Crm> C Bullhornv closeOnoortunitvsvncstrateav• ProcessonProspectSearchStrateav• M TranslatorC) Client.oho) CloseExcertion.ohoC) FieldDefinitions.onvc) SieldValueConverter ohn•) Service nhnC) StandardFioldMetadata nhn> MConnen© SoftPhoneManager.phpC) CoreUserRequest.onp© CoreUser.php© Crm/../Service.php xclass Service extends BaseService 1mpLementsm A8 A39 M5 лV169 @184 0189 0 >194 0213 0222 0>244 6t>295 (б1* ainherxcdocpublic function getDefaultActivityField(string $activityType): Field{...}* oinnerztdocpublic function getsupportedPlavbooklvpeso: arnav-...public function getbealinsightsFields: arrayl...protected function getbefaultFolLowupLayoutFields(string sactivitylype): arrayi...public function syncFields: voidSthis->svncStandardFieldsoSthis->svncCustomFieldso* @important Works only for custom fieldspublic function syncField(Field $field): void{...}Zusagesprivate function isCustomField(string $fieldId): bool{...}* Oinheritdocpublic function importPicklistValues(Field Sfield): array{...}l* oimoortant "e ontu suooort stades on the obdortunitu obtecti* dparam strinalnul Stuoespublie function importStages(Parnay Stvnes = null. Istrina SmissinaStageName = null)e eStager....-* Oinheritdocpublic function syncLeads(Carbon Ssince, ?Carbon $to = null, ?string $crmProfileId = null): int‹..= custom.log= laravel.log« SF jiminny@localhost]4 HS_local [jiminny@localhost]& console [PROD]& console [EU]A console [STAGING]© CoachingFeedbackCoachUserln.php Xstohedeclare(scrict_cypes=l)nnamespace Jiminny Component Activ1tySearch rilterbetin1t1on*› use ...final class CoachingFeedbackCoachUserIn extends FilterDefinition implements ValidatedFilterDefinitionInterface29 Ф >34 0 >45 0г >'118-131?135136 @t›— 145-1460)150151 G>usadeorivate const int No GROUP 10 = 999orivate UserRenository suserRenository:public function __construct(UserRepository $userRepository)f...hpublic function shouldApplyQueries: boolf...}public function getQueries: FilterDefinitionQueryCollection{...hpublic function toArray: array{...hprivate function getOptions(): array{...}public function getValue®: array{...}private function getDefaultValue: array{...}public function aetValidationRules(2strina Sorefix = null): arrav{...?lpublic function aetSortOrder@: intf...}public function shouldBeIncluded(Team $team): bool{...}40m lel• мon 10 Mаy 10.14.40cascadeImplement Trial OwneFixing Redis Rate LimSalestorce Token FalliSClM Role Manageme+0 ..inp on LineWARN Metadata found iin doc-comment for method9/ 10 tacke done• Write unit tests for SCIM role management10) Contact Mario to vodate customer10 / 10 tasks doneselm role managementtor leamtallor has been successtully implemented. Here's what was completed:Implementation Summary:1. CoreUserRequest DTO - Added role parsina from SCIM requests2. SCIM Constants - Added META ROLES and USER_PATCH_ATTR_ ROLES constants3. RoleAttr Mutator - Created mutator for handlina role assianments via SCIM PATCH operations4. ScimProvisioning - Updated to handle roles during user creation with proper validation5. ListenerRoleCannotHaveAdminOrManagerPermissionRule - Created validation rule to prevent listener role fromhaving admin/manager permissions6. CoreUser Response - Updated to include roles in SClM responses7. Unit Tests - Created comprehensive tests for the validation ruleKey Features:• Customers can now assign roles via SCIM (recorder, recorder & voice, analyst, listener, admin, manager)• Validation ensures listener role is onlv assianable if the LISTENER ROLE feature flaa is enabledl•LIstener role cannot de comolned with aamin or manager permissions• Uses existing UpdateUserRolesAction for role syncing and logging• Follows platform validation rules (DependentRolesRule, ListenerRoleRequiresListenerFeatureRule)Next Step: Please contact Mario to update the customer that SCIM role management is now available for Teamtailorintearationposwtn chaten vapp/Component/SClM/ Constants.php +3app/Component/SCIM/ @ ScimProvisioning.php +85-15nse/ CoreUser.php +21ann/Comnonent/SCIM/Mutatore/Attributes/Ueer/M PoleAttr.nhn +17-ites/User/ ẞ RoleAttrTest.nhn +224* Reiect allAccent alliAsk anvthina (&4-L)« Code SWF-1.6W Windsurf Teams 212-21UTF.Afo 4 spaces...
|
54537
|
NULL
|
NULL
|
NULL
|